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 {