feat: add Vultr DNS provider support for ACME automation

Kim committed May 14, 2026 at 23:15 UTC dc0d74d37157e8fc04326ead8421c45007ba6324
18 files changed +668 -15
.env.example
+4 -1
@@ -15,7 +15,7 @@ UDP_ENABLED=false
15 TCP_ENABLED=false
16
17
18 -# Supported managed values: cloudflare, gcloud, route53.
18 +# Supported managed values: cloudflare, gcloud, route53, vultr.
19 # Reused for ACME DNS-01, managed A records, ECH HTTPS records, and optional ENS DNS automation.
20 ACME_DNS_PROVIDER=
21
@@ -37,6 +37,9 @@ AWS_HOSTED_ZONE_ID=
37 # Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
38 AWS_DNSSEC_KMS_KEY_ARN=
39
40 +# Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr)
41 +VULTR_API_KEY=
42 +
43 # ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
44 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
45 ENS_GASLESS_ENABLED=false
cmd/relay-server/main.go
+4 -1
@@ -63,6 +63,7 @@ type relayServerConfig struct {
63 AWSRegion string
64 AWSHostedZoneID string
65 AWSDNSSECKMSKeyARN string
66 + VultrAPIKey string
67 }
68
69 func runServeCommand(args []string) error {
@@ -91,7 +92,7 @@ func runServeCommand(args []string) error {
92 utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
93 utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
94
94 - utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
95 + utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53|vultr); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
96 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")
97 utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
98 utils.StringFlagEnv(fs, &cfg.GCPProjectID, "gcp-project-id", "", "Google Cloud project id for Cloud DNS automation; auto-detected from ADC or GCE metadata when omitted", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GCE_PROJECT")
@@ -102,6 +103,7 @@ func runServeCommand(args []string) error {
103 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")
104 utils.StringFlagEnv(fs, &cfg.AWSHostedZoneID, "aws-hosted-zone-id", "", "explicit Route53 hosted zone ID override", "AWS_HOSTED_ZONE_ID")
105 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")
106 + utils.StringFlagEnv(fs, &cfg.VultrAPIKey, "vultr-api-key", "", "Vultr API key for DNS automation (required when acme-dns-provider=vultr)", "VULTR_API_KEY")
107
108 if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil {
109 if errors.Is(err, flag.ErrHelp) {
@@ -175,6 +177,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
177 AWSRegion: cfg.AWSRegion,
178 AWSHostedZoneID: cfg.AWSHostedZoneID,
179 AWSKMSKeyARN: cfg.AWSDNSSECKMSKeyARN,
180 + VultrAPIKey: cfg.VultrAPIKey,
181 },
182 })
183 if err != nil {
docker-compose.yml
+1
@@ -67,6 +67,7 @@ services:
67 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
68 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
69 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
70 + VULTR_API_KEY: ${VULTR_API_KEY:-}
71 volumes:
72 - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
73 # Uncomment when using a Google Cloud service account file for gcloud automation.
docs/src/routes/architecture/+page.md
+1 -1
@@ -197,7 +197,7 @@ UDP client
197 ### Operational Constraints
198
199 - For non-localhost deployments, relay TLS can run from manual certificate files in the relay `IDENTITY_PATH` directory or from managed ACME.
200 -- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, and `route53`.
200 +- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, `route53`, and `vultr`.
201 - ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync.
202 - Relay stores its state under `IDENTITY_PATH`, including `identity.json`, `admin_settings.json`, and certificate material. Tunnel and demo-app identities still use `IDENTITY_PATH` / `--identity-path` as a direct JSON file path.
203 - Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
docs/src/routes/configuration/+page.md
+13 -1
@@ -49,7 +49,7 @@ The relay server (`relay-server`) reads configuration from environment variables
49
50 | Variable | Default | Type | Description |
51 |----------|---------|------|-------------|
52 -| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `route53`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
52 +| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `route53` \| `vultr`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
53 | `ENS_GASLESS_ENABLED` | `false` | bool | Enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames |
54
55 ### Admin
@@ -90,6 +90,12 @@ The relay server (`relay-server`) reads configuration from environment variables
90 | `AWS_HOSTED_ZONE_ID` | | | string | Explicit Route53 hosted zone ID override |
91 | `AWS_DNSSEC_KMS_KEY_ARN` | | | string | AWS KMS key ARN used to create a Route53 DNSSEC key-signing key when needed |
92
93 +### Vultr
94 +
95 +| Variable | Default | Type | Description |
96 +|----------|---------|------|-------------|
97 +| `VULTR_API_KEY` | | string | Vultr API key for DNS automation; required when `ACME_DNS_PROVIDER=vultr` |
98 +
99 ---
100
101 ## Portal Tunnel CLI Flags
@@ -278,3 +284,9 @@ For ENS gasless behavior and wallet authentication details, see [Wallet and ENS]
284 | `AWS_REGION` | No | AWS region; defaults to `us-east-1` |
285 | `AWS_HOSTED_ZONE_ID` | No | Route53 hosted zone ID; inferred from the portal domain when omitted |
286 | `AWS_DNSSEC_KMS_KEY_ARN` | No | KMS key ARN for DNSSEC key-signing key creation |
287 +
288 +### Vultr DNS (`vultr`)
289 +
290 +| Variable | Required | Description |
291 +|----------|----------|-------------|
292 +| `VULTR_API_KEY` | Yes | Vultr API key with DNS domain, record, and DNSSEC write access |
docs/src/routes/deployment/+page.md
+35 -7
@@ -19,7 +19,7 @@ You need:
19 - A public domain, for example `example.com`
20 - A public Linux server with a static public IPv4
21 - Docker and Docker Compose
22 -- Optional for managed ACME DNS-01 automation, Portal-managed ECH HTTPS records, or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare`, `gcloud`, or `route53`
22 +- Optional for managed ACME DNS-01 automation, Portal-managed ECH HTTPS records, or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare`, `gcloud`, `route53`, or `vultr`
23 - Open inbound ports:
24 - `443/tcp`
25 - `4017/tcp`
@@ -42,7 +42,7 @@ Choose one of these modes:
42 - Set `ACME_DNS_PROVIDER`.
43 - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for ECH HTTPS records and DNSSEC + ENS TXT automation.
44 - Managed ACME mode
45 - - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, or `route53`.
45 + - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, `route53`, or `vultr`.
46 - Portal manages root/wildcard A records, ECH HTTPS records, and certificate renewal.
47 - If ENS gasless is enabled, Portal also manages DNSSEC.
48
@@ -57,6 +57,7 @@ Set `ACME_DNS_PROVIDER` to one of:
57 - `cloudflare`
58 - `gcloud`
59 - `route53`
60 +- `vultr`
61
62 For a focused explanation of wallet auth and ENS gasless DNS behavior, see
63 [Wallet and ENS](/wallet-and-ens).
@@ -163,7 +164,24 @@ Notes:
164 - `GOOGLE_APPLICATION_CREDENTIALS` should point to the in-container path when you run Portal in Docker with a mounted service account JSON file.
165 - Portal only targets public Cloud DNS managed zones.
166
166 -### 3.5 Optional ENS Gasless Automation
167 +### 3.5 Vultr DNS setup
168 +
169 +Create or select a Vultr DNS domain that covers your relay host.
170 +
171 +Required environment variable:
172 +
173 +- `VULTR_API_KEY`
174 +
175 +Equivalent relay flag:
176 +
177 +- `--vultr-api-key`
178 +
179 +Notes:
180 +
181 +- The API key needs permission to list DNS domains, edit DNS records, and update DNSSEC for the target domain.
182 +- Vultr uses `@` for apex records and relative names such as `www` or `*` for subdomains.
183 +
184 +### 3.6 Optional ENS Gasless Automation
185
186 Portal can optionally enable ENS gasless DNS import for the base domain and lease hostnames.
187
@@ -175,6 +193,7 @@ Portal can optionally enable ENS gasless DNS import for the base domain and leas
193 - Cloudflare can enable zone signing directly, but some registrars still require publishing the returned DS record.
194 - Google Cloud DNS can enable zone signing directly, but the registrar may still require publishing the returned DS record.
195 - Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
196 +- Vultr can enable zone signing directly, but the registrar may still require publishing the returned DS record.
197 - New lease hostnames such as `app.portal.example.com` are published automatically when they register and are cleaned up on unregister or expiry.
198 - ENS gasless import still depends on DNSSEC being valid for the domain.
199 - By default Portal writes `ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 <address>`.
@@ -188,20 +207,20 @@ Typical rollout:
207 1. Set `ACME_DNS_PROVIDER` and the provider credentials.
208 2. Set `ENS_GASLESS_ENABLED=true`.
209 3. Start Portal and confirm the log contains both `dnssec configured` and `ens gasless dns import configured`.
191 -4. If the DNSSEC state is `pending`, publish the returned `DS` record at your registrar and wait for propagation.
192 -5. Re-check until the provider DNSSEC state becomes `active`.
210 +4. If the DNSSEC state is `pending` or the provider returns a `DS` record, publish the returned `DS` record at your registrar and wait for propagation.
211 +5. Re-check until the provider DNSSEC state becomes `active` or `enabled`.
212 6. Verify external resolution with an ENS-aware client after DNSSEC is active.
213
214 Registrar DS publication:
215
197 -- Cloudflare, Google Cloud DNS, and Route53 can sign the zone and return the DS record, but they do not control your registrar unless the domain is registered with the same provider.
216 +- Cloudflare, Google Cloud DNS, Route53, and Vultr can sign the zone and return the DS record, but they do not control your registrar unless the domain is registered with the same provider.
217 - If your registrar is separate, you must copy the DS values from the provider into the registrar's DNSSEC or DS configuration screen.
218 - Example: if the domain is registered at Namecheap and delegated to Cloudflare nameservers, enable DNSSEC in Cloudflare first, then add the Cloudflare DS record in Namecheap under the domain's `Advanced DNS` DNSSEC section.
219 - Until the registrar publishes the DS record at the parent zone, provider status typically stays `pending` and ENS gasless resolution may fail even though Portal already wrote the `ENS1 ...` TXT record.
220
221 Verification checklist:
222
204 -- Provider DNSSEC status is `active`.
223 +- Provider DNSSEC status is `active` or `enabled`.
224 - `dig +short DS example.com` returns the DS record from the parent zone.
225 - `dig +short TXT example.com` returns the `ENS1 ...` TXT record.
226 - ENS-aware resolution returns the expected address for the base domain and each lease hostname.
@@ -290,6 +309,15 @@ GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/gcp-dns.json
309 ENS_GASLESS_ENABLED=false
310 ```
311
312 +Vultr example:
313 +
314 +```bash
315 +IDENTITY_PATH=/portal-certs
316 +ACME_DNS_PROVIDER=vultr
317 +VULTR_API_KEY=...
318 +ENS_GASLESS_ENABLED=false
319 +```
320 +
321 Notes:
322
323 - For non-apex deployments, set `PORTAL_URL` to the non-apex host value, for example `https://portal.example.com:8443`
docs/src/routes/self-hosting/+page.md
+1 -1
@@ -109,7 +109,7 @@ By default the relay expects you to place `fullchain.pem` and `privatekey.pem` i
109
110 ```yaml
111 environment:
112 - ACME_DNS_PROVIDER: cloudflare # or: gcloud, route53
112 + ACME_DNS_PROVIDER: cloudflare # or: gcloud, route53, vultr
113 CLOUDFLARE_TOKEN: <your-token>
114 ```
115
docs/src/routes/wallet-and-ens/+page.md
+1 -1
@@ -145,7 +145,7 @@ only prepares DNSSEC-backed DNS records for ENS-aware clients.
145 Requirements:
146
147 - public relay domain, not `localhost`
148 -- `ACME_DNS_PROVIDER=cloudflare`, `gcloud`, or `route53`
148 +- `ACME_DNS_PROVIDER=cloudflare`, `gcloud`, `route53`, or `vultr`
149 - provider credentials with DNS write access
150 - `ENS_GASLESS_ENABLED=true`
151 - DNSSEC active at the parent zone
docs/static/examples/nginx-proxy-multi-service/.env.example
+4 -1
@@ -19,7 +19,7 @@ TCP_ENABLED=false
19
20 # TLS/ACME materials live under IDENTITY_PATH as fullchain.pem/privatekey.pem.
21
22 -# Supported managed values: cloudflare, gcloud, route53
22 +# Supported managed values: cloudflare, gcloud, route53, vultr
23 ACME_DNS_PROVIDER=
24
25 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
@@ -40,6 +40,9 @@ AWS_HOSTED_ZONE_ID=
40 # Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
41 AWS_DNSSEC_KMS_KEY_ARN=
42
43 +# Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr)
44 +VULTR_API_KEY=
45 +
46 # ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
47 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
48 ENS_GASLESS_ENABLED=false
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+1
@@ -107,6 +107,7 @@ services:
107 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
108 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
109 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
110 + VULTR_API_KEY: ${VULTR_API_KEY:-}
111 volumes:
112 - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
113 # Uncomment when using a Google Cloud service account file for gcloud automation.
docs/static/examples/nginx-proxy/.env.example
+4 -1
@@ -19,7 +19,7 @@ TCP_ENABLED=false
19
20 # TLS/ACME materials live under IDENTITY_PATH as fullchain.pem/privatekey.pem.
21
22 -# Supported managed values: cloudflare, gcloud, route53
22 +# Supported managed values: cloudflare, gcloud, route53, vultr
23 ACME_DNS_PROVIDER=
24
25 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
@@ -40,6 +40,9 @@ AWS_HOSTED_ZONE_ID=
40 # Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
41 AWS_DNSSEC_KMS_KEY_ARN=
42
43 +# Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr)
44 +VULTR_API_KEY=
45 +
46 # ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
47 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
48 ENS_GASLESS_ENABLED=false
docs/static/examples/nginx-proxy/docker-compose.yaml
+1
@@ -106,6 +106,7 @@ services:
106 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
107 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
108 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
109 + VULTR_API_KEY: ${VULTR_API_KEY:-}
110 volumes:
111 - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
112 # Uncomment when using a Google Cloud service account file for gcloud automation.
go.mod
+4
@@ -28,6 +28,7 @@ require (
28 github.com/quic-go/quic-go v0.59.0
29 github.com/rs/zerolog v1.34.0
30 github.com/spruceid/siwe-go v0.2.1
31 + github.com/vultr/govultr/v3 v3.30.0
32 golang.org/x/crypto v0.50.0
33 golang.org/x/net v0.53.0
34 golang.org/x/oauth2 v0.36.0
@@ -73,10 +74,13 @@ require (
74 github.com/go-logr/stdr v1.2.2 // indirect
75 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
76 github.com/google/btree v1.1.2 // indirect
77 + github.com/google/go-querystring v1.2.0 // indirect
78 github.com/google/s2a-go v0.1.9 // indirect
79 github.com/google/uuid v1.6.0 // indirect
80 github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
81 github.com/googleapis/gax-go/v2 v2.21.0 // indirect
82 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
83 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
84 github.com/holiman/uint256 v1.3.2 // indirect
85 github.com/knadh/koanf/maps v0.1.2 // indirect
86 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
go.sum
+13
@@ -81,6 +81,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
81 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
82 github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
83 github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
84 +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
85 +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
86 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
87 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
88 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -103,8 +105,11 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
105 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
106 github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
107 github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
108 +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
109 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
110 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
111 +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
112 +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
113 github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
114 github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
115 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -115,6 +120,12 @@ github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl
120 github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
121 github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008 h1:KuP/5VlPJwqZNyAV5U60C/j8Pc5O8ENkWPTgP7mEvj0=
122 github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
123 +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
124 +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
125 +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
126 +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
127 +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
128 +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
129 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
130 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
131 github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
@@ -191,6 +202,8 @@ github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnu
202 github.com/spruceid/siwe-go v0.2.1/go.mod h1:MHpHbptGsM3lHth2L8quhZ9ipiwST8zsJH1CjWpeO1k=
203 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
204 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
205 +github.com/vultr/govultr/v3 v3.30.0 h1:kTeDJ+5or6g4CQJmD6Kmz4R63B18poNZ8RP87r9LZdg=
206 +github.com/vultr/govultr/v3 v3.30.0/go.mod h1:2zyUw9yADQaGwKnwDesmIOlBNLrm7edsCfWHFJpWKf8=
207 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
208 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
209 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
portal/acme/acme.go
+2
@@ -60,6 +60,7 @@ type Config struct {
60 AWSRegion string
61 AWSHostedZoneID string
62 AWSKMSKeyARN string
63 + VultrAPIKey string
64 }
65
66 type Manager struct {
@@ -162,6 +163,7 @@ func NewManager(cfg Config) (*Manager, error) {
163 cfg.AWSRegion = strings.TrimSpace(cfg.AWSRegion)
164 cfg.AWSHostedZoneID = strings.TrimSpace(cfg.AWSHostedZoneID)
165 cfg.AWSKMSKeyARN = strings.TrimSpace(cfg.AWSKMSKeyARN)
166 + cfg.VultrAPIKey = strings.TrimSpace(cfg.VultrAPIKey)
167 if cfg.ENSGaslessEnabled {
168 if cfg.ENSGaslessAddress == "" {
169 return nil, errors.New("ens gasless address is required when ens gasless import is enabled")
portal/acme/provider.go
+4
@@ -10,12 +10,14 @@ import (
10 "github.com/gosuda/portal-tunnel/v2/portal/acme/cloudflare"
11 "github.com/gosuda/portal-tunnel/v2/portal/acme/gcloud"
12 "github.com/gosuda/portal-tunnel/v2/portal/acme/route53"
13 + "github.com/gosuda/portal-tunnel/v2/portal/acme/vultr"
14 )
15
16 const (
17 TypeCloudflare = "cloudflare"
18 TypeGCloud = "gcloud"
19 TypeRoute53 = "route53"
20 + TypeVultr = "vultr"
21 )
22
23 type DNSProvider interface {
@@ -51,6 +53,8 @@ func NewDNSProvider(providerType string, cfg Config) (DNSProvider, error) {
53 HostedZoneID: cfg.AWSHostedZoneID,
54 KMSKeyARN: cfg.AWSKMSKeyARN,
55 }), nil
56 + case TypeVultr:
57 + return vultr.New(cfg.VultrAPIKey), nil
58 default:
59 return nil, fmt.Errorf("unsupported acme dns provider: %q", providerType)
60 }
portal/acme/vultr/provider.go new
+514
@@ -0,0 +1,514 @@
1 +package vultr
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "strconv"
8 + "strings"
9 + "sync"
10 +
11 + "github.com/go-acme/lego/v4/challenge"
12 + legovultr "github.com/go-acme/lego/v4/providers/dns/vultr"
13 + "github.com/vultr/govultr/v3"
14 + "golang.org/x/oauth2"
15 +
16 + "github.com/gosuda/portal-tunnel/v2/utils"
17 +)
18 +
19 +const defaultRecordTTL = 60
20 +
21 +type Provider struct {
22 + apiKey string
23 +
24 + zoneMu sync.RWMutex
25 + zones map[string]string
26 +}
27 +
28 +func New(apiKey string) *Provider {
29 + return &Provider{apiKey: strings.TrimSpace(apiKey)}
30 +}
31 +
32 +func (p *Provider) Name() string {
33 + return "vultr"
34 +}
35 +
36 +func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
37 + if p == nil {
38 + return nil, errors.New("vultr provider is nil")
39 + }
40 + if p.apiKey == "" {
41 + return nil, errors.New("vultr api key is required")
42 + }
43 +
44 + cfg := legovultr.NewDefaultConfig()
45 + cfg.APIKey = p.apiKey
46 +
47 + provider, err := legovultr.NewDNSProviderConfig(cfg)
48 + if err != nil {
49 + return nil, fmt.Errorf("create vultr lego provider: %w", err)
50 + }
51 + return provider, nil
52 +}
53 +
54 +func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
55 + if p == nil {
56 + return errors.New("vultr provider is nil")
57 + }
58 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
59 + if baseDomain == "" {
60 + return errors.New("base domain is required")
61 + }
62 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
63 + return err
64 + }
65 +
66 + client, err := p.newClient(ctx)
67 + if err != nil {
68 + return err
69 + }
70 + zone, err := p.findZone(ctx, client, baseDomain)
71 + if err != nil {
72 + return err
73 + }
74 +
75 + for _, recordName := range []string{baseDomain, "*." + baseDomain} {
76 + if err := ensureRecord(ctx, client, zone, recordName, "A", strings.TrimSpace(publicIPv4)); err != nil {
77 + return fmt.Errorf("upsert vultr A record %s: %w", recordName, err)
78 + }
79 + }
80 + return nil
81 +}
82 +
83 +func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
84 + if p == nil {
85 + return errors.New("vultr provider is nil")
86 + }
87 + name = utils.NormalizeHostname(name)
88 + if name == "" {
89 + return errors.New("record name is required")
90 + }
91 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
92 + return err
93 + }
94 +
95 + client, zone, err := p.clientAndZone(ctx, name)
96 + if err != nil {
97 + return err
98 + }
99 + if err := ensureRecord(ctx, client, zone, name, "A", strings.TrimSpace(publicIPv4)); err != nil {
100 + return fmt.Errorf("upsert vultr A record %s: %w", name, err)
101 + }
102 + return nil
103 +}
104 +
105 +func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
106 + if p == nil {
107 + return errors.New("vultr provider is nil")
108 + }
109 + name = utils.NormalizeHostname(name)
110 + if name == "" {
111 + return errors.New("record name is required")
112 + }
113 +
114 + client, zone, err := p.clientAndZone(ctx, name)
115 + if err != nil {
116 + return err
117 + }
118 + if err := deleteRecords(ctx, client, zone, name, "A", ""); err != nil {
119 + return fmt.Errorf("delete vultr A record %s: %w", name, err)
120 + }
121 + return nil
122 +}
123 +
124 +func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
125 + if p == nil {
126 + return errors.New("vultr provider is nil")
127 + }
128 + name = utils.NormalizeHostname(name)
129 + if name == "" {
130 + return errors.New("record name is required")
131 + }
132 + value = strings.TrimSpace(value)
133 + if value == "" {
134 + return errors.New("txt record value is required")
135 + }
136 +
137 + client, zone, err := p.clientAndZone(ctx, name)
138 + if err != nil {
139 + return err
140 + }
141 + if err := ensureTXTRecord(ctx, client, zone, name, value); err != nil {
142 + return fmt.Errorf("upsert vultr TXT record %s: %w", name, err)
143 + }
144 + return nil
145 +}
146 +
147 +func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
148 + if p == nil {
149 + return errors.New("vultr provider is nil")
150 + }
151 + name = utils.NormalizeHostname(name)
152 + if name == "" {
153 + return errors.New("record name is required")
154 + }
155 + matchPrefix = strings.TrimSpace(matchPrefix)
156 + if matchPrefix == "" {
157 + return errors.New("txt record match prefix is required")
158 + }
159 +
160 + client, zone, err := p.clientAndZone(ctx, name)
161 + if err != nil {
162 + return err
163 + }
164 + if err := deleteRecords(ctx, client, zone, name, "TXT", matchPrefix); err != nil {
165 + return fmt.Errorf("delete vultr TXT records %s: %w", name, err)
166 + }
167 + return nil
168 +}
169 +
170 +func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
171 + if p == nil {
172 + return errors.New("vultr provider is nil")
173 + }
174 + name = utils.NormalizeHostname(name)
175 + if name == "" {
176 + return errors.New("record name is required")
177 + }
178 + content = strings.TrimSpace(content)
179 + if content == "" {
180 + return errors.New("https record content is required")
181 + }
182 +
183 + client, zone, err := p.clientAndZone(ctx, name)
184 + if err != nil {
185 + return err
186 + }
187 + if err := ensureRecord(ctx, client, zone, name, "HTTPS", content); err != nil {
188 + return fmt.Errorf("upsert vultr HTTPS record %s: %w", name, err)
189 + }
190 + return nil
191 +}
192 +
193 +func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
194 + if p == nil {
195 + return errors.New("vultr provider is nil")
196 + }
197 + name = utils.NormalizeHostname(name)
198 + if name == "" {
199 + return errors.New("record name is required")
200 + }
201 +
202 + client, zone, err := p.clientAndZone(ctx, name)
203 + if err != nil {
204 + return err
205 + }
206 + if err := deleteRecords(ctx, client, zone, name, "HTTPS", ""); err != nil {
207 + return fmt.Errorf("delete vultr HTTPS record %s: %w", name, err)
208 + }
209 + return nil
210 +}
211 +
212 +func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error) {
213 + if p == nil {
214 + return "", "", "", errors.New("vultr provider is nil")
215 + }
216 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
217 + if baseDomain == "" {
218 + return "", "", "", errors.New("base domain is required")
219 + }
220 +
221 + client, err := p.newClient(ctx)
222 + if err != nil {
223 + return "", "", "", err
224 + }
225 + zone, err := p.findZone(ctx, client, baseDomain)
226 + if err != nil {
227 + return "", "", "", err
228 + }
229 +
230 + domain, _, err := client.Domain.Get(ctx, zone)
231 + if err != nil {
232 + return "", "", "", fmt.Errorf("get vultr domain %s: %w", zone, err)
233 + }
234 + if domain != nil {
235 + state = strings.TrimSpace(domain.DNSSec)
236 + }
237 + if !strings.EqualFold(state, "enabled") {
238 + if err := client.Domain.Update(ctx, zone, "enabled"); err != nil {
239 + return "", "", "", fmt.Errorf("enable vultr dnssec: %w", err)
240 + }
241 + domain, _, err = client.Domain.Get(ctx, zone)
242 + if err != nil {
243 + return "", "", "", fmt.Errorf("refresh vultr domain %s: %w", zone, err)
244 + }
245 + state = "enabled"
246 + if domain != nil && strings.TrimSpace(domain.DNSSec) != "" {
247 + state = strings.TrimSpace(domain.DNSSec)
248 + }
249 + }
250 +
251 + records, _, err := client.Domain.GetDNSSec(ctx, zone)
252 + if err != nil {
253 + return "", "", "", fmt.Errorf("get vultr dnssec records: %w", err)
254 + }
255 + dsRecord = preferredDSRecord(records)
256 + if dsRecord != "" {
257 + message = "publish the DS record at the registrar after Vultr zone signing is enabled"
258 + } else if strings.EqualFold(state, "enabled") {
259 + message = "wait for the active Vultr DS record before updating the registrar"
260 + }
261 + return state, dsRecord, message, nil
262 +}
263 +
264 +func (p *Provider) clientAndZone(ctx context.Context, domain string) (*govultr.Client, string, error) {
265 + client, err := p.newClient(ctx)
266 + if err != nil {
267 + return nil, "", err
268 + }
269 + zone, err := p.findZone(ctx, client, domain)
270 + if err != nil {
271 + return nil, "", err
272 + }
273 + return client, zone, nil
274 +}
275 +
276 +func (p *Provider) newClient(ctx context.Context) (*govultr.Client, error) {
277 + if p == nil {
278 + return nil, errors.New("vultr provider is nil")
279 + }
280 + if p.apiKey == "" {
281 + return nil, errors.New("vultr api key is required")
282 + }
283 + return govultr.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: p.apiKey}))), nil
284 +}
285 +
286 +func (p *Provider) findZone(ctx context.Context, client *govultr.Client, domain string) (string, error) {
287 + if client == nil {
288 + return "", errors.New("vultr client is nil")
289 + }
290 + domain = utils.NormalizeHostname(domain)
291 + candidates := utils.DomainCandidates(domain)
292 +
293 + p.zoneMu.RLock()
294 + for _, candidate := range candidates {
295 + if zone := p.zones[candidate]; zone != "" {
296 + p.zoneMu.RUnlock()
297 + return zone, nil
298 + }
299 + }
300 + p.zoneMu.RUnlock()
301 +
302 + listOptions := &govultr.ListOptions{PerPage: 100}
303 + for {
304 + domains, meta, _, err := client.Domain.List(ctx, listOptions)
305 + if err != nil {
306 + return "", fmt.Errorf("list vultr domains: %w", err)
307 + }
308 + for _, item := range domains {
309 + zone := utils.NormalizeBaseDomain(item.Domain)
310 + for _, candidate := range candidates {
311 + if zone != candidate {
312 + continue
313 + }
314 + p.zoneMu.Lock()
315 + if p.zones == nil {
316 + p.zones = make(map[string]string)
317 + }
318 + p.zones[candidate] = zone
319 + p.zoneMu.Unlock()
320 + return zone, nil
321 + }
322 + }
323 + if meta == nil || meta.Links == nil || meta.Links.Next == "" {
324 + break
325 + }
326 + listOptions.Cursor = meta.Links.Next
327 + }
328 +
329 + return "", fmt.Errorf("no vultr domain found for %s", domain)
330 +}
331 +
332 +func ensureRecord(ctx context.Context, client *govultr.Client, zone, fqdn, recordType, data string) error {
333 + recordName, err := relativeRecordName(fqdn, zone)
334 + if err != nil {
335 + return err
336 + }
337 + existing, err := listRecords(ctx, client, zone, fqdn, recordType)
338 + if err != nil {
339 + return err
340 + }
341 + for _, record := range existing {
342 + if strings.TrimSpace(record.Data) == data {
343 + return nil
344 + }
345 + }
346 + if len(existing) > 0 {
347 + name := recordName
348 + return client.DomainRecord.Update(ctx, zone, existing[0].ID, &govultr.DomainRecordUpdateReq{
349 + Name: &name,
350 + Type: recordType,
351 + Data: data,
352 + TTL: defaultRecordTTL,
353 + })
354 + }
355 +
356 + _, _, err = client.DomainRecord.Create(ctx, zone, &govultr.DomainRecordCreateReq{
357 + Name: recordName,
358 + Type: recordType,
359 + Data: data,
360 + TTL: defaultRecordTTL,
361 + })
362 + return err
363 +}
364 +
365 +func ensureTXTRecord(ctx context.Context, client *govultr.Client, zone, fqdn, value string) error {
366 + recordName, err := relativeRecordName(fqdn, zone)
367 + if err != nil {
368 + return err
369 + }
370 + existing, err := listRecords(ctx, client, zone, fqdn, "TXT")
371 + if err != nil {
372 + return err
373 + }
374 + for _, record := range existing {
375 + if txtContent(record.Data) == value {
376 + return nil
377 + }
378 + }
379 +
380 + _, _, err = client.DomainRecord.Create(ctx, zone, &govultr.DomainRecordCreateReq{
381 + Name: recordName,
382 + Type: "TXT",
383 + Data: value,
384 + TTL: defaultRecordTTL,
385 + })
386 + return err
387 +}
388 +
389 +func deleteRecords(ctx context.Context, client *govultr.Client, zone, fqdn, recordType, matchPrefix string) error {
390 + existing, err := listRecords(ctx, client, zone, fqdn, recordType)
391 + if err != nil {
392 + return err
393 + }
394 + for _, record := range existing {
395 + if matchPrefix != "" && !strings.HasPrefix(txtContent(record.Data), matchPrefix) {
396 + continue
397 + }
398 + if err := client.DomainRecord.Delete(ctx, zone, record.ID); err != nil {
399 + return err
400 + }
401 + }
402 + return nil
403 +}
404 +
405 +func listRecords(ctx context.Context, client *govultr.Client, zone, fqdn, recordType string) ([]govultr.DomainRecord, error) {
406 + if client == nil {
407 + return nil, errors.New("vultr client is nil")
408 + }
409 + recordName, err := relativeRecordName(fqdn, zone)
410 + if err != nil {
411 + return nil, err
412 + }
413 + recordType = strings.ToUpper(strings.TrimSpace(recordType))
414 + listOptions := &govultr.ListOptions{PerPage: 100}
415 +
416 + var filtered []govultr.DomainRecord
417 + for {
418 + records, meta, _, err := client.DomainRecord.List(ctx, zone, listOptions)
419 + if err != nil {
420 + return nil, err
421 + }
422 + for _, record := range records {
423 + if !strings.EqualFold(strings.TrimSpace(record.Type), recordType) || !sameRecordName(record.Name, recordName, fqdn, zone) {
424 + continue
425 + }
426 + filtered = append(filtered, record)
427 + }
428 + if meta == nil || meta.Links == nil || meta.Links.Next == "" {
429 + break
430 + }
431 + listOptions.Cursor = meta.Links.Next
432 + }
433 + return filtered, nil
434 +}
435 +
436 +func relativeRecordName(fqdn, zone string) (string, error) {
437 + fqdn = utils.NormalizeHostname(fqdn)
438 + zone = utils.NormalizeBaseDomain(zone)
439 + if fqdn == "" {
440 + return "", errors.New("record name is required")
441 + }
442 + if zone == "" {
443 + return "", errors.New("vultr zone is required")
444 + }
445 + if fqdn == zone {
446 + return "@", nil
447 + }
448 + suffix := "." + zone
449 + if !strings.HasSuffix(fqdn, suffix) {
450 + return "", fmt.Errorf("hostname %q is outside vultr zone %q", fqdn, zone)
451 + }
452 + return strings.TrimSuffix(fqdn, suffix), nil
453 +}
454 +
455 +func sameRecordName(recordName, expected, fqdn, zone string) bool {
456 + recordName = utils.NormalizeHostname(recordName)
457 + expected = strings.TrimSpace(strings.ToLower(expected))
458 + fqdn = utils.NormalizeHostname(fqdn)
459 + zone = utils.NormalizeBaseDomain(zone)
460 +
461 + if recordName == expected {
462 + return true
463 + }
464 + if expected == "@" && (recordName == "" || recordName == zone || recordName == fqdn) {
465 + return true
466 + }
467 + return recordName == fqdn
468 +}
469 +
470 +func txtContent(raw string) string {
471 + unquoted, err := strconv.Unquote(strings.TrimSpace(raw))
472 + if err == nil {
473 + return unquoted
474 + }
475 + return strings.Trim(strings.TrimSpace(raw), "\"")
476 +}
477 +
478 +func preferredDSRecord(records []string) string {
479 + candidates := make(map[string]string, len(records))
480 + first := ""
481 + for _, raw := range records {
482 + ds := normalizeDSRecord(raw)
483 + if ds == "" {
484 + continue
485 + }
486 + if first == "" {
487 + first = ds
488 + }
489 + fields := strings.Fields(ds)
490 + if len(fields) < 4 {
491 + continue
492 + }
493 + candidates[fields[2]] = ds
494 + }
495 + for _, digestType := range []string{"2", "4", "1"} {
496 + if record := candidates[digestType]; record != "" {
497 + return record
498 + }
499 + }
500 + return first
501 +}
502 +
503 +func normalizeDSRecord(raw string) string {
504 + fields := strings.Fields(strings.TrimSpace(raw))
505 + for i, field := range fields {
506 + if strings.EqualFold(field, "DS") && len(fields) >= i+5 {
507 + return strings.Join(fields[i+1:i+5], " ")
508 + }
509 + }
510 + if len(fields) == 4 {
511 + return strings.Join(fields, " ")
512 + }
513 + return ""
514 +}
portal/acme/vultr/provider_test.go new
+61
@@ -0,0 +1,61 @@
1 +package vultr
2 +
3 +import (
4 + "context"
5 + "testing"
6 +)
7 +
8 +func TestChallengeProviderRequiresAPIKey(t *testing.T) {
9 + t.Parallel()
10 +
11 + provider := New("")
12 + challengeProvider, err := provider.ChallengeProvider(context.Background())
13 + if challengeProvider != nil {
14 + t.Fatalf("ChallengeProvider() provider = %T, want nil", challengeProvider)
15 + }
16 + if err == nil || err.Error() != "vultr api key is required" {
17 + t.Fatalf("ChallengeProvider() error = %v, want local api key error", err)
18 + }
19 +}
20 +
21 +func TestRelativeRecordName(t *testing.T) {
22 + t.Parallel()
23 +
24 + testCases := []struct {
25 + name string
26 + fqdn string
27 + want string
28 + }{
29 + {name: "apex", fqdn: "example.com", want: "@"},
30 + {name: "subdomain", fqdn: "portal.example.com", want: "portal"},
31 + {name: "wildcard", fqdn: "*.example.com", want: "*"},
32 + {name: "nested", fqdn: "_ens.portal.example.com", want: "_ens.portal"},
33 + }
34 +
35 + for _, tc := range testCases {
36 + t.Run(tc.name, func(t *testing.T) {
37 + t.Parallel()
38 +
39 + got, err := relativeRecordName(tc.fqdn, "example.com")
40 + if err != nil {
41 + t.Fatalf("relativeRecordName() error = %v", err)
42 + }
43 + if got != tc.want {
44 + t.Fatalf("relativeRecordName() = %q, want %q", got, tc.want)
45 + }
46 + })
47 + }
48 +}
49 +
50 +func TestPreferredDSRecordPrefersSHA256(t *testing.T) {
51 + t.Parallel()
52 +
53 + got := preferredDSRecord([]string{
54 + "example.com IN DNSKEY 257 3 13 abc",
55 + "example.com IN DS 27933 13 1 2d9ac457e5c11a104e25d971d0a6254562bddde7",
56 + "example.com IN DS 27933 13 2 8858e7b0dfb881280ce2ca1e0eafcd93d5b53687c21da284d4f8799ba82208a9",
57 + })
58 + if got != "27933 13 2 8858e7b0dfb881280ce2ca1e0eafcd93d5b53687c21da284d4f8799ba82208a9" {
59 + t.Fatalf("preferredDSRecord() = %q", got)
60 + }
61 +}