feat: add acme route53 support
rabbitprincess committed
Mar 7, 2026 at 00:11 UTC
4078c17441bb7555c7aa7be662e7a05d3b2d32db
19 files changed
+955
-322
.env.example
+10
-2
@@ -8,11 +8,19 @@ SNI_PORT=443
8
9
# TLS/ACME and keyless materials
10
KEYLESS_DIR=./.portal-certs
11
+# Supported values: cloudflare, route53
12
+ACME_DNS_PROVIDER=cloudflare
13
12
-# Cloudflare API token
13
-# Leave empty for local/no-TLS usage
14
+# Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
15
CLOUDFLARE_TOKEN=
16
17
+# Route53 settings (use static credentials or ambient AWS credentials)
18
+AWS_ACCESS_KEY_ID=
19
+AWS_SECRET_ACCESS_KEY=
20
+AWS_SESSION_TOKEN=
21
+AWS_REGION=
22
+AWS_HOSTED_ZONE_ID=
23
+
24
# Admin/auth configuration
25
ADMIN_SECRET_KEY=
26
# Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
AGENTS.md
+2
-2
@@ -82,9 +82,9 @@ Descriptive docs under `docs/` should match current code paths.
82
2. **`make build-server` does not build the frontend first.**
83
- Why: `cmd/relay-server/dist/*` is embed input; build the frontend explicitly before packaging the relay binary.
84
85
-3. **ACME management keeps both root and wildcard DNS A records in sync for non-localhost deployments.**
85
+3. **ACME management supports only `cloudflare` and `route53`, and keeps both root and wildcard DNS A records in sync for non-localhost deployments.**
86
- Certificates and keys live under `KEYLESS_DIR` as `fullchain.pem` and `privatekey.pem`.
87
- - Localhost uses the development certificate path instead of Cloudflare-managed ACME.
87
+ - Localhost uses the development certificate path instead of DNS-provider-managed ACME.
88
89
## Change Discipline
90
cmd/relay-server/main.go
+34
-10
@@ -19,15 +19,21 @@ const (
19
)
20
21
type relayServerConfig struct {
22
- PortalURL string
23
- Bootstraps []string
24
- APIPort int
25
- SNIPort int
26
- AdminSecretKey string
27
- TrustProxyHeaders bool
28
- TrustedProxyCIDRs string
29
- KeylessDir string
30
- CloudflareToken string
22
+ PortalURL string
23
+ Bootstraps []string
24
+ APIPort int
25
+ SNIPort int
26
+ AdminSecretKey string
27
+ TrustProxyHeaders bool
28
+ TrustedProxyCIDRs string
29
+ KeylessDir string
30
+ ACMEDNSProvider string
31
+ CloudflareToken string
32
+ AWSAccessKeyID string
33
+ AWSSecretAccessKey string
34
+ AWSSessionToken string
35
+ AWSRegion string
36
+ AWSHostedZoneID string
37
}
38
39
func main() {
@@ -53,7 +59,19 @@ func main() {
59
if keylessDir == "" {
60
keylessDir = defaultKeylessDir
61
}
62
+ acmeDNSProvider := trimmedEnv("ACME_DNS_PROVIDER")
63
+ if acmeDNSProvider == "" {
64
+ acmeDNSProvider = "cloudflare"
65
+ }
66
cloudflareToken := trimmedEnv("CLOUDFLARE_TOKEN")
67
+ awsAccessKeyID := trimmedEnv("AWS_ACCESS_KEY_ID")
68
+ awsSecretAccessKey := trimmedEnv("AWS_SECRET_ACCESS_KEY")
69
+ awsSessionToken := trimmedEnv("AWS_SESSION_TOKEN")
70
+ awsRegion := trimmedEnv("AWS_REGION")
71
+ if awsRegion == "" {
72
+ awsRegion = trimmedEnv("AWS_DEFAULT_REGION")
73
+ }
74
+ awsHostedZoneID := trimmedEnv("AWS_HOSTED_ZONE_ID")
75
76
flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
77
flag.StringVar(&bootstrapsCSV, "bootstraps", bootstrapsCSV, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
@@ -65,7 +83,13 @@ func main() {
83
flag.StringVar(&cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", trustedProxyCIDRs, "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled (env: TRUSTED_PROXY_CIDRS)")
84
85
flag.StringVar(&cfg.KeylessDir, "keyless-dir", keylessDir, "directory path for relay keyless materials (env: KEYLESS_DIR)")
68
- flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (Zone:Read + DNS:Edit) (env: CLOUDFLARE_TOKEN)")
86
+ flag.StringVar(&cfg.ACMEDNSProvider, "acme-dns-provider", acmeDNSProvider, "ACME DNS provider for DNS-01 and A-record sync (cloudflare|route53) (env: ACME_DNS_PROVIDER)")
87
+ flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (required when acme-dns-provider=cloudflare) (env: CLOUDFLARE_TOKEN)")
88
+ flag.StringVar(&cfg.AWSAccessKeyID, "aws-access-key-id", awsAccessKeyID, "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted (env: AWS_ACCESS_KEY_ID)")
89
+ flag.StringVar(&cfg.AWSSecretAccessKey, "aws-secret-access-key", awsSecretAccessKey, "AWS secret access key for Route53 static credentials (env: AWS_SECRET_ACCESS_KEY)")
90
+ flag.StringVar(&cfg.AWSSessionToken, "aws-session-token", awsSessionToken, "AWS session token for Route53 temporary credentials (env: AWS_SESSION_TOKEN)")
91
+ flag.StringVar(&cfg.AWSRegion, "aws-region", awsRegion, "AWS region for Route53 and Route53-backed DNS-01; defaults to us-east-1 when unset (env: AWS_REGION or AWS_DEFAULT_REGION)")
92
+ flag.StringVar(&cfg.AWSHostedZoneID, "aws-hosted-zone-id", awsHostedZoneID, "explicit Route53 hosted zone ID override (env: AWS_HOSTED_ZONE_ID)")
93
flag.Parse()
94
95
cfg.Bootstraps = parseURLs(bootstrapsCSV)
cmd/relay-server/serve.go
+10
-3
@@ -38,9 +38,15 @@ func runServer(cfg relayServerConfig) error {
38
policy.SetTrustedProxyCIDRs(trustedProxyCIDRs)
39
40
acmeManager, err := acme.NewManager(acme.Config{
41
- BaseDomain: rootHost,
42
- KeyDir: cfg.KeylessDir,
43
- CloudflareToken: cfg.CloudflareToken,
41
+ BaseDomain: rootHost,
42
+ KeyDir: cfg.KeylessDir,
43
+ DNSProvider: cfg.ACMEDNSProvider,
44
+ CloudflareToken: cfg.CloudflareToken,
45
+ AWSAccessKeyID: cfg.AWSAccessKeyID,
46
+ AWSSecretAccessKey: cfg.AWSSecretAccessKey,
47
+ AWSSessionToken: cfg.AWSSessionToken,
48
+ AWSRegion: cfg.AWSRegion,
49
+ AWSHostedZoneID: cfg.AWSHostedZoneID,
50
})
51
if err != nil {
52
return fmt.Errorf("create acme manager: %w", err)
@@ -100,6 +106,7 @@ func runServer(cfg relayServerConfig) error {
106
Str("api_addr", portal.HostPortOrLoopback(server.APIAddr())).
107
Str("sni_addr", server.SNIAddr()).
108
Str("root_host", rootHost).
109
+ Str("acme_dns_provider", cfg.ACMEDNSProvider).
110
Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1").
111
Msg("relay server started")
112
docker-compose.yml
+8
-1
@@ -1,6 +1,6 @@
1
services:
2
portal:
3
- image: ghcr.io/gosuda/portal:1
3
+ image: ghcr.io/gosuda/portal:2
4
build:
5
context: .
6
dockerfile: Dockerfile
@@ -20,7 +20,14 @@ services:
20
21
# TLS/ACME and keyless materials
22
KEYLESS_DIR: ${KEYLESS_DIR:-.portal-certs}
23
+ ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
24
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
25
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
26
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
27
+ AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
28
+ AWS_REGION: ${AWS_REGION:-}
29
+ AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
30
+ AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
31
ports:
32
- "${API_PORT:-4017}:${API_PORT:-4017}"
33
- "443:443"
docs/architecture.md
+2
-2
@@ -49,7 +49,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
49
- `routeTable`: exact + single-label wildcard hostname lookup
50
- `leaseBroker`: per-lease ready queue for reverse sessions
51
- `reverseSession`: idle keepalive + activation state machine for one reverse TCP connection
52
-- `acme`: Cloudflare DNS sync + certificate provisioning/renewal for the relay root host and wildcard
52
+- `acme`: Cloudflare/Route53-backed root/wildcard A-record sync + certificate provisioning/renewal for the relay root host and wildcard
53
- `keyless`: admin/API TLS attach helpers and tenant-side signer integration
54
55
### SDK (`sdk/`)
@@ -170,7 +170,7 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
170
- Relay admin/API TLS uses the certificate in `KEYLESS_DIR`
171
- `fullchain.pem`
172
- `privatekey.pem`
173
-- For non-localhost deployments, ACME DNS-01 uses Cloudflare and keeps:
173
+- For non-localhost deployments, ACME DNS-01 currently supports `cloudflare` and `route53`, and keeps:
174
- root host A record
175
- wildcard host A record
176
- relay certificate renewal
docs/deployment.md
+54
-6
@@ -10,18 +10,29 @@ You need:
10
- A public Linux server with a static public IP
11
- Open inbound ports: `443/tcp`, `4017/tcp`
12
- Docker and Docker Compose
13
-- A Cloudflare-managed DNS zone (required for ACME DNS-01 automation)
13
+- A DNS provider account for ACME DNS-01 automation with a supported provider (`cloudflare` or `route53`)
14
15
-## 2. DNS and Cloudflare Setup
15
+## 2. DNS Provider Setup
16
17
-### 2.1 Add Domain to Cloudflare
17
+### 2.1 Choose ACME DNS provider
18
+
19
+Set `ACME_DNS_PROVIDER` to one of the currently supported values:
20
+
21
+- `ACME_DNS_PROVIDER=cloudflare`, or
22
+- `ACME_DNS_PROVIDER=route53`
23
+
24
+Both providers keep root and wildcard A records synchronized to the relay public IPv4 and use DNS-01 for certificate issuance.
25
+
26
+### 2.2 Cloudflare setup (`ACME_DNS_PROVIDER=cloudflare`)
27
+
28
+#### Add domain to Cloudflare
29
30
1. Cloudflare Dashboard -> `Websites` -> `Add a Site`
31
2. Enter your domain (`example.com`)
32
3. Complete onboarding and apply Cloudflare nameservers at your registrar
33
4. Wait until zone status is `Active`
34
24
-### 2.2 Create DNS Records
35
+#### Create DNS records
36
37
Cloudflare Dashboard -> `DNS` -> `Records`:
38
@@ -49,7 +60,7 @@ If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.exampl
60
Portal derives public lease hostnames from the normalized `PORTAL_URL` host.
61
Requests to the exact root host are not served by the wildcard route; they fall back to the admin/API listener.
62
52
-### 2.3 Create Cloudflare API Token
63
+#### Create Cloudflare API token
64
65
Cloudflare Dashboard -> `My Profile` -> `API Tokens` -> `Create Token`.
66
@@ -64,6 +75,29 @@ Scope:
75
76
Save this token for `CLOUDFLARE_TOKEN`.
77
78
+### 2.3 Route53 setup (`ACME_DNS_PROVIDER=route53`)
79
+
80
+Create or select a public hosted zone that covers your `PORTAL_URL` root host and provide Route53 write permissions through either static AWS credentials or ambient AWS credentials (for example, an instance role).
81
+
82
+Static credential environment variables:
83
+
84
+- `AWS_ACCESS_KEY_ID`
85
+- `AWS_SECRET_ACCESS_KEY`
86
+- Optional `AWS_SESSION_TOKEN` for temporary credentials
87
+- `AWS_REGION` (for example, `us-east-1`)
88
+
89
+Optional:
90
+
91
+- `AWS_HOSTED_ZONE_ID` (when omitted, relay selects a matching public hosted zone by domain suffix)
92
+
93
+Equivalent relay flags:
94
+
95
+- `--aws-access-key-id`
96
+- `--aws-secret-access-key`
97
+- `--aws-session-token`
98
+- `--aws-region`
99
+- `--aws-hosted-zone-id`
100
+
101
## 3. Relay Runtime Behavior
102
103
### 3.1 Control Plane and Reverse Sessions
@@ -81,7 +115,7 @@ Save this token for `CLOUDFLARE_TOKEN`.
115
- Relay certificates live in `KEYLESS_DIR`:
116
- `fullchain.pem`
117
- `privatekey.pem`
84
-- On non-localhost deployments, ACME DNS-01 uses the Cloudflare token to:
118
+- On non-localhost deployments, ACME DNS-01 uses the configured supported DNS provider to:
119
- ensure root and wildcard A records point to the current public IP
120
- provision the relay certificate
121
- keep DNS and certificate state refreshed over time
@@ -96,9 +130,23 @@ BOOTSTRAP_URIS=https://example.com
130
SNI_PORT=443
131
ADMIN_SECRET_KEY=your-admin-secret
132
KEYLESS_DIR=.portal-certs
133
+ACME_DNS_PROVIDER=cloudflare
134
CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
135
```
136
137
+Route53 example:
138
+
139
+```bash
140
+KEYLESS_DIR=.portal-certs
141
+ACME_DNS_PROVIDER=route53
142
+AWS_ACCESS_KEY_ID=AKIA...
143
+AWS_SECRET_ACCESS_KEY=...
144
+AWS_SESSION_TOKEN=...
145
+AWS_REGION=us-east-1
146
+# Optional override
147
+AWS_HOSTED_ZONE_ID=Z1234567890ABC
148
+```
149
+
150
For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value (for example, `https://portal.example.com:8443`).
151
`PORTAL_URL` path/query segments are ignored for route derivation; only the host component is used.
152
docs/glossary.md
+1
-1
@@ -72,7 +72,7 @@ This keeps the relay out of the tenant data plane while avoiding direct private-
72
## ACME DNS-01
73
74
The relay certificate issuance and renewal path for non-localhost deployments.
75
-It uses a Cloudflare DNS API token to provision the root and wildcard certificate coverage used by the relay.
75
+It currently supports `cloudflare` and `route53` to provision the root and wildcard certificate coverage used by the relay.
76
77
## Base Domain / Root Host
78
go.mod
+15
@@ -3,6 +3,10 @@ module gosuda.org/portal/v2
3
go 1.26.0
4
5
require (
6
+ github.com/aws/aws-sdk-go-v2 v1.41.1
7
+ github.com/aws/aws-sdk-go-v2/config v1.32.8
8
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.8
9
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
10
github.com/go-acme/lego/v4 v4.32.0
11
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
12
github.com/rs/zerolog v1.34.0
@@ -11,6 +15,17 @@ require (
15
)
16
17
require (
18
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
19
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
20
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
21
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
22
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
23
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
24
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
25
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
26
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
27
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
28
+ github.com/aws/smithy-go v1.24.0 // indirect
29
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
30
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
31
github.com/mattn/go-colorable v0.1.13 // indirect
go.sum
+30
@@ -1,3 +1,33 @@
1
+github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
2
+github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
3
+github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
4
+github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
5
+github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0=
6
+github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY=
7
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
8
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
9
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
10
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
11
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
12
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
13
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
14
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
15
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
16
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
17
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
18
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
19
+github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
20
+github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
21
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
22
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
23
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
24
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
25
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
26
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
27
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
28
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
29
+github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
30
+github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
31
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
32
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
33
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
portal/acme/acme.go
+87
-21
@@ -12,7 +12,9 @@ import (
12
"encoding/pem"
13
"errors"
14
"fmt"
15
+ "io"
16
"net"
17
+ "net/http"
18
"os"
19
"path/filepath"
20
"strings"
@@ -22,7 +24,6 @@ import (
24
"github.com/go-acme/lego/v4/certcrypto"
25
"github.com/go-acme/lego/v4/certificate"
26
lego "github.com/go-acme/lego/v4/lego"
25
- "github.com/go-acme/lego/v4/providers/dns/cloudflare"
27
"github.com/go-acme/lego/v4/registration"
28
"github.com/rs/zerolog/log"
29
)
@@ -39,9 +40,15 @@ const (
40
)
41
42
type Config struct {
42
- BaseDomain string
43
- KeyDir string
44
- CloudflareToken string
43
+ BaseDomain string
44
+ KeyDir string
45
+ DNSProvider string
46
+ CloudflareToken string
47
+ AWSAccessKeyID string
48
+ AWSSecretAccessKey string
49
+ AWSSessionToken string
50
+ AWSRegion string
51
+ AWSHostedZoneID string
52
}
53
54
type Manager struct {
@@ -49,6 +56,7 @@ type Manager struct {
56
cfg Config
57
wg sync.WaitGroup
58
mu sync.RWMutex
59
+ dns DNSProvider
60
startOnce sync.Once
61
stopOnce sync.Once
62
}
@@ -59,7 +67,7 @@ type provisionConfig struct {
67
AccountKeyFile string
68
RegistrationFile string
69
Email string
62
- CloudflareToken string
70
+ DNSProvider DNSProvider
71
Domains []string
72
}
73
@@ -72,7 +80,13 @@ type acmeUser struct {
80
func NewManager(cfg Config) (*Manager, error) {
81
cfg.BaseDomain = normalizeHost(cfg.BaseDomain)
82
cfg.KeyDir = strings.TrimSpace(cfg.KeyDir)
83
+ cfg.DNSProvider = strings.ToLower(strings.TrimSpace(cfg.DNSProvider))
84
cfg.CloudflareToken = strings.TrimSpace(cfg.CloudflareToken)
85
+ cfg.AWSAccessKeyID = strings.TrimSpace(cfg.AWSAccessKeyID)
86
+ cfg.AWSSecretAccessKey = strings.TrimSpace(cfg.AWSSecretAccessKey)
87
+ cfg.AWSSessionToken = strings.TrimSpace(cfg.AWSSessionToken)
88
+ cfg.AWSRegion = strings.TrimSpace(cfg.AWSRegion)
89
+ cfg.AWSHostedZoneID = strings.TrimSpace(cfg.AWSHostedZoneID)
90
91
if cfg.KeyDir == "" {
92
return nil, errors.New("acme key directory is required")
@@ -80,10 +94,30 @@ func NewManager(cfg Config) (*Manager, error) {
94
if cfg.BaseDomain == "" {
95
return nil, errors.New("acme base domain is required")
96
}
97
+ if isLocalhost(cfg.BaseDomain) {
98
+ return &Manager{
99
+ cfg: cfg,
100
+ stopCh: make(chan struct{}),
101
+ }, nil
102
+ }
103
+
104
+ dns, err := NewDNSProvider(DNSProviderConfig{
105
+ Type: cfg.DNSProvider,
106
+ CloudflareToken: cfg.CloudflareToken,
107
+ AWSAccessKeyID: cfg.AWSAccessKeyID,
108
+ AWSSecretAccessKey: cfg.AWSSecretAccessKey,
109
+ AWSSessionToken: cfg.AWSSessionToken,
110
+ AWSRegion: cfg.AWSRegion,
111
+ AWSHostedZoneID: cfg.AWSHostedZoneID,
112
+ })
113
+ if err != nil {
114
+ return nil, fmt.Errorf("create acme dns provider: %w", err)
115
+ }
116
117
return &Manager{
118
cfg: cfg,
119
stopCh: make(chan struct{}),
120
+ dns: dns,
121
}, nil
122
}
123
@@ -99,10 +133,6 @@ func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error)
133
return m.TLSFiles()
134
}
135
102
- if m.cfg.CloudflareToken == "" {
103
- return "", "", errors.New("cloudflare token is required for non-local relay certificates")
104
- }
105
-
136
if err := m.syncDNS(ctx); err != nil {
137
return "", "", fmt.Errorf("ensure dns records: %w", err)
138
}
@@ -122,7 +152,7 @@ func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error)
152
}
153
154
func (m *Manager) Start(ctx context.Context) {
125
- if m == nil || isLocalhost(m.cfg.BaseDomain) || m.cfg.CloudflareToken == "" {
155
+ if m == nil || isLocalhost(m.cfg.BaseDomain) {
156
return
157
}
158
@@ -161,7 +191,7 @@ func (m *Manager) provision(ctx context.Context) error {
191
AccountKeyFile: filepath.Join(m.cfg.KeyDir, accountKeyFileName),
192
RegistrationFile: filepath.Join(m.cfg.KeyDir, registrationFileName),
193
Email: defaultACMEEmailPrefix + m.cfg.BaseDomain,
164
- CloudflareToken: m.cfg.CloudflareToken,
194
+ DNSProvider: m.dns,
195
Domains: certificateDomains(m.cfg.BaseDomain),
196
}
197
@@ -174,7 +204,7 @@ func (m *Manager) provision(ctx context.Context) error {
204
return fmt.Errorf("acme provisioning canceled: %w", err)
205
}
206
177
- client, _, err := newClient(cfg)
207
+ client, _, err := newClient(ctx, cfg)
208
if err != nil {
209
return err
210
}
@@ -235,10 +265,19 @@ func (m *Manager) maintenanceLoop(ctx context.Context) {
265
}
266
267
func (m *Manager) syncDNS(ctx context.Context) error {
238
- if m == nil || isLocalhost(m.cfg.BaseDomain) || m.cfg.CloudflareToken == "" {
268
+ if m == nil || isLocalhost(m.cfg.BaseDomain) {
269
return nil
270
}
241
- return EnsureDNSRecords(ctx, m.cfg.BaseDomain, m.cfg.CloudflareToken)
271
+ if m.dns == nil {
272
+ return errors.New("acme dns provider is required")
273
+ }
274
+
275
+ publicIP, err := detectPublicIPv4(ctx)
276
+ if err != nil {
277
+ return fmt.Errorf("detect public ip: %w", err)
278
+ }
279
+
280
+ return m.dns.EnsureARecords(ctx, m.cfg.BaseDomain, publicIP)
281
}
282
283
func (m *Manager) shouldRenew() bool {
@@ -300,7 +339,7 @@ func certificateCoversHostname(cert *x509.Certificate, hostname string) bool {
339
return cert != nil && cert.VerifyHostname(hostname) == nil
340
}
341
303
-func newClient(cfg provisionConfig) (*lego.Client, *acmeUser, error) {
342
+func newClient(ctx context.Context, cfg provisionConfig) (*lego.Client, *acmeUser, error) {
343
accountKey, err := loadOrCreateAccountKey(cfg.AccountKeyFile)
344
if err != nil {
345
return nil, nil, fmt.Errorf("load acme account key: %w", err)
@@ -325,14 +364,14 @@ func newClient(cfg provisionConfig) (*lego.Client, *acmeUser, error) {
364
return nil, nil, fmt.Errorf("create acme client: %w", err)
365
}
366
328
- cfConfig := cloudflare.NewDefaultConfig()
329
- cfConfig.AuthToken = cfg.CloudflareToken
330
-
331
- provider, err := cloudflare.NewDNSProviderConfig(cfConfig)
367
+ if cfg.DNSProvider == nil {
368
+ return nil, nil, errors.New("acme dns provider is required")
369
+ }
370
+ challengeProvider, err := cfg.DNSProvider.ChallengeProvider(ctx)
371
if err != nil {
333
- return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
372
+ return nil, nil, fmt.Errorf("create dns challenge provider: %w", err)
373
}
335
- if err := client.Challenge.SetDNS01Provider(provider); err != nil {
374
+ if err := client.Challenge.SetDNS01Provider(challengeProvider); err != nil {
375
return nil, nil, fmt.Errorf("set dns01 provider: %w", err)
376
}
377
@@ -497,3 +536,30 @@ func isLocalhost(host string) bool {
536
}
537
return strings.HasSuffix(host, ".localhost")
538
}
539
+
540
+func detectPublicIPv4(ctx context.Context) (string, error) {
541
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
542
+ defer cancel()
543
+
544
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api4.ipify.org", nil)
545
+ if err != nil {
546
+ return "", err
547
+ }
548
+
549
+ resp, err := http.DefaultClient.Do(req)
550
+ if err != nil {
551
+ return "", err
552
+ }
553
+ defer resp.Body.Close()
554
+
555
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
556
+ if err != nil {
557
+ return "", err
558
+ }
559
+ ip := strings.TrimSpace(string(body))
560
+ parsed := net.ParseIP(ip)
561
+ if parsed == nil || parsed.To4() == nil {
562
+ return "", fmt.Errorf("invalid ipv4 address: %q", ip)
563
+ }
564
+ return ip, nil
565
+}
portal/acme/cloudflare/provider.go
new
+279
@@ -0,0 +1,279 @@
1
+package cloudflare
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "net/http"
10
+ "net/url"
11
+ "strings"
12
+
13
+ "github.com/go-acme/lego/v4/challenge"
14
+ "github.com/go-acme/lego/v4/providers/dns/cloudflare"
15
+)
16
+
17
+const (
18
+ apiBase = "https://api.cloudflare.com/client/v4"
19
+)
20
+
21
+type Provider struct {
22
+ token string
23
+}
24
+
25
+type apiError struct {
26
+ Message string `json:"message"`
27
+ Code int `json:"code"`
28
+}
29
+
30
+type zone struct {
31
+ ID string `json:"id"`
32
+ Name string `json:"name"`
33
+}
34
+
35
+type dnsRecord struct {
36
+ ID string `json:"id"`
37
+ Type string `json:"type"`
38
+ Name string `json:"name"`
39
+ Content string `json:"content"`
40
+}
41
+
42
+type zonesResult struct {
43
+ Errors []apiError `json:"errors"`
44
+ Result []zone `json:"result"`
45
+ Success bool `json:"success"`
46
+}
47
+
48
+type recordsResult struct {
49
+ Errors []apiError `json:"errors"`
50
+ Result []dnsRecord `json:"result"`
51
+ Success bool `json:"success"`
52
+}
53
+
54
+type recordResult struct {
55
+ Result dnsRecord `json:"result"`
56
+ Errors []apiError `json:"errors"`
57
+ Success bool `json:"success"`
58
+}
59
+
60
+func New(token string) *Provider {
61
+ return &Provider{token: strings.TrimSpace(token)}
62
+}
63
+
64
+func (p *Provider) Name() string {
65
+ return "cloudflare"
66
+}
67
+
68
+func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
69
+ if p == nil {
70
+ return nil, errors.New("cloudflare provider is nil")
71
+ }
72
+ if p.token == "" {
73
+ return nil, errors.New("cloudflare token is required")
74
+ }
75
+
76
+ cfg := cloudflare.NewDefaultConfig()
77
+ cfg.AuthToken = p.token
78
+
79
+ provider, err := cloudflare.NewDNSProviderConfig(cfg)
80
+ if err != nil {
81
+ return nil, fmt.Errorf("create cloudflare lego provider: %w", err)
82
+ }
83
+ return provider, nil
84
+}
85
+
86
+func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
87
+ if p == nil {
88
+ return errors.New("cloudflare provider is nil")
89
+ }
90
+ baseDomain = normalizeHost(baseDomain)
91
+ if baseDomain == "" {
92
+ return errors.New("base domain is required")
93
+ }
94
+ if p.token == "" {
95
+ return errors.New("cloudflare token is required")
96
+ }
97
+ publicIPv4 = strings.TrimSpace(publicIPv4)
98
+ if publicIPv4 == "" {
99
+ return errors.New("public ipv4 is required")
100
+ }
101
+
102
+ zoneID, err := findZoneID(ctx, p.token, baseDomain)
103
+ if err != nil {
104
+ return fmt.Errorf("find cloudflare zone: %w", err)
105
+ }
106
+
107
+ for _, name := range []string{baseDomain, "*." + baseDomain} {
108
+ if err := ensureARecord(ctx, p.token, zoneID, name, publicIPv4); err != nil {
109
+ return fmt.Errorf("ensure A record for %s: %w", name, err)
110
+ }
111
+ }
112
+ return nil
113
+}
114
+
115
+func findZoneID(ctx context.Context, token, domain string) (string, error) {
116
+ parts := strings.Split(domain, ".")
117
+ for i := range len(parts) - 1 {
118
+ candidate := strings.Join(parts[i:], ".")
119
+ zones, err := listZones(ctx, token, candidate)
120
+ if err != nil {
121
+ return "", err
122
+ }
123
+ for _, z := range zones {
124
+ if strings.EqualFold(z.Name, candidate) {
125
+ return z.ID, nil
126
+ }
127
+ }
128
+ }
129
+ return "", fmt.Errorf("no cloudflare zone found for %s", domain)
130
+}
131
+
132
+func ensureARecord(ctx context.Context, token, zoneID, name, ip string) error {
133
+ records, err := listDNSRecords(ctx, token, zoneID, name, "A")
134
+ if err != nil {
135
+ return err
136
+ }
137
+
138
+ for _, record := range records {
139
+ if !strings.EqualFold(record.Name, name) {
140
+ continue
141
+ }
142
+ if record.Content == ip {
143
+ return nil
144
+ }
145
+ return updateDNSRecord(ctx, token, zoneID, record.ID, name, ip)
146
+ }
147
+
148
+ return createDNSRecord(ctx, token, zoneID, name, ip)
149
+}
150
+
151
+func listZones(ctx context.Context, token, name string) ([]zone, error) {
152
+ u, _ := url.Parse(apiBase + "/zones")
153
+ q := u.Query()
154
+ q.Set("name", name)
155
+ u.RawQuery = q.Encode()
156
+
157
+ var out zonesResult
158
+ if err := doGet(ctx, token, u.String(), &out); err != nil {
159
+ return nil, err
160
+ }
161
+ if !out.Success {
162
+ return nil, wrapErrors(out.Errors)
163
+ }
164
+ return out.Result, nil
165
+}
166
+
167
+func listDNSRecords(ctx context.Context, token, zoneID, name, recordType string) ([]dnsRecord, error) {
168
+ u, _ := url.Parse(fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID))
169
+ q := u.Query()
170
+ q.Set("name", name)
171
+ q.Set("type", recordType)
172
+ u.RawQuery = q.Encode()
173
+
174
+ var out recordsResult
175
+ if err := doGet(ctx, token, u.String(), &out); err != nil {
176
+ return nil, err
177
+ }
178
+ if !out.Success {
179
+ return nil, wrapErrors(out.Errors)
180
+ }
181
+ return out.Result, nil
182
+}
183
+
184
+func createDNSRecord(ctx context.Context, token, zoneID, name, ip string) error {
185
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID)
186
+ body := map[string]any{
187
+ "type": "A",
188
+ "name": name,
189
+ "content": ip,
190
+ "ttl": 1,
191
+ "proxied": false,
192
+ }
193
+
194
+ var out recordResult
195
+ if err := doMutate(ctx, http.MethodPost, token, endpoint, body, &out); err != nil {
196
+ return err
197
+ }
198
+ if !out.Success {
199
+ return wrapErrors(out.Errors)
200
+ }
201
+ return nil
202
+}
203
+
204
+func updateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip string) error {
205
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
206
+ body := map[string]any{
207
+ "type": "A",
208
+ "name": name,
209
+ "content": ip,
210
+ "ttl": 1,
211
+ "proxied": false,
212
+ }
213
+
214
+ var out recordResult
215
+ if err := doMutate(ctx, http.MethodPut, token, endpoint, body, &out); err != nil {
216
+ return err
217
+ }
218
+ if !out.Success {
219
+ return wrapErrors(out.Errors)
220
+ }
221
+ return nil
222
+}
223
+
224
+func doGet(ctx context.Context, token, rawURL string, out any) error {
225
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
226
+ if err != nil {
227
+ return err
228
+ }
229
+ req.Header.Set("Authorization", "Bearer "+token)
230
+ req.Header.Set("Content-Type", "application/json")
231
+
232
+ resp, err := http.DefaultClient.Do(req)
233
+ if err != nil {
234
+ return err
235
+ }
236
+ defer resp.Body.Close()
237
+
238
+ return json.NewDecoder(resp.Body).Decode(out)
239
+}
240
+
241
+func doMutate(ctx context.Context, method, token, rawURL string, body any, out any) error {
242
+ payload, err := json.Marshal(body)
243
+ if err != nil {
244
+ return err
245
+ }
246
+
247
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(payload))
248
+ if err != nil {
249
+ return err
250
+ }
251
+ req.Header.Set("Authorization", "Bearer "+token)
252
+ req.Header.Set("Content-Type", "application/json")
253
+
254
+ resp, err := http.DefaultClient.Do(req)
255
+ if err != nil {
256
+ return err
257
+ }
258
+ defer resp.Body.Close()
259
+
260
+ return json.NewDecoder(resp.Body).Decode(out)
261
+}
262
+
263
+func wrapErrors(errs []apiError) error {
264
+ if len(errs) == 0 {
265
+ return errors.New("cloudflare api request failed")
266
+ }
267
+ messages := make([]string, 0, len(errs))
268
+ for _, apiErr := range errs {
269
+ messages = append(messages, fmt.Sprintf("[%d] %s", apiErr.Code, apiErr.Message))
270
+ }
271
+ return errors.New(strings.Join(messages, "; "))
272
+}
273
+
274
+func normalizeHost(host string) string {
275
+ host = strings.ToLower(strings.TrimSpace(host))
276
+ host = strings.TrimPrefix(host, "*.")
277
+ host = strings.TrimSuffix(host, ".")
278
+ return host
279
+}
portal/acme/cloudflare/provider_test.go
new
+16
@@ -0,0 +1,16 @@
1
+package cloudflare
2
+
3
+import (
4
+ "context"
5
+ "testing"
6
+)
7
+
8
+func TestChallengeProviderRequiresToken(t *testing.T) {
9
+ t.Parallel()
10
+
11
+ provider := New("")
12
+ _, err := provider.ChallengeProvider(context.Background())
13
+ if err == nil {
14
+ t.Fatal("ChallengeProvider() error = nil, want error")
15
+ }
16
+}
portal/acme/dnsrecord.go
deleted
-268
@@ -1,268 +0,0 @@
1
-package acme
2
-
3
-import (
4
- "bytes"
5
- "context"
6
- "encoding/json"
7
- "errors"
8
- "fmt"
9
- "io"
10
- "net"
11
- "net/http"
12
- "net/url"
13
- "strings"
14
- "time"
15
-)
16
-
17
-const (
18
- cfAPIBase = "https://api.cloudflare.com/client/v4"
19
- publicIPURL = "https://api4.ipify.org"
20
- dnsHTTPTimeout = 15 * time.Second
21
- dnsAutoTTL = 1
22
-)
23
-
24
-type cfError struct {
25
- Message string `json:"message"`
26
- Code int `json:"code"`
27
-}
28
-
29
-type cfZone struct {
30
- ID string `json:"id"`
31
- Name string `json:"name"`
32
-}
33
-
34
-type cfDNSRecord struct {
35
- ID string `json:"id"`
36
- Type string `json:"type"`
37
- Name string `json:"name"`
38
- Content string `json:"content"`
39
-}
40
-
41
-type cfZonesResult struct {
42
- Errors []cfError `json:"errors"`
43
- Result []cfZone `json:"result"`
44
- Success bool `json:"success"`
45
-}
46
-
47
-type cfRecordsResult struct {
48
- Errors []cfError `json:"errors"`
49
- Result []cfDNSRecord `json:"result"`
50
- Success bool `json:"success"`
51
-}
52
-
53
-type cfRecordResult struct {
54
- Result cfDNSRecord `json:"result"`
55
- Errors []cfError `json:"errors"`
56
- Success bool `json:"success"`
57
-}
58
-
59
-func EnsureDNSRecords(ctx context.Context, baseDomain, cloudflareToken string) error {
60
- baseDomain = normalizeHost(baseDomain)
61
- cloudflareToken = strings.TrimSpace(cloudflareToken)
62
-
63
- if baseDomain == "" || cloudflareToken == "" || isLocalhost(baseDomain) {
64
- return nil
65
- }
66
-
67
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
68
- defer cancel()
69
-
70
- publicIP, err := detectPublicIP(ctx)
71
- if err != nil {
72
- return fmt.Errorf("detect public ip: %w", err)
73
- }
74
-
75
- zoneID, err := findZoneID(ctx, cloudflareToken, baseDomain)
76
- if err != nil {
77
- return fmt.Errorf("find cloudflare zone: %w", err)
78
- }
79
-
80
- for _, name := range []string{baseDomain, "*." + baseDomain} {
81
- if err := ensureARecord(ctx, cloudflareToken, zoneID, name, publicIP); err != nil {
82
- return fmt.Errorf("ensure A record for %s: %w", name, err)
83
- }
84
- }
85
- return nil
86
-}
87
-
88
-func detectPublicIP(ctx context.Context) (string, error) {
89
- ctx, cancel := context.WithTimeout(ctx, dnsHTTPTimeout)
90
- defer cancel()
91
-
92
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, publicIPURL, nil)
93
- if err != nil {
94
- return "", err
95
- }
96
- resp, err := http.DefaultClient.Do(req)
97
- if err != nil {
98
- return "", err
99
- }
100
- defer resp.Body.Close()
101
-
102
- body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
103
- if err != nil {
104
- return "", err
105
- }
106
- ip := strings.TrimSpace(string(body))
107
- parsed := net.ParseIP(ip)
108
- if parsed == nil || parsed.To4() == nil {
109
- return "", fmt.Errorf("invalid ipv4 address: %q", ip)
110
- }
111
- return ip, nil
112
-}
113
-
114
-func findZoneID(ctx context.Context, token, domain string) (string, error) {
115
- parts := strings.Split(domain, ".")
116
- for i := range len(parts) - 1 {
117
- candidate := strings.Join(parts[i:], ".")
118
- zones, err := cfListZones(ctx, token, candidate)
119
- if err != nil {
120
- return "", err
121
- }
122
- for _, zone := range zones {
123
- if strings.EqualFold(zone.Name, candidate) {
124
- return zone.ID, nil
125
- }
126
- }
127
- }
128
- return "", fmt.Errorf("no cloudflare zone found for %s", domain)
129
-}
130
-
131
-func ensureARecord(ctx context.Context, token, zoneID, name, ip string) error {
132
- records, err := cfListDNSRecords(ctx, token, zoneID, name, "A")
133
- if err != nil {
134
- return err
135
- }
136
-
137
- for _, record := range records {
138
- if !strings.EqualFold(record.Name, name) {
139
- continue
140
- }
141
- if record.Content == ip {
142
- return nil
143
- }
144
- return cfUpdateDNSRecord(ctx, token, zoneID, record.ID, name, ip)
145
- }
146
- return cfCreateDNSRecord(ctx, token, zoneID, name, ip)
147
-}
148
-
149
-func cfListZones(ctx context.Context, token, name string) ([]cfZone, error) {
150
- u, _ := url.Parse(cfAPIBase + "/zones")
151
- q := u.Query()
152
- q.Set("name", name)
153
- u.RawQuery = q.Encode()
154
-
155
- var out cfZonesResult
156
- if err := cfGet(ctx, token, u.String(), &out); err != nil {
157
- return nil, err
158
- }
159
- if !out.Success {
160
- return nil, cfErrs(out.Errors)
161
- }
162
- return out.Result, nil
163
-}
164
-
165
-func cfListDNSRecords(ctx context.Context, token, zoneID, name, recordType string) ([]cfDNSRecord, error) {
166
- u, _ := url.Parse(fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID))
167
- q := u.Query()
168
- q.Set("name", name)
169
- q.Set("type", recordType)
170
- u.RawQuery = q.Encode()
171
-
172
- var out cfRecordsResult
173
- if err := cfGet(ctx, token, u.String(), &out); err != nil {
174
- return nil, err
175
- }
176
- if !out.Success {
177
- return nil, cfErrs(out.Errors)
178
- }
179
- return out.Result, nil
180
-}
181
-
182
-func cfCreateDNSRecord(ctx context.Context, token, zoneID, name, ip string) error {
183
- endpoint := fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID)
184
- body := map[string]any{
185
- "type": "A",
186
- "name": name,
187
- "content": ip,
188
- "ttl": dnsAutoTTL,
189
- "proxied": false,
190
- }
191
-
192
- var out cfRecordResult
193
- if err := cfMutate(ctx, http.MethodPost, token, endpoint, body, &out); err != nil {
194
- return err
195
- }
196
- if !out.Success {
197
- return cfErrs(out.Errors)
198
- }
199
- return nil
200
-}
201
-
202
-func cfUpdateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip string) error {
203
- endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
204
- body := map[string]any{
205
- "type": "A",
206
- "name": name,
207
- "content": ip,
208
- "ttl": dnsAutoTTL,
209
- "proxied": false,
210
- }
211
-
212
- var out cfRecordResult
213
- if err := cfMutate(ctx, http.MethodPut, token, endpoint, body, &out); err != nil {
214
- return err
215
- }
216
- if !out.Success {
217
- return cfErrs(out.Errors)
218
- }
219
- return nil
220
-}
221
-
222
-func cfGet(ctx context.Context, token, rawURL string, out any) error {
223
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
224
- if err != nil {
225
- return err
226
- }
227
- req.Header.Set("Authorization", "Bearer "+token)
228
- req.Header.Set("Content-Type", "application/json")
229
-
230
- resp, err := http.DefaultClient.Do(req)
231
- if err != nil {
232
- return err
233
- }
234
- defer resp.Body.Close()
235
- return json.NewDecoder(resp.Body).Decode(out)
236
-}
237
-
238
-func cfMutate(ctx context.Context, method, token, rawURL string, body any, out any) error {
239
- payload, err := json.Marshal(body)
240
- if err != nil {
241
- return err
242
- }
243
-
244
- req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(payload))
245
- if err != nil {
246
- return err
247
- }
248
- req.Header.Set("Authorization", "Bearer "+token)
249
- req.Header.Set("Content-Type", "application/json")
250
-
251
- resp, err := http.DefaultClient.Do(req)
252
- if err != nil {
253
- return err
254
- }
255
- defer resp.Body.Close()
256
- return json.NewDecoder(resp.Body).Decode(out)
257
-}
258
-
259
-func cfErrs(errs []cfError) error {
260
- if len(errs) == 0 {
261
- return errors.New("cloudflare api request failed")
262
- }
263
- messages := make([]string, 0, len(errs))
264
- for _, cfErr := range errs {
265
- messages = append(messages, fmt.Sprintf("[%d] %s", cfErr.Code, cfErr.Message))
266
- }
267
- return errors.New(strings.Join(messages, "; "))
268
-}
portal/acme/provider.go
new
+50
@@ -0,0 +1,50 @@
1
+package acme
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/go-acme/lego/v4/challenge"
9
+
10
+ "gosuda.org/portal/v2/portal/acme/cloudflare"
11
+ "gosuda.org/portal/v2/portal/acme/route53"
12
+)
13
+
14
+const (
15
+ TypeCloudflare = "cloudflare"
16
+ TypeRoute53 = "route53"
17
+)
18
+
19
+type DNSProviderConfig struct {
20
+ Type string
21
+ CloudflareToken string
22
+ AWSAccessKeyID string
23
+ AWSSecretAccessKey string
24
+ AWSSessionToken string
25
+ AWSRegion string
26
+ AWSHostedZoneID string
27
+}
28
+
29
+type DNSProvider interface {
30
+ Name() string
31
+ ChallengeProvider(ctx context.Context) (challenge.Provider, error)
32
+ EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error
33
+}
34
+
35
+func NewDNSProvider(cfg DNSProviderConfig) (DNSProvider, error) {
36
+ switch strings.ToLower(strings.TrimSpace(cfg.Type)) {
37
+ case TypeCloudflare:
38
+ return cloudflare.New(cfg.CloudflareToken), nil
39
+ case TypeRoute53:
40
+ return route53.New(route53.Config{
41
+ AccessKeyID: cfg.AWSAccessKeyID,
42
+ SecretAccessKey: cfg.AWSSecretAccessKey,
43
+ SessionToken: cfg.AWSSessionToken,
44
+ Region: cfg.AWSRegion,
45
+ HostedZoneID: cfg.AWSHostedZoneID,
46
+ }), nil
47
+ default:
48
+ return nil, fmt.Errorf("unsupported acme dns provider: %q", cfg.Type)
49
+ }
50
+}
portal/acme/route53/provider.go
new
+264
@@ -0,0 +1,264 @@
1
+package route53
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "strings"
9
+
10
+ "github.com/aws/aws-sdk-go-v2/aws"
11
+ "github.com/aws/aws-sdk-go-v2/config"
12
+ "github.com/aws/aws-sdk-go-v2/credentials"
13
+ awsroute53 "github.com/aws/aws-sdk-go-v2/service/route53"
14
+ "github.com/aws/aws-sdk-go-v2/service/route53/types"
15
+ "github.com/go-acme/lego/v4/challenge"
16
+ "github.com/go-acme/lego/v4/providers/dns/route53"
17
+)
18
+
19
+const defaultAWSRegion = "us-east-1"
20
+
21
+type Config struct {
22
+ AccessKeyID string
23
+ SecretAccessKey string
24
+ SessionToken string
25
+ Region string
26
+ HostedZoneID string
27
+}
28
+
29
+type Provider struct {
30
+ cfg Config
31
+}
32
+
33
+func New(cfg Config) *Provider {
34
+ return &Provider{
35
+ cfg: Config{
36
+ AccessKeyID: strings.TrimSpace(cfg.AccessKeyID),
37
+ SecretAccessKey: strings.TrimSpace(cfg.SecretAccessKey),
38
+ SessionToken: strings.TrimSpace(cfg.SessionToken),
39
+ Region: strings.TrimSpace(cfg.Region),
40
+ HostedZoneID: normalizeZoneID(cfg.HostedZoneID),
41
+ },
42
+ }
43
+}
44
+
45
+func (p *Provider) Name() string {
46
+ return "route53"
47
+}
48
+
49
+func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
50
+ if p == nil {
51
+ return nil, errors.New("route53 provider is nil")
52
+ }
53
+ if err := validateConfig(p.cfg); err != nil {
54
+ return nil, err
55
+ }
56
+
57
+ cfg := route53.NewDefaultConfig()
58
+ cfg.AccessKeyID = p.cfg.AccessKeyID
59
+ cfg.SecretAccessKey = p.cfg.SecretAccessKey
60
+ cfg.SessionToken = p.cfg.SessionToken
61
+ cfg.Region = p.awsRegion()
62
+ cfg.HostedZoneID = p.cfg.HostedZoneID
63
+
64
+ provider, err := route53.NewDNSProviderConfig(cfg)
65
+ if err != nil {
66
+ return nil, fmt.Errorf("create route53 lego provider: %w", err)
67
+ }
68
+ return provider, nil
69
+}
70
+
71
+func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
72
+ if p == nil {
73
+ return errors.New("route53 provider is nil")
74
+ }
75
+ baseDomain = normalizeHost(baseDomain)
76
+ if baseDomain == "" {
77
+ return errors.New("base domain is required")
78
+ }
79
+ if err := validateIPv4(publicIPv4); err != nil {
80
+ return err
81
+ }
82
+
83
+ client, err := newClient(ctx, p.cfg)
84
+ if err != nil {
85
+ return err
86
+ }
87
+
88
+ hostedZoneID, err := findHostedZoneID(ctx, client, baseDomain, p.cfg.HostedZoneID)
89
+ if err != nil {
90
+ return err
91
+ }
92
+
93
+ for _, recordName := range []string{baseDomain, "*." + baseDomain} {
94
+ if err := upsertARecord(ctx, client, hostedZoneID, recordName, publicIPv4); err != nil {
95
+ return fmt.Errorf("upsert route53 A record %s: %w", recordName, err)
96
+ }
97
+ }
98
+ return nil
99
+}
100
+
101
+func newClient(ctx context.Context, cfg Config) (*awsroute53.Client, error) {
102
+ if err := validateConfig(cfg); err != nil {
103
+ return nil, err
104
+ }
105
+
106
+ loadOptions := []func(*config.LoadOptions) error{
107
+ config.WithRegion(regionOrDefault(cfg.Region)),
108
+ }
109
+ if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" {
110
+ loadOptions = append(loadOptions, config.WithCredentialsProvider(
111
+ credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, cfg.SessionToken),
112
+ ))
113
+ }
114
+
115
+ awsCfg, err := config.LoadDefaultConfig(ctx, loadOptions...)
116
+ if err != nil {
117
+ return nil, fmt.Errorf("load aws config: %w", err)
118
+ }
119
+ return awsroute53.NewFromConfig(awsCfg), nil
120
+}
121
+
122
+func findHostedZoneID(ctx context.Context, client *awsroute53.Client, domain, explicitZoneID string) (string, error) {
123
+ if explicitZoneID = normalizeZoneID(explicitZoneID); explicitZoneID != "" {
124
+ return explicitZoneID, nil
125
+ }
126
+ if client == nil {
127
+ return "", errors.New("route53 client is nil")
128
+ }
129
+
130
+ candidates := domainCandidates(domain)
131
+ if len(candidates) == 0 {
132
+ return "", fmt.Errorf("invalid base domain for hosted zone lookup: %q", domain)
133
+ }
134
+
135
+ zonesByName := make(map[string]string)
136
+ paginator := awsroute53.NewListHostedZonesPaginator(client, &awsroute53.ListHostedZonesInput{})
137
+ for paginator.HasMorePages() {
138
+ page, err := paginator.NextPage(ctx)
139
+ if err != nil {
140
+ return "", fmt.Errorf("list hosted zones: %w", err)
141
+ }
142
+ for _, hostedZone := range page.HostedZones {
143
+ if hostedZone.Config != nil && hostedZone.Config.PrivateZone {
144
+ continue
145
+ }
146
+ zoneName := normalizeHost(aws.ToString(hostedZone.Name))
147
+ zoneID := normalizeZoneID(aws.ToString(hostedZone.Id))
148
+ if zoneName == "" || zoneID == "" {
149
+ continue
150
+ }
151
+ zonesByName[zoneName] = zoneID
152
+ }
153
+ }
154
+
155
+ for _, candidate := range candidates {
156
+ if zoneID, ok := zonesByName[candidate]; ok {
157
+ return zoneID, nil
158
+ }
159
+ }
160
+
161
+ return "", fmt.Errorf("no route53 public hosted zone found for %s", domain)
162
+}
163
+
164
+func upsertARecord(ctx context.Context, client *awsroute53.Client, hostedZoneID, name, ip string) error {
165
+ if client == nil {
166
+ return errors.New("route53 client is nil")
167
+ }
168
+ if hostedZoneID == "" {
169
+ return errors.New("hosted zone id is required")
170
+ }
171
+
172
+ fqdn := ensureTrailingDot(normalizeHost(name))
173
+ recordSet := &types.ResourceRecordSet{
174
+ Name: aws.String(fqdn),
175
+ Type: types.RRTypeA,
176
+ TTL: aws.Int64(60),
177
+ ResourceRecords: []types.ResourceRecord{
178
+ {Value: aws.String(strings.TrimSpace(ip))},
179
+ },
180
+ }
181
+
182
+ _, err := client.ChangeResourceRecordSets(ctx, &awsroute53.ChangeResourceRecordSetsInput{
183
+ HostedZoneId: aws.String(hostedZoneID),
184
+ ChangeBatch: &types.ChangeBatch{
185
+ Comment: aws.String("Managed by Portal ACME"),
186
+ Changes: []types.Change{
187
+ {
188
+ Action: types.ChangeActionUpsert,
189
+ ResourceRecordSet: recordSet,
190
+ },
191
+ },
192
+ },
193
+ })
194
+ if err != nil {
195
+ return err
196
+ }
197
+ return nil
198
+}
199
+
200
+func validateIPv4(raw string) error {
201
+ ip := net.ParseIP(strings.TrimSpace(raw))
202
+ if ip == nil || ip.To4() == nil {
203
+ return fmt.Errorf("invalid ipv4 address: %q", raw)
204
+ }
205
+ return nil
206
+}
207
+
208
+func domainCandidates(domain string) []string {
209
+ parts := strings.Split(strings.TrimSpace(strings.TrimSuffix(domain, ".")), ".")
210
+ if len(parts) < 2 {
211
+ return nil
212
+ }
213
+
214
+ candidates := make([]string, 0, len(parts)-1)
215
+ for i := range len(parts) - 1 {
216
+ candidate := normalizeHost(strings.Join(parts[i:], "."))
217
+ if candidate != "" {
218
+ candidates = append(candidates, candidate)
219
+ }
220
+ }
221
+ return candidates
222
+}
223
+
224
+func (p *Provider) awsRegion() string {
225
+ if p == nil {
226
+ return defaultAWSRegion
227
+ }
228
+ return regionOrDefault(p.cfg.Region)
229
+}
230
+
231
+func regionOrDefault(region string) string {
232
+ if trimmed := strings.TrimSpace(region); trimmed != "" {
233
+ return trimmed
234
+ }
235
+ return defaultAWSRegion
236
+}
237
+
238
+func validateConfig(cfg Config) error {
239
+ switch {
240
+ case cfg.SessionToken != "" && (cfg.AccessKeyID == "" || cfg.SecretAccessKey == ""):
241
+ return errors.New("route53 session token requires access key id and secret access key")
242
+ case (cfg.AccessKeyID == "") != (cfg.SecretAccessKey == ""):
243
+ return errors.New("route53 access key id and secret access key must be supplied together")
244
+ }
245
+ return nil
246
+}
247
+
248
+func normalizeHost(host string) string {
249
+ host = strings.ToLower(strings.TrimSpace(host))
250
+ host = strings.TrimSuffix(host, ".")
251
+ return host
252
+}
253
+
254
+func normalizeZoneID(raw string) string {
255
+ trimmed := strings.TrimSpace(raw)
256
+ return strings.TrimPrefix(trimmed, "/hostedzone/")
257
+}
258
+
259
+func ensureTrailingDot(name string) string {
260
+ if strings.HasSuffix(name, ".") {
261
+ return name
262
+ }
263
+ return name + "."
264
+}
portal/acme/route53/provider_test.go
new
+90
@@ -0,0 +1,90 @@
1
+package route53
2
+
3
+import (
4
+ "context"
5
+ "testing"
6
+)
7
+
8
+func TestDomainCandidates(t *testing.T) {
9
+ t.Parallel()
10
+
11
+ got := domainCandidates("portal.example.com")
12
+ if len(got) != 2 {
13
+ t.Fatalf("len(domainCandidates) = %d, want 2", len(got))
14
+ }
15
+ if got[0] != "portal.example.com" || got[1] != "example.com" {
16
+ t.Fatalf("domainCandidates() = %v, want [portal.example.com example.com]", got)
17
+ }
18
+}
19
+
20
+func TestFindHostedZoneIDExplicitOverride(t *testing.T) {
21
+ t.Parallel()
22
+
23
+ got, err := findHostedZoneID(context.Background(), nil, "portal.example.com", "/hostedzone/Z123456789")
24
+ if err != nil {
25
+ t.Fatalf("findHostedZoneID() error = %v", err)
26
+ }
27
+ if got != "Z123456789" {
28
+ t.Fatalf("findHostedZoneID() = %q, want %q", got, "Z123456789")
29
+ }
30
+}
31
+
32
+func TestValidateConfig(t *testing.T) {
33
+ t.Parallel()
34
+
35
+ testCases := []struct {
36
+ name string
37
+ cfg Config
38
+ wantErr string
39
+ }{
40
+ {
41
+ name: "access key without secret",
42
+ cfg: Config{
43
+ AccessKeyID: "abc",
44
+ },
45
+ wantErr: "route53 access key id and secret access key must be supplied together",
46
+ },
47
+ {
48
+ name: "secret without access key",
49
+ cfg: Config{
50
+ SecretAccessKey: "def",
51
+ },
52
+ wantErr: "route53 access key id and secret access key must be supplied together",
53
+ },
54
+ {
55
+ name: "session token without static credentials",
56
+ cfg: Config{
57
+ SessionToken: "ghi",
58
+ },
59
+ wantErr: "route53 session token requires access key id and secret access key",
60
+ },
61
+ {
62
+ name: "valid static credentials",
63
+ cfg: Config{
64
+ AccessKeyID: "abc",
65
+ SecretAccessKey: "def",
66
+ SessionToken: "ghi",
67
+ },
68
+ },
69
+ {
70
+ name: "ambient credentials",
71
+ cfg: Config{},
72
+ },
73
+ }
74
+
75
+ for _, tc := range testCases {
76
+ t.Run(tc.name, func(t *testing.T) {
77
+ t.Parallel()
78
+
79
+ err := validateConfig(tc.cfg)
80
+ if tc.wantErr == "" && err != nil {
81
+ t.Fatalf("validateConfig() error = %v", err)
82
+ }
83
+ if tc.wantErr != "" {
84
+ if err == nil || err.Error() != tc.wantErr {
85
+ t.Fatalf("validateConfig() error = %v, want %q", err, tc.wantErr)
86
+ }
87
+ }
88
+ })
89
+ }
90
+}
sdk/client.go
+1
-4
@@ -150,10 +150,7 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
150
if leaseTTL <= 0 {
151
leaseTTL = c.leaseTTL
152
}
153
- acceptedCap := readyTarget * 2
154
- if acceptedCap < 1 {
155
- acceptedCap = 1
156
- }
153
+ acceptedCap := max(readyTarget*2, 1)
154
155
registerReq := types.RegisterRequest{
156
Name: req.Name,
types/api.go
+2
-2
@@ -31,7 +31,7 @@ type RegisterRequest struct {
31
Name string `json:"name"`
32
ReverseToken string `json:"reverse_token"`
33
Hostnames []string `json:"hostnames,omitempty"`
34
- Metadata LeaseMetadata `json:"metadata,omitempty"`
34
+ Metadata LeaseMetadata `json:"metadata"`
35
TTLSeconds int `json:"ttl_seconds,omitempty"`
36
TLS bool `json:"tls"`
37
}
@@ -41,7 +41,7 @@ type RegisterResponse struct {
41
LeaseID string `json:"lease_id"`
42
ConnectURL string `json:"connect_url"`
43
Hostnames []string `json:"hostnames"`
44
- Metadata LeaseMetadata `json:"metadata,omitempty"`
44
+ Metadata LeaseMetadata `json:"metadata"`
45
}
46
47
type RenewRequest struct {