feat: add Hetzner DNS provider support for ACME automation and update documentation

Kim committed May 15, 2026 at 16:33 UTC 0c459a454f4149f0974aec4e75acfd26d2bcc349
16 files changed +627 -12
.env.example
+4 -1
@@ -15,7 +15,7 @@ UDP_ENABLED=false
15 TCP_ENABLED=false
16
17
18 -# Supported managed values: cloudflare, gcloud, route53, vultr.
18 +# Supported managed values: cloudflare, gcloud, hetzner, route53, vultr.
19 # Reused for ACME DNS-01, managed A records, ECH HTTPS records, and optional ENS DNS automation.
20 ACME_DNS_PROVIDER=
21
@@ -27,6 +27,9 @@ GCP_PROJECT_ID=
27 GCP_MANAGED_ZONE=
28 GOOGLE_APPLICATION_CREDENTIALS=
29
30 +# Hetzner DNS settings (required when ACME_DNS_PROVIDER=hetzner)
31 +HETZNER_API_TOKEN=
32 +
33 # Route53 settings (required when ACME_DNS_PROVIDER=route53)
34 AWS_ACCESS_KEY_ID=
35 AWS_SECRET_ACCESS_KEY=
cmd/relay-server/main.go
+4 -1
@@ -57,6 +57,7 @@ type relayServerConfig struct {
57 CloudflareToken string
58 GCPProjectID string
59 GCPManagedZone string
60 + HetznerAPIToken string
61 AWSAccessKeyID string
62 AWSSecretAccessKey string
63 AWSSessionToken string
@@ -92,11 +93,12 @@ func runServeCommand(args []string) error {
93 utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
94 utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
95
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.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|hetzner|route53|vultr); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
97 utils.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")
98 utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
99 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")
100 utils.StringFlagEnv(fs, &cfg.GCPManagedZone, "gcp-managed-zone", "", "explicit Google Cloud DNS managed zone name or numeric ID override", "GCP_MANAGED_ZONE", "GCP_ZONE", "GCE_ZONE_ID")
101 + utils.StringFlagEnv(fs, &cfg.HetznerAPIToken, "hetzner-api-token", "", "Hetzner Cloud API token for DNS automation (required when acme-dns-provider=hetzner)", "HETZNER_API_TOKEN", "HCLOUD_TOKEN")
102 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")
103 utils.StringFlagEnv(fs, &cfg.AWSSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key for Route53 static credentials", "AWS_SECRET_ACCESS_KEY")
104 utils.StringFlagEnv(fs, &cfg.AWSSessionToken, "aws-session-token", "", "AWS session token for Route53 temporary credentials", "AWS_SESSION_TOKEN")
@@ -171,6 +173,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
173 CloudflareToken: cfg.CloudflareToken,
174 GCPProjectID: cfg.GCPProjectID,
175 GCPManagedZone: cfg.GCPManagedZone,
176 + HetznerAPIToken: cfg.HetznerAPIToken,
177 AWSAccessKeyID: cfg.AWSAccessKeyID,
178 AWSSecretAccessKey: cfg.AWSSecretAccessKey,
179 AWSSessionToken: cfg.AWSSessionToken,
docker-compose.yml
+1
@@ -60,6 +60,7 @@ services:
60 GCP_PROJECT_ID: ${GCP_PROJECT_ID:-}
61 GCP_MANAGED_ZONE: ${GCP_MANAGED_ZONE:-}
62 GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
63 + HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-}
64 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
65 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
66 AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
docs/src/routes/architecture/+page.md
+2 -2
@@ -197,8 +197,8 @@ 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`, `route53`, and `vultr`.
201 -- ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync.
200 +- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, `hetzner`, `route53`, and `vultr`.
201 +- ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync when the selected provider supports DNSSEC.
202 - Relay stores its state under `IDENTITY_PATH`, including `identity.json`, `admin_settings.json`, and certificate material. Tunnel and demo-app identities still use `IDENTITY_PATH` / `--identity-path` as a direct JSON file path.
203 - Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
204 - Relay certificate material lives under `IDENTITY_PATH` as `fullchain.pem` and `privatekey.pem`.
docs/src/routes/configuration/+page.md
+15 -1
@@ -49,7 +49,7 @@ The relay server (`relay-server`) reads configuration from environment variables
49
50 | Variable | Default | Type | Description |
51 |----------|---------|------|-------------|
52 -| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `route53` \| `vultr`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
52 +| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `hetzner` \| `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
@@ -79,6 +79,12 @@ The relay server (`relay-server`) reads configuration from environment variables
79 | `GCP_MANAGED_ZONE` | `GCP_ZONE`, `GCE_ZONE_ID` | | string | Explicit Google Cloud DNS managed zone name or numeric ID override |
80 | `GOOGLE_APPLICATION_CREDENTIALS` | | | string | Path to GCP service account key file (standard ADC; used by the GCP client library) |
81
82 +### Hetzner
83 +
84 +| Variable | Aliases | Default | Type | Description |
85 +|----------|---------|---------|------|-------------|
86 +| `HETZNER_API_TOKEN` | `HCLOUD_TOKEN` | | string | Hetzner Cloud API token for DNS automation; required when `ACME_DNS_PROVIDER=hetzner` |
87 +
88 ### AWS
89
90 | Variable | Aliases | Default | Type | Description |
@@ -285,6 +291,14 @@ For ENS gasless behavior and wallet authentication details, see [Wallet and ENS]
291 | `AWS_HOSTED_ZONE_ID` | No | Route53 hosted zone ID; inferred from the portal domain when omitted |
292 | `AWS_DNSSEC_KMS_KEY_ARN` | No | KMS key ARN for DNSSEC key-signing key creation |
293
294 +### Hetzner DNS (`hetzner`)
295 +
296 +| Variable | Required | Description |
297 +|----------|----------|-------------|
298 +| `HETZNER_API_TOKEN` | Yes | Hetzner Cloud API token with DNS zone and RRSet write access |
299 +
300 +Note: Hetzner DNS does not support provider-side DNSSEC signing, so `ACME_DNS_PROVIDER=hetzner` supports ACME, A records, and HTTPS/ECH records, but not ENS gasless DNSSEC automation.
301 +
302 ### Vultr DNS (`vultr`)
303
304 | Variable | Required | Description |
docs/src/routes/deployment/+page.md
+34 -6
@@ -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`, `route53`, or `vultr`
22 +- Optional for managed ACME DNS-01 automation and Portal-managed ECH HTTPS records: a supported DNS provider account for `cloudflare`, `gcloud`, `hetzner`, `route53`, or `vultr`
23 - Open inbound ports:
24 - `443/tcp`
25 - `4017/tcp`
@@ -39,12 +39,12 @@ Choose one of these modes:
39 - Portal uses the files as-is and does not modify DNS or renew the certificate.
40 - Manual certificate + gasless mode
41 - Place `fullchain.pem` and `privatekey.pem` in `IDENTITY_PATH`.
42 - - Set `ACME_DNS_PROVIDER`.
42 + - Set `ACME_DNS_PROVIDER` to a DNSSEC-capable provider.
43 - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for ECH HTTPS records and DNSSEC + ENS TXT automation.
44 - Managed ACME mode
45 - - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, `route53`, or `vultr`.
45 + - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, `hetzner`, `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.
47 + - ENS gasless additionally requires a DNSSEC-capable provider.
48
49 If you only need a relay and do not need Portal-managed DNS or automatic renewal, manual certificate mode is the simplest option.
50
@@ -56,6 +56,7 @@ Set `ACME_DNS_PROVIDER` to one of:
56
57 - `cloudflare`
58 - `gcloud`
59 +- `hetzner`
60 - `route53`
61 - `vultr`
62
@@ -164,7 +165,25 @@ Notes:
165 - `GOOGLE_APPLICATION_CREDENTIALS` should point to the in-container path when you run Portal in Docker with a mounted service account JSON file.
166 - Portal only targets public Cloud DNS managed zones.
167
167 -### 3.5 Vultr DNS setup
168 +### 3.5 Hetzner DNS setup
169 +
170 +Create or select a Hetzner DNS zone that covers your relay host in Hetzner Console.
171 +
172 +Required environment variable:
173 +
174 +- `HETZNER_API_TOKEN`
175 +
176 +Equivalent relay flag:
177 +
178 +- `--hetzner-api-token`
179 +
180 +Notes:
181 +
182 +- The token needs permission to list DNS zones and edit RRSets for the target zone.
183 +- Hetzner uses `@` for apex records and relative names such as `www` or `*` for subdomains.
184 +- Hetzner DNS does not support provider-side DNSSEC signing, so ENS gasless automation is not supported with `ACME_DNS_PROVIDER=hetzner`.
185 +
186 +### 3.6 Vultr DNS setup
187
188 Create or select a Vultr DNS domain that covers your relay host.
189
@@ -181,7 +200,7 @@ Notes:
200 - The API key needs permission to list DNS domains, edit DNS records, and update DNSSEC for the target domain.
201 - Vultr uses `@` for apex records and relative names such as `www` or `*` for subdomains.
202
184 -### 3.6 Optional ENS Gasless Automation
203 +### 3.7 Optional ENS Gasless Automation
204
205 Portal can optionally enable ENS gasless DNS import for the base domain and lease hostnames.
206
@@ -318,6 +337,15 @@ VULTR_API_KEY=...
337 ENS_GASLESS_ENABLED=false
338 ```
339
340 +Hetzner example:
341 +
342 +```bash
343 +IDENTITY_PATH=/portal-certs
344 +ACME_DNS_PROVIDER=hetzner
345 +HETZNER_API_TOKEN=...
346 +ENS_GASLESS_ENABLED=false
347 +```
348 +
349 Notes:
350
351 - 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, vultr
112 + ACME_DNS_PROVIDER: cloudflare # or: gcloud, hetzner, route53, vultr
113 CLOUDFLARE_TOKEN: <your-token>
114 ```
115
docs/src/routes/wallet-and-ens/+page.md
+2
@@ -150,6 +150,8 @@ Requirements:
150 - `ENS_GASLESS_ENABLED=true`
151 - DNSSEC active at the parent zone
152
153 +Hetzner is supported for managed ACME DNS automation, but not for ENS gasless automation because Hetzner DNS does not support provider-side DNSSEC signing.
154 +
155 Example:
156
157 ```bash
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+1
@@ -100,6 +100,7 @@ services:
100 GCP_PROJECT_ID: ${GCP_PROJECT_ID:-}
101 GCP_MANAGED_ZONE: ${GCP_MANAGED_ZONE:-}
102 GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
103 + HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-}
104 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
105 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
106 AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
docs/static/examples/nginx-proxy/docker-compose.yaml
+1
@@ -99,6 +99,7 @@ services:
99 GCP_PROJECT_ID: ${GCP_PROJECT_ID:-}
100 GCP_MANAGED_ZONE: ${GCP_MANAGED_ZONE:-}
101 GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
102 + HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-}
103 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
104 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
105 AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
go.mod
+1
@@ -18,6 +18,7 @@ require (
18 github.com/go-rod/rod v0.116.2
19 github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008
20 github.com/hashicorp/yamux v0.1.2
21 + github.com/hetznercloud/hcloud-go/v2 v2.40.0
22 github.com/knadh/koanf/parsers/toml/v2 v2.2.0
23 github.com/knadh/koanf/providers/file v1.2.1
24 github.com/knadh/koanf/v2 v2.3.4
go.sum
+3
@@ -4,6 +4,7 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
4 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
5 cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
6 cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
7 +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0=
8 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
9 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
10 github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
@@ -128,6 +129,8 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
129 github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
130 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
131 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
132 +github.com/hetznercloud/hcloud-go/v2 v2.40.0 h1:fuP7khfiDQAIXdKyQq7f3LnnOjyZg0PXTafXjUKkqIA=
133 +github.com/hetznercloud/hcloud-go/v2 v2.40.0/go.mod h1:ANz38eerXjPv00dm9dckKhttOGtYeeGmjjvwL5e6c5E=
134 github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
135 github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
136 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
portal/acme/acme.go
+2
@@ -54,6 +54,7 @@ type Config struct {
54 CloudflareToken string
55 GCPProjectID string
56 GCPManagedZone string
57 + HetznerAPIToken string
58 AWSAccessKeyID string
59 AWSSecretAccessKey string
60 AWSSessionToken string
@@ -156,6 +157,7 @@ func NewManager(cfg Config) (*Manager, error) {
157 cfg.CloudflareToken = strings.TrimSpace(cfg.CloudflareToken)
158 cfg.GCPProjectID = strings.TrimSpace(cfg.GCPProjectID)
159 cfg.GCPManagedZone = strings.TrimSpace(cfg.GCPManagedZone)
160 + cfg.HetznerAPIToken = strings.TrimSpace(cfg.HetznerAPIToken)
161 cfg.AWSAccessKeyID = strings.TrimSpace(cfg.AWSAccessKeyID)
162 cfg.AWSSecretAccessKey = strings.TrimSpace(cfg.AWSSecretAccessKey)
163 cfg.AWSSessionToken = strings.TrimSpace(cfg.AWSSessionToken)
portal/acme/hetzner/provider.go new
+482
@@ -0,0 +1,482 @@
1 +package hetzner
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "net/http"
8 + "slices"
9 + "strings"
10 + "time"
11 +
12 + "github.com/go-acme/lego/v4/challenge"
13 + legohetzner "github.com/go-acme/lego/v4/providers/dns/hetzner"
14 + "github.com/hetznercloud/hcloud-go/v2/hcloud"
15 + "github.com/hetznercloud/hcloud-go/v2/hcloud/exp/zoneutil"
16 +
17 + "github.com/gosuda/portal-tunnel/v2/utils"
18 +)
19 +
20 +const defaultRecordTTL = 60
21 +
22 +type Provider struct {
23 + apiToken string
24 +
25 + zones *utils.Snapshot[map[string]string]
26 +}
27 +
28 +func New(apiToken string) *Provider {
29 + return &Provider{
30 + apiToken: strings.TrimSpace(apiToken),
31 + zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
32 + }
33 +}
34 +
35 +func (p *Provider) Name() string {
36 + return "hetzner"
37 +}
38 +
39 +func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
40 + if p == nil {
41 + return nil, errors.New("hetzner provider is nil")
42 + }
43 + if p.apiToken == "" {
44 + return nil, errors.New("hetzner api token is required")
45 + }
46 +
47 + cfg := legohetzner.NewDefaultConfig()
48 + cfg.APIToken = p.apiToken
49 +
50 + provider, err := legohetzner.NewDNSProviderConfig(cfg)
51 + if err != nil {
52 + return nil, fmt.Errorf("create hetzner lego provider: %w", err)
53 + }
54 + return provider, nil
55 +}
56 +
57 +func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
58 + if p == nil {
59 + return errors.New("hetzner provider is nil")
60 + }
61 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
62 + if baseDomain == "" {
63 + return errors.New("base domain is required")
64 + }
65 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
66 + return err
67 + }
68 +
69 + client, zone, err := p.clientAndZone(ctx, baseDomain)
70 + if err != nil {
71 + return err
72 + }
73 +
74 + for _, recordName := range []string{baseDomain, "*." + baseDomain} {
75 + if err := ensureRecord(ctx, client, zone, recordName, hcloud.ZoneRRSetTypeA, strings.TrimSpace(publicIPv4)); err != nil {
76 + return fmt.Errorf("upsert hetzner A record %s: %w", recordName, err)
77 + }
78 + }
79 + return nil
80 +}
81 +
82 +func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
83 + if p == nil {
84 + return errors.New("hetzner provider is nil")
85 + }
86 + name = utils.NormalizeHostname(name)
87 + if name == "" {
88 + return errors.New("record name is required")
89 + }
90 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
91 + return err
92 + }
93 +
94 + client, zone, err := p.clientAndZone(ctx, name)
95 + if err != nil {
96 + return err
97 + }
98 + if err := ensureRecord(ctx, client, zone, name, hcloud.ZoneRRSetTypeA, strings.TrimSpace(publicIPv4)); err != nil {
99 + return fmt.Errorf("upsert hetzner A record %s: %w", name, err)
100 + }
101 + return nil
102 +}
103 +
104 +func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
105 + if p == nil {
106 + return errors.New("hetzner provider is nil")
107 + }
108 + name = utils.NormalizeHostname(name)
109 + if name == "" {
110 + return errors.New("record name is required")
111 + }
112 +
113 + client, zone, err := p.clientAndZone(ctx, name)
114 + if err != nil {
115 + return err
116 + }
117 + if err := deleteRRSet(ctx, client, zone, name, hcloud.ZoneRRSetTypeA); err != nil {
118 + return fmt.Errorf("delete hetzner A record %s: %w", name, err)
119 + }
120 + return nil
121 +}
122 +
123 +func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
124 + if p == nil {
125 + return errors.New("hetzner provider is nil")
126 + }
127 + name = utils.NormalizeHostname(name)
128 + if name == "" {
129 + return errors.New("record name is required")
130 + }
131 + value = strings.TrimSpace(value)
132 + if value == "" {
133 + return errors.New("txt record value is required")
134 + }
135 +
136 + client, zone, err := p.clientAndZone(ctx, name)
137 + if err != nil {
138 + return err
139 + }
140 + if err := ensureTXTRecord(ctx, client, zone, name, value); err != nil {
141 + return fmt.Errorf("upsert hetzner TXT record %s: %w", name, err)
142 + }
143 + return nil
144 +}
145 +
146 +func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
147 + if p == nil {
148 + return errors.New("hetzner provider is nil")
149 + }
150 + name = utils.NormalizeHostname(name)
151 + if name == "" {
152 + return errors.New("record name is required")
153 + }
154 + matchPrefix = strings.TrimSpace(matchPrefix)
155 + if matchPrefix == "" {
156 + return errors.New("txt record match prefix is required")
157 + }
158 +
159 + client, zone, err := p.clientAndZone(ctx, name)
160 + if err != nil {
161 + return err
162 + }
163 + if err := deleteTXTRecords(ctx, client, zone, name, matchPrefix); err != nil {
164 + return fmt.Errorf("delete hetzner TXT records %s: %w", name, err)
165 + }
166 + return nil
167 +}
168 +
169 +func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
170 + if p == nil {
171 + return errors.New("hetzner provider is nil")
172 + }
173 + name = utils.NormalizeHostname(name)
174 + if name == "" {
175 + return errors.New("record name is required")
176 + }
177 + content = strings.TrimSpace(content)
178 + if content == "" {
179 + return errors.New("https record content is required")
180 + }
181 +
182 + client, zone, err := p.clientAndZone(ctx, name)
183 + if err != nil {
184 + return err
185 + }
186 + if err := ensureRecord(ctx, client, zone, name, hcloud.ZoneRRSetTypeHTTPS, content); err != nil {
187 + return fmt.Errorf("upsert hetzner HTTPS record %s: %w", name, err)
188 + }
189 + return nil
190 +}
191 +
192 +func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
193 + if p == nil {
194 + return errors.New("hetzner provider is nil")
195 + }
196 + name = utils.NormalizeHostname(name)
197 + if name == "" {
198 + return errors.New("record name is required")
199 + }
200 +
201 + client, zone, err := p.clientAndZone(ctx, name)
202 + if err != nil {
203 + return err
204 + }
205 + if err := deleteRRSet(ctx, client, zone, name, hcloud.ZoneRRSetTypeHTTPS); err != nil {
206 + return fmt.Errorf("delete hetzner HTTPS record %s: %w", name, err)
207 + }
208 + return nil
209 +}
210 +
211 +func (p *Provider) EnsureDNSSEC(_ context.Context, baseDomain string) (state, dsRecord, message string, err error) {
212 + if p == nil {
213 + return "", "", "", errors.New("hetzner provider is nil")
214 + }
215 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
216 + if baseDomain == "" {
217 + return "", "", "", errors.New("base domain is required")
218 + }
219 + if p.apiToken == "" {
220 + return "", "", "", errors.New("hetzner api token is required")
221 + }
222 + return "", "", "", errors.New("hetzner dns does not support provider-side dnssec signing; use a DNSSEC-capable provider for ENS gasless automation")
223 +}
224 +
225 +func (p *Provider) clientAndZone(ctx context.Context, domain string) (*hcloud.Client, *hcloud.Zone, error) {
226 + client, err := p.newClient()
227 + if err != nil {
228 + return nil, nil, err
229 + }
230 + zone, err := p.findZone(ctx, client, domain)
231 + if err != nil {
232 + return nil, nil, err
233 + }
234 + return client, zone, nil
235 +}
236 +
237 +func (p *Provider) newClient() (*hcloud.Client, error) {
238 + if p == nil {
239 + return nil, errors.New("hetzner provider is nil")
240 + }
241 + if p.apiToken == "" {
242 + return nil, errors.New("hetzner api token is required")
243 + }
244 + return hcloud.NewClient(
245 + hcloud.WithToken(p.apiToken),
246 + hcloud.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
247 + hcloud.WithPollOpts(hcloud.PollOpts{BackoffFunc: hcloud.ConstantBackoff(2 * time.Second)}),
248 + ), nil
249 +}
250 +
251 +func (p *Provider) findZone(ctx context.Context, client *hcloud.Client, domain string) (*hcloud.Zone, error) {
252 + if client == nil {
253 + return nil, errors.New("hetzner client is nil")
254 + }
255 + domain = utils.NormalizeHostname(domain)
256 + candidates := utils.DomainCandidates(domain)
257 +
258 + zones := p.zones.Load()
259 + for _, candidate := range candidates {
260 + if zoneName := zones[candidate]; zoneName != "" {
261 + return &hcloud.Zone{Name: zoneName}, nil
262 + }
263 + }
264 +
265 + for _, candidate := range candidates {
266 + zone, _, err := client.Zone.GetByName(ctx, candidate)
267 + if err != nil {
268 + return nil, fmt.Errorf("get hetzner zone %s: %w", candidate, err)
269 + }
270 + if zone == nil {
271 + continue
272 + }
273 + zoneName := utils.NormalizeBaseDomain(zone.Name)
274 + if zoneName == "" {
275 + continue
276 + }
277 + p.zones.UpdateCopy(func(zones *map[string]string) {
278 + if *zones == nil {
279 + *zones = make(map[string]string)
280 + }
281 + (*zones)[candidate] = zoneName
282 + })
283 + return zone, nil
284 + }
285 +
286 + return nil, fmt.Errorf("no hetzner zone found for %s", domain)
287 +}
288 +
289 +func ensureRecord(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn string, recordType hcloud.ZoneRRSetType, value string) error {
290 + recordName, err := relativeRecordName(fqdn, zone)
291 + if err != nil {
292 + return err
293 + }
294 + value = strings.TrimSpace(value)
295 + if value == "" {
296 + return errors.New("record value is required")
297 + }
298 + desired := []hcloud.ZoneRRSetRecord{{Value: value}}
299 +
300 + existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, recordType)
301 + if err != nil {
302 + return err
303 + }
304 + if existing == nil {
305 + ttl := defaultRecordTTL
306 + result, _, err := client.Zone.CreateRRSet(ctx, zone, hcloud.ZoneRRSetCreateOpts{
307 + Name: recordName,
308 + Type: recordType,
309 + TTL: &ttl,
310 + Records: desired,
311 + })
312 + if err != nil {
313 + return err
314 + }
315 + return waitAction(ctx, client, result.Action)
316 + }
317 + if sameRecords(existing.Records, desired) {
318 + return nil
319 + }
320 +
321 + action, _, err := client.Zone.SetRRSetRecords(ctx, existing, hcloud.ZoneRRSetSetRecordsOpts{Records: desired})
322 + if err != nil {
323 + return err
324 + }
325 + return waitAction(ctx, client, action)
326 +}
327 +
328 +func ensureTXTRecord(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn, value string) error {
329 + recordName, err := relativeRecordName(fqdn, zone)
330 + if err != nil {
331 + return err
332 + }
333 + formatted := zoneutil.FormatTXTRecord(value)
334 + desired := []hcloud.ZoneRRSetRecord{{Value: formatted}}
335 +
336 + existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, hcloud.ZoneRRSetTypeTXT)
337 + if err != nil {
338 + return err
339 + }
340 + if existing == nil {
341 + ttl := defaultRecordTTL
342 + result, _, err := client.Zone.CreateRRSet(ctx, zone, hcloud.ZoneRRSetCreateOpts{
343 + Name: recordName,
344 + Type: hcloud.ZoneRRSetTypeTXT,
345 + TTL: &ttl,
346 + Records: desired,
347 + })
348 + if err != nil {
349 + return err
350 + }
351 + return waitAction(ctx, client, result.Action)
352 + }
353 + for _, record := range existing.Records {
354 + if txtContent(record.Value) == value {
355 + return nil
356 + }
357 + }
358 +
359 + ttl := defaultRecordTTL
360 + action, _, err := client.Zone.AddRRSetRecords(ctx, existing, hcloud.ZoneRRSetAddRecordsOpts{
361 + Records: desired,
362 + TTL: &ttl,
363 + })
364 + if err != nil {
365 + return err
366 + }
367 + return waitAction(ctx, client, action)
368 +}
369 +
370 +func deleteRRSet(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn string, recordType hcloud.ZoneRRSetType) error {
371 + recordName, err := relativeRecordName(fqdn, zone)
372 + if err != nil {
373 + return err
374 + }
375 + existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, recordType)
376 + if err != nil {
377 + return err
378 + }
379 + if existing == nil {
380 + return nil
381 + }
382 +
383 + result, _, err := client.Zone.DeleteRRSet(ctx, existing)
384 + if err != nil {
385 + return err
386 + }
387 + return waitAction(ctx, client, result.Action)
388 +}
389 +
390 +func deleteTXTRecords(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn, matchPrefix string) error {
391 + recordName, err := relativeRecordName(fqdn, zone)
392 + if err != nil {
393 + return err
394 + }
395 + existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, hcloud.ZoneRRSetTypeTXT)
396 + if err != nil {
397 + return err
398 + }
399 + if existing == nil {
400 + return nil
401 + }
402 +
403 + remaining := existing.Records[:0]
404 + for _, record := range existing.Records {
405 + if strings.HasPrefix(txtContent(record.Value), matchPrefix) {
406 + continue
407 + }
408 + remaining = append(remaining, record)
409 + }
410 + if len(remaining) == len(existing.Records) {
411 + return nil
412 + }
413 + if len(remaining) == 0 {
414 + result, _, err := client.Zone.DeleteRRSet(ctx, existing)
415 + if err != nil {
416 + return err
417 + }
418 + return waitAction(ctx, client, result.Action)
419 + }
420 +
421 + action, _, err := client.Zone.SetRRSetRecords(ctx, existing, hcloud.ZoneRRSetSetRecordsOpts{Records: remaining})
422 + if err != nil {
423 + return err
424 + }
425 + return waitAction(ctx, client, action)
426 +}
427 +
428 +func relativeRecordName(fqdn string, zone *hcloud.Zone) (string, error) {
429 + fqdn = utils.NormalizeHostname(fqdn)
430 + if fqdn == "" {
431 + return "", errors.New("record name is required")
432 + }
433 + if zone == nil {
434 + return "", errors.New("hetzner zone is required")
435 + }
436 + zoneName := utils.NormalizeBaseDomain(zone.Name)
437 + if zoneName == "" && zone.ID != 0 {
438 + return "", errors.New("hetzner zone name is required")
439 + }
440 + if fqdn == zoneName {
441 + return "@", nil
442 + }
443 + suffix := "." + zoneName
444 + if !strings.HasSuffix(fqdn, suffix) {
445 + return "", fmt.Errorf("hostname %q is outside hetzner zone %q", fqdn, zoneName)
446 + }
447 + return strings.TrimSuffix(fqdn, suffix), nil
448 +}
449 +
450 +func sameRecords(current, desired []hcloud.ZoneRRSetRecord) bool {
451 + if len(current) != len(desired) {
452 + return false
453 + }
454 + current = slices.Clone(current)
455 + desired = slices.Clone(desired)
456 + slices.SortFunc(current, compareRecords)
457 + slices.SortFunc(desired, compareRecords)
458 + for i := range current {
459 + if current[i] != desired[i] {
460 + return false
461 + }
462 + }
463 + return true
464 +}
465 +
466 +func compareRecords(a, b hcloud.ZoneRRSetRecord) int {
467 + if cmp := strings.Compare(a.Value, b.Value); cmp != 0 {
468 + return cmp
469 + }
470 + return strings.Compare(a.Comment, b.Comment)
471 +}
472 +
473 +func txtContent(raw string) string {
474 + return zoneutil.ParseTXTRecord(strings.TrimSpace(raw))
475 +}
476 +
477 +func waitAction(ctx context.Context, client *hcloud.Client, action *hcloud.Action) error {
478 + if action == nil {
479 + return nil
480 + }
481 + return client.Action.WaitFor(ctx, action)
482 +}
portal/acme/hetzner/provider_test.go new
+70
@@ -0,0 +1,70 @@
1 +package hetzner
2 +
3 +import (
4 + "context"
5 + "testing"
6 +
7 + "github.com/hetznercloud/hcloud-go/v2/hcloud"
8 +)
9 +
10 +func TestChallengeProviderRequiresAPIToken(t *testing.T) {
11 + t.Parallel()
12 +
13 + provider := New("")
14 + challengeProvider, err := provider.ChallengeProvider(context.Background())
15 + if challengeProvider != nil {
16 + t.Fatalf("ChallengeProvider() provider = %T, want nil", challengeProvider)
17 + }
18 + if err == nil || err.Error() != "hetzner api token is required" {
19 + t.Fatalf("ChallengeProvider() error = %v, want local api token error", err)
20 + }
21 +}
22 +
23 +func TestRelativeRecordName(t *testing.T) {
24 + t.Parallel()
25 +
26 + zone := &hcloud.Zone{Name: "example.com"}
27 + testCases := []struct {
28 + name string
29 + fqdn string
30 + want string
31 + }{
32 + {name: "apex", fqdn: "example.com", want: "@"},
33 + {name: "subdomain", fqdn: "portal.example.com", want: "portal"},
34 + {name: "wildcard", fqdn: "*.example.com", want: "*"},
35 + {name: "nested", fqdn: "_ens.portal.example.com", want: "_ens.portal"},
36 + }
37 +
38 + for _, tc := range testCases {
39 + t.Run(tc.name, func(t *testing.T) {
40 + t.Parallel()
41 +
42 + got, err := relativeRecordName(tc.fqdn, zone)
43 + if err != nil {
44 + t.Fatalf("relativeRecordName() error = %v", err)
45 + }
46 + if got != tc.want {
47 + t.Fatalf("relativeRecordName() = %q, want %q", got, tc.want)
48 + }
49 + })
50 + }
51 +}
52 +
53 +func TestTXTContent(t *testing.T) {
54 + t.Parallel()
55 +
56 + got := txtContent(`"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01" " 0xabc"`)
57 + if got != "ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 0xabc" {
58 + t.Fatalf("txtContent() = %q", got)
59 + }
60 +}
61 +
62 +func TestSameRecordsIgnoresOrder(t *testing.T) {
63 + t.Parallel()
64 +
65 + a := []hcloud.ZoneRRSetRecord{{Value: "b"}, {Value: "a", Comment: "one"}}
66 + b := []hcloud.ZoneRRSetRecord{{Value: "a", Comment: "one"}, {Value: "b"}}
67 + if !sameRecords(a, b) {
68 + t.Fatal("sameRecords() = false, want true")
69 + }
70 +}
portal/acme/provider.go
+4
@@ -9,6 +9,7 @@ import (
9
10 "github.com/gosuda/portal-tunnel/v2/portal/acme/cloudflare"
11 "github.com/gosuda/portal-tunnel/v2/portal/acme/gcloud"
12 + "github.com/gosuda/portal-tunnel/v2/portal/acme/hetzner"
13 "github.com/gosuda/portal-tunnel/v2/portal/acme/route53"
14 "github.com/gosuda/portal-tunnel/v2/portal/acme/vultr"
15 )
@@ -16,6 +17,7 @@ import (
17 const (
18 TypeCloudflare = "cloudflare"
19 TypeGCloud = "gcloud"
20 + TypeHetzner = "hetzner"
21 TypeRoute53 = "route53"
22 TypeVultr = "vultr"
23 )
@@ -44,6 +46,8 @@ func NewDNSProvider(providerType string, cfg Config) (DNSProvider, error) {
46 ProjectID: cfg.GCPProjectID,
47 ManagedZone: cfg.GCPManagedZone,
48 }), nil
49 + case TypeHetzner:
50 + return hetzner.New(cfg.HetznerAPIToken), nil
51 case TypeRoute53:
52 return route53.New(route53.Config{
53 AccessKeyID: cfg.AWSAccessKeyID,