feat: implement dnssec gasless ens
rabbitprincess committed
Apr 1, 2026 at 23:20 UTC
88ba388c2f1eb5988c6555bc39071f7a77bdb7a4
12 files changed
+783
-47
.env.example
+6
@@ -17,6 +17,8 @@ UDP_PORT_COUNT=0
17
KEYLESS_DIR=/portal-certs
18
# Supported values: cloudflare, route53
19
ACME_DNS_PROVIDER=cloudflare
20
+# Optional ENS gasless DNS import automation. When enabled, Portal also manages DNSSEC for the zone.
21
+ENS_GASLESS_ENABLED=false
22
23
# Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
24
CLOUDFLARE_TOKEN=
@@ -28,6 +30,10 @@ AWS_SESSION_TOKEN=
30
AWS_REGION=
31
AWS_DEFAULT_REGION=
32
AWS_HOSTED_ZONE_ID=
33
+# Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
34
+AWS_DNSSEC_KMS_KEY_ARN=
35
+# Optional Route53 key-signing key name override. Defaults to portal_ksk.
36
+DNSSEC_KSK_NAME=
37
38
# Admin/auth configuration
39
ADMIN_SECRET_KEY=
cmd/relay-server/main.go
+10
@@ -48,12 +48,15 @@ type relayServerConfig struct {
48
AdminSettingsPath string
49
KeylessDir string
50
ACMEDNSProvider string
51
+ ENSGaslessEnabled bool
52
CloudflareToken string
53
AWSAccessKeyID string
54
AWSSecretAccessKey string
55
AWSSessionToken string
56
AWSRegion string
57
AWSHostedZoneID string
58
+ AWSDNSSECKMSKeyARN string
59
+ DNSSECKSKName string
60
}
61
62
func runServeCommand(args []string) error {
@@ -78,12 +81,15 @@ func runServeCommand(args []string) error {
81
utils.StringFlagEnv(fs, &cfg.KeylessDir, "keyless-dir", "./.portal-certs", "directory path for relay keyless materials", "KEYLESS_DIR")
82
utils.StringFlagEnv(fs, &cfg.AdminSettingsPath, "admin-settings-path", "admin_settings.json", "admin settings file path", "ADMIN_SETTINGS_PATH")
83
utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "cloudflare", "ACME DNS provider for DNS-01 and A-record sync (cloudflare|route53)", "ACME_DNS_PROVIDER")
84
+ 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")
85
utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
86
utils.StringFlagEnv(fs, &cfg.AWSAccessKeyID, "aws-access-key-id", "", "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted", "AWS_ACCESS_KEY_ID")
87
utils.StringFlagEnv(fs, &cfg.AWSSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key for Route53 static credentials", "AWS_SECRET_ACCESS_KEY")
88
utils.StringFlagEnv(fs, &cfg.AWSSessionToken, "aws-session-token", "", "AWS session token for Route53 temporary credentials", "AWS_SESSION_TOKEN")
89
utils.StringFlagEnv(fs, &cfg.AWSRegion, "aws-region", "", "AWS region for Route53 and Route53-backed DNS-01; defaults to us-east-1 when unset", "AWS_REGION", "AWS_DEFAULT_REGION")
90
utils.StringFlagEnv(fs, &cfg.AWSHostedZoneID, "aws-hosted-zone-id", "", "explicit Route53 hosted zone ID override", "AWS_HOSTED_ZONE_ID")
91
+ 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")
92
+ utils.StringFlagEnv(fs, &cfg.DNSSECKSKName, "dnssec-ksk-name", "", "optional key-signing key name override for Route53 DNSSEC automation", "DNSSEC_KSK_NAME")
93
94
if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil {
95
if errors.Is(err, flag.ErrHelp) {
@@ -103,6 +109,7 @@ func runServeCommand(args []string) error {
109
Str("admin_settings_path", cfg.AdminSettingsPath).
110
Bool("landing_page_enabled", cfg.LandingPageEnabled).
111
Bool("discovery_enabled", cfg.DiscoveryEnabled).
112
+ Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
113
Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
114
Bool("udp_enabled", cfg.UDPPortCount > 0).
115
Msg("configured relay server")
@@ -129,12 +136,15 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
136
ACME: acme.Config{
137
KeyDir: cfg.KeylessDir,
138
DNSProvider: cfg.ACMEDNSProvider,
139
+ ENSGaslessEnabled: cfg.ENSGaslessEnabled,
140
CloudflareToken: cfg.CloudflareToken,
141
AWSAccessKeyID: cfg.AWSAccessKeyID,
142
AWSSecretAccessKey: cfg.AWSSecretAccessKey,
143
AWSSessionToken: cfg.AWSSessionToken,
144
AWSRegion: cfg.AWSRegion,
145
AWSHostedZoneID: cfg.AWSHostedZoneID,
146
+ AWSKMSKeyARN: cfg.AWSDNSSECKMSKeyARN,
147
+ DNSSECKSKName: cfg.DNSSECKSKName,
148
},
149
APIPort: cfg.APIPort,
150
SNIPort: cfg.SNIPort,
docs/deployment.md
+29
@@ -60,6 +60,8 @@ Required permissions:
60
61
- `Zone:Read`
62
- `DNS:Edit`
63
+- optional when `ENS_GASLESS_ENABLED=true`:
64
+ - `Zone Settings:Edit`
65
66
Scope:
67
@@ -95,6 +97,27 @@ Equivalent relay flags:
97
- `--aws-region`
98
- `--aws-hosted-zone-id`
99
100
+When `ENS_GASLESS_ENABLED=true` and the hosted zone does not already have an active Route53 key-signing key (KSK), also provide:
101
+
102
+- `AWS_DNSSEC_KMS_KEY_ARN`
103
+- optional `DNSSEC_KSK_NAME`
104
+
105
+### 2.4 Optional ENS Gasless Automation
106
+
107
+Portal can optionally enable ENS gasless DNS import for the managed zone and lease hostnames.
108
+
109
+- This is not required for normal Portal deployment.
110
+- Enable it only when you specifically need ENS gasless DNS import.
111
+- Portal manages DNSSEC as part of this mode.
112
+- Cloudflare can enable zone signing directly, but some registrars still require publishing the returned DS record.
113
+- Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
114
+- New lease hostnames such as `app.portal.example.com` are published automatically when they register and are cleaned up on unregister or expiry.
115
+- ENS gasless import still depends on DNSSEC being valid for the domain.
116
+- By default Portal writes `ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 <address>`.
117
+- The address is derived automatically from the relay identity for the base domain and from each lease identity for lease hostnames.
118
+- This enables offchain gasless DNSSEC usage in ENS-aware clients. It does not perform an onchain ENS claim transaction.
119
+- Keep `ENS_GASLESS_ENABLED=false` unless you intend to use ENS gasless DNS import.
120
+
121
## 3. Run Relay Server
122
123
### 3.1 Create `.env` at repository root
@@ -112,6 +135,7 @@ ADMIN_SECRET_KEY=your-admin-secret
135
KEYLESS_DIR=/portal-certs
136
ACME_DNS_PROVIDER=cloudflare
137
CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
138
+ENS_GASLESS_ENABLED=false
139
```
140
141
Route53 example:
@@ -126,6 +150,11 @@ AWS_SESSION_TOKEN=...
150
AWS_REGION=us-east-1
151
# Optional override
152
AWS_HOSTED_ZONE_ID=Z1234567890ABC
153
+# Required only for ENS gasless automation when no ACTIVE KSK already exists.
154
+AWS_DNSSEC_KMS_KEY_ARN=arn:aws:kms:...
155
+# Optional override
156
+DNSSEC_KSK_NAME=portal_ksk
157
+ENS_GASLESS_ENABLED=false
158
```
159
160
Notes:
portal/acme/acme.go
+117
-15
@@ -28,36 +28,44 @@ import (
28
)
29
30
const (
31
- fullChainFileName = "fullchain.pem"
32
- keyFileName = "privatekey.pem"
33
- accountKeyFileName = "acme-account.key"
34
- registrationFileName = "acme-registration.json"
35
- defaultACMEEmailPrefix = "acme@"
36
- defaultRenewInterval = 24 * time.Hour
37
- defaultDNSSyncInterval = 10 * time.Minute
38
- defaultSyncTimeout = 2 * time.Minute
31
+ fullChainFileName = "fullchain.pem"
32
+ keyFileName = "privatekey.pem"
33
+ accountKeyFileName = "acme-account.key"
34
+ registrationFileName = "acme-registration.json"
35
+ gaslessENSTXTPrefix = "ENS1 "
36
+ defaultENSGaslessResolver = "0x238A8F792dFA6033814B18618aD4100654aeef01"
37
+ defaultACMEEmailPrefix = "acme@"
38
+ defaultRenewInterval = 24 * time.Hour
39
+ defaultDNSSyncInterval = 10 * time.Minute
40
+ defaultSyncTimeout = 2 * time.Minute
41
)
42
43
type Config struct {
44
BaseDomain string
45
KeyDir string
46
DNSProvider string
47
+ ENSGaslessEnabled bool
48
+ ENSGaslessAddress string
49
CloudflareToken string
50
AWSAccessKeyID string
51
AWSSecretAccessKey string
52
AWSSessionToken string
53
AWSRegion string
54
AWSHostedZoneID string
55
+ AWSKMSKeyARN string
56
+ DNSSECKSKName string
57
}
58
59
type Manager struct {
54
- stopCh chan struct{}
55
- cfg Config
56
- wg sync.WaitGroup
57
- mu sync.RWMutex
58
- dns DNSProvider
59
- startOnce sync.Once
60
- stopOnce sync.Once
60
+ stopCh chan struct{}
61
+ cfg Config
62
+ wg sync.WaitGroup
63
+ mu sync.RWMutex
64
+ dns DNSProvider
65
+ startOnce sync.Once
66
+ stopOnce sync.Once
67
+ dnssecLogOnce sync.Once
68
+ ensLogOnce sync.Once
69
}
70
71
type provisionConfig struct {
@@ -80,12 +88,25 @@ func NewManager(cfg Config) (*Manager, error) {
88
cfg.BaseDomain = strings.TrimPrefix(utils.NormalizeHostname(cfg.BaseDomain), "*.")
89
cfg.KeyDir = strings.TrimSpace(cfg.KeyDir)
90
cfg.DNSProvider = strings.ToLower(strings.TrimSpace(cfg.DNSProvider))
91
+ cfg.ENSGaslessAddress = strings.TrimSpace(cfg.ENSGaslessAddress)
92
cfg.CloudflareToken = strings.TrimSpace(cfg.CloudflareToken)
93
cfg.AWSAccessKeyID = strings.TrimSpace(cfg.AWSAccessKeyID)
94
cfg.AWSSecretAccessKey = strings.TrimSpace(cfg.AWSSecretAccessKey)
95
cfg.AWSSessionToken = strings.TrimSpace(cfg.AWSSessionToken)
96
cfg.AWSRegion = strings.TrimSpace(cfg.AWSRegion)
97
cfg.AWSHostedZoneID = strings.TrimSpace(cfg.AWSHostedZoneID)
98
+ cfg.AWSKMSKeyARN = strings.TrimSpace(cfg.AWSKMSKeyARN)
99
+ cfg.DNSSECKSKName = strings.TrimSpace(cfg.DNSSECKSKName)
100
+ if cfg.ENSGaslessEnabled {
101
+ if cfg.ENSGaslessAddress == "" {
102
+ return nil, errors.New("ens gasless address is required when ens gasless import is enabled")
103
+ }
104
+ address, err := utils.NormalizeEVMAddress(cfg.ENSGaslessAddress)
105
+ if err != nil {
106
+ return nil, fmt.Errorf("normalize ens gasless address: %w", err)
107
+ }
108
+ cfg.ENSGaslessAddress = address
109
+ }
110
111
if cfg.KeyDir == "" {
112
return nil, errors.New("acme key directory is required")
@@ -108,6 +129,8 @@ func NewManager(cfg Config) (*Manager, error) {
129
AWSSessionToken: cfg.AWSSessionToken,
130
AWSRegion: cfg.AWSRegion,
131
AWSHostedZoneID: cfg.AWSHostedZoneID,
132
+ AWSKMSKeyARN: cfg.AWSKMSKeyARN,
133
+ DNSSECKSKName: cfg.DNSSECKSKName,
134
})
135
if err != nil {
136
return nil, fmt.Errorf("create acme dns provider: %w", err)
@@ -287,6 +310,35 @@ func (m *Manager) syncDNS(ctx context.Context) error {
310
if m.dns == nil {
311
return errors.New("acme dns provider is required")
312
}
313
+ if m.cfg.ENSGaslessEnabled {
314
+ status, err := m.dns.EnsureDNSSEC(ctx, m.cfg.BaseDomain)
315
+ if err != nil {
316
+ return fmt.Errorf("ensure dnssec: %w", err)
317
+ }
318
+ m.dnssecLogOnce.Do(func() {
319
+ event := log.Info().
320
+ Str("provider", m.dns.Name()).
321
+ Str("base_domain", m.cfg.BaseDomain).
322
+ Str("state", strings.TrimSpace(status.State))
323
+ if strings.TrimSpace(status.DSRecord) != "" {
324
+ event = event.Str("ds_record", strings.TrimSpace(status.DSRecord))
325
+ }
326
+ if strings.TrimSpace(status.Message) != "" {
327
+ event = event.Str("message", strings.TrimSpace(status.Message))
328
+ }
329
+ event.Msg("dnssec configured")
330
+ })
331
+ if err := m.SyncENSGaslessHostname(ctx, m.cfg.BaseDomain, m.cfg.ENSGaslessAddress); err != nil {
332
+ return fmt.Errorf("ensure ens gasless txt: %w", err)
333
+ }
334
+ m.ensLogOnce.Do(func() {
335
+ log.Info().
336
+ Str("provider", m.dns.Name()).
337
+ Str("base_domain", m.cfg.BaseDomain).
338
+ Str("address", m.cfg.ENSGaslessAddress).
339
+ Msg("ens gasless dns import configured")
340
+ })
341
+ }
342
343
publicIP, err := utils.ResolvePublicIPv4(ctx)
344
if err != nil {
@@ -296,6 +348,56 @@ func (m *Manager) syncDNS(ctx context.Context) error {
348
return m.dns.EnsureARecords(ctx, m.cfg.BaseDomain, publicIP)
349
}
350
351
+func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address string) error {
352
+ if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
353
+ return nil
354
+ }
355
+ if m.dns == nil {
356
+ return errors.New("acme dns provider is required")
357
+ }
358
+
359
+ hostname = utils.NormalizeHostname(hostname)
360
+ if hostname == "" {
361
+ return errors.New("hostname is required")
362
+ }
363
+ if !hostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
364
+ return fmt.Errorf("hostname %q is outside acme base domain %q", hostname, m.cfg.BaseDomain)
365
+ }
366
+
367
+ address, err := utils.NormalizeEVMAddress(address)
368
+ if err != nil {
369
+ return fmt.Errorf("normalize ens gasless address: %w", err)
370
+ }
371
+ return m.dns.EnsureTXTRecord(ctx, hostname, gaslessENSTXTPrefix+defaultENSGaslessResolver+" "+strings.TrimSpace(address))
372
+}
373
+
374
+func (m *Manager) DeleteENSGaslessHostname(ctx context.Context, hostname string) error {
375
+ if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
376
+ return nil
377
+ }
378
+ if m.dns == nil {
379
+ return errors.New("acme dns provider is required")
380
+ }
381
+
382
+ hostname = utils.NormalizeHostname(hostname)
383
+ if hostname == "" {
384
+ return nil
385
+ }
386
+ if !hostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
387
+ return nil
388
+ }
389
+ return m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix)
390
+}
391
+
392
+func hostnameMatchesBaseDomain(hostname, baseDomain string) bool {
393
+ hostname = utils.NormalizeHostname(hostname)
394
+ baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
395
+ if hostname == "" || baseDomain == "" {
396
+ return false
397
+ }
398
+ return hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)
399
+}
400
+
401
func (m *Manager) shouldRenew() bool {
402
m.mu.RLock()
403
defer m.mu.RUnlock()
portal/acme/cloudflare/provider.go
+194
-14
@@ -11,6 +11,7 @@ import (
11
"github.com/go-acme/lego/v4/challenge"
12
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
13
14
+ "github.com/gosuda/portal/v2/types"
15
"github.com/gosuda/portal/v2/utils"
16
)
17
@@ -57,6 +58,17 @@ type recordResult struct {
58
Success bool `json:"success"`
59
}
60
61
+type dnssecDetails struct {
62
+ DS string `json:"ds"`
63
+ Status string `json:"status"`
64
+}
65
+
66
+type dnssecResult struct {
67
+ Result dnssecDetails `json:"result"`
68
+ Errors []apiError `json:"errors"`
69
+ Success bool `json:"success"`
70
+}
71
+
72
func New(token string) *Provider {
73
return &Provider{token: strings.TrimSpace(token)}
74
}
@@ -105,13 +117,119 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
117
}
118
119
for _, name := range []string{baseDomain, "*." + baseDomain} {
108
- if err := ensureARecord(ctx, p.token, zoneID, name, publicIPv4); err != nil {
120
+ if err := ensureDNSRecord(ctx, p.token, zoneID, name, "A", publicIPv4); err != nil {
121
return fmt.Errorf("ensure A record for %s: %w", name, err)
122
}
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("cloudflare provider is nil")
130
+ }
131
+ name = utils.NormalizeHostname(name)
132
+ if name == "" {
133
+ return errors.New("record name is required")
134
+ }
135
+ if p.token == "" {
136
+ return errors.New("cloudflare token is required")
137
+ }
138
+ value = strings.TrimSpace(value)
139
+ if value == "" {
140
+ return errors.New("txt record value is required")
141
+ }
142
+
143
+ zoneID, err := findZoneID(ctx, p.token, name)
144
+ if err != nil {
145
+ return fmt.Errorf("find cloudflare zone: %w", err)
146
+ }
147
+ if err := ensureTXTRecord(ctx, p.token, zoneID, name, value); err != nil {
148
+ return fmt.Errorf("ensure TXT record for %s: %w", name, err)
149
+ }
150
+ return nil
151
+}
152
+
153
+func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
154
+ if p == nil {
155
+ return errors.New("cloudflare provider is nil")
156
+ }
157
+ name = utils.NormalizeHostname(name)
158
+ if name == "" {
159
+ return errors.New("record name is required")
160
+ }
161
+ if p.token == "" {
162
+ return errors.New("cloudflare token is required")
163
+ }
164
+ matchPrefix = strings.TrimSpace(matchPrefix)
165
+ if matchPrefix == "" {
166
+ return errors.New("txt record match prefix is required")
167
+ }
168
+
169
+ zoneID, err := findZoneID(ctx, p.token, name)
170
+ if err != nil {
171
+ return fmt.Errorf("find cloudflare zone: %w", err)
172
+ }
173
+
174
+ records, err := listDNSRecords(ctx, p.token, zoneID, name, "TXT")
175
+ if err != nil {
176
+ return err
177
+ }
178
+ for _, record := range records {
179
+ if !strings.EqualFold(record.Name, name) || !strings.HasPrefix(strings.TrimSpace(record.Content), matchPrefix) {
180
+ continue
181
+ }
182
+ if err := deleteDNSRecord(ctx, p.token, zoneID, record.ID); err != nil {
183
+ return fmt.Errorf("delete TXT record %s: %w", name, err)
184
+ }
185
+ }
186
+ return nil
187
+}
188
+
189
+func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (types.DNSSECStatus, error) {
190
+ if p == nil {
191
+ return types.DNSSECStatus{}, errors.New("cloudflare provider is nil")
192
+ }
193
+ baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
194
+ if baseDomain == "" {
195
+ return types.DNSSECStatus{}, errors.New("base domain is required")
196
+ }
197
+ if p.token == "" {
198
+ return types.DNSSECStatus{}, errors.New("cloudflare token is required")
199
+ }
200
+
201
+ zoneID, err := findZoneID(ctx, p.token, baseDomain)
202
+ if err != nil {
203
+ return types.DNSSECStatus{}, fmt.Errorf("find cloudflare zone: %w", err)
204
+ }
205
+
206
+ details, err := getDNSSEC(ctx, p.token, zoneID)
207
+ if err != nil {
208
+ return types.DNSSECStatus{}, fmt.Errorf("get cloudflare dnssec status: %w", err)
209
+ }
210
+
211
+ switch strings.ToLower(strings.TrimSpace(details.Status)) {
212
+ case "active", "pending":
213
+ default:
214
+ if err := enableDNSSEC(ctx, p.token, zoneID); err != nil {
215
+ return types.DNSSECStatus{}, fmt.Errorf("enable cloudflare dnssec: %w", err)
216
+ }
217
+ details, err = getDNSSEC(ctx, p.token, zoneID)
218
+ if err != nil {
219
+ return types.DNSSECStatus{}, fmt.Errorf("refresh cloudflare dnssec status: %w", err)
220
+ }
221
+ }
222
+
223
+ status := types.DNSSECStatus{
224
+ State: strings.TrimSpace(details.Status),
225
+ DSRecord: strings.TrimSpace(details.DS),
226
+ }
227
+ if status.DSRecord != "" {
228
+ status.Message = "publish the DS record at the registrar if Cloudflare Registrar does not manage the zone"
229
+ }
230
+ return status, nil
231
+}
232
+
233
func findZoneID(ctx context.Context, token, domain string) (string, error) {
234
parts := strings.Split(domain, ".")
235
for i := range len(parts) - 1 {
@@ -129,8 +247,8 @@ func findZoneID(ctx context.Context, token, domain string) (string, error) {
247
return "", fmt.Errorf("no cloudflare zone found for %s", domain)
248
}
249
132
-func ensureARecord(ctx context.Context, token, zoneID, name, ip string) error {
133
- records, err := listDNSRecords(ctx, token, zoneID, name, "A")
250
+func ensureDNSRecord(ctx context.Context, token, zoneID, name, recordType, content string) error {
251
+ records, err := listDNSRecords(ctx, token, zoneID, name, recordType)
252
if err != nil {
253
return err
254
}
@@ -139,13 +257,29 @@ func ensureARecord(ctx context.Context, token, zoneID, name, ip string) error {
257
if !strings.EqualFold(record.Name, name) {
258
continue
259
}
142
- if record.Content == ip {
260
+ if record.Content == content {
261
return nil
262
}
145
- return updateDNSRecord(ctx, token, zoneID, record.ID, name, ip)
263
+ return updateDNSRecord(ctx, token, zoneID, record.ID, recordType, name, content)
264
}
265
148
- return createDNSRecord(ctx, token, zoneID, name, ip)
266
+ return createDNSRecord(ctx, token, zoneID, recordType, name, content)
267
+}
268
+
269
+func ensureTXTRecord(ctx context.Context, token, zoneID, name, value string) error {
270
+ records, err := listDNSRecords(ctx, token, zoneID, name, "TXT")
271
+ if err != nil {
272
+ return err
273
+ }
274
+ for _, record := range records {
275
+ if !strings.EqualFold(record.Name, name) {
276
+ continue
277
+ }
278
+ if strings.TrimSpace(record.Content) == value {
279
+ return nil
280
+ }
281
+ }
282
+ return createDNSRecord(ctx, token, zoneID, "TXT", name, value)
283
}
284
285
func listZones(ctx context.Context, token, name string) ([]zone, error) {
@@ -181,14 +315,45 @@ func listDNSRecords(ctx context.Context, token, zoneID, name, recordType string)
315
return out.Result, nil
316
}
317
184
-func createDNSRecord(ctx context.Context, token, zoneID, name, ip string) error {
318
+func getDNSSEC(ctx context.Context, token, zoneID string) (dnssecDetails, error) {
319
+ endpoint := fmt.Sprintf("%s/zones/%s/dnssec", apiBase, zoneID)
320
+
321
+ var out dnssecResult
322
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodGet, endpoint, nil, cloudflareHeaders(token), &out); err != nil {
323
+ return dnssecDetails{}, err
324
+ }
325
+ if !out.Success {
326
+ return dnssecDetails{}, wrapErrors(out.Errors)
327
+ }
328
+ return out.Result, nil
329
+}
330
+
331
+func enableDNSSEC(ctx context.Context, token, zoneID string) error {
332
+ endpoint := fmt.Sprintf("%s/zones/%s/dnssec", apiBase, zoneID)
333
+ body := map[string]any{
334
+ "status": "active",
335
+ }
336
+
337
+ var out dnssecResult
338
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodPatch, endpoint, body, cloudflareHeaders(token), &out); err != nil {
339
+ return err
340
+ }
341
+ if !out.Success {
342
+ return wrapErrors(out.Errors)
343
+ }
344
+ return nil
345
+}
346
+
347
+func createDNSRecord(ctx context.Context, token, zoneID, recordType, name, content string) error {
348
endpoint := fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID)
349
body := map[string]any{
187
- "type": "A",
350
+ "type": recordType,
351
"name": name,
189
- "content": ip,
352
+ "content": content,
353
"ttl": 1,
191
- "proxied": false,
354
+ }
355
+ if strings.EqualFold(recordType, "A") {
356
+ body["proxied"] = false
357
}
358
359
var out recordResult
@@ -201,14 +366,16 @@ func createDNSRecord(ctx context.Context, token, zoneID, name, ip string) error
366
return nil
367
}
368
204
-func updateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip string) error {
369
+func updateDNSRecord(ctx context.Context, token, zoneID, recordID, recordType, name, content string) error {
370
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
371
body := map[string]any{
207
- "type": "A",
372
+ "type": recordType,
373
"name": name,
209
- "content": ip,
374
+ "content": content,
375
"ttl": 1,
211
- "proxied": false,
376
+ }
377
+ if strings.EqualFold(recordType, "A") {
378
+ body["proxied"] = false
379
}
380
381
var out recordResult
@@ -221,6 +388,19 @@ func updateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip stri
388
return nil
389
}
390
391
+func deleteDNSRecord(ctx context.Context, token, zoneID, recordID string) error {
392
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
393
+
394
+ var out recordResult
395
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodDelete, endpoint, nil, cloudflareHeaders(token), &out); err != nil {
396
+ return err
397
+ }
398
+ if !out.Success {
399
+ return wrapErrors(out.Errors)
400
+ }
401
+ return nil
402
+}
403
+
404
func cloudflareHeaders(token string) http.Header {
405
return http.Header{
406
"Authorization": []string{"Bearer " + token},
portal/acme/provider.go
+8
@@ -9,6 +9,7 @@ import (
9
10
"github.com/gosuda/portal/v2/portal/acme/cloudflare"
11
"github.com/gosuda/portal/v2/portal/acme/route53"
12
+ "github.com/gosuda/portal/v2/types"
13
)
14
15
const (
@@ -24,12 +25,17 @@ type DNSProviderConfig struct {
25
AWSSessionToken string
26
AWSRegion string
27
AWSHostedZoneID string
28
+ AWSKMSKeyARN string
29
+ DNSSECKSKName string
30
}
31
32
type DNSProvider interface {
33
Name() string
34
ChallengeProvider(ctx context.Context) (challenge.Provider, error)
35
EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error
36
+ EnsureTXTRecord(ctx context.Context, name, value string) error
37
+ DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error
38
+ EnsureDNSSEC(ctx context.Context, baseDomain string) (types.DNSSECStatus, error)
39
}
40
41
func NewDNSProvider(cfg DNSProviderConfig) (DNSProvider, error) {
@@ -43,6 +49,8 @@ func NewDNSProvider(cfg DNSProviderConfig) (DNSProvider, error) {
49
SessionToken: cfg.AWSSessionToken,
50
Region: cfg.AWSRegion,
51
HostedZoneID: cfg.AWSHostedZoneID,
52
+ KMSKeyARN: cfg.AWSKMSKeyARN,
53
+ DNSSECKSKName: cfg.DNSSECKSKName,
54
}), nil
55
default:
56
return nil, fmt.Errorf("unsupported acme dns provider: %q", cfg.Type)
portal/acme/route53/provider.go
+357
-13
@@ -5,20 +5,26 @@ import (
5
"errors"
6
"fmt"
7
"net"
8
+ "strconv"
9
"strings"
10
+ "time"
11
12
"github.com/aws/aws-sdk-go-v2/aws"
13
"github.com/aws/aws-sdk-go-v2/config"
14
"github.com/aws/aws-sdk-go-v2/credentials"
15
awsroute53 "github.com/aws/aws-sdk-go-v2/service/route53"
14
- "github.com/aws/aws-sdk-go-v2/service/route53/types"
16
+ route53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
17
"github.com/go-acme/lego/v4/challenge"
18
"github.com/go-acme/lego/v4/providers/dns/route53"
19
20
+ "github.com/gosuda/portal/v2/types"
21
"github.com/gosuda/portal/v2/utils"
22
)
23
21
-const defaultAWSRegion = "us-east-1"
24
+const (
25
+ defaultAWSRegion = "us-east-1"
26
+ defaultDNSSECKSKName = "portal_ksk"
27
+)
28
29
type Config struct {
30
AccessKeyID string
@@ -26,6 +32,8 @@ type Config struct {
32
SessionToken string
33
Region string
34
HostedZoneID string
35
+ KMSKeyARN string
36
+ DNSSECKSKName string
37
}
38
39
type Provider struct {
@@ -40,6 +48,8 @@ func New(cfg Config) *Provider {
48
SessionToken: strings.TrimSpace(cfg.SessionToken),
49
Region: strings.TrimSpace(cfg.Region),
50
HostedZoneID: normalizeZoneID(cfg.HostedZoneID),
51
+ KMSKeyARN: strings.TrimSpace(cfg.KMSKeyARN),
52
+ DNSSECKSKName: strings.TrimSpace(cfg.DNSSECKSKName),
53
},
54
}
55
}
@@ -100,6 +110,116 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
110
return nil
111
}
112
113
+func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
114
+ if p == nil {
115
+ return errors.New("route53 provider is nil")
116
+ }
117
+ name = utils.NormalizeHostname(name)
118
+ if name == "" {
119
+ return errors.New("record name is required")
120
+ }
121
+ value = strings.TrimSpace(value)
122
+ if value == "" {
123
+ return errors.New("txt record value is required")
124
+ }
125
+
126
+ client, err := newClient(ctx, p.cfg)
127
+ if err != nil {
128
+ return err
129
+ }
130
+
131
+ hostedZoneID, err := findHostedZoneID(ctx, client, name, p.cfg.HostedZoneID)
132
+ if err != nil {
133
+ return err
134
+ }
135
+ if err := ensureTXTRecord(ctx, client, hostedZoneID, name, value); err != nil {
136
+ return fmt.Errorf("upsert route53 TXT record %s: %w", name, err)
137
+ }
138
+ return nil
139
+}
140
+
141
+func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
142
+ if p == nil {
143
+ return errors.New("route53 provider is nil")
144
+ }
145
+ name = utils.NormalizeHostname(name)
146
+ if name == "" {
147
+ return errors.New("record name is required")
148
+ }
149
+ matchPrefix = strings.TrimSpace(matchPrefix)
150
+ if matchPrefix == "" {
151
+ return errors.New("txt record match prefix is required")
152
+ }
153
+
154
+ client, err := newClient(ctx, p.cfg)
155
+ if err != nil {
156
+ return err
157
+ }
158
+
159
+ hostedZoneID, err := findHostedZoneID(ctx, client, name, p.cfg.HostedZoneID)
160
+ if err != nil {
161
+ return err
162
+ }
163
+ if err := deleteTXTRecords(ctx, client, hostedZoneID, name, matchPrefix); err != nil {
164
+ return fmt.Errorf("delete route53 TXT records %s: %w", name, err)
165
+ }
166
+ return nil
167
+}
168
+
169
+func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (types.DNSSECStatus, error) {
170
+ if p == nil {
171
+ return types.DNSSECStatus{}, errors.New("route53 provider is nil")
172
+ }
173
+ baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
174
+ if baseDomain == "" {
175
+ return types.DNSSECStatus{}, errors.New("base domain is required")
176
+ }
177
+
178
+ client, err := newClient(ctx, p.cfg)
179
+ if err != nil {
180
+ return types.DNSSECStatus{}, err
181
+ }
182
+
183
+ hostedZoneID, err := findHostedZoneID(ctx, client, baseDomain, p.cfg.HostedZoneID)
184
+ if err != nil {
185
+ return types.DNSSECStatus{}, err
186
+ }
187
+
188
+ out, err := getDNSSECStatus(ctx, client, hostedZoneID)
189
+ if err != nil {
190
+ return types.DNSSECStatus{}, fmt.Errorf("get route53 dnssec status: %w", err)
191
+ }
192
+ status := dnssecStatusFromOutput(out)
193
+ if strings.EqualFold(status.State, "SIGNING") {
194
+ return status, nil
195
+ }
196
+
197
+ if _, ok := activeKeySigningKey(out.KeySigningKeys); !ok {
198
+ if err := ensureActiveKeySigningKey(ctx, client, hostedZoneID, p.cfg, out.KeySigningKeys); err != nil {
199
+ return types.DNSSECStatus{}, err
200
+ }
201
+ out, err = getDNSSECStatus(ctx, client, hostedZoneID)
202
+ if err != nil {
203
+ return types.DNSSECStatus{}, fmt.Errorf("refresh route53 dnssec status: %w", err)
204
+ }
205
+ if _, ok := activeKeySigningKey(out.KeySigningKeys); !ok {
206
+ return types.DNSSECStatus{}, errors.New("route53 dnssec requires an ACTIVE key-signing key")
207
+ }
208
+ }
209
+
210
+ if _, err := client.EnableHostedZoneDNSSEC(ctx, &awsroute53.EnableHostedZoneDNSSECInput{
211
+ HostedZoneId: aws.String(hostedZoneID),
212
+ }); err != nil {
213
+ return types.DNSSECStatus{}, fmt.Errorf("enable route53 dnssec: %w", err)
214
+ }
215
+
216
+ out, err = getDNSSECStatus(ctx, client, hostedZoneID)
217
+ if err != nil {
218
+ return types.DNSSECStatus{}, fmt.Errorf("refresh route53 dnssec status: %w", err)
219
+ }
220
+ return dnssecStatusFromOutput(out), nil
221
+}
222
+
223
func newClient(ctx context.Context, cfg Config) (*awsroute53.Client, error) {
224
if err := validateConfig(cfg); err != nil {
225
return nil, err
@@ -164,6 +284,65 @@ func findHostedZoneID(ctx context.Context, client *awsroute53.Client, domain, ex
284
}
285
286
func upsertARecord(ctx context.Context, client *awsroute53.Client, hostedZoneID, name, ip string) error {
287
+ return upsertRecord(ctx, client, hostedZoneID, name, route53types.RRTypeA, []string{strings.TrimSpace(ip)}, "Managed by Portal ACME")
288
+}
289
+
290
+func upsertTXTRecord(ctx context.Context, client *awsroute53.Client, hostedZoneID, name, value string) error {
291
+ return upsertRecord(ctx, client, hostedZoneID, name, route53types.RRTypeTxt, []string{route53TXTValue(value)}, "Managed by Portal ENS")
292
+}
293
+
294
+func ensureTXTRecord(ctx context.Context, client *awsroute53.Client, hostedZoneID, name, value string) error {
295
+ recordSet, err := getTXTRecordSet(ctx, client, hostedZoneID, name)
296
+ if err != nil {
297
+ return err
298
+ }
299
+ if recordSet == nil {
300
+ return upsertTXTRecord(ctx, client, hostedZoneID, name, value)
301
+ }
302
+
303
+ for _, record := range recordSet.ResourceRecords {
304
+ if route53TXTContent(aws.ToString(record.Value)) == value {
305
+ return nil
306
+ }
307
+ }
308
+
309
+ values := make([]string, 0, len(recordSet.ResourceRecords)+1)
310
+ for _, record := range recordSet.ResourceRecords {
311
+ values = append(values, aws.ToString(record.Value))
312
+ }
313
+ values = append(values, route53TXTValue(value))
314
+ return upsertRecord(ctx, client, hostedZoneID, name, route53types.RRTypeTxt, values, "Managed by Portal ENS")
315
+}
316
+
317
+func deleteTXTRecords(ctx context.Context, client *awsroute53.Client, hostedZoneID, name, matchPrefix string) error {
318
+ recordSet, err := getTXTRecordSet(ctx, client, hostedZoneID, name)
319
+ if err != nil {
320
+ return err
321
+ }
322
+ if recordSet == nil {
323
+ return nil
324
+ }
325
+
326
+ remaining := make([]string, 0, len(recordSet.ResourceRecords))
327
+ removed := false
328
+ for _, record := range recordSet.ResourceRecords {
329
+ value := aws.ToString(record.Value)
330
+ if strings.HasPrefix(route53TXTContent(value), matchPrefix) {
331
+ removed = true
332
+ continue
333
+ }
334
+ remaining = append(remaining, value)
335
+ }
336
+ if !removed {
337
+ return nil
338
+ }
339
+ if len(remaining) == 0 {
340
+ return deleteRecordSet(ctx, client, hostedZoneID, recordSet, "Managed by Portal ENS cleanup")
341
+ }
342
+ return upsertRecord(ctx, client, hostedZoneID, name, route53types.RRTypeTxt, remaining, "Managed by Portal ENS cleanup")
343
+}
344
+
345
+func upsertRecord(ctx context.Context, client *awsroute53.Client, hostedZoneID, name string, recordType route53types.RRType, values []string, comment string) error {
346
if client == nil {
347
return errors.New("route53 client is nil")
348
}
@@ -175,22 +354,25 @@ func upsertARecord(ctx context.Context, client *awsroute53.Client, hostedZoneID,
354
if !strings.HasSuffix(fqdn, ".") {
355
fqdn += "."
356
}
178
- recordSet := &types.ResourceRecordSet{
179
- Name: aws.String(fqdn),
180
- Type: types.RRTypeA,
181
- TTL: aws.Int64(60),
182
- ResourceRecords: []types.ResourceRecord{
183
- {Value: aws.String(strings.TrimSpace(ip))},
184
- },
357
+ recordSet := &route53types.ResourceRecordSet{
358
+ Name: aws.String(fqdn),
359
+ Type: recordType,
360
+ TTL: aws.Int64(60),
361
+ ResourceRecords: make([]route53types.ResourceRecord, 0, len(values)),
362
+ }
363
+ for _, value := range values {
364
+ recordSet.ResourceRecords = append(recordSet.ResourceRecords, route53types.ResourceRecord{
365
+ Value: aws.String(strings.TrimSpace(value)),
366
+ })
367
}
368
369
_, err := client.ChangeResourceRecordSets(ctx, &awsroute53.ChangeResourceRecordSetsInput{
370
HostedZoneId: aws.String(hostedZoneID),
189
- ChangeBatch: &types.ChangeBatch{
190
- Comment: aws.String("Managed by Portal ACME"),
191
- Changes: []types.Change{
371
+ ChangeBatch: &route53types.ChangeBatch{
372
+ Comment: aws.String(comment),
373
+ Changes: []route53types.Change{
374
{
193
- Action: types.ChangeActionUpsert,
375
+ Action: route53types.ChangeActionUpsert,
376
ResourceRecordSet: recordSet,
377
},
378
},
@@ -202,6 +384,68 @@ func upsertARecord(ctx context.Context, client *awsroute53.Client, hostedZoneID,
384
return nil
385
}
386
387
+func route53TXTValue(value string) string {
388
+ return strconv.Quote(strings.TrimSpace(value))
389
+}
390
+
391
+func route53TXTContent(value string) string {
392
+ unquoted, err := strconv.Unquote(strings.TrimSpace(value))
393
+ if err == nil {
394
+ return unquoted
395
+ }
396
+ return strings.Trim(strings.TrimSpace(value), "\"")
397
+}
398
+
399
+func getTXTRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneID, name string) (*route53types.ResourceRecordSet, error) {
400
+ if client == nil {
401
+ return nil, errors.New("route53 client is nil")
402
+ }
403
+ fqdn := utils.NormalizeHostname(name)
404
+ if !strings.HasSuffix(fqdn, ".") {
405
+ fqdn += "."
406
+ }
407
+
408
+ out, err := client.ListResourceRecordSets(ctx, &awsroute53.ListResourceRecordSetsInput{
409
+ HostedZoneId: aws.String(hostedZoneID),
410
+ StartRecordName: aws.String(fqdn),
411
+ StartRecordType: route53types.RRTypeTxt,
412
+ MaxItems: aws.Int32(1),
413
+ })
414
+ if err != nil {
415
+ return nil, err
416
+ }
417
+ if len(out.ResourceRecordSets) == 0 {
418
+ return nil, nil
419
+ }
420
+ recordSet := out.ResourceRecordSets[0]
421
+ if !strings.EqualFold(strings.TrimSpace(aws.ToString(recordSet.Name)), fqdn) || recordSet.Type != route53types.RRTypeTxt {
422
+ return nil, nil
423
+ }
424
+ return &recordSet, nil
425
+}
426
+
427
+func deleteRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneID string, recordSet *route53types.ResourceRecordSet, comment string) error {
428
+ if client == nil {
429
+ return errors.New("route53 client is nil")
430
+ }
431
+ if recordSet == nil {
432
+ return nil
433
+ }
434
+ _, err := client.ChangeResourceRecordSets(ctx, &awsroute53.ChangeResourceRecordSetsInput{
435
+ HostedZoneId: aws.String(hostedZoneID),
436
+ ChangeBatch: &route53types.ChangeBatch{
437
+ Comment: aws.String(comment),
438
+ Changes: []route53types.Change{
439
+ {
440
+ Action: route53types.ChangeActionDelete,
441
+ ResourceRecordSet: recordSet,
442
+ },
443
+ },
444
+ },
445
+ })
446
+ return err
447
+}
448
+
449
func validateIPv4(raw string) error {
450
ip := net.ParseIP(strings.TrimSpace(raw))
451
if ip == nil || ip.To4() == nil {
@@ -252,3 +496,103 @@ func normalizeZoneID(raw string) string {
496
trimmed := strings.TrimSpace(raw)
497
return strings.TrimPrefix(trimmed, "/hostedzone/")
498
}
499
+
500
+func getDNSSECStatus(ctx context.Context, client *awsroute53.Client, hostedZoneID string) (*awsroute53.GetDNSSECOutput, error) {
501
+ if client == nil {
502
+ return nil, errors.New("route53 client is nil")
503
+ }
504
+ if hostedZoneID == "" {
505
+ return nil, errors.New("hosted zone id is required")
506
+ }
507
+ return client.GetDNSSEC(ctx, &awsroute53.GetDNSSECInput{
508
+ HostedZoneId: aws.String(hostedZoneID),
509
+ })
510
+}
511
+
512
+func ensureActiveKeySigningKey(ctx context.Context, client *awsroute53.Client, hostedZoneID string, cfg Config, keys []route53types.KeySigningKey) error {
513
+ if client == nil {
514
+ return errors.New("route53 client is nil")
515
+ }
516
+ kskName := strings.TrimSpace(cfg.DNSSECKSKName)
517
+ if kskName == "" {
518
+ kskName = defaultDNSSECKSKName
519
+ }
520
+
521
+ if existing, ok := keySigningKeyByName(keys, kskName); ok {
522
+ if strings.EqualFold(strings.TrimSpace(aws.ToString(existing.Status)), "ACTIVE") {
523
+ return nil
524
+ }
525
+ _, err := client.ActivateKeySigningKey(ctx, &awsroute53.ActivateKeySigningKeyInput{
526
+ HostedZoneId: aws.String(hostedZoneID),
527
+ Name: aws.String(kskName),
528
+ })
529
+ if err != nil {
530
+ return fmt.Errorf("activate route53 key-signing key %q: %w", kskName, err)
531
+ }
532
+ return nil
533
+ }
534
+
535
+ if strings.TrimSpace(cfg.KMSKeyARN) == "" {
536
+ return errors.New("route53 dnssec requires AWS_DNSSEC_KMS_KEY_ARN when no active key-signing key exists")
537
+ }
538
+
539
+ _, err := client.CreateKeySigningKey(ctx, &awsroute53.CreateKeySigningKeyInput{
540
+ CallerReference: aws.String(fmt.Sprintf("portal-%d", time.Now().UTC().UnixNano())),
541
+ HostedZoneId: aws.String(hostedZoneID),
542
+ KeyManagementServiceArn: aws.String(cfg.KMSKeyARN),
543
+ Name: aws.String(kskName),
544
+ Status: aws.String("ACTIVE"),
545
+ })
546
+ if err != nil {
547
+ var alreadyExists *route53types.KeySigningKeyAlreadyExists
548
+ if errors.As(err, &alreadyExists) {
549
+ return nil
550
+ }
551
+ return fmt.Errorf("create route53 key-signing key %q: %w", kskName, err)
552
+ }
553
+ return nil
554
+}
555
+
556
+func dnssecStatusFromOutput(out *awsroute53.GetDNSSECOutput) types.DNSSECStatus {
557
+ if out == nil {
558
+ return types.DNSSECStatus{}
559
+ }
560
+
561
+ status := types.DNSSECStatus{}
562
+ if out.Status != nil {
563
+ status.State = strings.TrimSpace(aws.ToString(out.Status.ServeSignature))
564
+ status.Message = strings.TrimSpace(aws.ToString(out.Status.StatusMessage))
565
+ }
566
+ if active, ok := activeKeySigningKey(out.KeySigningKeys); ok {
567
+ status.DSRecord = strings.TrimSpace(aws.ToString(active.DSRecord))
568
+ } else {
569
+ for _, key := range out.KeySigningKeys {
570
+ if strings.TrimSpace(aws.ToString(key.DSRecord)) != "" {
571
+ status.DSRecord = strings.TrimSpace(aws.ToString(key.DSRecord))
572
+ break
573
+ }
574
+ }
575
+ }
576
+ if status.Message == "" && status.DSRecord != "" {
577
+ status.Message = "publish the DS record at the registrar after Route53 zone signing is enabled"
578
+ }
579
+ return status
580
+}
581
+
582
+func activeKeySigningKey(keys []route53types.KeySigningKey) (route53types.KeySigningKey, bool) {
583
+ for _, key := range keys {
584
+ if strings.EqualFold(strings.TrimSpace(aws.ToString(key.Status)), "ACTIVE") {
585
+ return key, true
586
+ }
587
+ }
588
+ return route53types.KeySigningKey{}, false
589
+}
590
+
591
+func keySigningKeyByName(keys []route53types.KeySigningKey, name string) (route53types.KeySigningKey, bool) {
592
+ for _, key := range keys {
593
+ if strings.EqualFold(strings.TrimSpace(aws.ToString(key.Name)), name) {
594
+ return key, true
595
+ }
596
+ }
597
+ return route53types.KeySigningKey{}, false
598
+}
portal/api_server.go
+16
@@ -357,6 +357,15 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
357
leaseLookupError(err).Write(w)
358
return
359
}
360
+ deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
361
+ defer cancel()
362
+ if err := s.deleteLeaseENSGasless(deleteCtx, record); err != nil {
363
+ log.Warn().
364
+ Err(err).
365
+ Str("hostname", record.Hostname).
366
+ Str("address", record.Address).
367
+ Msg("delete lease ens gasless txt")
368
+ }
369
if record != nil {
370
record.Close()
371
}
@@ -591,6 +600,13 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
600
record.Close()
601
return types.RegisterResponse{}, err
602
}
603
+ syncCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
604
+ defer cancel()
605
+ if err := s.syncLeaseENSGasless(syncCtx, record); err != nil {
606
+ _, _ = s.registry.Unregister(record.Copy())
607
+ record.Close()
608
+ return types.RegisterResponse{}, err
609
+ }
610
611
resp := types.RegisterResponse{
612
Identity: record.Copy(),
portal/lease.go
+6
-3
@@ -52,7 +52,7 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
52
return out
53
}
54
55
-func (r *leaseRegistry) RunJanitor(ctx context.Context, interval time.Duration) error {
55
+func (r *leaseRegistry) RunJanitor(ctx context.Context, interval time.Duration, onExpired func(*leaseRecord)) error {
56
if interval <= 0 {
57
return errors.New("janitor interval must be positive")
58
}
@@ -65,7 +65,7 @@ func (r *leaseRegistry) RunJanitor(ctx context.Context, interval time.Duration)
65
case <-ctx.Done():
66
return nil
67
case <-ticker.C:
68
- r.cleanupExpired(time.Now())
68
+ r.cleanupExpired(time.Now(), onExpired)
69
}
70
}
71
}
@@ -246,7 +246,7 @@ func (r *leaseRegistry) Touch(identity types.Identity, clientIP string, now time
246
return record
247
}
248
249
-func (r *leaseRegistry) cleanupExpired(now time.Time) {
249
+func (r *leaseRegistry) cleanupExpired(now time.Time, onExpired func(*leaseRecord)) {
250
expiredLeases := r.removeExpired(now)
251
r.mu.Lock()
252
for challengeID, challenge := range r.registerChallenges {
@@ -256,6 +256,9 @@ func (r *leaseRegistry) cleanupExpired(now time.Time) {
256
}
257
r.mu.Unlock()
258
for _, lease := range expiredLeases {
259
+ if onExpired != nil {
260
+ onExpired(lease)
261
+ }
262
lease.Close()
263
}
264
}
portal/lease_test.go
+1
-1
@@ -181,7 +181,7 @@ func TestLeaseRegistryRunJanitorRejectsNonPositiveInterval(t *testing.T) {
181
t.Parallel()
182
183
registry := newLeaseRegistry(policy.NewRuntime())
184
- err := registry.RunJanitor(context.Background(), 0)
184
+ err := registry.RunJanitor(context.Background(), 0, nil)
185
if err == nil {
186
t.Fatal("RunJanitor() error = nil, want validation error")
187
}
portal/server.go
+33
-1
@@ -227,7 +227,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
227
group.Go(s.overlay.Serve)
228
}
229
group.Go(func() error { return s.runSNIListener(groupCtx) })
230
- group.Go(func() error { return s.registry.RunJanitor(groupCtx, 5*time.Second) })
230
+ group.Go(func() error { return s.registry.RunJanitor(groupCtx, 5*time.Second, s.cleanupExpiredLeaseDNS) })
231
if s.cfg.DiscoveryEnabled {
232
group.Go(func() error { return s.runRelayDiscoveryLoop(groupCtx) })
233
}
@@ -398,6 +398,9 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
398
return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("acme base domain %q does not match portal root host %q", acmeCfg.BaseDomain, s.identity.Name)
399
}
400
acmeCfg.BaseDomain = s.identity.Name
401
+ if strings.TrimSpace(acmeCfg.ENSGaslessAddress) == "" {
402
+ acmeCfg.ENSGaslessAddress = s.identity.Address
403
+ }
404
405
manager, err := acme.NewManager(acmeCfg)
406
if err != nil {
@@ -426,6 +429,35 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
429
return apiTLS, manager, nil
430
}
431
432
+func (s *Server) syncLeaseENSGasless(ctx context.Context, lease *leaseRecord) error {
433
+ if s == nil || s.acmeManager == nil || lease == nil {
434
+ return nil
435
+ }
436
+ return s.acmeManager.SyncENSGaslessHostname(ctx, lease.Hostname, lease.Address)
437
+}
438
+
439
+func (s *Server) deleteLeaseENSGasless(ctx context.Context, lease *leaseRecord) error {
440
+ if s == nil || s.acmeManager == nil || lease == nil {
441
+ return nil
442
+ }
443
+ return s.acmeManager.DeleteENSGaslessHostname(ctx, lease.Hostname)
444
+}
445
+
446
+func (s *Server) cleanupExpiredLeaseDNS(lease *leaseRecord) {
447
+ if s == nil || lease == nil {
448
+ return
449
+ }
450
+ ctx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
451
+ defer cancel()
452
+ if err := s.deleteLeaseENSGasless(ctx, lease); err != nil {
453
+ log.Warn().
454
+ Err(err).
455
+ Str("hostname", lease.Hostname).
456
+ Str("address", lease.Address).
457
+ Msg("delete expired lease ens gasless txt")
458
+ }
459
+}
460
+
461
func (s *Server) runSNIListener(ctx context.Context) error {
462
for {
463
conn, err := s.sniListener.Accept()
types/identity.go
+6
@@ -111,3 +111,9 @@ type DesiredPeer struct {
111
WireGuardEndpoint string `json:"wireguard_endpoint"`
112
AllowedIPs []string `json:"allowed_ips,omitempty"`
113
}
114
+
115
+type DNSSECStatus struct {
116
+ State string `json:"state,omitempty"`
117
+ DSRecord string `json:"ds_record,omitempty"`
118
+ Message string `json:"message,omitempty"`
119
+}