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,