feat: add Njalla DNS provider support and related configuration

Kim committed May 22, 2026 at 17:47 UTC 1f066d148c0772f6c9086278f15fc68894d1f510
13 files changed +700 -9
cmd/relay-server/main.go
+4 -1
@@ -65,6 +65,7 @@ type relayServerConfig struct {
65 AWSHostedZoneID string
66 AWSDNSSECKMSKeyARN string
67 VultrAPIKey string
68 + NjallaToken string
69 }
70
71 func runServeCommand(args []string) error {
@@ -93,7 +94,7 @@ func runServeCommand(args []string) error {
94 utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
95 utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
96
96 - utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|hetzner|route53|vultr); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
97 + utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|hetzner|njalla|route53|vultr); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
98 utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
99 utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
100 utils.StringFlagEnv(fs, &cfg.GCPProjectID, "gcp-project-id", "", "Google Cloud project id for Cloud DNS automation; auto-detected from ADC or GCE metadata when omitted", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GCE_PROJECT")
@@ -106,6 +107,7 @@ func runServeCommand(args []string) error {
107 utils.StringFlagEnv(fs, &cfg.AWSHostedZoneID, "aws-hosted-zone-id", "", "explicit Route53 hosted zone ID override", "AWS_HOSTED_ZONE_ID")
108 utils.StringFlagEnv(fs, &cfg.AWSDNSSECKMSKeyARN, "aws-dnssec-kms-key-arn", "", "AWS KMS key ARN used to create a Route53 DNSSEC key-signing key when needed", "AWS_DNSSEC_KMS_KEY_ARN")
109 utils.StringFlagEnv(fs, &cfg.VultrAPIKey, "vultr-api-key", "", "Vultr API key for DNS automation (required when acme-dns-provider=vultr)", "VULTR_API_KEY")
110 + utils.StringFlagEnv(fs, &cfg.NjallaToken, "njalla-token", "", "Njalla API token for DNS automation (required when acme-dns-provider=njalla)", "NJALLA_TOKEN")
111
112 if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil {
113 if errors.Is(err, flag.ErrHelp) {
@@ -181,6 +183,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
183 AWSHostedZoneID: cfg.AWSHostedZoneID,
184 AWSKMSKeyARN: cfg.AWSDNSSECKMSKeyARN,
185 VultrAPIKey: cfg.VultrAPIKey,
186 + NjallaToken: cfg.NjallaToken,
187 },
188 })
189 if err != nil {
docker-compose.yml
+1
@@ -69,6 +69,7 @@ services:
69 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
70 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
71 VULTR_API_KEY: ${VULTR_API_KEY:-}
72 + NJALLA_TOKEN: ${NJALLA_TOKEN:-}
73 volumes:
74 - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
75 # Uncomment when using a Google Cloud service account file for gcloud automation.
docs/src/routes/architecture/+page.md
+1 -1
@@ -197,7 +197,7 @@ UDP client
197 ### Operational Constraints
198
199 - For non-localhost deployments, relay TLS can run from manual certificate files in the relay `IDENTITY_PATH` directory or from managed ACME.
200 -- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, `hetzner`, `route53`, and `vultr`.
200 +- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, `hetzner`, `njalla`, `route53`, and `vultr`.
201 - ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync when the selected provider supports DNSSEC.
202 - Relay stores its state under `IDENTITY_PATH`, including `identity.json`, `admin_settings.json`, and certificate material. Tunnel and demo-app identities still use `IDENTITY_PATH` / `--identity-path` as a direct JSON file path.
203 - Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
docs/src/routes/configuration/+page.md
+15 -1
@@ -49,7 +49,7 @@ The relay server (`relay-server`) reads configuration from environment variables
49
50 | Variable | Default | Type | Description |
51 |----------|---------|------|-------------|
52 -| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `hetzner` \| `route53` \| `vultr`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
52 +| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `hetzner` \| `njalla` \| `route53` \| `vultr`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
53 | `ENS_GASLESS_ENABLED` | `false` | bool | Enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames |
54
55 ### Admin
@@ -102,6 +102,12 @@ The relay server (`relay-server`) reads configuration from environment variables
102 |----------|---------|------|-------------|
103 | `VULTR_API_KEY` | | string | Vultr API key for DNS automation; required when `ACME_DNS_PROVIDER=vultr` |
104
105 +### Njalla
106 +
107 +| Variable | Default | Type | Description |
108 +|----------|---------|------|-------------|
109 +| `NJALLA_TOKEN` | | string | Njalla API token for DNS automation; required when `ACME_DNS_PROVIDER=njalla` |
110 +
111 ---
112
113 ## Portal Tunnel CLI Flags
@@ -299,6 +305,14 @@ For ENS gasless behavior and wallet authentication details, see [Wallet and ENS]
305
306 Note: Hetzner DNS does not support provider-side DNSSEC signing, so `ACME_DNS_PROVIDER=hetzner` supports ACME, A records, and HTTPS/ECH records, but not ENS gasless DNSSEC automation.
307
308 +### Njalla DNS (`njalla`)
309 +
310 +| Variable | Required | Description |
311 +|----------|----------|-------------|
312 +| `NJALLA_TOKEN` | Yes | Njalla API token with DNS record write access |
313 +
314 +Note: Njalla supports managed ACME, A records, TXT records, and HTTPS/ECH records. Portal does not automate Njalla DNSSEC signing, so `ACME_DNS_PROVIDER=njalla` does not support ENS gasless DNSSEC automation.
315 +
316 ### Vultr DNS (`vultr`)
317
318 | Variable | Required | Description |
docs/src/routes/deployment/+page.md
+32 -3
@@ -19,7 +19,7 @@ You need:
19 - A public domain, for example `example.com`
20 - A public Linux server with a static public IPv4
21 - Docker and Docker Compose
22 -- Optional for managed ACME DNS-01 automation and Portal-managed ECH HTTPS records: a supported DNS provider account for `cloudflare`, `gcloud`, `hetzner`, `route53`, or `vultr`
22 +- Optional for managed ACME DNS-01 automation and Portal-managed ECH HTTPS records: a supported DNS provider account for `cloudflare`, `gcloud`, `hetzner`, `njalla`, `route53`, or `vultr`
23 - Open inbound ports:
24 - `443/tcp`
25 - `4017/tcp`
@@ -42,7 +42,7 @@ Choose one of these modes:
42 - Set `ACME_DNS_PROVIDER` to a DNSSEC-capable provider.
43 - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for ECH HTTPS records and DNSSEC + ENS TXT automation.
44 - Managed ACME mode
45 - - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, `hetzner`, `route53`, or `vultr`.
45 + - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, `hetzner`, `njalla`, `route53`, or `vultr`.
46 - Portal manages root/wildcard A records, ECH HTTPS records, and certificate renewal.
47 - ENS gasless additionally requires a DNSSEC-capable provider.
48
@@ -57,6 +57,7 @@ Set `ACME_DNS_PROVIDER` to one of:
57 - `cloudflare`
58 - `gcloud`
59 - `hetzner`
60 +- `njalla`
61 - `route53`
62 - `vultr`
63
@@ -200,7 +201,25 @@ Notes:
201 - The API key needs permission to list DNS domains, edit DNS records, and update DNSSEC for the target domain.
202 - Vultr uses `@` for apex records and relative names such as `www` or `*` for subdomains.
203
203 -### 3.7 Optional ENS Gasless Automation
204 +### 3.7 Njalla DNS setup
205 +
206 +Create or select a Njalla DNS domain that covers your relay host.
207 +
208 +Required environment variable:
209 +
210 +- `NJALLA_TOKEN`
211 +
212 +Equivalent relay flag:
213 +
214 +- `--njalla-token`
215 +
216 +Notes:
217 +
218 +- The token needs permission to list and edit DNS records for the target domain.
219 +- Njalla uses `@` for apex records and relative names such as `www` or `*` for subdomains.
220 +- Portal does not automate Njalla DNSSEC signing, so ENS gasless automation is not supported with `ACME_DNS_PROVIDER=njalla`.
221 +
222 +### 3.8 Optional ENS Gasless Automation
223
224 Portal can optionally enable ENS gasless DNS import for the base domain and lease hostnames.
225
@@ -213,6 +232,7 @@ Portal can optionally enable ENS gasless DNS import for the base domain and leas
232 - Google Cloud DNS can enable zone signing directly, but the registrar may still require publishing the returned DS record.
233 - Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
234 - Vultr can enable zone signing directly, but the registrar may still require publishing the returned DS record.
235 +- Hetzner and Njalla are supported for managed ACME DNS automation, but not for ENS gasless automation.
236 - New lease hostnames such as `app.portal.example.com` are published automatically when they register and are cleaned up on unregister or expiry.
237 - ENS gasless import still depends on DNSSEC being valid for the domain.
238 - By default Portal writes `ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 <address>`.
@@ -337,6 +357,15 @@ VULTR_API_KEY=...
357 ENS_GASLESS_ENABLED=false
358 ```
359
360 +Njalla example:
361 +
362 +```bash
363 +IDENTITY_PATH=/portal-certs
364 +ACME_DNS_PROVIDER=njalla
365 +NJALLA_TOKEN=...
366 +ENS_GASLESS_ENABLED=false
367 +```
368 +
369 Hetzner example:
370
371 ```bash
docs/src/routes/self-hosting/+page.md
+1 -1
@@ -109,7 +109,7 @@ By default the relay expects you to place `fullchain.pem` and `privatekey.pem` i
109
110 ```yaml
111 environment:
112 - ACME_DNS_PROVIDER: cloudflare # or: gcloud, hetzner, route53, vultr
112 + ACME_DNS_PROVIDER: cloudflare # or: gcloud, hetzner, njalla, route53, vultr
113 CLOUDFLARE_TOKEN: <your-token>
114 ```
115
docs/src/routes/wallet-and-ens/+page.md
+2 -2
@@ -150,7 +150,7 @@ Requirements:
150 - `ENS_GASLESS_ENABLED=true`
151 - DNSSEC active at the parent zone
152
153 -Hetzner is supported for managed ACME DNS automation, but not for ENS gasless automation because Hetzner DNS does not support provider-side DNSSEC signing.
153 +Hetzner and Njalla are supported for managed ACME DNS automation, but not for ENS gasless automation because Portal does not automate DNSSEC signing for those providers.
154
155 Example:
156
@@ -235,6 +235,6 @@ A lease hostname has no ENS TXT record:
235
236 ## Next Steps
237
238 -- [Deployment](/deployment#35-optional-ens-gasless-automation): production setup
238 +- [Deployment](/deployment#38-optional-ens-gasless-automation): production setup
239 - [Security Model](/security-model): identity and TLS trust boundaries
240 - [Portal Agent](/portal-agent): local durable tunnel management
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+1
@@ -109,6 +109,7 @@ services:
109 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
110 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
111 VULTR_API_KEY: ${VULTR_API_KEY:-}
112 + NJALLA_TOKEN: ${NJALLA_TOKEN:-}
113 volumes:
114 - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
115 # Uncomment when using a Google Cloud service account file for gcloud automation.
docs/static/examples/nginx-proxy/docker-compose.yaml
+1
@@ -108,6 +108,7 @@ services:
108 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
109 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
110 VULTR_API_KEY: ${VULTR_API_KEY:-}
111 + NJALLA_TOKEN: ${NJALLA_TOKEN:-}
112 volumes:
113 - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
114 # Uncomment when using a Google Cloud service account file for gcloud automation.
portal/acme/acme.go
+2
@@ -62,6 +62,7 @@ type Config struct {
62 AWSHostedZoneID string
63 AWSKMSKeyARN string
64 VultrAPIKey string
65 + NjallaToken string
66 }
67
68 type Manager struct {
@@ -165,6 +166,7 @@ func NewManager(cfg Config) (*Manager, error) {
166 cfg.AWSHostedZoneID = strings.TrimSpace(cfg.AWSHostedZoneID)
167 cfg.AWSKMSKeyARN = strings.TrimSpace(cfg.AWSKMSKeyARN)
168 cfg.VultrAPIKey = strings.TrimSpace(cfg.VultrAPIKey)
169 + cfg.NjallaToken = strings.TrimSpace(cfg.NjallaToken)
170 if cfg.ENSGaslessEnabled {
171 if cfg.ENSGaslessAddress == "" {
172 return nil, errors.New("ens gasless address is required when ens gasless import is enabled")
portal/acme/njalla/provider.go new
+573
@@ -0,0 +1,573 @@
1 +package njalla
2 +
3 +import (
4 + "bytes"
5 + "context"
6 + "encoding/json"
7 + "errors"
8 + "fmt"
9 + "io"
10 + "net/http"
11 + "strconv"
12 + "strings"
13 + "time"
14 +
15 + "github.com/go-acme/lego/v4/challenge"
16 + legonjalla "github.com/go-acme/lego/v4/providers/dns/njalla"
17 +
18 + "github.com/gosuda/portal-tunnel/v2/utils"
19 +)
20 +
21 +const (
22 + apiEndpoint = "https://njal.la/api/1/"
23 + defaultRecordTTL = 60
24 + defaultTimeout = 30 * time.Second
25 +)
26 +
27 +type Provider struct {
28 + token string
29 + zones *utils.Snapshot[map[string]string]
30 +}
31 +
32 +func New(token string) *Provider {
33 + return &Provider{
34 + token: strings.TrimSpace(token),
35 + zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
36 + }
37 +}
38 +
39 +func (p *Provider) Name() string {
40 + return "njalla"
41 +}
42 +
43 +func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
44 + if p == nil {
45 + return nil, errors.New("njalla provider is nil")
46 + }
47 + if p.token == "" {
48 + return nil, errors.New("njalla token is required")
49 + }
50 +
51 + cfg := legonjalla.NewDefaultConfig()
52 + cfg.Token = p.token
53 +
54 + provider, err := legonjalla.NewDNSProviderConfig(cfg)
55 + if err != nil {
56 + return nil, fmt.Errorf("create njalla lego provider: %w", err)
57 + }
58 + return provider, nil
59 +}
60 +
61 +func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
62 + if p == nil {
63 + return errors.New("njalla provider is nil")
64 + }
65 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
66 + if baseDomain == "" {
67 + return errors.New("base domain is required")
68 + }
69 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
70 + return err
71 + }
72 +
73 + client, zone, err := p.clientAndZone(ctx, baseDomain)
74 + if err != nil {
75 + return err
76 + }
77 +
78 + for _, recordName := range []string{baseDomain, "*." + baseDomain} {
79 + if err := ensureRecord(ctx, client, zone, recordName, "A", strings.TrimSpace(publicIPv4)); err != nil {
80 + return fmt.Errorf("upsert njalla A record %s: %w", recordName, err)
81 + }
82 + }
83 + return nil
84 +}
85 +
86 +func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
87 + if p == nil {
88 + return errors.New("njalla provider is nil")
89 + }
90 + name = utils.NormalizeHostname(name)
91 + if name == "" {
92 + return errors.New("record name is required")
93 + }
94 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
95 + return err
96 + }
97 +
98 + client, zone, err := p.clientAndZone(ctx, name)
99 + if err != nil {
100 + return err
101 + }
102 + if err := ensureRecord(ctx, client, zone, name, "A", strings.TrimSpace(publicIPv4)); err != nil {
103 + return fmt.Errorf("upsert njalla A record %s: %w", name, err)
104 + }
105 + return nil
106 +}
107 +
108 +func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
109 + if p == nil {
110 + return errors.New("njalla provider is nil")
111 + }
112 + name = utils.NormalizeHostname(name)
113 + if name == "" {
114 + return errors.New("record name is required")
115 + }
116 +
117 + client, zone, err := p.clientAndZone(ctx, name)
118 + if err != nil {
119 + return err
120 + }
121 + if err := deleteRecords(ctx, client, zone, name, "A", ""); err != nil {
122 + return fmt.Errorf("delete njalla A record %s: %w", name, err)
123 + }
124 + return nil
125 +}
126 +
127 +func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
128 + if p == nil {
129 + return errors.New("njalla provider is nil")
130 + }
131 + name = utils.NormalizeHostname(name)
132 + if name == "" {
133 + return errors.New("record name is required")
134 + }
135 + value = strings.TrimSpace(value)
136 + if value == "" {
137 + return errors.New("txt record value is required")
138 + }
139 +
140 + client, zone, err := p.clientAndZone(ctx, name)
141 + if err != nil {
142 + return err
143 + }
144 + if err := ensureTXTRecord(ctx, client, zone, name, value); err != nil {
145 + return fmt.Errorf("upsert njalla TXT record %s: %w", name, err)
146 + }
147 + return nil
148 +}
149 +
150 +func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
151 + if p == nil {
152 + return errors.New("njalla provider is nil")
153 + }
154 + name = utils.NormalizeHostname(name)
155 + if name == "" {
156 + return errors.New("record name is required")
157 + }
158 + matchPrefix = strings.TrimSpace(matchPrefix)
159 + if matchPrefix == "" {
160 + return errors.New("txt record match prefix is required")
161 + }
162 +
163 + client, zone, err := p.clientAndZone(ctx, name)
164 + if err != nil {
165 + return err
166 + }
167 + if err := deleteRecords(ctx, client, zone, name, "TXT", matchPrefix); err != nil {
168 + return fmt.Errorf("delete njalla TXT records %s: %w", name, err)
169 + }
170 + return nil
171 +}
172 +
173 +func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
174 + if p == nil {
175 + return errors.New("njalla provider is nil")
176 + }
177 + name = utils.NormalizeHostname(name)
178 + if name == "" {
179 + return errors.New("record name is required")
180 + }
181 + content = strings.TrimSpace(content)
182 + if content == "" {
183 + return errors.New("https record content is required")
184 + }
185 +
186 + client, zone, err := p.clientAndZone(ctx, name)
187 + if err != nil {
188 + return err
189 + }
190 + if err := ensureRecord(ctx, client, zone, name, "HTTPS", content); err != nil {
191 + return fmt.Errorf("upsert njalla HTTPS record %s: %w", name, err)
192 + }
193 + return nil
194 +}
195 +
196 +func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
197 + if p == nil {
198 + return errors.New("njalla provider is nil")
199 + }
200 + name = utils.NormalizeHostname(name)
201 + if name == "" {
202 + return errors.New("record name is required")
203 + }
204 +
205 + client, zone, err := p.clientAndZone(ctx, name)
206 + if err != nil {
207 + return err
208 + }
209 + if err := deleteRecords(ctx, client, zone, name, "HTTPS", ""); err != nil {
210 + return fmt.Errorf("delete njalla HTTPS record %s: %w", name, err)
211 + }
212 + return nil
213 +}
214 +
215 +func (p *Provider) EnsureDNSSEC(_ context.Context, baseDomain string) (state, dsRecord, message string, err error) {
216 + if p == nil {
217 + return "", "", "", errors.New("njalla provider is nil")
218 + }
219 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
220 + if baseDomain == "" {
221 + return "", "", "", errors.New("base domain is required")
222 + }
223 + if p.token == "" {
224 + return "", "", "", errors.New("njalla token is required")
225 + }
226 + return "", "", "", errors.New("njalla dnssec automation is not supported; use a DNSSEC-capable provider for ENS gasless automation")
227 +}
228 +
229 +func (p *Provider) clientAndZone(ctx context.Context, domain string) (*apiClient, string, error) {
230 + client, err := p.newClient()
231 + if err != nil {
232 + return nil, "", err
233 + }
234 + zone, err := p.findZone(ctx, client, domain)
235 + if err != nil {
236 + return nil, "", err
237 + }
238 + return client, zone, nil
239 +}
240 +
241 +func (p *Provider) newClient() (*apiClient, error) {
242 + if p == nil {
243 + return nil, errors.New("njalla provider is nil")
244 + }
245 + if p.token == "" {
246 + return nil, errors.New("njalla token is required")
247 + }
248 + return &apiClient{
249 + token: p.token,
250 + endpoint: apiEndpoint,
251 + httpClient: utils.NewHTTPClient(utils.WithHTTPTimeout(defaultTimeout)),
252 + }, nil
253 +}
254 +
255 +func (p *Provider) findZone(ctx context.Context, client *apiClient, domain string) (string, error) {
256 + if client == nil {
257 + return "", errors.New("njalla client is nil")
258 + }
259 + domain = utils.NormalizeHostname(domain)
260 + candidates := utils.DomainCandidates(domain)
261 +
262 + zones := p.zones.Load()
263 + for _, candidate := range candidates {
264 + if zone := zones[candidate]; zone != "" {
265 + return zone, nil
266 + }
267 + }
268 +
269 + var lastErr error
270 + for _, candidate := range candidates {
271 + if _, err := client.listRecords(ctx, candidate); err != nil {
272 + lastErr = err
273 + continue
274 + }
275 + p.zones.UpdateCopy(func(zones *map[string]string) {
276 + if *zones == nil {
277 + *zones = make(map[string]string)
278 + }
279 + (*zones)[candidate] = candidate
280 + })
281 + return candidate, nil
282 + }
283 + if lastErr != nil {
284 + return "", fmt.Errorf("no njalla zone found for %s: %w", domain, lastErr)
285 + }
286 + return "", fmt.Errorf("no njalla zone found for %s", domain)
287 +}
288 +
289 +func ensureRecord(ctx context.Context, client *apiClient, zone, fqdn, recordType, content string) error {
290 + recordName, err := relativeRecordName(fqdn, zone)
291 + if err != nil {
292 + return err
293 + }
294 + content = strings.TrimSpace(content)
295 + if content == "" {
296 + return errors.New("record content is required")
297 + }
298 +
299 + existing, err := listRecords(ctx, client, zone, fqdn, recordType)
300 + if err != nil {
301 + return err
302 + }
303 + needsAdd := true
304 + for _, record := range existing {
305 + if strings.TrimSpace(record.Content) == content {
306 + needsAdd = false
307 + continue
308 + }
309 + if err := client.removeRecord(ctx, record.ID.String(), zone); err != nil {
310 + return err
311 + }
312 + }
313 + if !needsAdd {
314 + return nil
315 + }
316 + _, err = client.addRecord(ctx, record{
317 + Domain: zone,
318 + Name: recordName,
319 + Type: strings.ToUpper(strings.TrimSpace(recordType)),
320 + TTL: defaultRecordTTL,
321 + Content: content,
322 + })
323 + return err
324 +}
325 +
326 +func ensureTXTRecord(ctx context.Context, client *apiClient, zone, fqdn, value string) error {
327 + recordName, err := relativeRecordName(fqdn, zone)
328 + if err != nil {
329 + return err
330 + }
331 + existing, err := listRecords(ctx, client, zone, fqdn, "TXT")
332 + if err != nil {
333 + return err
334 + }
335 + for _, record := range existing {
336 + if txtContent(record.Content) == value {
337 + return nil
338 + }
339 + }
340 + _, err = client.addRecord(ctx, record{
341 + Domain: zone,
342 + Name: recordName,
343 + Type: "TXT",
344 + TTL: defaultRecordTTL,
345 + Content: value,
346 + })
347 + return err
348 +}
349 +
350 +func deleteRecords(ctx context.Context, client *apiClient, zone, fqdn, recordType, matchPrefix string) error {
351 + existing, err := listRecords(ctx, client, zone, fqdn, recordType)
352 + if err != nil {
353 + return err
354 + }
355 + for _, record := range existing {
356 + if matchPrefix != "" && !strings.HasPrefix(txtContent(record.Content), matchPrefix) {
357 + continue
358 + }
359 + if err := client.removeRecord(ctx, record.ID.String(), zone); err != nil {
360 + return err
361 + }
362 + }
363 + return nil
364 +}
365 +
366 +func listRecords(ctx context.Context, client *apiClient, zone, fqdn, recordType string) ([]record, error) {
367 + if client == nil {
368 + return nil, errors.New("njalla client is nil")
369 + }
370 + recordName, err := relativeRecordName(fqdn, zone)
371 + if err != nil {
372 + return nil, err
373 + }
374 + recordType = strings.ToUpper(strings.TrimSpace(recordType))
375 +
376 + records, err := client.listRecords(ctx, zone)
377 + if err != nil {
378 + return nil, err
379 + }
380 + filtered := make([]record, 0, len(records))
381 + for _, record := range records {
382 + if !strings.EqualFold(strings.TrimSpace(record.Type), recordType) || !sameRecordName(record.Name, recordName, fqdn, zone) {
383 + continue
384 + }
385 + filtered = append(filtered, record)
386 + }
387 + return filtered, nil
388 +}
389 +
390 +func relativeRecordName(fqdn, zone string) (string, error) {
391 + fqdn = utils.NormalizeHostname(fqdn)
392 + zone = utils.NormalizeBaseDomain(zone)
393 + if fqdn == "" {
394 + return "", errors.New("record name is required")
395 + }
396 + if zone == "" {
397 + return "", errors.New("njalla zone is required")
398 + }
399 + if fqdn == zone {
400 + return "@", nil
401 + }
402 + suffix := "." + zone
403 + if !strings.HasSuffix(fqdn, suffix) {
404 + return "", fmt.Errorf("hostname %q is outside njalla zone %q", fqdn, zone)
405 + }
406 + return strings.TrimSuffix(fqdn, suffix), nil
407 +}
408 +
409 +func sameRecordName(recordName, expected, fqdn, zone string) bool {
410 + recordName = utils.NormalizeHostname(recordName)
411 + expected = strings.TrimSpace(strings.ToLower(expected))
412 + fqdn = utils.NormalizeHostname(fqdn)
413 + zone = utils.NormalizeBaseDomain(zone)
414 +
415 + if recordName == expected {
416 + return true
417 + }
418 + if expected == "@" && (recordName == "" || recordName == zone || recordName == fqdn) {
419 + return true
420 + }
421 + return recordName == fqdn
422 +}
423 +
424 +func txtContent(raw string) string {
425 + unquoted, err := strconv.Unquote(strings.TrimSpace(raw))
426 + if err == nil {
427 + return unquoted
428 + }
429 + return strings.Trim(strings.TrimSpace(raw), "\"")
430 +}
431 +
432 +type apiClient struct {
433 + token string
434 + endpoint string
435 + httpClient *http.Client
436 +}
437 +
438 +type apiRequest struct {
439 + Method string `json:"method"`
440 + Params any `json:"params"`
441 +}
442 +
443 +type apiResponse struct {
444 + Error *apiError `json:"error,omitempty"`
445 + Result json.RawMessage `json:"result,omitempty"`
446 +}
447 +
448 +type apiError struct {
449 + Code int `json:"code"`
450 + Message string `json:"message"`
451 +}
452 +
453 +func (e apiError) Error() string {
454 + return fmt.Sprintf("code: %d, message: %s", e.Code, e.Message)
455 +}
456 +
457 +type recordID string
458 +
459 +func (id recordID) String() string {
460 + return string(id)
461 +}
462 +
463 +func (id *recordID) UnmarshalJSON(data []byte) error {
464 + var value any
465 + decoder := json.NewDecoder(bytes.NewReader(data))
466 + decoder.UseNumber()
467 + if err := decoder.Decode(&value); err != nil {
468 + return err
469 + }
470 + switch v := value.(type) {
471 + case nil:
472 + *id = ""
473 + case string:
474 + *id = recordID(v)
475 + case json.Number:
476 + *id = recordID(v.String())
477 + default:
478 + return fmt.Errorf("unsupported njalla record id %s", strings.TrimSpace(string(data)))
479 + }
480 + return nil
481 +}
482 +
483 +type record struct {
484 + ID recordID `json:"id,omitempty"`
485 + Content string `json:"content,omitempty"`
486 + Domain string `json:"domain,omitempty"`
487 + Name string `json:"name,omitempty"`
488 + TTL int `json:"ttl,omitempty"`
489 + Type string `json:"type,omitempty"`
490 +}
491 +
492 +type recordsResult struct {
493 + Records []record `json:"records,omitempty"`
494 +}
495 +
496 +func (c *apiClient) addRecord(ctx context.Context, rec record) (*record, error) {
497 + var out record
498 + if err := c.do(ctx, "add-record", rec, &out); err != nil {
499 + return nil, err
500 + }
501 + return &out, nil
502 +}
503 +
504 +func (c *apiClient) removeRecord(ctx context.Context, id, domain string) error {
505 + id = strings.TrimSpace(id)
506 + if id == "" {
507 + return errors.New("njalla record id is required")
508 + }
509 + return c.do(ctx, "remove-record", record{ID: recordID(id), Domain: domain}, nil)
510 +}
511 +
512 +func (c *apiClient) listRecords(ctx context.Context, domain string) ([]record, error) {
513 + var out recordsResult
514 + if err := c.do(ctx, "list-records", record{Domain: domain}, &out); err != nil {
515 + return nil, err
516 + }
517 + return out.Records, nil
518 +}
519 +
520 +func (c *apiClient) do(ctx context.Context, method string, params any, out any) error {
521 + if c == nil {
522 + return errors.New("njalla client is nil")
523 + }
524 + endpoint := strings.TrimSpace(c.endpoint)
525 + if endpoint == "" {
526 + endpoint = apiEndpoint
527 + }
528 + body, err := json.Marshal(apiRequest{Method: method, Params: params})
529 + if err != nil {
530 + return fmt.Errorf("marshal njalla api request: %w", err)
531 + }
532 +
533 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
534 + if err != nil {
535 + return fmt.Errorf("create njalla api request: %w", err)
536 + }
537 + req.Header.Set("Accept", "application/json")
538 + req.Header.Set("Authorization", "Njalla "+c.token)
539 + req.Header.Set("Content-Type", "application/json")
540 +
541 + client := c.httpClient
542 + if client == nil {
543 + client = utils.DefaultHTTPClient
544 + }
545 + resp, err := client.Do(req)
546 + if err != nil {
547 + return err
548 + }
549 + defer resp.Body.Close()
550 +
551 + raw, err := io.ReadAll(resp.Body)
552 + if err != nil {
553 + return fmt.Errorf("read njalla api response: %w", err)
554 + }
555 + if resp.StatusCode != http.StatusOK {
556 + return fmt.Errorf("njalla api %s failed: %s", method, resp.Status)
557 + }
558 +
559 + var envelope apiResponse
560 + if err := json.Unmarshal(raw, &envelope); err != nil {
561 + return fmt.Errorf("decode njalla api response: %w", err)
562 + }
563 + if envelope.Error != nil {
564 + return envelope.Error
565 + }
566 + if out == nil || len(envelope.Result) == 0 {
567 + return nil
568 + }
569 + if err := json.Unmarshal(envelope.Result, out); err != nil {
570 + return fmt.Errorf("decode njalla api result: %w", err)
571 + }
572 + return nil
573 +}
portal/acme/njalla/provider_test.go new
+63
@@ -0,0 +1,63 @@
1 +package njalla
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "testing"
7 +)
8 +
9 +func TestChallengeProviderRequiresToken(t *testing.T) {
10 + t.Parallel()
11 +
12 + provider := New("")
13 + challengeProvider, err := provider.ChallengeProvider(context.Background())
14 + if challengeProvider != nil {
15 + t.Fatalf("ChallengeProvider() provider = %T, want nil", challengeProvider)
16 + }
17 + if err == nil || err.Error() != "njalla token is required" {
18 + t.Fatalf("ChallengeProvider() error = %v, want local token error", err)
19 + }
20 +}
21 +
22 +func TestRelativeRecordName(t *testing.T) {
23 + t.Parallel()
24 +
25 + testCases := []struct {
26 + name string
27 + fqdn string
28 + want string
29 + }{
30 + {name: "apex", fqdn: "example.com", want: "@"},
31 + {name: "subdomain", fqdn: "portal.example.com", want: "portal"},
32 + {name: "wildcard", fqdn: "*.example.com", want: "*"},
33 + {name: "nested", fqdn: "_ens.portal.example.com", want: "_ens.portal"},
34 + }
35 +
36 + for _, tc := range testCases {
37 + t.Run(tc.name, func(t *testing.T) {
38 + t.Parallel()
39 +
40 + got, err := relativeRecordName(tc.fqdn, "example.com")
41 + if err != nil {
42 + t.Fatalf("relativeRecordName() error = %v", err)
43 + }
44 + if got != tc.want {
45 + t.Fatalf("relativeRecordName() = %q, want %q", got, tc.want)
46 + }
47 + })
48 + }
49 +}
50 +
51 +func TestRecordIDUnmarshal(t *testing.T) {
52 + t.Parallel()
53 +
54 + for _, raw := range []string{`{"id":"123"}`, `{"id":123}`} {
55 + var record record
56 + if err := json.Unmarshal([]byte(raw), &record); err != nil {
57 + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err)
58 + }
59 + if record.ID.String() != "123" {
60 + t.Fatalf("record id = %q, want 123", record.ID.String())
61 + }
62 + }
63 +}
portal/acme/provider.go
+4
@@ -10,6 +10,7 @@ import (
10 "github.com/gosuda/portal-tunnel/v2/portal/acme/cloudflare"
11 "github.com/gosuda/portal-tunnel/v2/portal/acme/gcloud"
12 "github.com/gosuda/portal-tunnel/v2/portal/acme/hetzner"
13 + "github.com/gosuda/portal-tunnel/v2/portal/acme/njalla"
14 "github.com/gosuda/portal-tunnel/v2/portal/acme/route53"
15 "github.com/gosuda/portal-tunnel/v2/portal/acme/vultr"
16 )
@@ -18,6 +19,7 @@ const (
19 TypeCloudflare = "cloudflare"
20 TypeGCloud = "gcloud"
21 TypeHetzner = "hetzner"
22 + TypeNjalla = "njalla"
23 TypeRoute53 = "route53"
24 TypeVultr = "vultr"
25 )
@@ -48,6 +50,8 @@ func NewDNSProvider(providerType string, cfg Config) (DNSProvider, error) {
50 }), nil
51 case TypeHetzner:
52 return hetzner.New(cfg.HetznerAPIToken), nil
53 + case TypeNjalla:
54 + return njalla.New(cfg.NjallaToken), nil
55 case TypeRoute53:
56 return route53.New(route53.Config{
57 AccessKeyID: cfg.AWSAccessKeyID,