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 +}