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,