detached ed49430

add public ACME TLS support

commit
ed49430b8fb59d75cc43e50be5c78aee7e93fed5
author
brice · · 2026-04-29 07:22 UTC
parent
574f9a2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
54 files · +6652 -65
A docs/cli/tls.md +106 -0
@@ -0,0 +1,106 @@
1+# TLS Commands
2+
3+Inspect public TLS/ACME certificate status.
4+
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
8+## gordon tls
9+
10+### Subcommands
11+
12+| Subcommand | Description |
13+|------------|-------------|
14+| `status` | Show public TLS certificate status |
15+
16+---
17+
18+## gordon tls status
19+
20+Display the current public TLS/ACME certificate status, including ACME mode,
21+certificate details, route coverage, and any errors.
22+
23+```bash
24+gordon tls status
25+gordon tls status --json
26+gordon tls status --remote https://gordon.mydomain.com --token $TOKEN
27+```
28+
29+### Flags
30+
31+| Flag | Description |
32+|------|-------------|
33+| `--json` | Output as JSON |
34+
35+### Human Output
36+
37+```
38+Public TLS / ACME Status
39+
40+ACME: enabled
41+Configured Mode: acme
42+Effective Mode: acme-staging
43+Reason: configured
44+Token Source: env
45+
46+Certificates
47+ ID: cert-abc123
48+ Names: example.com, www.example.com
49+ Status: valid
50+ Not After: 2026-05-29 12:00:00
51+
52+Route Coverage
53+ example.com covered=yes covered_by=cert-abc123
54+ internal.local covered=no error=self-signed cert
55+
56+Errors
57+ route internal.local has no ACME cert
58+```
59+
60+### JSON Output
61+
62+```json
63+{
64+ "acme_enabled": true,
65+ "configured_mode": "acme",
66+ "effective_mode": "acme-staging",
67+ "selection_reason": "configured",
68+ "token_source": "env",
69+ "certificates": [
70+ {
71+ "id": "cert-abc123",
72+ "names": ["example.com", "www.example.com"],
73+ "challenge": "",
74+ "status": "valid",
75+ "not_after": "2026-05-29T12:00:00Z",
76+ "renewal_pending": false
77+ }
78+ ],
79+ "routes": [
80+ {
81+ "domain": "example.com",
82+ "covered": true,
83+ "covered_by": "cert-abc123",
84+ "required_acme": true
85+ },
86+ {
87+ "domain": "internal.local",
88+ "covered": false,
89+ "required_acme": false,
90+ "error": "self-signed cert"
91+ }
92+ ],
93+ "errors": ["route internal.local has no ACME cert"]
94+}
95+```
96+
97+### Token Source
98+
99+The `token_source` field indicates where the ACME token was sourced from
100+(e.g., `env`, `file`, `config`). The token value is never displayed.
101+
102+## Related
103+
104+- [CLI Overview](./index.md)
105+- [Status Command](./status.md)
106+- [Routes Command](./routes.md)
M go.mod +9 -4
@@ -10,6 +10,7 @@ require (
1010 github.com/coreos/go-systemd/v22 v22.7.0
1111 github.com/docker/docker v28.5.2+incompatible
1212 github.com/docker/go-connections v0.6.0
13+ github.com/go-acme/lego/v4 v4.35.2
1314 github.com/golang-jwt/jwt/v5 v5.3.1
1415 github.com/google/go-containerregistry v0.21.5
1516 github.com/google/uuid v1.6.0
@@ -46,10 +47,12 @@ require (
4647 github.com/docker/cli v29.4.0+incompatible // indirect
4748 github.com/docker/docker-credential-helpers v0.9.5 // indirect
4849 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
50+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
4951 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
5052 github.com/klauspost/compress v1.18.5 // indirect
5153 github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
5254 github.com/mattn/go-localereader v0.0.1 // indirect
55+ github.com/miekg/dns v1.1.72 // indirect
5356 github.com/mitchellh/go-homedir v1.1.0 // indirect
5457 github.com/moby/sys/atomicwriter v0.1.0 // indirect
5558 github.com/morikuni/aec v1.1.0 // indirect
@@ -63,7 +66,9 @@ require (
6366 go.opentelemetry.io/otel/log v0.19.0 // indirect
6467 go.opentelemetry.io/proto/otlp v1.10.0 // indirect
6568 go.yaml.in/yaml/v3 v3.0.4 // indirect
66- golang.org/x/net v0.52.0 // indirect
69+ golang.org/x/crypto v0.50.0 // indirect
70+ golang.org/x/net v0.53.0 // indirect
71+ golang.org/x/tools v0.44.0 // indirect
6772 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
6873 google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
6974 google.golang.org/grpc v1.80.0 // indirect
@@ -72,8 +77,8 @@ require (
7277 )
7378
7479 require (
75- github.com/davecgh/go-spew v1.1.1 // indirect
76- github.com/pmezard/go-difflib v1.0.0 // indirect
80+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
81+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
7782 github.com/stretchr/objx v0.5.3 // indirect
7883 go.uber.org/mock v0.6.0 // direct
7984 golang.org/x/time v0.15.0
@@ -111,7 +116,7 @@ require (
111116 go.opentelemetry.io/otel/metric v1.43.0
112117 go.opentelemetry.io/otel/trace v1.43.0
113118 golang.org/x/sys v0.43.0
114- golang.org/x/text v0.35.0 // indirect
119+ golang.org/x/text v0.36.0 // indirect
115120 gopkg.in/yaml.v3 v3.0.1 // indirect
116121 gotest.tools/v3 v3.5.2 // indirect
117122 )
M go.sum +20 -10
@@ -45,8 +45,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpk
4545 github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
4646 github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
4747 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
48-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
49-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
48+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
49+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5050 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
5151 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
5252 github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
@@ -67,6 +67,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
6767 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
6868 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
6969 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
70+github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
71+github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
72+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
73+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
7074 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
7175 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
7276 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -105,6 +109,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
105109 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
106110 github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
107111 github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
112+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
113+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
108114 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
109115 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
110116 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -131,8 +137,8 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4
131137 github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
132138 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
133139 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
134-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
135-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
140+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
141+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
136142 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
137143 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
138144 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -203,21 +209,25 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
203209 go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
204210 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
205211 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
206-golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
207-golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
212+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
213+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
214+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
215+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
208216 golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
209217 golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
210-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
211-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
218+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
219+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
212220 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
213221 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
214222 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
215223 golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
216224 golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
217-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
218-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
225+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
226+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
219227 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
220228 golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
229+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
230+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
221231 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
222232 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
223233 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
A internal/adapters/dto/tls.go +35 -0
@@ -0,0 +1,35 @@
1+package dto
2+
3+import "time"
4+
5+// TLSStatusResponse represents the public TLS/ACME status for the admin API.
6+type TLSStatusResponse struct {
7+ ACMEEnabled bool `json:"acme_enabled"`
8+ ConfiguredMode string `json:"configured_mode"`
9+ EffectiveMode string `json:"effective_mode"`
10+ SelectionReason string `json:"selection_reason"`
11+ TokenSource string `json:"token_source"`
12+ Certificates []TLSCertificateEntry `json:"certificates"`
13+ Routes []TLSRouteCoverage `json:"routes"`
14+ Errors []string `json:"errors,omitempty"`
15+}
16+
17+// TLSCertificateEntry represents a managed certificate entry in the TLS status.
18+type TLSCertificateEntry struct {
19+ ID string `json:"id"`
20+ Names []string `json:"names"`
21+ Challenge string `json:"challenge"`
22+ Status string `json:"status"`
23+ NotAfter time.Time `json:"not_after"`
24+ LastError string `json:"last_error,omitempty"`
25+ RenewalPending bool `json:"renewal_pending"`
26+}
27+
28+// TLSRouteCoverage represents a route's TLS coverage status.
29+type TLSRouteCoverage struct {
30+ Domain string `json:"domain"`
31+ Covered bool `json:"covered"`
32+ CoveredBy string `json:"covered_by,omitempty"`
33+ RequiredACME bool `json:"required_acme"`
34+ Error string `json:"error,omitempty"`
35+}
M internal/adapters/in/cli/attachments_push_test.go +4 -0
@@ -108,6 +108,10 @@ func (c *attachmentPushTestControlPlane) RemoveAutoRouteAllowedDomain(context.Co
108108 panic("unexpected call")
109109 }
110110
111+func (c *attachmentPushTestControlPlane) GetTLSStatus(context.Context) (*dto.TLSStatusResponse, error) {
112+ return &dto.TLSStatusResponse{}, nil
113+}
114+
111115 func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
112116 if c.getStatus != nil {
113117 return c.getStatus(ctx)
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -38,6 +38,7 @@ type ControlPlane interface {
3838 RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error
3939
4040 GetStatus(ctx context.Context) (*remote.Status, error)
41+ GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
4142 Reload(ctx context.Context) error
4243 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
4344 GetConfig(ctx context.Context) (*remote.Config, error)
M internal/adapters/in/cli/controlplane_local.go +50 -0
@@ -25,6 +25,7 @@ type localControlPlane struct {
2525 healthSvc in.HealthService
2626 logSvc in.LogService
2727 volumeSvc in.VolumeService
28+ publicTLSSvc in.PublicTLSService
2829 }
2930
3031 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
@@ -50,6 +51,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
5051 healthSvc: kernel.Health(),
5152 logSvc: kernel.Logs(),
5253 volumeSvc: kernel.Volumes(),
54+ publicTLSSvc: kernel.PublicTLS(),
5355 }
5456 }
5557
@@ -331,6 +333,16 @@ func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pa
331333 return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
332334 }
333335
336+func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
337+ if l.publicTLSSvc == nil {
338+ return &dto.TLSStatusResponse{}, nil
339+ }
340+
341+ status := l.publicTLSSvc.Status(ctx)
342+ result := toLocalTLSStatusResponse(status)
343+ return &result, nil
344+}
345+
334346 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
335347 if l.configSvc == nil {
336348 return nil, fmt.Errorf("local config service unavailable")
@@ -625,6 +637,44 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
625637 return out
626638 }
627639
640+// toLocalTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse.
641+func toLocalTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse {
642+ certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates))
643+ for _, c := range s.Certificates {
644+ certs = append(certs, dto.TLSCertificateEntry{
645+ ID: c.ID,
646+ Names: c.Names,
647+ Challenge: string(c.Challenge),
648+ Status: string(c.Status),
649+ NotAfter: c.NotAfter,
650+ LastError: c.LastError,
651+ RenewalPending: c.RenewalPending,
652+ })
653+ }
654+
655+ routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes))
656+ for _, r := range s.Routes {
657+ routes = append(routes, dto.TLSRouteCoverage{
658+ Domain: r.Domain,
659+ Covered: r.Covered,
660+ CoveredBy: r.CoveredBy,
661+ RequiredACME: r.RequiredACME,
662+ Error: r.Error,
663+ })
664+ }
665+
666+ return dto.TLSStatusResponse{
667+ ACMEEnabled: s.ACMEEnabled,
668+ ConfiguredMode: string(s.ConfiguredMode),
669+ EffectiveMode: string(s.EffectiveMode),
670+ SelectionReason: s.SelectionReason,
671+ TokenSource: string(s.TokenSource),
672+ Certificates: certs,
673+ Routes: routes,
674+ Errors: s.Errors,
675+ }
676+}
677+
628678 func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
629679 var startedAt *time.Time
630680 if !job.StartedAt.IsZero() {
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -104,6 +104,10 @@ func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, err
104104 return r.client.GetStatus(ctx)
105105 }
106106
107+func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
108+ return r.client.GetTLSStatus(ctx)
109+}
110+
107111 func (r *remoteControlPlane) Reload(ctx context.Context) error {
108112 return r.client.Reload(ctx)
109113 }
M internal/adapters/in/cli/json_parity_test.go +1 -0
@@ -25,6 +25,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
2525 {"routes show", newRoutesShowCmd},
2626 {"networks list", newNetworksListCmd},
2727 {"images tags", newImagesTagsCmd},
28+ {"tls status", newTLSStatusCmd},
2829 }
2930
3031 for _, tc := range commands {
M internal/adapters/in/cli/push_test.go +4 -0
@@ -114,6 +114,10 @@ func (c *resolveFromImageTestControlPlane) RemoveAutoRouteAllowedDomain(context.
114114 panic("unexpected call")
115115 }
116116
117+func (c *resolveFromImageTestControlPlane) GetTLSStatus(context.Context) (*dto.TLSStatusResponse, error) {
118+ return &dto.TLSStatusResponse{}, nil
119+}
120+
117121 func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
118122 panic("unexpected call")
119123 }
M internal/adapters/in/cli/remote/client.go +15 -0
@@ -657,6 +657,21 @@ type Status struct {
657657 ContainerStatus map[string]string `json:"container_status"`
658658 }
659659
660+// GetTLSStatus returns the public TLS/ACME status.
661+func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
662+ resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil)
663+ if err != nil {
664+ return nil, err
665+ }
666+
667+ var status dto.TLSStatusResponse
668+ if err := parseResponse(resp, &status); err != nil {
669+ return nil, err
670+ }
671+
672+ return &status, nil
673+}
674+
660675 // GetStatus returns the Gordon server status.
661676 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
662677 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
M internal/adapters/in/cli/remote/client_test.go +17 -0
@@ -116,6 +116,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
116116 require.NoError(t, err)
117117 }
118118
119+func TestClientGetTLSStatus(t *testing.T) {
120+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+ require.Equal(t, "/admin/tls/status", r.URL.Path)
122+ require.Equal(t, http.MethodGet, r.Method)
123+ w.Header().Set("Content-Type", "application/json")
124+ _, _ = w.Write([]byte(`{"acme_enabled":true,"effective_mode":"http-01"}`))
125+ }))
126+ defer srv.Close()
127+
128+ client := NewClient(srv.URL)
129+ status, err := client.GetTLSStatus(context.Background())
130+ require.NoError(t, err)
131+ require.NotNil(t, status)
132+ assert.True(t, status.ACMEEnabled)
133+ assert.Equal(t, "http-01", status.EffectiveMode)
134+}
135+
119136 func TestClientListImages(t *testing.T) {
120137 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121138 require.Equal(t, "/admin/images", r.URL.Path)
M internal/adapters/in/cli/root.go +4 -0
@@ -149,6 +149,10 @@ Commands are organized by where they run:
149149 previewCmd.GroupID = groupManage
150150 rootCmd.AddCommand(previewCmd)
151151
152+ tlsCmd := newTLSCmd()
153+ tlsCmd.GroupID = groupManage
154+ rootCmd.AddCommand(tlsCmd)
155+
152156 // Client-only commands (no server needed)
153157 remotesCmd := newRemotesCmd()
154158 remotesCmd.GroupID = groupClient
A internal/adapters/in/cli/tls.go +199 -0
@@ -0,0 +1,199 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "time"
9+
10+ "github.com/spf13/cobra"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+)
14+
15+// tlsResolveControlPlane allows tests to override control-plane resolution.
16+var tlsResolveControlPlane = resolveControlPlane
17+
18+func newTLSCmd() *cobra.Command {
19+ cmd := &cobra.Command{
20+ Use: "tls",
21+ Short: "Inspect TLS certificate status",
22+ }
23+
24+ cmd.AddCommand(newTLSStatusCmd())
25+
26+ return cmd
27+}
28+
29+func newTLSStatusCmd() *cobra.Command {
30+ var jsonOut bool
31+
32+ cmd := &cobra.Command{
33+ Use: "status",
34+ Short: "Show public TLS certificate status",
35+ RunE: func(cmd *cobra.Command, _ []string) error {
36+ handle, err := tlsResolveControlPlane(configPath)
37+ if err != nil {
38+ return err
39+ }
40+ defer handle.close()
41+ return runTLSStatusCmd(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
42+ },
43+ }
44+
45+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
46+
47+ return cmd
48+}
49+
50+func runTLSStatusCmd(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
51+ status, err := cp.GetTLSStatus(ctx)
52+ if err != nil {
53+ return fmt.Errorf("failed to get TLS status: %w", err)
54+ }
55+
56+ if jsonOut {
57+ return writeJSON(out, status)
58+ }
59+
60+ return renderTLSStatusText(out, status)
61+}
62+
63+func renderTLSStatusText(out io.Writer, s *dto.TLSStatusResponse) error {
64+ if err := cliWriteLine(out, cliRenderTitle("Public TLS / ACME Status")); err != nil {
65+ return err
66+ }
67+
68+ if err := renderTLSModeSection(out, s); err != nil {
69+ return err
70+ }
71+
72+ if err := renderTLSCertificatesSection(out, s); err != nil {
73+ return err
74+ }
75+
76+ if err := renderTLSRouteCoverageSection(out, s); err != nil {
77+ return err
78+ }
79+
80+ return renderTLSErrorsSection(out, s)
81+}
82+
83+func renderTLSModeSection(out io.Writer, s *dto.TLSStatusResponse) error {
84+ acmeStatus := "disabled"
85+ if s.ACMEEnabled {
86+ acmeStatus = "enabled"
87+ }
88+ if err := cliWriteLine(out, cliRenderMeta("ACME:", acmeStatus)); err != nil {
89+ return err
90+ }
91+ if err := cliWriteLine(out, cliRenderMeta("Configured Mode:", s.ConfiguredMode)); err != nil {
92+ return err
93+ }
94+ if err := cliWriteLine(out, cliRenderMeta("Effective Mode:", s.EffectiveMode)); err != nil {
95+ return err
96+ }
97+ if s.SelectionReason != "" {
98+ if err := cliWriteLine(out, cliRenderMeta("Reason:", s.SelectionReason)); err != nil {
99+ return err
100+ }
101+ }
102+ return cliWriteLine(out, cliRenderMeta("Token Source:", s.TokenSource))
103+}
104+
105+func renderTLSCertificatesSection(out io.Writer, s *dto.TLSStatusResponse) error {
106+ if len(s.Certificates) == 0 {
107+ return nil
108+ }
109+
110+ if err := cliWriteLine(out, ""); err != nil {
111+ return err
112+ }
113+ if err := cliWriteLine(out, cliRenderTitle("Certificates")); err != nil {
114+ return err
115+ }
116+
117+ for _, cert := range s.Certificates {
118+ if err := cliWritef(out, " %s\n", cliRenderMeta("ID:", cert.ID)); err != nil {
119+ return err
120+ }
121+ if err := cliWriteLine(out, cliRenderMeta(" Names:", strings.Join(cert.Names, ", "))); err != nil {
122+ return err
123+ }
124+ if err := cliWriteLine(out, cliRenderMeta(" Status:", cert.Status)); err != nil {
125+ return err
126+ }
127+ if !cert.NotAfter.IsZero() {
128+ if err := cliWriteLine(out, cliRenderMeta(" Not After:", cert.NotAfter.Format(time.DateTime))); err != nil {
129+ return err
130+ }
131+ }
132+ if cert.LastError != "" {
133+ if err := cliWriteLine(out, cliRenderMeta(" Last Error:", cert.LastError)); err != nil {
134+ return err
135+ }
136+ }
137+ }
138+
139+ return nil
140+}
141+
142+func renderTLSRouteCoverageSection(out io.Writer, s *dto.TLSStatusResponse) error {
143+ if len(s.Routes) == 0 {
144+ return nil
145+ }
146+
147+ if err := cliWriteLine(out, ""); err != nil {
148+ return err
149+ }
150+ if err := cliWriteLine(out, cliRenderTitle("Route Coverage")); err != nil {
151+ return err
152+ }
153+
154+ for _, route := range s.Routes {
155+ covered := "no"
156+ if route.Covered {
157+ covered = "yes"
158+ }
159+ if err := cliWritef(out, " %s covered=%s", route.Domain, covered); err != nil {
160+ return err
161+ }
162+ if route.CoveredBy != "" {
163+ if err := cliWritef(out, " covered_by=%s", route.CoveredBy); err != nil {
164+ return err
165+ }
166+ }
167+ if route.Error != "" {
168+ if err := cliWritef(out, " error=%s", route.Error); err != nil {
169+ return err
170+ }
171+ }
172+ if err := cliWriteLine(out, ""); err != nil {
173+ return err
174+ }
175+ }
176+
177+ return nil
178+}
179+
180+func renderTLSErrorsSection(out io.Writer, s *dto.TLSStatusResponse) error {
181+ if len(s.Errors) == 0 {
182+ return nil
183+ }
184+
185+ if err := cliWriteLine(out, ""); err != nil {
186+ return err
187+ }
188+ if err := cliWriteLine(out, cliRenderTitle("Errors")); err != nil {
189+ return err
190+ }
191+
192+ for _, e := range s.Errors {
193+ if err := cliWriteLine(out, " "+e); err != nil {
194+ return err
195+ }
196+ }
197+
198+ return nil
199+}
A internal/adapters/in/cli/tls_test.go +293 -0
@@ -0,0 +1,293 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+// tlsTestControlPlane implements ControlPlane for TLS tests.
19+type tlsTestControlPlane struct {
20+ getTLSStatus func(context.Context) (*dto.TLSStatusResponse, error)
21+}
22+
23+var _ ControlPlane = (*tlsTestControlPlane)(nil)
24+
25+func (c *tlsTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
26+ panic("unexpected call")
27+}
28+
29+func (c *tlsTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) {
30+ panic("unexpected call")
31+}
32+
33+func (c *tlsTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
34+ panic("unexpected call")
35+}
36+
37+func (c *tlsTestControlPlane) FindRoutesByImage(context.Context, string) ([]domain.Route, error) {
38+ panic("unexpected call")
39+}
40+
41+func (c *tlsTestControlPlane) AddRoute(context.Context, domain.Route) error {
42+ panic("unexpected call")
43+}
44+
45+func (c *tlsTestControlPlane) UpdateRoute(context.Context, domain.Route) error {
46+ panic("unexpected call")
47+}
48+
49+func (c *tlsTestControlPlane) RemoveRoute(context.Context, string) error {
50+ panic("unexpected call")
51+}
52+
53+func (c *tlsTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
54+ panic("unexpected call")
55+}
56+
57+func (c *tlsTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {
58+ panic("unexpected call")
59+}
60+
61+func (c *tlsTestControlPlane) SetSecrets(context.Context, string, map[string]string) error {
62+ panic("unexpected call")
63+}
64+
65+func (c *tlsTestControlPlane) DeleteSecret(context.Context, string, string) error {
66+ panic("unexpected call")
67+}
68+
69+func (c *tlsTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error {
70+ panic("unexpected call")
71+}
72+
73+func (c *tlsTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error {
74+ panic("unexpected call")
75+}
76+
77+func (c *tlsTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) {
78+ panic("unexpected call")
79+}
80+
81+func (c *tlsTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) {
82+ panic("unexpected call")
83+}
84+
85+func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) {
86+ panic("unexpected call")
87+}
88+
89+func (c *tlsTestControlPlane) AddAttachment(context.Context, string, string) error {
90+ panic("unexpected call")
91+}
92+
93+func (c *tlsTestControlPlane) RemoveAttachment(context.Context, string, string) error {
94+ panic("unexpected call")
95+}
96+
97+func (c *tlsTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) {
98+ panic("unexpected call")
99+}
100+
101+func (c *tlsTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error {
102+ panic("unexpected call")
103+}
104+
105+func (c *tlsTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error {
106+ panic("unexpected call")
107+}
108+
109+func (c *tlsTestControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
110+ if c.getTLSStatus != nil {
111+ return c.getTLSStatus(ctx)
112+ }
113+ return nil, nil
114+}
115+
116+func (c *tlsTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
117+ panic("unexpected call")
118+}
119+
120+func (c *tlsTestControlPlane) Reload(context.Context) error {
121+ panic("unexpected call")
122+}
123+
124+func (c *tlsTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) {
125+ panic("unexpected call")
126+}
127+
128+func (c *tlsTestControlPlane) GetConfig(context.Context) (*remote.Config, error) {
129+ panic("unexpected call")
130+}
131+
132+func (c *tlsTestControlPlane) DeployIntent(context.Context, string) error {
133+ panic("unexpected call")
134+}
135+
136+func (c *tlsTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) {
137+ panic("unexpected call")
138+}
139+
140+func (c *tlsTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) {
141+ panic("unexpected call")
142+}
143+
144+func (c *tlsTestControlPlane) ListTags(context.Context, string) ([]string, error) {
145+ panic("unexpected call")
146+}
147+
148+func (c *tlsTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) {
149+ panic("unexpected call")
150+}
151+
152+func (c *tlsTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) {
153+ panic("unexpected call")
154+}
155+
156+func (c *tlsTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) {
157+ panic("unexpected call")
158+}
159+
160+func (c *tlsTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) {
161+ panic("unexpected call")
162+}
163+
164+func (c *tlsTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) {
165+ panic("unexpected call")
166+}
167+
168+func (c *tlsTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) {
169+ panic("unexpected call")
170+}
171+
172+func (c *tlsTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) {
173+ panic("unexpected call")
174+}
175+
176+func (c *tlsTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) {
177+ panic("unexpected call")
178+}
179+
180+func (c *tlsTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
181+ panic("unexpected call")
182+}
183+
184+func (c *tlsTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
185+ panic("unexpected call")
186+}
187+
188+func TestTLSStatus_HumanOutput(t *testing.T) {
189+ notAfter := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
190+
191+ cp := &tlsTestControlPlane{
192+ getTLSStatus: func(_ context.Context) (*dto.TLSStatusResponse, error) {
193+ return &dto.TLSStatusResponse{
194+ ACMEEnabled: true,
195+ ConfiguredMode: "acme",
196+ EffectiveMode: "acme-staging",
197+ SelectionReason: "configured",
198+ TokenSource: "env",
199+ Certificates: []dto.TLSCertificateEntry{
200+ {
201+ ID: "cert-abc123",
202+ Names: []string{"example.com", "www.example.com"},
203+ Status: "valid",
204+ NotAfter: notAfter,
205+ LastError: "",
206+ },
207+ },
208+ Routes: []dto.TLSRouteCoverage{
209+ {
210+ Domain: "example.com",
211+ Covered: true,
212+ CoveredBy: "cert-abc123",
213+ RequiredACME: true,
214+ },
215+ {
216+ Domain: "internal.local",
217+ Covered: false,
218+ RequiredACME: false,
219+ Error: "self-signed cert",
220+ },
221+ },
222+ Errors: []string{"route internal.local has no ACME cert"},
223+ }, nil
224+ },
225+ }
226+
227+ var buf bytes.Buffer
228+ err := runTLSStatusCmd(context.Background(), cp, &buf, false)
229+ require.NoError(t, err)
230+
231+ output := buf.String()
232+ t.Log("Human output:\n", output)
233+
234+ // Check mode info is present
235+ assert.Contains(t, output, "ACME:")
236+ assert.Contains(t, output, "enabled")
237+ assert.Contains(t, output, "Configured Mode:")
238+ assert.Contains(t, output, "acme")
239+ assert.Contains(t, output, "Effective Mode:")
240+ assert.Contains(t, output, "acme-staging")
241+
242+ // Check token source (not value)
243+ assert.Contains(t, output, "Token Source:")
244+ assert.Contains(t, output, "env")
245+ assert.NotContains(t, output, "token_secret_value")
246+
247+ // Check certificate ID
248+ assert.Contains(t, output, "cert-abc123")
249+ assert.Contains(t, output, "example.com, www.example.com")
250+ assert.Contains(t, output, "valid")
251+ assert.Contains(t, output, notAfter.Format(time.DateTime))
252+
253+ // Check route coverage
254+ assert.Contains(t, output, "example.com")
255+ assert.Contains(t, output, "covered=yes")
256+ assert.Contains(t, output, "covered_by=cert-abc123")
257+ assert.Contains(t, output, "internal.local")
258+ assert.Contains(t, output, "covered=no")
259+
260+ // Check errors section
261+ assert.Contains(t, output, "Errors")
262+ assert.Contains(t, output, "route internal.local has no ACME cert")
263+}
264+
265+func TestTLSStatus_JSONOutput(t *testing.T) {
266+ notAfter := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
267+
268+ cp := &tlsTestControlPlane{
269+ getTLSStatus: func(_ context.Context) (*dto.TLSStatusResponse, error) {
270+ return &dto.TLSStatusResponse{
271+ ACMEEnabled: true,
272+ ConfiguredMode: "acme",
273+ EffectiveMode: "acme-staging",
274+ TokenSource: "env",
275+ }, nil
276+ },
277+ }
278+
279+ var buf bytes.Buffer
280+ err := runTLSStatusCmd(context.Background(), cp, &buf, true)
281+ require.NoError(t, err)
282+
283+ var result dto.TLSStatusResponse
284+ err = json.Unmarshal(buf.Bytes(), &result)
285+ require.NoError(t, err)
286+
287+ assert.True(t, result.ACMEEnabled)
288+ assert.Equal(t, "acme", result.ConfiguredMode)
289+ assert.Equal(t, "acme-staging", result.EffectiveMode)
290+ assert.Equal(t, "env", result.TokenSource)
291+
292+ _ = notAfter
293+}
A internal/adapters/in/http/acme/handler.go +82 -0
@@ -0,0 +1,82 @@
1+// Package acme provides an HTTP handler for the ACME HTTP-01 challenge.
2+// It serves key authorizations at /.well-known/acme-challenge/{token}.
3+package acme
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strings"
9+)
10+
11+// Prefix is the path prefix for ACME HTTP-01 challenge requests.
12+const Prefix = "/.well-known/acme-challenge/"
13+
14+// ChallengeService defines the interface for retrieving HTTP-01 challenge tokens.
15+type ChallengeService interface {
16+ GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool)
17+}
18+
19+// Handler serves ACME HTTP-01 challenge responses.
20+type Handler struct {
21+ svc ChallengeService
22+}
23+
24+// NewHandler creates a new Handler with the given ChallengeService.
25+func NewHandler(svc ChallengeService) *Handler {
26+ return &Handler{svc: svc}
27+}
28+
29+// ServeHTTP handles incoming HTTP requests for ACME HTTP-01 challenges.
30+// Only GET and HEAD methods are allowed; all others return 405 Method Not Allowed.
31+// The token must be a safe path component and must exist in the challenge store.
32+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
33+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
34+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
35+ return
36+ }
37+
38+ token := strings.TrimPrefix(r.URL.Path, Prefix)
39+ if !strings.HasPrefix(r.URL.Path, Prefix) || !safeToken(token) {
40+ http.NotFound(w, r)
41+ return
42+ }
43+
44+ if h.svc == nil {
45+ http.NotFound(w, r)
46+ return
47+ }
48+
49+ keyAuth, ok := h.svc.GetHTTP01Challenge(r.Context(), token)
50+ if !ok {
51+ http.NotFound(w, r)
52+ return
53+ }
54+
55+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
56+ w.Header().Set("Cache-Control", "no-store")
57+ w.WriteHeader(http.StatusOK)
58+
59+ if r.Method == http.MethodHead {
60+ return
61+ }
62+
63+ _, _ = w.Write([]byte(keyAuth))
64+}
65+
66+// safeToken validates that the token does not contain path traversal or special
67+// characters that could be used to access files outside the challenge directory.
68+func safeToken(token string) bool {
69+ if token == "" {
70+ return false
71+ }
72+ if strings.Contains(token, "/") {
73+ return false
74+ }
75+ if strings.Contains(token, "\\") {
76+ return false
77+ }
78+ if strings.Contains(token, "..") {
79+ return false
80+ }
81+ return true
82+}
A internal/adapters/in/http/acme/handler_test.go +171 -0
@@ -0,0 +1,171 @@
1+package acme
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+)
9+
10+// challengeSvc is a simple map-backed implementation of ChallengeService for testing.
11+type challengeSvc map[string]string
12+
13+func (s challengeSvc) GetHTTP01Challenge(_ context.Context, token string) (string, bool) {
14+ keyAuth, ok := s[token]
15+ return keyAuth, ok
16+}
17+
18+func TestHandlerServesGETChallenge(t *testing.T) {
19+ svc := challengeSvc{"abc": "key-auth"}
20+ h := NewHandler(svc)
21+
22+ req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil)
23+ rec := httptest.NewRecorder()
24+
25+ h.ServeHTTP(rec, req)
26+
27+ resp := rec.Result()
28+ defer resp.Body.Close()
29+
30+ if resp.StatusCode != http.StatusOK {
31+ t.Errorf("expected status 200, got %d", resp.StatusCode)
32+ }
33+
34+ if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
35+ t.Errorf("expected Content-Type text/plain; charset=utf-8, got %q", ct)
36+ }
37+
38+ if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
39+ t.Errorf("expected Cache-Control no-store, got %q", cc)
40+ }
41+
42+ body := rec.Body.String()
43+ if body != "key-auth" {
44+ t.Errorf("expected body %q, got %q", "key-auth", body)
45+ }
46+}
47+
48+func TestHandlerServesHEADChallenge(t *testing.T) {
49+ svc := challengeSvc{"abc": "key-auth"}
50+ h := NewHandler(svc)
51+
52+ req := httptest.NewRequest(http.MethodHead, "/.well-known/acme-challenge/abc", nil)
53+ rec := httptest.NewRecorder()
54+
55+ h.ServeHTTP(rec, req)
56+
57+ resp := rec.Result()
58+ defer resp.Body.Close()
59+
60+ if resp.StatusCode != http.StatusOK {
61+ t.Errorf("expected status 200, got %d", resp.StatusCode)
62+ }
63+
64+ if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
65+ t.Errorf("expected Content-Type text/plain; charset=utf-8, got %q", ct)
66+ }
67+
68+ if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
69+ t.Errorf("expected Cache-Control no-store, got %q", cc)
70+ }
71+
72+ body := rec.Body.String()
73+ if body != "" {
74+ t.Errorf("expected empty body for HEAD, got %q", body)
75+ }
76+}
77+
78+func TestHandlerRejectsUnsafeToken(t *testing.T) {
79+ tests := []struct {
80+ name string
81+ path string
82+ }{
83+ {name: "path traversal", path: "/.well-known/acme-challenge/../secret"},
84+ {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"},
85+ {name: "empty token", path: "/.well-known/acme-challenge/"},
86+ {name: "backslash in token", path: "/.well-known/acme-challenge/foo\\bar"},
87+ }
88+
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ h := NewHandler(challengeSvc{"abc": "key-auth"})
92+
93+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
94+ rec := httptest.NewRecorder()
95+
96+ h.ServeHTTP(rec, req)
97+
98+ resp := rec.Result()
99+ defer resp.Body.Close()
100+
101+ if resp.StatusCode != http.StatusNotFound {
102+ t.Errorf("expected status 404, got %d for path %q", resp.StatusCode, tt.path)
103+ }
104+ })
105+ }
106+}
107+
108+func TestHandlerRejectsPOST(t *testing.T) {
109+ svc := challengeSvc{"abc": "key-auth"}
110+ h := NewHandler(svc)
111+
112+ req := httptest.NewRequest(http.MethodPost, "/.well-known/acme-challenge/abc", nil)
113+ rec := httptest.NewRecorder()
114+
115+ h.ServeHTTP(rec, req)
116+
117+ resp := rec.Result()
118+ defer resp.Body.Close()
119+
120+ if resp.StatusCode != http.StatusMethodNotAllowed {
121+ t.Errorf("expected status 405, got %d", resp.StatusCode)
122+ }
123+}
124+
125+func TestHandlerRejectsMissingPrefix(t *testing.T) {
126+ h := NewHandler(challengeSvc{"abc": "key-auth"})
127+
128+ req := httptest.NewRequest(http.MethodGet, "/some-other-path", nil)
129+ rec := httptest.NewRecorder()
130+
131+ h.ServeHTTP(rec, req)
132+
133+ resp := rec.Result()
134+ defer resp.Body.Close()
135+
136+ if resp.StatusCode != http.StatusNotFound {
137+ t.Errorf("expected status 404 for non-matching prefix, got %d", resp.StatusCode)
138+ }
139+}
140+
141+func TestHandlerRejectsUnknownToken(t *testing.T) {
142+ h := NewHandler(challengeSvc{"abc": "key-auth"})
143+
144+ req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/unknown", nil)
145+ rec := httptest.NewRecorder()
146+
147+ h.ServeHTTP(rec, req)
148+
149+ resp := rec.Result()
150+ defer resp.Body.Close()
151+
152+ if resp.StatusCode != http.StatusNotFound {
153+ t.Errorf("expected status 404 for unknown token, got %d", resp.StatusCode)
154+ }
155+}
156+
157+func TestHandlerRejectsNilService(t *testing.T) {
158+ h := NewHandler(nil)
159+
160+ req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil)
161+ rec := httptest.NewRecorder()
162+
163+ h.ServeHTTP(rec, req)
164+
165+ resp := rec.Result()
166+ defer resp.Body.Close()
167+
168+ if resp.StatusCode != http.StatusNotFound {
169+ t.Errorf("expected status 404 for nil service, got %d", resp.StatusCode)
170+ }
171+}
M internal/adapters/in/http/admin/handler.go +65 -0
@@ -52,6 +52,7 @@ type Handler struct {
5252 registrySvc registryDeployService
5353 previewSvc previewService
5454 reloadTrigger reloadTrigger
55+ publicTLSSvc in.PublicTLSService
5556 log zerowrap.Logger
5657 }
5758
@@ -149,6 +150,7 @@ type HandlerDeps struct {
149150 ImageSvc in.ImageService
150151 VolumeSvc in.VolumeService
151152 ReloadTrigger reloadTrigger
153+ PublicTLSSvc in.PublicTLSService
152154 }
153155
154156 // NewHandler creates a new admin HTTP handler.
@@ -166,6 +168,7 @@ func NewHandler(deps HandlerDeps) *Handler {
166168 registrySvc: deps.RegistrySvc,
167169 previewSvc: deps.PreviewSvc,
168170 reloadTrigger: deps.ReloadTrigger,
171+ publicTLSSvc: deps.PublicTLSSvc,
169172 log: deps.Log,
170173 }
171174 }
@@ -216,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
216219 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
217220 "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
218221 "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
222+ "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
219223 }
220224 if handler, ok := exactRoutes[path]; ok {
221225 return handler, true
@@ -1906,3 +1910,64 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
19061910
19071911 h.sendJSON(w, http.StatusOK, response)
19081912 }
1913+
1914+// handleTLSStatus handles GET /admin/tls/status.
1915+func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
1916+ if r.Method != http.MethodGet {
1917+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1918+ return
1919+ }
1920+
1921+ ctx := r.Context()
1922+
1923+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1924+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1925+ return
1926+ }
1927+
1928+ if h.publicTLSSvc == nil {
1929+ h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{})
1930+ return
1931+ }
1932+
1933+ status := h.publicTLSSvc.Status(ctx)
1934+ h.sendJSON(w, http.StatusOK, toTLSStatusResponse(status))
1935+}
1936+
1937+// toTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse.
1938+func toTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse {
1939+ certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates))
1940+ for _, c := range s.Certificates {
1941+ certs = append(certs, dto.TLSCertificateEntry{
1942+ ID: c.ID,
1943+ Names: c.Names,
1944+ Challenge: string(c.Challenge),
1945+ Status: string(c.Status),
1946+ NotAfter: c.NotAfter,
1947+ LastError: c.LastError,
1948+ RenewalPending: c.RenewalPending,
1949+ })
1950+ }
1951+
1952+ routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes))
1953+ for _, r := range s.Routes {
1954+ routes = append(routes, dto.TLSRouteCoverage{
1955+ Domain: r.Domain,
1956+ Covered: r.Covered,
1957+ CoveredBy: r.CoveredBy,
1958+ RequiredACME: r.RequiredACME,
1959+ Error: r.Error,
1960+ })
1961+ }
1962+
1963+ return dto.TLSStatusResponse{
1964+ ACMEEnabled: s.ACMEEnabled,
1965+ ConfiguredMode: string(s.ConfiguredMode),
1966+ EffectiveMode: string(s.EffectiveMode),
1967+ SelectionReason: s.SelectionReason,
1968+ TokenSource: string(s.TokenSource),
1969+ Certificates: certs,
1970+ Routes: routes,
1971+ Errors: s.Errors,
1972+ }
1973+}
M internal/adapters/in/http/admin/handler_test.go +117 -0
@@ -3,6 +3,7 @@ package admin
33 import (
44 "bytes"
55 "context"
6+ "crypto/tls"
67 "encoding/json"
78 "errors"
89 "fmt"
@@ -2585,3 +2586,119 @@ func TestHandler_Images_Authorization(t *testing.T) {
25852586 assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
25862587 })
25872588 }
2589+
2590+// fakeTLSService is a minimal in.PublicTLSService implementation for testing.
2591+type fakeTLSService struct {
2592+ statusFunc func(ctx context.Context) domain.PublicTLSStatus
2593+}
2594+
2595+func (f *fakeTLSService) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
2596+ return nil, nil
2597+}
2598+
2599+func (f *fakeTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
2600+ return "", false
2601+}
2602+
2603+func (f *fakeTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
2604+ if f.statusFunc != nil {
2605+ return f.statusFunc(ctx)
2606+ }
2607+ return domain.PublicTLSStatus{}
2608+}
2609+
2610+func (f *fakeTLSService) Reconcile(ctx context.Context) error {
2611+ return nil
2612+}
2613+
2614+func (f *fakeTLSService) Stop(ctx context.Context) error {
2615+ return nil
2616+}
2617+
2618+func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2619+ handler := newTestHandler(t, func(d *HandlerDeps) {
2620+ d.PublicTLSSvc = &fakeTLSService{}
2621+ })
2622+
2623+ req := httptest.NewRequest("GET", "/admin/tls/status", nil)
2624+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2625+ rec := httptest.NewRecorder()
2626+
2627+ handler.ServeHTTP(rec, req)
2628+
2629+ assert.Equal(t, http.StatusForbidden, rec.Code)
2630+ assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
2631+}
2632+
2633+func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
2634+ now := time.Now()
2635+ fakeSvc := &fakeTLSService{
2636+ statusFunc: func(ctx context.Context) domain.PublicTLSStatus {
2637+ return domain.PublicTLSStatus{
2638+ ACMEEnabled: true,
2639+ ConfiguredMode: domain.ACMEChallengeAuto,
2640+ EffectiveMode: domain.ACMEChallengeHTTP01,
2641+ SelectionReason: "auto-selected",
2642+ TokenSource: domain.ACMETokenSourcePass,
2643+ Certificates: []domain.ManagedCertificate{
2644+ {
2645+ ID: "cert-1",
2646+ Names: []string{"example.com"},
2647+ Challenge: domain.ACMEChallengeHTTP01,
2648+ Status: domain.TLSCertificateStatusValid,
2649+ NotAfter: now.Add(60 * 24 * time.Hour),
2650+ },
2651+ },
2652+ Routes: []domain.TLSRouteCoverage{
2653+ {
2654+ Domain: "example.com",
2655+ Covered: true,
2656+ CoveredBy: "cert-1",
2657+ RequiredACME: true,
2658+ },
2659+ },
2660+ }
2661+ },
2662+ }
2663+
2664+ handler := newTestHandler(t, func(d *HandlerDeps) {
2665+ d.PublicTLSSvc = fakeSvc
2666+ })
2667+
2668+ server := newScopedTestServer(t, handler, "admin:status:read")
2669+ resp, err := http.Get(server.URL + "/admin/tls/status")
2670+ require.NoError(t, err)
2671+ defer resp.Body.Close()
2672+
2673+ assert.Equal(t, http.StatusOK, resp.StatusCode)
2674+
2675+ var body dto.TLSStatusResponse
2676+ err = json.NewDecoder(resp.Body).Decode(&body)
2677+ require.NoError(t, err)
2678+
2679+ assert.True(t, body.ACMEEnabled)
2680+ assert.Equal(t, "auto", body.ConfiguredMode)
2681+ assert.Equal(t, "http-01", body.EffectiveMode)
2682+ assert.Equal(t, "auto-selected", body.SelectionReason)
2683+ assert.Equal(t, "pass", body.TokenSource)
2684+ require.Len(t, body.Certificates, 1)
2685+ assert.Equal(t, "cert-1", body.Certificates[0].ID)
2686+ assert.Equal(t, "valid", body.Certificates[0].Status)
2687+ assert.Equal(t, "http-01", body.Certificates[0].Challenge)
2688+ require.Len(t, body.Routes, 1)
2689+ assert.Equal(t, "example.com", body.Routes[0].Domain)
2690+ assert.True(t, body.Routes[0].Covered)
2691+}
2692+
2693+func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
2694+ handler := newTestHandler(t, func(d *HandlerDeps) {
2695+ d.PublicTLSSvc = &fakeTLSService{}
2696+ })
2697+
2698+ server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
2699+ resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil)
2700+ require.NoError(t, err)
2701+ defer resp.Body.Close()
2702+
2703+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
2704+}
A internal/adapters/out/acmelego/cloudflare.go +169 -0
@@ -0,0 +1,169 @@
1+package acmelego
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+
12+ "github.com/bnema/gordon/internal/boundaries/out"
13+)
14+
15+// zoneAPIResponse is a minimal Cloudflare API response for zone listing.
16+type zoneAPIResponse struct {
17+ Success bool `json:"success"`
18+ Errors []zoneAPIError `json:"errors"`
19+ Result []zoneAPIResult `json:"result"`
20+}
21+
22+type zoneAPIError struct {
23+ Code int `json:"code"`
24+ Message string `json:"message"`
25+}
26+
27+type zoneAPIResult struct {
28+ ID string `json:"id"`
29+ Name string `json:"name"`
30+ Status string `json:"status"`
31+}
32+
33+const defaultCloudflareBaseURL = "https://api.cloudflare.com/client/v4"
34+
35+// CloudflareZoneResolver resolves a hostname to its Cloudflare DNS zone by
36+// searching from the full hostname down to the TLD.
37+type CloudflareZoneResolver struct {
38+ token string
39+ baseURL string
40+ client *http.Client
41+}
42+
43+// CloudflareZoneResolverOption configures a CloudflareZoneResolver.
44+type CloudflareZoneResolverOption func(*CloudflareZoneResolver)
45+
46+// WithCloudflareBaseURL sets the base URL for the Cloudflare API.
47+func WithCloudflareBaseURL(baseURL string) CloudflareZoneResolverOption {
48+ return func(r *CloudflareZoneResolver) {
49+ r.baseURL = strings.TrimRight(baseURL, "/")
50+ }
51+}
52+
53+// WithCloudflareHTTPClient sets the HTTP client for the resolver.
54+func WithCloudflareHTTPClient(client *http.Client) CloudflareZoneResolverOption {
55+ return func(r *CloudflareZoneResolver) {
56+ r.client = client
57+ }
58+}
59+
60+// NewCloudflareZoneResolver creates a new CloudflareZoneResolver.
61+func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOption) *CloudflareZoneResolver {
62+ r := &CloudflareZoneResolver{
63+ token: token,
64+ baseURL: defaultCloudflareBaseURL,
65+ client: http.DefaultClient,
66+ }
67+ for _, opt := range opts {
68+ opt(r)
69+ }
70+ return r
71+}
72+
73+// compile-time interface check
74+var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil)
75+
76+// FindZone finds the most specific Cloudflare zone for the given domain.
77+// It searches from the full hostname down to the TLD (e.g. for
78+// api.prod.example.com it tries api.prod.example.com, prod.example.com,
79+// example.com, com) and returns the first active zone found.
80+func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string) (out.CloudflareZone, error) {
81+ domainName = strings.TrimSuffix(strings.TrimSpace(domainName), ".")
82+ if domainName == "" {
83+ return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain")
84+ }
85+
86+ parts := strings.Split(domainName, ".")
87+
88+ // candidateCount is the total possible candidates (full domain down to TLD).
89+ // For "api.prod.example.com" -> 4 candidates; for "example.com" -> 2.
90+ candidateCount := len(parts)
91+ candidates := make([]string, 0, candidateCount)
92+
93+ // Build candidates from full hostname down to the TLD.
94+ // For parts ["api","prod","example","com"]:
95+ // "api.prod.example.com", "prod.example.com", "example.com", "com"
96+ for i := range parts {
97+ candidate := strings.Join(parts[i:], ".")
98+ candidates = append(candidates, candidate)
99+ }
100+
101+ for _, candidate := range candidates {
102+ zone, err := r.findZoneByName(ctx, candidate)
103+ if err != nil {
104+ continue
105+ }
106+ return zone, nil
107+ }
108+
109+ return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: no active zone found for %q", domainName)
110+}
111+
112+// findZoneByName queries the Cloudflare API for an active zone with the exact given name.
113+func (r *CloudflareZoneResolver) findZoneByName(ctx context.Context, name string) (out.CloudflareZone, error) {
114+ u, err := url.Parse(r.baseURL + "/zones")
115+ if err != nil {
116+ return out.CloudflareZone{}, fmt.Errorf("parse url: %w", err)
117+ }
118+
119+ q := u.Query()
120+ q.Set("name", name)
121+ q.Set("status", "active")
122+ u.RawQuery = q.Encode()
123+
124+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
125+ if err != nil {
126+ return out.CloudflareZone{}, fmt.Errorf("create request: %w", err)
127+ }
128+ req.Header.Set("Authorization", "Bearer "+r.token)
129+ req.Header.Set("Accept", "application/json")
130+
131+ resp, err := r.client.Do(req)
132+ if err != nil {
133+ return out.CloudflareZone{}, fmt.Errorf("request failed: %w", err)
134+ }
135+ defer resp.Body.Close()
136+
137+ body, err := io.ReadAll(resp.Body)
138+ if err != nil {
139+ return out.CloudflareZone{}, fmt.Errorf("read response: %w", err)
140+ }
141+
142+ if resp.StatusCode != http.StatusOK {
143+ return out.CloudflareZone{}, fmt.Errorf("cloudflare api: status %d", resp.StatusCode)
144+ }
145+
146+ var apiResp zoneAPIResponse
147+ if err := json.Unmarshal(body, &apiResp); err != nil {
148+ return out.CloudflareZone{}, fmt.Errorf("parse response: %w", err)
149+ }
150+
151+ if !apiResp.Success {
152+ errMsg := "cloudflare api returned unsuccessfully"
153+ if len(apiResp.Errors) > 0 {
154+ errMsg = apiResp.Errors[0].Message
155+ }
156+ return out.CloudflareZone{}, fmt.Errorf("cloudflare api: %s", errMsg)
157+ }
158+
159+ for _, z := range apiResp.Result {
160+ if z.Name == name && z.Status == "active" {
161+ return out.CloudflareZone{
162+ ID: z.ID,
163+ Name: z.Name,
164+ }, nil
165+ }
166+ }
167+
168+ return out.CloudflareZone{}, fmt.Errorf("no active zone found for %q", name)
169+}
A internal/adapters/out/acmelego/cloudflare_test.go +173 -0
@@ -0,0 +1,173 @@
1+package acmelego
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+)
15+
16+func TestCloudflareZoneResolverFindsParentZone(t *testing.T) {
17+ var requestCount int
18+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ requestCount++
20+
21+ // Verify auth header
22+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
23+
24+ // Verify query parameters
25+ name := r.URL.Query().Get("name")
26+ status := r.URL.Query().Get("status")
27+ assert.Equal(t, "active", status)
28+
29+ switch name {
30+ case "api.prod.example.com":
31+ // Return empty - no zone found
32+ w.Header().Set("Content-Type", "application/json")
33+ json.NewEncoder(w).Encode(zoneAPIResponse{
34+ Success: true,
35+ Result: []zoneAPIResult{},
36+ })
37+ case "prod.example.com":
38+ // Return empty - no zone found
39+ w.Header().Set("Content-Type", "application/json")
40+ json.NewEncoder(w).Encode(zoneAPIResponse{
41+ Success: true,
42+ Result: []zoneAPIResult{},
43+ })
44+ case "example.com":
45+ // Return active zone
46+ w.Header().Set("Content-Type", "application/json")
47+ json.NewEncoder(w).Encode(zoneAPIResponse{
48+ Success: true,
49+ Result: []zoneAPIResult{
50+ {
51+ ID: "zone-id-123",
52+ Name: "example.com",
53+ Status: "active",
54+ },
55+ },
56+ })
57+ default:
58+ t.Errorf("unexpected zone name query: %s", name)
59+ }
60+ }))
61+ defer server.Close()
62+
63+ resolver := NewCloudflareZoneResolver("test-token",
64+ WithCloudflareBaseURL(server.URL))
65+
66+ zone, err := resolver.FindZone(context.Background(), "api.prod.example.com")
67+ require.NoError(t, err)
68+ assert.Equal(t, "zone-id-123", zone.ID)
69+ assert.Equal(t, "example.com", zone.Name)
70+ assert.Equal(t, 3, requestCount, "should have made 3 API calls")
71+}
72+
73+func TestCloudflareZoneResolverDoesNotLeakTokenInError(t *testing.T) {
74+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+ // Return a 500 error
76+ w.WriteHeader(http.StatusInternalServerError)
77+ w.Write([]byte(`{"success":false,"errors":[]}`))
78+ }))
79+ defer server.Close()
80+
81+ resolver := NewCloudflareZoneResolver("super-secret-token-abc123",
82+ WithCloudflareBaseURL(server.URL))
83+
84+ _, err := resolver.FindZone(context.Background(), "example.com")
85+ require.Error(t, err)
86+ errStr := err.Error()
87+
88+ // The error must NOT contain the token
89+ assert.NotContains(t, errStr, "super-secret-token-abc123")
90+ assert.NotContains(t, errStr, "secret-token")
91+}
92+
93+func TestCloudflareZoneResolverSimpleDomain(t *testing.T) {
94+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ name := r.URL.Query().Get("name")
96+ assert.Equal(t, "example.com", name)
97+
98+ w.Header().Set("Content-Type", "application/json")
99+ json.NewEncoder(w).Encode(zoneAPIResponse{
100+ Success: true,
101+ Result: []zoneAPIResult{
102+ {
103+ ID: "zone-1",
104+ Name: "example.com",
105+ Status: "active",
106+ },
107+ },
108+ })
109+ }))
110+ defer server.Close()
111+
112+ resolver := NewCloudflareZoneResolver("test-token",
113+ WithCloudflareBaseURL(server.URL))
114+
115+ zone, err := resolver.FindZone(context.Background(), "example.com")
116+ require.NoError(t, err)
117+ assert.Equal(t, "zone-1", zone.ID)
118+ assert.Equal(t, "example.com", zone.Name)
119+}
120+
121+func TestCloudflareZoneResolverNoZoneFound(t *testing.T) {
122+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123+ w.Header().Set("Content-Type", "application/json")
124+ json.NewEncoder(w).Encode(zoneAPIResponse{
125+ Success: true,
126+ Result: []zoneAPIResult{},
127+ })
128+ }))
129+ defer server.Close()
130+
131+ resolver := NewCloudflareZoneResolver("test-token",
132+ WithCloudflareBaseURL(server.URL))
133+
134+ _, err := resolver.FindZone(context.Background(), "missing.example.com")
135+ require.Error(t, err)
136+ assert.Contains(t, err.Error(), "no active zone found")
137+}
138+
139+func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) {
140+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141+ w.Header().Set("Content-Type", "application/json")
142+ json.NewEncoder(w).Encode(zoneAPIResponse{
143+ Success: false,
144+ Errors: []zoneAPIError{
145+ {Code: 1000, Message: "authentication error"},
146+ },
147+ Result: []zoneAPIResult{},
148+ })
149+ }))
150+ defer server.Close()
151+
152+ resolver := NewCloudflareZoneResolver("bad-token",
153+ WithCloudflareBaseURL(server.URL))
154+
155+ _, err := resolver.FindZone(context.Background(), "example.com")
156+ require.Error(t, err)
157+ // The resolver iterates all candidates; the final error is the generic one.
158+ assert.Contains(t, err.Error(), "no active zone found")
159+ // Token must never leak in error messages.
160+ assert.NotContains(t, err.Error(), "bad-token")
161+}
162+
163+// Ensure the interface compliance.
164+func TestCloudflareZoneResolverImplementsInterface(t *testing.T) {
165+ var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil)
166+
167+ // Test NewCloudflareZoneResolver returns non-nil
168+ r := NewCloudflareZoneResolver("token")
169+ require.NotNil(t, r)
170+
171+ // Check defaults
172+ assert.Equal(t, "https://api.cloudflare.com/client/v4", r.baseURL)
173+}
A internal/adapters/out/acmelego/http_provider.go +41 -0
@@ -0,0 +1,41 @@
1+// Package acmelego implements ACME certificate operations using the lego library.
2+package acmelego
3+
4+// HTTPChallengeSink defines the storage interface for HTTP-01 challenge
5+// tokens. The usecase layer implements this to serve challenge responses
6+// via the proxy.
7+type HTTPChallengeSink interface {
8+ Present(token, keyAuth string)
9+ CleanUp(token string)
10+}
11+
12+// HTTPProvider adapts an HTTPChallengeSink to the lego challenge.Provider
13+// interface for HTTP-01 challenge solving.
14+type HTTPProvider struct {
15+ sink HTTPChallengeSink
16+}
17+
18+// NewHTTPProvider creates a new HTTPProvider.
19+func NewHTTPProvider(sink HTTPChallengeSink) *HTTPProvider {
20+ return &HTTPProvider{sink: sink}
21+}
22+
23+// Present stores the key authorization for the given token via the sink.
24+// If the sink is nil, it returns nil (no-op) to allow graceful degradation
25+// in test or misconfigured scenarios.
26+func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {
27+ if p.sink == nil {
28+ return nil
29+ }
30+ p.sink.Present(token, keyAuth)
31+ return nil
32+}
33+
34+// CleanUp removes the key authorization for the given token via the sink.
35+func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error {
36+ if p.sink == nil {
37+ return nil
38+ }
39+ p.sink.CleanUp(token)
40+ return nil
41+}
A internal/adapters/out/acmelego/http_provider_test.go +46 -0
@@ -0,0 +1,46 @@
1+package acmelego
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+// challengeSink is a test sink that stores challenge tokens in a map.
10+type challengeSink struct {
11+ values map[string]string
12+}
13+
14+func (s *challengeSink) Present(token, keyAuth string) {
15+ if s.values == nil {
16+ s.values = make(map[string]string)
17+ }
18+ s.values[token] = keyAuth
19+}
20+
21+func (s *challengeSink) CleanUp(token string) {
22+ delete(s.values, token)
23+}
24+
25+func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) {
26+ sink := &challengeSink{}
27+ provider := NewHTTPProvider(sink)
28+
29+ err := provider.Present("example.com", "token", "key-auth")
30+ assert.NoError(t, err)
31+ assert.Equal(t, "key-auth", sink.values["token"])
32+
33+ err = provider.CleanUp("example.com", "token", "key-auth")
34+ assert.NoError(t, err)
35+ assert.Empty(t, sink.values["token"])
36+}
37+
38+func TestHTTPProviderNilSink(t *testing.T) {
39+ provider := NewHTTPProvider(nil)
40+
41+ err := provider.Present("example.com", "token", "key-auth")
42+ assert.NoError(t, err)
43+
44+ err = provider.CleanUp("example.com", "token", "key-auth")
45+ assert.NoError(t, err)
46+}
A internal/adapters/out/acmelego/issuer.go +325 -0
@@ -0,0 +1,325 @@
1+// Package acmelego implements ACME certificate operations using the lego library.
2+package acmelego
3+
4+import (
5+ "context"
6+ "crypto/tls"
7+ "crypto/x509"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "net/http"
12+ "sync"
13+ "time"
14+
15+ "github.com/go-acme/lego/v4/acme"
16+ "github.com/go-acme/lego/v4/certcrypto"
17+ "github.com/go-acme/lego/v4/certificate"
18+ "github.com/go-acme/lego/v4/lego"
19+ "github.com/go-acme/lego/v4/providers/dns/cloudflare"
20+ "github.com/go-acme/lego/v4/registration"
21+
22+ "github.com/bnema/gordon/internal/boundaries/out"
23+ "github.com/bnema/gordon/internal/domain"
24+)
25+
26+// Config holds the configuration for the ACME certificate issuer.
27+type Config struct {
28+ // Email is the ACME account contact email.
29+ Email string
30+
31+ // Challenge is the ACME challenge mode to use.
32+ Challenge domain.ACMEChallengeMode
33+
34+ // Token is the Cloudflare API token (required for DNS-01 challenge).
35+ Token string
36+
37+ // Store persists ACME accounts and certificates.
38+ Store out.CertificateStore
39+
40+ // HTTPChallengeSink is used for HTTP-01 challenge token storage.
41+ HTTPChallengeSink HTTPChallengeSink
42+
43+ // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used.
44+ CADirectoryURL string
45+
46+ // HTTPClient is an optional HTTP client for the ACME client. If nil, a default is used.
47+ HTTPClient *http.Client
48+}
49+
50+// Issuer implements out.PublicCertificateIssuer using the lego ACME client.
51+type Issuer struct {
52+ cfg Config
53+ mu sync.Mutex
54+ client *lego.Client
55+ user *AccountUser
56+}
57+
58+// NewIssuer creates a new Issuer. Config is validated; the ACME account is
59+// loaded or created lazily on first Obtain or Renew call.
60+func NewIssuer(cfg Config) (*Issuer, error) {
61+ if cfg.Email == "" {
62+ return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired)
63+ }
64+ if cfg.Store == nil {
65+ return nil, errors.New("acmelego: certificate store is required")
66+ }
67+ if cfg.Challenge == "" {
68+ cfg.Challenge = domain.ACMEChallengeHTTP01
69+ }
70+ if cfg.Challenge == domain.ACMEChallengeCloudflareDNS01 && cfg.Token == "" {
71+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
72+ }
73+
74+ return &Issuer{cfg: cfg}, nil
75+}
76+
77+// compile-time interface check
78+var _ out.PublicCertificateIssuer = (*Issuer)(nil)
79+
80+// Obtain obtains a new certificate for the given order. The ACME client is
81+// initialized lazily on the first call.
82+func (i *Issuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
83+ if err := i.ensureClient(ctx); err != nil {
84+ return nil, err
85+ }
86+
87+ resource, err := i.client.Certificate.Obtain(certificate.ObtainRequest{
88+ Domains: order.Names,
89+ Bundle: true,
90+ })
91+ if err != nil {
92+ return nil, fmt.Errorf("obtain certificate: %w", err)
93+ }
94+
95+ return resourceToStored(order, resource), nil
96+}
97+
98+// Renew renews an existing certificate. The ACME client is initialized lazily
99+// on the first call. Bundle and MustStaple are set to true/false respectively.
100+func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
101+ if err := i.ensureClient(ctx); err != nil {
102+ return nil, err
103+ }
104+
105+ domain := ""
106+ if len(cert.Names) > 0 {
107+ domain = cert.Names[0]
108+ }
109+
110+ legoCert := certificate.Resource{
111+ Domain: domain,
112+ PrivateKey: cert.PrivateKeyPEM,
113+ Certificate: cert.CertPEM,
114+ IssuerCertificate: cert.ChainPEM,
115+ }
116+
117+ // If FullchainPEM is available but CertPEM is empty, use FullchainPEM.
118+ if len(legoCert.Certificate) == 0 && len(cert.FullchainPEM) > 0 {
119+ legoCert.Certificate = cert.FullchainPEM
120+ }
121+
122+ renewed, err := i.client.Certificate.RenewWithOptions(legoCert, &certificate.RenewOptions{
123+ Bundle: true,
124+ })
125+ if err != nil {
126+ return nil, fmt.Errorf("renew certificate: %w", err)
127+ }
128+
129+ // Preserve the original order info
130+ order := out.CertificateOrder{
131+ ID: cert.ID,
132+ Names: cert.Names,
133+ Challenge: cert.Challenge,
134+ }
135+
136+ return resourceToStored(order, renewed), nil
137+}
138+
139+// ensureClient initializes the lego client and ACME account if not yet done.
140+func (i *Issuer) ensureClient(ctx context.Context) error {
141+ i.mu.Lock()
142+ defer i.mu.Unlock()
143+
144+ if i.client != nil {
145+ return nil
146+ }
147+
148+ // Load or create ACME account
149+ user, err := i.loadOrCreateAccount(ctx)
150+ if err != nil {
151+ return fmt.Errorf("acme account: %w", err)
152+ }
153+ i.user = user
154+
155+ // Build lego config
156+ legoCfg := lego.NewConfig(user)
157+ legoCfg.Certificate.KeyType = certcrypto.EC256 // ECDSA P-256
158+
159+ if i.cfg.CADirectoryURL != "" {
160+ legoCfg.CADirURL = i.cfg.CADirectoryURL
161+ }
162+ if i.cfg.HTTPClient != nil {
163+ legoCfg.HTTPClient = i.cfg.HTTPClient
164+ }
165+
166+ client, err := lego.NewClient(legoCfg)
167+ if err != nil {
168+ return fmt.Errorf("create lego client: %w", err)
169+ }
170+
171+ // Set up challenge providers
172+ switch i.cfg.Challenge {
173+ case domain.ACMEChallengeHTTP01:
174+ if i.cfg.HTTPChallengeSink != nil {
175+ if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {
176+ return fmt.Errorf("set http-01 provider: %w", err)
177+ }
178+ }
179+ case domain.ACMEChallengeCloudflareDNS01:
180+ cfCfg := cloudflare.NewDefaultConfig()
181+ cfCfg.AuthToken = i.cfg.Token
182+ cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg)
183+ if err != nil {
184+ return fmt.Errorf("create cloudflare dns provider: %w", err)
185+ }
186+ if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {
187+ return fmt.Errorf("set dns-01 provider: %w", err)
188+ }
189+ }
190+
191+ i.client = client
192+ return nil
193+}
194+
195+// loadOrCreateAccount loads an existing ACME account from the store or creates
196+// a new one. If creating, it registers the account with the ACME server.
197+func (i *Issuer) loadOrCreateAccount(ctx context.Context) (*AccountUser, error) {
198+ stored, err := i.cfg.Store.LoadAccount(ctx)
199+ if err != nil {
200+ return nil, fmt.Errorf("load account: %w", err)
201+ }
202+
203+ if stored != nil && len(stored.PrivateKeyPEM) > 0 {
204+ return restoreAccount(stored)
205+ }
206+
207+ // Generate new ECDSA P-256 private key for the ACME account
208+ privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
209+ if err != nil {
210+ return nil, fmt.Errorf("generate account key: %w", err)
211+ }
212+
213+ user := NewAccountUser(i.cfg.Email, privateKey, nil)
214+
215+ // We need a temporary client to register the account
216+ legoCfg := lego.NewConfig(user)
217+ legoCfg.Certificate.KeyType = certcrypto.EC256
218+ if i.cfg.CADirectoryURL != "" {
219+ legoCfg.CADirURL = i.cfg.CADirectoryURL
220+ }
221+ if i.cfg.HTTPClient != nil {
222+ legoCfg.HTTPClient = i.cfg.HTTPClient
223+ }
224+
225+ tmpClient, err := lego.NewClient(legoCfg)
226+ if err != nil {
227+ return nil, fmt.Errorf("create temporary lego client: %w", err)
228+ }
229+
230+ reg, err := tmpClient.Registration.Register(registration.RegisterOptions{
231+ TermsOfServiceAgreed: true,
232+ })
233+ if err != nil {
234+ return nil, fmt.Errorf("register acme account: %w", err)
235+ }
236+
237+ user = NewAccountUser(i.cfg.Email, privateKey, reg)
238+
239+ // Persist the account
240+ bodyJSON, err := json.Marshal(reg.Body)
241+ if err != nil {
242+ return nil, fmt.Errorf("marshal account body: %w", err)
243+ }
244+
245+ privateKeyPEM := certcrypto.PEMEncode(privateKey)
246+
247+ if err := i.cfg.Store.SaveAccount(ctx, out.ACMEAccount{
248+ Email: i.cfg.Email,
249+ PrivateKeyPEM: privateKeyPEM,
250+ RegistrationURI: reg.URI,
251+ BodyJSON: bodyJSON,
252+ }); err != nil {
253+ return nil, fmt.Errorf("save account: %w", err)
254+ }
255+
256+ return user, nil
257+}
258+
259+// restoreAccount reconstructs an AccountUser from a stored ACMEAccount.
260+func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) {
261+ privateKey, err := certcrypto.ParsePEMPrivateKey(stored.PrivateKeyPEM)
262+ if err != nil {
263+ return nil, fmt.Errorf("parse account private key: %w", err)
264+ }
265+
266+ reg := &registration.Resource{
267+ URI: stored.RegistrationURI,
268+ }
269+
270+ if len(stored.BodyJSON) > 0 {
271+ var body acme.Account
272+ if err := json.Unmarshal(stored.BodyJSON, &body); err != nil {
273+ return nil, fmt.Errorf("unmarshal account body: %w", err)
274+ }
275+ reg.Body = body
276+ }
277+
278+ return NewAccountUser(stored.Email, privateKey, reg), nil
279+}
280+
281+// acmeAccount mirrors acme.Account for JSON round-trip without importing acme
282+// in the test file.
283+type acmeAccount struct {
284+ Status string `json:"status,omitempty"`
285+ Contact []string `json:"contact,omitempty"`
286+ TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
287+ Orders string `json:"orders,omitempty"`
288+}
289+
290+// resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
291+func resourceToStored(order out.CertificateOrder, res *certificate.Resource) *out.StoredCertificate {
292+ var fullchainPEM []byte
293+ if len(res.IssuerCertificate) > 0 {
294+ fullchainPEM = append(append([]byte{}, res.Certificate...), res.IssuerCertificate...)
295+ } else {
296+ fullchainPEM = res.Certificate
297+ }
298+
299+ // Parse the certificate to extract NotAfter and populate tls.Certificate.
300+ var tlsCert tls.Certificate
301+ notAfter := time.Time{}
302+ if parsedTLSCert, err := tls.X509KeyPair(fullchainPEM, res.PrivateKey); err == nil && len(parsedTLSCert.Certificate) > 0 {
303+ tlsCert = parsedTLSCert
304+ if parsed, parseErr := x509.ParseCertificate(parsedTLSCert.Certificate[0]); parseErr == nil {
305+ notAfter = parsed.NotAfter
306+ }
307+ }
308+
309+ names := order.Names
310+ if names == nil {
311+ names = []string{}
312+ }
313+
314+ return &out.StoredCertificate{
315+ ID: order.ID,
316+ Names: names,
317+ Challenge: order.Challenge,
318+ Certificate: tlsCert,
319+ CertPEM: res.Certificate,
320+ ChainPEM: res.IssuerCertificate,
321+ FullchainPEM: fullchainPEM,
322+ PrivateKeyPEM: res.PrivateKey,
323+ NotAfter: notAfter,
324+ }
325+}
A internal/adapters/out/acmelego/issuer_test.go +290 -0
@@ -0,0 +1,290 @@
1+package acmelego
2+
3+import (
4+ "context"
5+ "crypto/ecdsa"
6+ "crypto/elliptic"
7+ "crypto/rand"
8+ "crypto/rsa"
9+ "crypto/tls"
10+ "crypto/x509"
11+ "crypto/x509/pkix"
12+ "encoding/json"
13+ "encoding/pem"
14+ "math/big"
15+ "testing"
16+ "time"
17+
18+ "github.com/go-acme/lego/v4/certcrypto"
19+ "github.com/go-acme/lego/v4/certificate"
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "github.com/bnema/gordon/internal/boundaries/out"
24+ "github.com/bnema/gordon/internal/domain"
25+)
26+
27+func TestNewIssuerValidatesEmail(t *testing.T) {
28+ _, err := NewIssuer(Config{})
29+ assert.Error(t, err)
30+ assert.ErrorIs(t, err, domain.ErrACMEEmailRequired)
31+}
32+
33+func TestNewIssuerValidatesStore(t *testing.T) {
34+ _, err := NewIssuer(Config{Email: "test@example.com"})
35+ assert.Error(t, err)
36+ assert.Contains(t, err.Error(), "certificate store is required")
37+}
38+
39+func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
40+ _, err := NewIssuer(Config{
41+ Email: "test@example.com",
42+ Challenge: domain.ACMEChallengeCloudflareDNS01,
43+ Store: &mockStore{},
44+ })
45+ assert.Error(t, err)
46+ assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
47+}
48+
49+func TestNewIssuerValidWithDefaults(t *testing.T) {
50+ issuer, err := NewIssuer(Config{
51+ Email: "test@example.com",
52+ Store: &mockStore{},
53+ })
54+ require.NoError(t, err)
55+ require.NotNil(t, issuer)
56+ assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
57+}
58+
59+// mockStore implements out.CertificateStore for testing.
60+type mockStore struct {
61+ account *out.ACMEAccount
62+ certs []out.StoredCertificate
63+}
64+
65+func (m *mockStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) {
66+ return m.account, nil
67+}
68+
69+func (m *mockStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error {
70+ m.account = &account
71+ return nil
72+}
73+
74+func (m *mockStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) {
75+ return m.certs, nil
76+}
77+
78+func (m *mockStore) Save(ctx context.Context, cert out.StoredCertificate) error {
79+ return nil
80+}
81+
82+func (m *mockStore) Lock(ctx context.Context) (func() error, error) {
83+ return func() error { return nil }, nil
84+}
85+
86+func TestPrivateKeyRoundTrip(t *testing.T) {
87+ // Generate an ECDSA P-256 private key
88+ privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
89+ require.NoError(t, err)
90+ require.NotNil(t, privateKey)
91+
92+ // PEM encode
93+ pemBytes := certcrypto.PEMEncode(privateKey)
94+ require.NotEmpty(t, pemBytes)
95+
96+ // Parse back
97+ parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes)
98+ require.NoError(t, err)
99+ require.NotNil(t, parsedKey)
100+
101+ // Verify it's the same curve (ECDSA P-256)
102+ ecKey, ok := parsedKey.(*ecdsa.PrivateKey)
103+ require.True(t, ok, "expected *ecdsa.PrivateKey")
104+ assert.Equal(t, elliptic.P256(), ecKey.Curve)
105+
106+ // Verify we can sign with the original and verify with the parsed
107+ digest := []byte("test message")
108+ sig, err := ecdsa.SignASN1(rand.Reader, privateKey.(*ecdsa.PrivateKey), digest[:])
109+ require.NoError(t, err)
110+ assert.True(t, ecdsa.VerifyASN1(&ecKey.PublicKey, digest[:], sig))
111+}
112+
113+func TestPrivateKeyRSARoundTrip(t *testing.T) {
114+ // Test RSA private key round-trip via certcrypto
115+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
116+ require.NoError(t, err)
117+
118+ pemBytes := certcrypto.PEMEncode(privateKey)
119+ require.NotEmpty(t, pemBytes)
120+
121+ parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes)
122+ require.NoError(t, err)
123+ require.NotNil(t, parsedKey)
124+
125+ _, ok := parsedKey.(*rsa.PrivateKey)
126+ require.True(t, ok)
127+}
128+
129+func TestResourceConversionToStoredCertificate(t *testing.T) {
130+ // Generate a self-signed certificate for testing
131+ certPEM, keyPEM, err := generateSelfSignedCert("example.com")
132+ require.NoError(t, err)
133+
134+ // Generate a fake issuer certificate
135+ issuerPEM, _, err := generateSelfSignedCert("fake-ca.example.com")
136+ require.NoError(t, err)
137+
138+ resource := &certificate.Resource{
139+ Domain: "example.com",
140+ CertURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123",
141+ CertStableURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123",
142+ PrivateKey: keyPEM,
143+ Certificate: certPEM,
144+ IssuerCertificate: issuerPEM,
145+ }
146+
147+ order := out.CertificateOrder{
148+ ID: "test-cert",
149+ Names: []string{"example.com"},
150+ Challenge: domain.ACMEChallengeHTTP01,
151+ }
152+
153+ stored := resourceToStored(order, resource)
154+ require.NotNil(t, stored)
155+ assert.Equal(t, "test-cert", stored.ID)
156+ assert.Equal(t, []string{"example.com"}, stored.Names)
157+ assert.Equal(t, domain.ACMEChallengeHTTP01, stored.Challenge)
158+ assert.Equal(t, certPEM, stored.CertPEM)
159+ assert.Equal(t, issuerPEM, stored.ChainPEM)
160+
161+ // FullchainPEM should be cert + issuer
162+ expectedFullchain := append(append([]byte{}, certPEM...), issuerPEM...)
163+ assert.Equal(t, expectedFullchain, stored.FullchainPEM)
164+ assert.Equal(t, keyPEM, stored.PrivateKeyPEM)
165+
166+ // NotAfter should be set
167+ assert.False(t, stored.NotAfter.IsZero(), "NotAfter should be set")
168+
169+ // Verify tls.Certificate is populated from the PEM data
170+ assert.NotEmpty(t, stored.Certificate.Certificate, "tls.Certificate should be populated from PEM")
171+ assert.NotNil(t, stored.Certificate.PrivateKey, "private key should be populated")
172+}
173+
174+func TestResourceConversionNoIssuer(t *testing.T) {
175+ certPEM, keyPEM, err := generateSelfSignedCert("example.com")
176+ require.NoError(t, err)
177+
178+ resource := &certificate.Resource{
179+ Domain: "example.com",
180+ PrivateKey: keyPEM,
181+ Certificate: certPEM,
182+ }
183+
184+ order := out.CertificateOrder{
185+ ID: "test-cert-no-issuer",
186+ Names: []string{"example.com"},
187+ }
188+
189+ stored := resourceToStored(order, resource)
190+ require.NotNil(t, stored)
191+ assert.Equal(t, certPEM, stored.FullchainPEM, "fullchain should equal cert when no issuer")
192+ assert.Empty(t, stored.ChainPEM)
193+}
194+
195+func TestAccountJSONRoundTrip(t *testing.T) {
196+ // Simulate an ACME account with BodyJSON containing the serialized acme.Account
197+ body := acmeAccount{
198+ Status: "valid",
199+ Contact: []string{"mailto:test@example.com"},
200+ TermsOfServiceAgreed: true,
201+ Orders: "https://acme-v02.api.letsencrypt.org/acme/orders/abc123",
202+ }
203+
204+ bodyJSON, err := json.Marshal(body)
205+ require.NoError(t, err)
206+
207+ var restored acmeAccount
208+ err = json.Unmarshal(bodyJSON, &restored)
209+ require.NoError(t, err)
210+ assert.Equal(t, body.Status, restored.Status)
211+ assert.Equal(t, body.Contact, restored.Contact)
212+ assert.Equal(t, body.TermsOfServiceAgreed, restored.TermsOfServiceAgreed)
213+ assert.Equal(t, body.Orders, restored.Orders)
214+}
215+
216+// generateSelfSignedCert creates a self-signed certificate and its private key
217+// for testing purposes.
218+func generateSelfSignedCert(domain string) (certPEM, keyPEM []byte, err error) {
219+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
220+ if err != nil {
221+ return nil, nil, err
222+ }
223+
224+ serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
225+ if err != nil {
226+ return nil, nil, err
227+ }
228+
229+ template := &x509.Certificate{
230+ SerialNumber: serialNumber,
231+ Subject: pkix.Name{
232+ CommonName: domain,
233+ Organization: []string{"Test"},
234+ },
235+ DNSNames: []string{domain},
236+ NotBefore: time.Now().Add(-1 * time.Hour),
237+ NotAfter: time.Now().Add(24 * time.Hour),
238+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
239+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
240+ BasicConstraintsValid: true,
241+ }
242+
243+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
244+ if err != nil {
245+ return nil, nil, err
246+ }
247+
248+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
249+ if certPEM == nil {
250+ return nil, nil, err
251+ }
252+
253+ keyDER, err := x509.MarshalECPrivateKey(privateKey)
254+ if err != nil {
255+ return nil, nil, err
256+ }
257+
258+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
259+ if keyPEM == nil {
260+ return nil, nil, err
261+ }
262+
263+ return certPEM, keyPEM, nil
264+}
265+
266+// Ensure TestResourceConversion also validates the tls.X509KeyPair parse works.
267+func TestParsedCertificateNotAfter(t *testing.T) {
268+ certPEM, keyPEM, err := generateSelfSignedCert("test.example.com")
269+ require.NoError(t, err)
270+
271+ resource := &certificate.Resource{
272+ Domain: "test.example.com",
273+ PrivateKey: keyPEM,
274+ Certificate: certPEM,
275+ }
276+
277+ order := out.CertificateOrder{
278+ ID: "parse-test",
279+ Names: []string{"test.example.com"},
280+ }
281+
282+ stored := resourceToStored(order, resource)
283+ require.NotNil(t, stored)
284+ assert.False(t, stored.NotAfter.IsZero())
285+ assert.True(t, stored.NotAfter.After(time.Now()))
286+
287+ // Confirm tls.X509KeyPair succeeds on the stored PEMs
288+ _, parseErr := tls.X509KeyPair(stored.FullchainPEM, stored.PrivateKeyPEM)
289+ assert.NoError(t, parseErr, "tls.X509KeyPair should succeed")
290+}
A internal/adapters/out/acmelego/user.go +42 -0
@@ -0,0 +1,42 @@
1+package acmelego
2+
3+import (
4+ "crypto"
5+
6+ "github.com/go-acme/lego/v4/registration"
7+)
8+
9+// AccountUser implements the lego registration.User interface for ACME
10+// account registration and certificate operations.
11+type AccountUser struct {
12+ email string
13+ privateKey crypto.PrivateKey
14+ registration *registration.Resource
15+}
16+
17+// NewAccountUser creates a new AccountUser.
18+func NewAccountUser(email string, key crypto.PrivateKey, reg *registration.Resource) *AccountUser {
19+ return &AccountUser{
20+ email: email,
21+ privateKey: key,
22+ registration: reg,
23+ }
24+}
25+
26+// GetEmail returns the account email.
27+func (u *AccountUser) GetEmail() string {
28+ return u.email
29+}
30+
31+// GetRegistration returns the ACME registration resource.
32+func (u *AccountUser) GetRegistration() *registration.Resource {
33+ return u.registration
34+}
35+
36+// GetPrivateKey returns the account private key.
37+func (u *AccountUser) GetPrivateKey() crypto.PrivateKey {
38+ return u.privateKey
39+}
40+
41+// compile-time interface check
42+var _ registration.User = (*AccountUser)(nil)
A internal/adapters/out/acmestore/store.go +341 -0
@@ -0,0 +1,341 @@
1+package acmestore
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+// certMetadata is the JSON structure persisted in each certificate directory.
19+type certMetadata struct {
20+ ID string `json:"id"`
21+ Names []string `json:"names"`
22+ Challenge string `json:"challenge"`
23+ NotAfter time.Time `json:"not_after,omitempty"`
24+ LastError string `json:"last_error,omitempty"`
25+}
26+
27+const (
28+ lockFile = ".lock"
29+ accountFile = "account.json"
30+ certDir = "certs"
31+ certFile = "cert.pem"
32+ chainFile = "chain.pem"
33+ fullchainFile = "fullchain.pem"
34+ privkeyFile = "privkey.pem"
35+ metaFile = "metadata.json"
36+
37+ dirMode os.FileMode = 0700
38+ privKeyMode os.FileMode = 0600
39+ metaMode os.FileMode = 0600
40+ accountMode os.FileMode = 0600
41+ lockMode os.FileMode = 0600
42+ pemMode os.FileMode = 0644
43+)
44+
45+// Store implements out.CertificateStore using the local filesystem.
46+type Store struct {
47+ root string
48+}
49+
50+// New creates a Store rooted at root. root must be non-empty and is created
51+// along with the certs subdirectory if they do not exist.
52+func New(root string) (*Store, error) {
53+ if root == "" {
54+ return nil, errors.New("acmestore: root is required")
55+ }
56+ if err := os.MkdirAll(root, dirMode); err != nil {
57+ return nil, fmt.Errorf("acmestore: mkdir root: %w", err)
58+ }
59+ if err := os.MkdirAll(filepath.Join(root, certDir), dirMode); err != nil {
60+ return nil, fmt.Errorf("acmestore: mkdir certs: %w", err)
61+ }
62+ return &Store{root: root}, nil
63+}
64+
65+// LoadAccount reads the account from <root>/account.json.
66+// It returns nil, nil when the file does not exist.
67+func (s *Store) LoadAccount(_ context.Context) (*out.ACMEAccount, error) {
68+ path := filepath.Join(s.root, accountFile)
69+ data, err := os.ReadFile(path)
70+ if err != nil {
71+ if os.IsNotExist(err) {
72+ return nil, nil
73+ }
74+ return nil, fmt.Errorf("acmestore: read account: %w", err)
75+ }
76+ var acct out.ACMEAccount
77+ if err := json.Unmarshal(data, &acct); err != nil {
78+ return nil, fmt.Errorf("acmestore: unmarshal account: %w", err)
79+ }
80+ return &acct, nil
81+}
82+
83+// SaveAccount writes the account to <root>/account.json atomically with mode 0600.
84+func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error {
85+ data, err := json.MarshalIndent(account, "", " ")
86+ if err != nil {
87+ return fmt.Errorf("acmestore: marshal account: %w", err)
88+ }
89+ if err := writeAtomic(filepath.Join(s.root, accountFile), data, accountMode); err != nil {
90+ return fmt.Errorf("acmestore: save account: %w", err)
91+ }
92+ return nil
93+}
94+
95+// LoadAll reads all certificate directories under <root>/certs/ and returns
96+// the stored certificates. Directories with unsafe IDs are skipped.
97+func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
98+ certsPath := filepath.Join(s.root, certDir)
99+ entries, err := os.ReadDir(certsPath)
100+ if err != nil {
101+ if os.IsNotExist(err) {
102+ return nil, nil
103+ }
104+ return nil, fmt.Errorf("acmestore: list certs: %w", err)
105+ }
106+
107+ var result []out.StoredCertificate
108+ for _, entry := range entries {
109+ if !entry.IsDir() {
110+ continue
111+ }
112+ id := entry.Name()
113+ if !safeID(id) {
114+ continue
115+ }
116+
117+ cert, err := s.loadOne(id)
118+ if err != nil {
119+ return nil, fmt.Errorf("acmestore: load %s: %w", id, err)
120+ }
121+ result = append(result, *cert)
122+ }
123+ return result, nil
124+}
125+
126+// loadOne reads a single certificate directory and returns the stored
127+// certificate.
128+func (s *Store) loadOne(id string) (*out.StoredCertificate, error) {
129+ dir := filepath.Join(s.root, certDir, id)
130+
131+ meta, err := readMetadata(filepath.Join(dir, metaFile))
132+ if err != nil {
133+ return nil, fmt.Errorf("acmestore: metadata %s: %w", id, err)
134+ }
135+
136+ certPEM, err := readPEM(filepath.Join(dir, certFile))
137+ if err != nil {
138+ return nil, fmt.Errorf("acmestore: read %s cert.pem: %w", id, err)
139+ }
140+ chainPEM, err := readPEM(filepath.Join(dir, chainFile))
141+ if err != nil {
142+ return nil, fmt.Errorf("acmestore: read %s chain.pem: %w", id, err)
143+ }
144+ fullchainPEM, err := readPEM(filepath.Join(dir, fullchainFile))
145+ if err != nil {
146+ return nil, fmt.Errorf("acmestore: read %s fullchain.pem: %w", id, err)
147+ }
148+ privKeyPEM, err := readPEM(filepath.Join(dir, privkeyFile))
149+ if err != nil {
150+ return nil, fmt.Errorf("acmestore: read %s privkey.pem: %w", id, err)
151+ }
152+
153+ var tlsCert tls.Certificate
154+ if len(fullchainPEM) > 0 && len(privKeyPEM) > 0 {
155+ parsed, err := tls.X509KeyPair(fullchainPEM, privKeyPEM)
156+ if err != nil {
157+ return nil, fmt.Errorf("acmestore: parse key pair %s: %w", id, err)
158+ }
159+ tlsCert = parsed
160+ }
161+
162+ challenge, err := domain.ParseACMEChallengeMode(meta.Challenge)
163+ if err != nil {
164+ return nil, fmt.Errorf("acmestore: challenge %s: %w", id, err)
165+ }
166+
167+ return &out.StoredCertificate{
168+ ID: id,
169+ Names: meta.Names,
170+ Challenge: challenge,
171+ Certificate: tlsCert,
172+ CertPEM: certPEM,
173+ ChainPEM: chainPEM,
174+ FullchainPEM: fullchainPEM,
175+ PrivateKeyPEM: privKeyPEM,
176+ NotAfter: meta.NotAfter,
177+ LastError: meta.LastError,
178+ }, nil
179+}
180+
181+// Save persists a certificate to <root>/certs/<safe-id>/.
182+// All PEM files and metadata are written atomically.
183+func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
184+ if !safeID(cert.ID) {
185+ return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID)
186+ }
187+
188+ dir := filepath.Join(s.root, certDir, cert.ID)
189+ if err := os.MkdirAll(dir, dirMode); err != nil {
190+ return fmt.Errorf("acmestore: mkdir %s: %w", cert.ID, err)
191+ }
192+
193+ meta := certMetadata{
194+ ID: cert.ID,
195+ Names: cert.Names,
196+ Challenge: string(cert.Challenge),
197+ NotAfter: cert.NotAfter,
198+ LastError: cert.LastError,
199+ }
200+
201+ if err := writeFileIfNonNil(filepath.Join(dir, certFile), cert.CertPEM, pemMode); err != nil {
202+ return fmt.Errorf("acmestore: write cert.pem: %w", err)
203+ }
204+ if err := writeFileIfNonNil(filepath.Join(dir, chainFile), cert.ChainPEM, pemMode); err != nil {
205+ return fmt.Errorf("acmestore: write chain.pem: %w", err)
206+ }
207+ if err := writeFileIfNonNil(filepath.Join(dir, fullchainFile), cert.FullchainPEM, pemMode); err != nil {
208+ return fmt.Errorf("acmestore: write fullchain.pem: %w", err)
209+ }
210+ if err := writeFileIfNonNil(filepath.Join(dir, privkeyFile), cert.PrivateKeyPEM, privKeyMode); err != nil {
211+ return fmt.Errorf("acmestore: write privkey.pem: %w", err)
212+ }
213+
214+ metaData, err := json.MarshalIndent(meta, "", " ")
215+ if err != nil {
216+ return fmt.Errorf("acmestore: marshal metadata: %w", err)
217+ }
218+ if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil {
219+ return fmt.Errorf("acmestore: write metadata: %w", err)
220+ }
221+
222+ return nil
223+}
224+
225+// Lock acquires an exclusive lock using a lock file at <root>/.lock.
226+// The lock is released by calling the returned unlock function.
227+// If the lock file already exists, Lock returns an error.
228+// Context cancellation is respected before attempting the lock.
229+func (s *Store) Lock(ctx context.Context) (func() error, error) {
230+ select {
231+ case <-ctx.Done():
232+ return nil, ctx.Err()
233+ default:
234+ }
235+
236+ path := filepath.Join(s.root, lockFile)
237+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, lockMode)
238+ if err != nil {
239+ if os.IsExist(err) {
240+ return nil, fmt.Errorf("acmestore: lock already held")
241+ }
242+ return nil, fmt.Errorf("acmestore: acquire lock: %w", err)
243+ }
244+ if err := f.Close(); err != nil {
245+ os.Remove(path)
246+ return nil, fmt.Errorf("acmestore: close lock file: %w", err)
247+ }
248+
249+ return func() error {
250+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
251+ return fmt.Errorf("acmestore: release lock: %w", err)
252+ }
253+ return nil
254+ }, nil
255+}
256+
257+// safeID rejects IDs that could cause path traversal.
258+func safeID(id string) bool {
259+ if id == "" {
260+ return false
261+ }
262+ if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
263+ return false
264+ }
265+ return true
266+}
267+
268+// writeAtomic writes data to path atomically by writing to a temporary file
269+// in the same directory and then renaming.
270+func writeAtomic(path string, data []byte, mode os.FileMode) error {
271+ dir := filepath.Dir(path)
272+ tmp, err := os.CreateTemp(dir, ".tmp-*")
273+ if err != nil {
274+ return fmt.Errorf("create temp: %w", err)
275+ }
276+ tmpName := tmp.Name()
277+
278+ // Clean up temp file on any failure
279+ removeOnErr := true
280+ defer func() {
281+ if removeOnErr {
282+ os.Remove(tmpName)
283+ }
284+ }()
285+
286+ if err := tmp.Chmod(mode); err != nil {
287+ tmp.Close()
288+ return fmt.Errorf("chmod temp: %w", err)
289+ }
290+ if _, err := tmp.Write(data); err != nil {
291+ tmp.Close()
292+ return fmt.Errorf("write temp: %w", err)
293+ }
294+ // Sync before close to ensure data is on disk.
295+ if err := tmp.Sync(); err != nil {
296+ tmp.Close()
297+ return fmt.Errorf("sync temp: %w", err)
298+ }
299+ if err := tmp.Close(); err != nil {
300+ return fmt.Errorf("close temp: %w", err)
301+ }
302+ if err := os.Rename(tmpName, path); err != nil {
303+ return fmt.Errorf("rename temp: %w", err)
304+ }
305+ removeOnErr = false
306+ return nil
307+}
308+
309+// writeFileIfNonNil writes data to path atomically only when data is non-nil.
310+func writeFileIfNonNil(path string, data []byte, mode os.FileMode) error {
311+ if data == nil {
312+ return nil
313+ }
314+ return writeAtomic(path, data, mode)
315+}
316+
317+// readPEM reads a PEM file from path. If the file does not exist it returns
318+// nil, nil without error. Other read errors are returned.
319+func readPEM(path string) ([]byte, error) {
320+ data, err := os.ReadFile(path)
321+ if err != nil {
322+ if os.IsNotExist(err) {
323+ return nil, nil
324+ }
325+ return nil, err
326+ }
327+ return data, nil
328+}
329+
330+// readMetadata reads and unmarshals a metadata JSON file.
331+func readMetadata(path string) (*certMetadata, error) {
332+ data, err := os.ReadFile(path)
333+ if err != nil {
334+ return nil, fmt.Errorf("read: %w", err)
335+ }
336+ var meta certMetadata
337+ if err := json.Unmarshal(data, &meta); err != nil {
338+ return nil, fmt.Errorf("unmarshal: %w", err)
339+ }
340+ return &meta, nil
341+}
A internal/adapters/out/acmestore/store_test.go +305 -0
@@ -0,0 +1,305 @@
1+package acmestore
2+
3+import (
4+ "context"
5+ "crypto/ecdsa"
6+ "crypto/elliptic"
7+ "crypto/rand"
8+ "crypto/x509"
9+ "crypto/x509/pkix"
10+ "encoding/json"
11+ "encoding/pem"
12+ "math/big"
13+ "os"
14+ "path/filepath"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/internal/boundaries/out"
22+ "github.com/bnema/gordon/internal/domain"
23+)
24+
25+// generateTestCertPEM creates a self-signed ECDSA P256 certificate and its
26+// private key, both PEM-encoded, for use in tests that require a valid
27+// tls.Certificate.
28+func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
29+ t.Helper()
30+
31+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
32+ require.NoError(t, err)
33+
34+ keyDER, err := x509.MarshalECPrivateKey(key)
35+ require.NoError(t, err)
36+
37+ keyPEM = pem.EncodeToMemory(&pem.Block{
38+ Type: "EC PRIVATE KEY",
39+ Bytes: keyDER,
40+ })
41+
42+ template := &x509.Certificate{
43+ SerialNumber: big.NewInt(1),
44+ Subject: pkix.Name{
45+ CommonName: "test.example.com",
46+ },
47+ NotBefore: time.Now().Add(-1 * time.Hour),
48+ NotAfter: time.Now().Add(24 * time.Hour),
49+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
50+ }
51+
52+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
53+ require.NoError(t, err)
54+
55+ certPEM = pem.EncodeToMemory(&pem.Block{
56+ Type: "CERTIFICATE",
57+ Bytes: certDER,
58+ })
59+
60+ return certPEM, keyPEM
61+}
62+
63+func TestStoreSaveLoadAll(t *testing.T) {
64+ root := t.TempDir()
65+ ctx := context.Background()
66+
67+ store, err := New(root)
68+ require.NoError(t, err)
69+
70+ now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
71+
72+ fullchainPEM, privKeyPEM := generateTestCertPEM(t)
73+
74+ cert := out.StoredCertificate{
75+ ID: "dns01-example.com",
76+ Names: []string{"example.com", "*.example.com"},
77+ Challenge: domain.ACMEChallengeCloudflareDNS01,
78+ CertPEM: []byte("dummy-cert"),
79+ ChainPEM: []byte("dummy-chain"),
80+ FullchainPEM: fullchainPEM,
81+ PrivateKeyPEM: privKeyPEM,
82+ NotAfter: now,
83+ }
84+
85+ err = store.Save(ctx, cert)
86+ require.NoError(t, err)
87+
88+ certs, err := store.LoadAll(ctx)
89+ require.NoError(t, err)
90+ require.Len(t, certs, 1)
91+
92+ assert.Equal(t, "dns01-example.com", certs[0].ID)
93+ assert.Equal(t, []string{"example.com", "*.example.com"}, certs[0].Names)
94+ assert.Equal(t, privKeyPEM, certs[0].PrivateKeyPEM)
95+ // Verify tls.Certificate is populated from valid PEM
96+ assert.NotEmpty(t, certs[0].Certificate.Certificate, "tls.Certificate.Certificate should be populated")
97+}
98+
99+func TestStorePrivateKeyMode(t *testing.T) {
100+ root := t.TempDir()
101+ ctx := context.Background()
102+
103+ store, err := New(root)
104+ require.NoError(t, err)
105+
106+ cert := out.StoredCertificate{
107+ ID: "http01-app.example.com",
108+ Names: []string{"app.example.com"},
109+ Challenge: domain.ACMEChallengeHTTP01,
110+ PrivateKeyPEM: []byte("key"),
111+ }
112+
113+ err = store.Save(ctx, cert)
114+ require.NoError(t, err)
115+
116+ privkeyPath := filepath.Join(root, certDir, "http01-app.example.com", privkeyFile)
117+ info, err := os.Stat(privkeyPath)
118+ require.NoError(t, err)
119+
120+ // Mode 0600 (owner read+write only)
121+ assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
122+}
123+
124+func TestStoreRejectsUnsafeID(t *testing.T) {
125+ root := t.TempDir()
126+ ctx := context.Background()
127+
128+ store, err := New(root)
129+ require.NoError(t, err)
130+
131+ cert := out.StoredCertificate{
132+ ID: "../escape",
133+ PrivateKeyPEM: []byte("key"),
134+ }
135+
136+ err = store.Save(ctx, cert)
137+ require.Error(t, err)
138+ assert.Contains(t, err.Error(), "unsafe certificate id")
139+}
140+
141+func TestStoreSaveLoadAccount(t *testing.T) {
142+ root := t.TempDir()
143+ ctx := context.Background()
144+
145+ store, err := New(root)
146+ require.NoError(t, err)
147+
148+ acct := out.ACMEAccount{
149+ Email: "admin@example.com",
150+ PrivateKeyPEM: []byte("key"),
151+ RegistrationURI: "https://acme.test/acct/1",
152+ BodyJSON: []byte(`{"status":"valid"}`),
153+ }
154+
155+ err = store.SaveAccount(ctx, acct)
156+ require.NoError(t, err)
157+
158+ loaded, err := store.LoadAccount(ctx)
159+ require.NoError(t, err)
160+ require.NotNil(t, loaded)
161+
162+ assert.Equal(t, "admin@example.com", loaded.Email)
163+ assert.Equal(t, []byte("key"), loaded.PrivateKeyPEM)
164+ assert.Equal(t, "https://acme.test/acct/1", loaded.RegistrationURI)
165+ assert.Equal(t, []byte(`{"status":"valid"}`), loaded.BodyJSON)
166+}
167+
168+func TestStoreAccountFileMode(t *testing.T) {
169+ root := t.TempDir()
170+ ctx := context.Background()
171+
172+ store, err := New(root)
173+ require.NoError(t, err)
174+
175+ acct := out.ACMEAccount{
176+ Email: "test@example.com",
177+ PrivateKeyPEM: []byte("test-key"),
178+ RegistrationURI: "https://acme.test/acct/99",
179+ BodyJSON: []byte(`{"status":"valid"}`),
180+ }
181+
182+ err = store.SaveAccount(ctx, acct)
183+ require.NoError(t, err)
184+
185+ accountPath := filepath.Join(root, accountFile)
186+ info, err := os.Stat(accountPath)
187+ require.NoError(t, err)
188+
189+ assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
190+}
191+
192+func TestStoreLoadAllInvalidChallenge(t *testing.T) {
193+ root := t.TempDir()
194+ ctx := context.Background()
195+
196+ store, err := New(root)
197+ require.NoError(t, err)
198+
199+ // Create a cert dir manually with invalid challenge in metadata
200+ certDirPath := filepath.Join(root, certDir, "bad-challenge.example.com")
201+ err = os.MkdirAll(certDirPath, dirMode)
202+ require.NoError(t, err)
203+
204+ meta := certMetadata{
205+ ID: "bad-challenge.example.com",
206+ Names: []string{"bad-challenge.example.com"},
207+ Challenge: "invalid-challenge-mode",
208+ }
209+ metaData, err := json.MarshalIndent(meta, "", " ")
210+ require.NoError(t, err)
211+ err = os.WriteFile(filepath.Join(certDirPath, metaFile), metaData, metaMode)
212+ require.NoError(t, err)
213+
214+ // LoadAll should return an error
215+ _, err = store.LoadAll(ctx)
216+ require.Error(t, err)
217+ assert.Contains(t, err.Error(), "acme challenge invalid")
218+}
219+
220+func TestStoreLoadAllCorruptPrivkey(t *testing.T) {
221+ root := t.TempDir()
222+ ctx := context.Background()
223+
224+ store, err := New(root)
225+ require.NoError(t, err)
226+
227+ // Save a valid cert first
228+ cert := out.StoredCertificate{
229+ ID: "corrupt-privkey.example.com",
230+ Names: []string{"corrupt-privkey.example.com"},
231+ Challenge: domain.ACMEChallengeHTTP01,
232+ FullchainPEM: []byte("fullchain-data"),
233+ PrivateKeyPEM: []byte("privkey-data"),
234+ }
235+ err = store.Save(ctx, cert)
236+ require.NoError(t, err)
237+
238+ // Replace privkey.pem with an unreadable directory (simulate corruption)
239+ privkeyPath := filepath.Join(root, certDir, "corrupt-privkey.example.com", privkeyFile)
240+ err = os.Remove(privkeyPath)
241+ require.NoError(t, err)
242+ // Create a directory in its place to cause a read error
243+ err = os.MkdirAll(privkeyPath, 0700)
244+ require.NoError(t, err)
245+
246+ // LoadAll should return an error
247+ _, err = store.LoadAll(ctx)
248+ require.Error(t, err)
249+ assert.Contains(t, err.Error(), "privkey.pem")
250+}
251+
252+func TestStoreLoadAllCorruptFullchain(t *testing.T) {
253+ root := t.TempDir()
254+ ctx := context.Background()
255+
256+ store, err := New(root)
257+ require.NoError(t, err)
258+
259+ // Save a valid cert first
260+ cert := out.StoredCertificate{
261+ ID: "corrupt-fullchain.example.com",
262+ Names: []string{"corrupt-fullchain.example.com"},
263+ Challenge: domain.ACMEChallengeHTTP01,
264+ FullchainPEM: []byte("fullchain-data"),
265+ PrivateKeyPEM: []byte("privkey-data"),
266+ }
267+ err = store.Save(ctx, cert)
268+ require.NoError(t, err)
269+
270+ // Replace fullchain.pem with an unreadable directory
271+ fullchainPath := filepath.Join(root, certDir, "corrupt-fullchain.example.com", fullchainFile)
272+ err = os.Remove(fullchainPath)
273+ require.NoError(t, err)
274+ err = os.MkdirAll(fullchainPath, 0700)
275+ require.NoError(t, err)
276+
277+ // LoadAll should return an error
278+ _, err = store.LoadAll(ctx)
279+ require.Error(t, err)
280+ assert.Contains(t, err.Error(), "fullchain.pem")
281+}
282+
283+func TestStoreLoadAllInvalidPEM(t *testing.T) {
284+ root := t.TempDir()
285+ ctx := context.Background()
286+
287+ store, err := New(root)
288+ require.NoError(t, err)
289+
290+ // Save a cert with non-empty but invalid PEM bytes (mismatched key)
291+ cert := out.StoredCertificate{
292+ ID: "invalid-pem.example.com",
293+ Names: []string{"invalid-pem.example.com"},
294+ Challenge: domain.ACMEChallengeHTTP01,
295+ FullchainPEM: []byte("invalid-fullchain"),
296+ PrivateKeyPEM: []byte("invalid-privkey"),
297+ }
298+ err = store.Save(ctx, cert)
299+ require.NoError(t, err)
300+
301+ // LoadAll should return an error because tls.X509KeyPair fails
302+ _, err = store.LoadAll(ctx)
303+ require.Error(t, err)
304+ assert.Contains(t, err.Error(), "parse key pair")
305+}
A internal/adapters/out/secrets/public_tls_resolver.go +113 -0
@@ -0,0 +1,113 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/gordon/internal/boundaries/out"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+const defaultCloudflareTokenPassName = "gordon/cloudflare/api-token"
17+
18+// PublicTLSResolverConfig holds optional overrides for PublicTLSResolver.
19+type PublicTLSResolverConfig struct {
20+ PassLookup func(ctx context.Context, name string) (string, error)
21+ EnvLookup func(key string) string
22+}
23+
24+// PublicTLSResolver resolves Cloudflare API tokens using pass, file, or env.
25+type PublicTLSResolver struct {
26+ passLookup func(ctx context.Context, name string) (string, error)
27+ envLookup func(key string) string
28+}
29+
30+var _ out.SecretResolver = (*PublicTLSResolver)(nil)
31+
32+// NewPublicTLSResolver creates a new PublicTLSResolver with the given config.
33+// Defaults PassLookup to defaultPassLookup and EnvLookup to os.Getenv.
34+func NewPublicTLSResolver(cfg PublicTLSResolverConfig) *PublicTLSResolver {
35+ r := &PublicTLSResolver{
36+ passLookup: defaultPassLookup,
37+ envLookup: os.Getenv,
38+ }
39+ if cfg.PassLookup != nil {
40+ r.passLookup = cfg.PassLookup
41+ }
42+ if cfg.EnvLookup != nil {
43+ r.envLookup = cfg.EnvLookup
44+ }
45+ return r
46+}
47+
48+// ResolveCloudflareToken resolves the Cloudflare API token.
49+// Lookup order: pass, token file env, token env.
50+func (r *PublicTLSResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) {
51+ var passErr error
52+ var fileErr error
53+
54+ // 1. Try pass
55+ if token, err := r.passLookup(ctx, defaultCloudflareTokenPassName); err == nil {
56+ if trimmed := strings.TrimSpace(token); trimmed != "" {
57+ return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourcePass}, nil
58+ }
59+ } else if !errors.Is(err, os.ErrNotExist) {
60+ passErr = err
61+ }
62+
63+ // 2. Try token file env
64+ if filePath := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN_FILE"); filePath != "" {
65+ if data, err := os.ReadFile(filePath); err == nil {
66+ if trimmed := strings.TrimSpace(string(data)); trimmed != "" {
67+ return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceFile}, nil
68+ }
69+ } else {
70+ fileErr = fmt.Errorf("read token file: %w", err)
71+ }
72+ }
73+
74+ // 3. Try token env
75+ if token := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN"); token != "" {
76+ if trimmed := strings.TrimSpace(token); trimmed != "" {
77+ return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceEnv}, nil
78+ }
79+ }
80+
81+ // 4. No token found — return remembered errors if any
82+ if passErr != nil {
83+ return out.SecretValue{Source: domain.ACMETokenSourceNone}, fmt.Errorf("resolve pass token: %w", passErr)
84+ }
85+ if fileErr != nil {
86+ return out.SecretValue{Source: domain.ACMETokenSourceNone}, fileErr
87+ }
88+ return out.SecretValue{Source: domain.ACMETokenSourceNone}, nil
89+}
90+
91+// defaultPassLookup executes "pass show <name>" with a 2-second timeout.
92+// Returns the raw output with trailing newline. Never logs token values.
93+func defaultPassLookup(ctx context.Context, name string) (string, error) {
94+ // Only allow the known Cloudflare token pass name.
95+ if name != defaultCloudflareTokenPassName {
96+ return "", fmt.Errorf("pass lookup: disallowed name %q", name)
97+ }
98+
99+ passCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
100+ defer cancel()
101+
102+ // #nosec G204 — name is validated above as the allowlisted constant
103+ // defaultCloudflareTokenPassName; no shell is used.
104+ cmd := exec.CommandContext(passCtx, "pass", "show", name)
105+ output, err := cmd.Output()
106+ if err != nil {
107+ if passCtx.Err() == context.DeadlineExceeded {
108+ return "", context.DeadlineExceeded
109+ }
110+ return "", err
111+ }
112+ return string(output), nil
113+}
A internal/adapters/out/secrets/public_tls_resolver_test.go +197 -0
@@ -0,0 +1,197 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestPublicTLSResolverPrefersPass(t *testing.T) {
18+ env := map[string]string{
19+ "GORDON_CLOUDFLARE_API_TOKEN": "env-token",
20+ }
21+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
22+ PassLookup: func(_ context.Context, _ string) (string, error) {
23+ return " pass-token\n", nil
24+ },
25+ EnvLookup: func(key string) string {
26+ return env[key]
27+ },
28+ })
29+
30+ sv, err := resolver.ResolveCloudflareToken(context.Background())
31+ require.NoError(t, err)
32+ assert.Equal(t, "pass-token", sv.Value)
33+ assert.Equal(t, domain.ACMETokenSourcePass, sv.Source)
34+}
35+
36+func TestPublicTLSResolverFallsBackToTokenFile(t *testing.T) {
37+ tmpDir := t.TempDir()
38+ tmpFile := filepath.Join(tmpDir, "cf-token")
39+ err := os.WriteFile(tmpFile, []byte("file-token\n"), 0644)
40+ require.NoError(t, err)
41+
42+ env := map[string]string{
43+ "GORDON_CLOUDFLARE_API_TOKEN_FILE": tmpFile,
44+ }
45+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
46+ PassLookup: func(_ context.Context, _ string) (string, error) {
47+ return "", os.ErrNotExist
48+ },
49+ EnvLookup: func(key string) string {
50+ return env[key]
51+ },
52+ })
53+
54+ sv, err := resolver.ResolveCloudflareToken(context.Background())
55+ require.NoError(t, err)
56+ assert.Equal(t, "file-token", sv.Value)
57+ assert.Equal(t, domain.ACMETokenSourceFile, sv.Source)
58+}
59+
60+func TestPublicTLSResolverFallsBackToEnv(t *testing.T) {
61+ env := map[string]string{
62+ "GORDON_CLOUDFLARE_API_TOKEN": "env-token",
63+ }
64+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
65+ PassLookup: func(_ context.Context, _ string) (string, error) {
66+ return "", os.ErrNotExist
67+ },
68+ EnvLookup: func(key string) string {
69+ return env[key]
70+ },
71+ })
72+
73+ sv, err := resolver.ResolveCloudflareToken(context.Background())
74+ require.NoError(t, err)
75+ assert.Equal(t, "env-token", sv.Value)
76+ assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source)
77+}
78+
79+func TestPublicTLSResolverNoToken(t *testing.T) {
80+ env := map[string]string{}
81+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
82+ PassLookup: func(_ context.Context, _ string) (string, error) {
83+ return "", os.ErrNotExist
84+ },
85+ EnvLookup: func(key string) string {
86+ return env[key]
87+ },
88+ })
89+
90+ sv, err := resolver.ResolveCloudflareToken(context.Background())
91+ require.NoError(t, err)
92+ assert.Empty(t, sv.Value)
93+ assert.Equal(t, domain.ACMETokenSourceNone, sv.Source)
94+}
95+
96+func TestPublicTLSResolverPassErrorWithoutFallback(t *testing.T) {
97+ expectedErr := errors.New("pass show failed")
98+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
99+ PassLookup: func(_ context.Context, _ string) (string, error) {
100+ return "", expectedErr
101+ },
102+ EnvLookup: func(key string) string {
103+ return ""
104+ },
105+ })
106+
107+ sv, err := resolver.ResolveCloudflareToken(context.Background())
108+ require.Error(t, err)
109+ assert.Contains(t, err.Error(), "resolve pass token")
110+ assert.Contains(t, err.Error(), expectedErr.Error())
111+ assert.Empty(t, sv.Value)
112+ assert.Equal(t, domain.ACMETokenSourceNone, sv.Source)
113+}
114+
115+func TestPublicTLSResolverPassErrorWithEnvFallback(t *testing.T) {
116+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
117+ PassLookup: func(_ context.Context, _ string) (string, error) {
118+ return "", errors.New("pass not available")
119+ },
120+ EnvLookup: func(key string) string {
121+ if key == "GORDON_CLOUDFLARE_API_TOKEN" {
122+ return "env-token"
123+ }
124+ return ""
125+ },
126+ })
127+
128+ sv, err := resolver.ResolveCloudflareToken(context.Background())
129+ require.NoError(t, err)
130+ assert.Equal(t, "env-token", sv.Value)
131+ assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source)
132+}
133+
134+func TestPublicTLSResolverTokenFileErrorWithoutFallback(t *testing.T) {
135+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
136+ PassLookup: func(_ context.Context, _ string) (string, error) {
137+ return "", os.ErrNotExist
138+ },
139+ EnvLookup: func(key string) string {
140+ if key == "GORDON_CLOUDFLARE_API_TOKEN_FILE" {
141+ return "/nonexistent/token-file"
142+ }
143+ return ""
144+ },
145+ })
146+
147+ sv, err := resolver.ResolveCloudflareToken(context.Background())
148+ require.Error(t, err)
149+ assert.Contains(t, err.Error(), "read token file")
150+ assert.Empty(t, sv.Value)
151+ assert.Equal(t, domain.ACMETokenSourceNone, sv.Source)
152+}
153+
154+func TestPublicTLSResolverTokenFileErrorWithEnvFallback(t *testing.T) {
155+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
156+ PassLookup: func(_ context.Context, _ string) (string, error) {
157+ return "", os.ErrNotExist
158+ },
159+ EnvLookup: func(key string) string {
160+ if key == "GORDON_CLOUDFLARE_API_TOKEN_FILE" {
161+ return "/nonexistent/token-file"
162+ }
163+ if key == "GORDON_CLOUDFLARE_API_TOKEN" {
164+ return "env-token"
165+ }
166+ return ""
167+ },
168+ })
169+
170+ sv, err := resolver.ResolveCloudflareToken(context.Background())
171+ require.NoError(t, err)
172+ assert.Equal(t, "env-token", sv.Value)
173+ assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source)
174+}
175+
176+func TestPublicTLSResolverEmptyPassValueFallback(t *testing.T) {
177+ // Empty string from pass (no error) should fall through to other sources
178+ resolver := NewPublicTLSResolver(PublicTLSResolverConfig{
179+ PassLookup: func(_ context.Context, _ string) (string, error) {
180+ return "", nil // no error but empty value
181+ },
182+ EnvLookup: func(key string) string {
183+ if key == "GORDON_CLOUDFLARE_API_TOKEN" {
184+ return "env-token"
185+ }
186+ return ""
187+ },
188+ })
189+
190+ sv, err := resolver.ResolveCloudflareToken(context.Background())
191+ require.NoError(t, err)
192+ assert.Equal(t, "env-token", sv.Value)
193+ assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source)
194+}
195+
196+// Ensure PublicTLSResolver implements out.SecretResolver at compile time.
197+var _ out.SecretResolver = (*PublicTLSResolver)(nil)
A internal/app/acme_config_test.go +17 -0
@@ -0,0 +1,17 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestLoadConfig_ACMEDefaults(t *testing.T) {
12+ v := viper.New()
13+ require.NoError(t, loadConfig(v, ""))
14+ assert.False(t, v.GetBool("tls.acme.enabled"))
15+ assert.Equal(t, "", v.GetString("tls.acme.email"))
16+ assert.Equal(t, "auto", v.GetString("tls.acme.challenge"))
17+}
M internal/app/kernel.go +22 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "io"
7+ "time"
78
89 "github.com/bnema/zerowrap"
910
@@ -25,6 +26,7 @@ type Kernel struct {
2526 healthSvc in.HealthService
2627 logSvc in.LogService
2728 volumeSvc in.VolumeService
29+ publicTLSSvc in.PublicTLSService
2830 cleanup func()
2931 }
3032
@@ -59,7 +61,22 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
5961
6062 // Prefer full service wiring so local CLI can execute the same operations
6163 // as remote mode without going through HTTP admin endpoints.
62- if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil {
64+ // Use StartPublicTLS: false to prevent ACME Reconcile and renewal loop
65+ // side effects during read-only CLI commands (e.g. gordon tls status).
66+ if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}); fullErr == nil {
67+ // Wrap cleanup to stop public TLS service (with its renewal loop)
68+ // before the logger is cleaned up.
69+ wrappedCleanup := func() {
70+ if svc.publicTLSSvc != nil {
71+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
72+ defer cancel()
73+ if err := svc.publicTLSSvc.Stop(ctx); err != nil {
74+ log.Warn().Err(err).Msg("failed to stop public TLS service")
75+ }
76+ }
77+ cleanup()
78+ }
79+
6380 return &Kernel{
6481 authEnabled: cfg.Auth.Enabled,
6582 configSvc: svc.configSvc,
@@ -70,7 +87,8 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
7087 healthSvc: svc.healthSvc,
7188 logSvc: svc.logSvc,
7289 volumeSvc: svc.volumeSvc,
73- cleanup: cleanup,
90+ publicTLSSvc: svc.publicTLSSvc,
91+ cleanup: wrappedCleanup,
7492 }, nil
7593 } else {
7694 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
@@ -126,4 +144,6 @@ func (k *Kernel) Logs() in.LogService { return k.logSvc }
126144
127145 func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc }
128146
147+func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc }
148+
129149 func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }
M internal/app/migrate_env_test.go +2 -2
@@ -32,7 +32,7 @@ func requirePass(t *testing.T) {
3232 }
3333 }
3434
35-func cleanupPassDomain(t *testing.T, domainName string, keys []string) {
35+func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {
3636 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
3737 if err != nil {
3838 return
@@ -87,7 +87,7 @@ func TestMigrateEnvFile(t *testing.T) {
8787 assert.Equal(t, "pass456", values["DB_PASSWORD"])
8888 }
8989
90-func cleanupPassAttachment(t *testing.T, containerName string, keys []string) {
90+func cleanupPassAttachment(_ *testing.T, containerName string, keys []string) {
9191 for _, key := range keys {
9292 path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key)
9393 _ = passCmd("rm", "-f", path)
A internal/app/public_tls_wiring_test.go +171 -0
@@ -0,0 +1,171 @@
1+package app
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "crypto/rsa"
7+ "crypto/tls"
8+ "crypto/x509"
9+ "math/big"
10+ "net"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+// stubPublicTLS implements in.PublicTLSService for testing the certificate selector.
21+type stubPublicTLS struct {
22+ getCertFunc func(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
23+}
24+
25+func (s *stubPublicTLS) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
26+ if s.getCertFunc != nil {
27+ return s.getCertFunc(hello)
28+ }
29+ return nil, nil
30+}
31+
32+func (s *stubPublicTLS) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
33+ return "", false
34+}
35+
36+func (s *stubPublicTLS) Status(ctx context.Context) domain.PublicTLSStatus {
37+ return domain.PublicTLSStatus{}
38+}
39+
40+func (s *stubPublicTLS) Reconcile(ctx context.Context) error {
41+ return nil
42+}
43+
44+func (s *stubPublicTLS) Stop(ctx context.Context) error {
45+ return nil
46+}
47+
48+// generateTestCert creates a self-signed certificate for the given hostnames.
49+func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
50+ t.Helper()
51+
52+ privKey, err := rsa.GenerateKey(rand.Reader, 2048)
53+ require.NoError(t, err)
54+
55+ template := &x509.Certificate{
56+ SerialNumber: big.NewInt(1),
57+ NotBefore: time.Now().Add(-1 * time.Hour),
58+ NotAfter: time.Now().Add(24 * time.Hour),
59+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
60+ }
61+ for _, host := range hosts {
62+ if ip := net.ParseIP(host); ip != nil {
63+ template.IPAddresses = append(template.IPAddresses, ip)
64+ } else {
65+ template.DNSNames = append(template.DNSNames, host)
66+ }
67+ }
68+
69+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
70+ require.NoError(t, err)
71+
72+ return tls.Certificate{
73+ Certificate: [][]byte{certDER},
74+ PrivateKey: privKey,
75+ }
76+}
77+
78+// TestCertificateSelector_StaticCertWins tests that a static certificate matching
79+// the server name is returned before public TLS.
80+func TestCertificateSelector_StaticCertWins(t *testing.T) {
81+ staticCert := generateTestCert(t, "static.example.com")
82+ publicCert := generateTestCert(t, "static.example.com")
83+
84+ selector := &certificateSelector{
85+ staticCerts: []tls.Certificate{staticCert},
86+ publicTLS: &stubPublicTLS{
87+ getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
88+ return &publicCert, nil
89+ },
90+ },
91+ }
92+
93+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})
94+ require.NoError(t, err)
95+ require.NotNil(t, got)
96+ assert.Equal(t, staticCert.Certificate[0], got.Certificate[0],
97+ "static cert should be returned, not public TLS")
98+}
99+
100+// TestCertificateSelector_PublicTLSErrorPropagates tests that if public TLS
101+// returns an error (e.g. ErrTLSRouteNotCovered), the error propagates.
102+func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) {
103+ selector := &certificateSelector{
104+ publicTLS: &stubPublicTLS{
105+ getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
106+ return nil, domain.ErrTLSRouteNotCovered
107+ },
108+ },
109+ }
110+
111+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
112+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered,
113+ "public TLS error should propagate")
114+ assert.Nil(t, got, "no certificate should be returned on error")
115+}
116+
117+// TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
118+// nil,nil and no local PKI, the selector returns nil,nil.
119+func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
120+ selector := &certificateSelector{
121+ publicTLS: &stubPublicTLS{
122+ getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
123+ return nil, nil
124+ },
125+ },
126+ }
127+
128+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
129+ require.NoError(t, err)
130+ assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil")
131+}
132+
133+// TestCertificateSelector_NoSource tests that when no source has a matching cert,
134+// nil, nil is returned.
135+func TestCertificateSelector_NoSource(t *testing.T) {
136+ selector := &certificateSelector{}
137+
138+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"})
139+ assert.NoError(t, err)
140+ assert.Nil(t, got)
141+}
142+
143+// TestMatchingStaticCert tests the matchingStaticCert helper.
144+func TestMatchingStaticCert(t *testing.T) {
145+ certA := generateTestCert(t, "app.example.com")
146+ certB := generateTestCert(t, "api.example.com")
147+
148+ certs := []tls.Certificate{certA, certB}
149+
150+ // Exact match
151+ got := matchingStaticCert(certs, "app.example.com")
152+ require.NotNil(t, got)
153+ assert.Equal(t, certA.Certificate[0], got.Certificate[0])
154+
155+ // Second cert match
156+ got = matchingStaticCert(certs, "api.example.com")
157+ require.NotNil(t, got)
158+ assert.Equal(t, certB.Certificate[0], got.Certificate[0])
159+
160+ // No match
161+ got = matchingStaticCert(certs, "other.example.com")
162+ assert.Nil(t, got)
163+
164+ // Empty server name
165+ got = matchingStaticCert(certs, "")
166+ assert.Nil(t, got)
167+
168+ // Empty cert list
169+ got = matchingStaticCert(nil, "app.example.com")
170+ assert.Nil(t, got)
171+}
M internal/app/run.go +278 -42
@@ -5,6 +5,7 @@ import (
55 "context"
66 "crypto/rand"
77 "crypto/tls"
8+ "crypto/x509"
89 "encoding/hex"
910 "encoding/json"
1011 "errors"
@@ -27,6 +28,8 @@ import (
2728
2829 // Adapters - Output
2930 "github.com/bnema/gordon/internal/adapters/out/accesslog"
31+ acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego"
32+ acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore"
3033 "github.com/bnema/gordon/internal/adapters/out/docker"
3134 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
3235 "github.com/bnema/gordon/internal/adapters/out/envloader"
@@ -40,11 +43,15 @@ import (
4043 "github.com/bnema/gordon/internal/adapters/out/telemetry"
4144 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
4245
46+ // Boundaries
47+ "github.com/bnema/gordon/internal/boundaries/in"
48+
4349 // OTel
4450 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
4551
4652 // Adapters - Input
4753 "github.com/bnema/gordon/internal/adapters/dto"
54+ acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme"
4855 "github.com/bnema/gordon/internal/adapters/in/http/admin"
4956 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
5057 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
@@ -75,6 +82,7 @@ import (
7582 "github.com/bnema/gordon/internal/usecase/logs"
7683 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
7784 "github.com/bnema/gordon/internal/usecase/proxy"
85+ "github.com/bnema/gordon/internal/usecase/publictls"
7886 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
7987 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
8088 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
@@ -189,6 +197,14 @@ type Config struct {
189197 } `mapstructure:"containers"`
190198
191199 Telemetry telemetry.Config `mapstructure:"telemetry"`
200+
201+ TLS struct {
202+ ACME struct {
203+ Enabled bool `mapstructure:"enabled"`
204+ Email string `mapstructure:"email"`
205+ Challenge string `mapstructure:"challenge"`
206+ } `mapstructure:"acme"`
207+ } `mapstructure:"tls"`
192208 }
193209
194210 // services holds all the services used by the application.
@@ -224,6 +240,7 @@ type services struct {
224240 caAdapter *pkiadapter.CA
225241 pkiSvc *pkiusecase.Service
226242 reloadCoordinator *reloadCoordinator
243+ publicTLSSvc in.PublicTLSService
227244 registryHandler interface {
228245 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
229246 }
@@ -401,27 +418,53 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
401418 return writer, nil
402419 }
403420
421+// serviceOptions controls optional behavior for createServicesWithOptions.
422+type serviceOptions struct {
423+ // StartPublicTLS controls whether ACME Reconcile and renewal loop are started.
424+ // Server runtime sets true; Kernel/local CLI sets false (read-only status).
425+ StartPublicTLS bool
426+}
427+
404428 // serviceInit holds the shared context for service initialization helpers.
405429 type serviceInit struct {
406- ctx context.Context
407- v *viper.Viper
408- cfg Config
409- log zerowrap.Logger
410- svc *services
430+ ctx context.Context
431+ v *viper.Viper
432+ cfg Config
433+ log zerowrap.Logger
434+ svc *services
435+ startPublicTLS bool
411436 }
412437
413-// createServices creates all the application services.
438+// createServices creates all the application services for server runtime.
439+// It calls createServicesWithOptions with StartPublicTLS: true.
414440 func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
441+ return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: true})
442+}
443+
444+// createServicesWithOptions creates all the application services with the given options.
445+// When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that
446+// read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects.
447+func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) {
415448 si := &serviceInit{
416- ctx: ctx,
417- v: v,
418- cfg: cfg,
419- log: log,
420- svc: &services{},
449+ ctx: ctx,
450+ v: v,
451+ cfg: cfg,
452+ log: log,
453+ svc: &services{},
454+ startPublicTLS: opts.StartPublicTLS,
421455 }
422456 defer func() {
423- if retErr != nil && si.svc.pkiSvc != nil {
424- si.svc.pkiSvc.Stop()
457+ if retErr != nil {
458+ if si.svc.pkiSvc != nil {
459+ si.svc.pkiSvc.Stop()
460+ }
461+ if si.svc.publicTLSSvc != nil {
462+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
463+ defer cancel()
464+ if err := si.svc.publicTLSSvc.Stop(ctx); err != nil {
465+ si.log.Warn().Err(err).Msg("failed to stop public TLS service during createServices cleanup")
466+ }
467+ }
425468 }
426469 }()
427470 var err error
@@ -465,11 +508,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
465508 return nil, err
466509 }
467510
511+ if err := si.initPublicTLS(); err != nil {
512+ return nil, err
513+ }
514+
468515 if err := si.initRuntimeAndProxy(); err != nil {
469516 return nil, err
470517 }
471518
472- si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log)
519+ si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log)
473520
474521 si.initHandlers()
475522
@@ -496,6 +543,92 @@ func (si *serviceInit) initPKI() error {
496543 return nil
497544 }
498545
546+// initPublicTLS initializes the public ACME TLS service if enabled.
547+func (si *serviceInit) initPublicTLS() error {
548+ if !si.cfg.TLS.ACME.Enabled {
549+ return nil
550+ }
551+
552+ if si.cfg.Server.TLSPort == 0 {
553+ return nil
554+ }
555+
556+ ctx := si.ctx
557+ log := si.log
558+
559+ publicTLSCfg := publictls.Config{
560+ Enabled: si.cfg.TLS.ACME.Enabled,
561+ Email: si.cfg.TLS.ACME.Email,
562+ Challenge: si.cfg.TLS.ACME.Challenge,
563+ HTTPPort: si.cfg.Server.Port,
564+ TLSPort: si.cfg.Server.TLSPort,
565+ DataDir: resolveDataDir(si.cfg.Server.DataDir),
566+ }
567+
568+ tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
569+
570+ effective, err := publictls.ResolveEffectiveChallenge(ctx, publicTLSCfg, tokenResolver)
571+ if err != nil {
572+ return log.WrapErr(err, "resolve ACME challenge")
573+ }
574+
575+ store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
576+ if err != nil {
577+ return log.WrapErr(err, "create ACME store")
578+ }
579+
580+ challenges := publictls.NewHTTP01Challenges()
581+
582+ var zoneResolver *acmelego.CloudflareZoneResolver
583+ if effective.Mode == domain.ACMEChallengeCloudflareDNS01 {
584+ zoneResolver = acmelego.NewCloudflareZoneResolver(effective.Token)
585+ }
586+
587+ issuer, err := acmelego.NewIssuer(acmelego.Config{
588+ Email: si.cfg.TLS.ACME.Email,
589+ Challenge: effective.Mode,
590+ Token: effective.Token,
591+ Store: store,
592+ HTTPChallengeSink: challenges,
593+ })
594+ if err != nil {
595+ return log.WrapErr(err, "create ACME issuer")
596+ }
597+
598+ svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{
599+ Routes: si.svc.configSvc,
600+ Issuer: issuer,
601+ Store: store,
602+ ZoneResolver: zoneResolver,
603+ Challenges: challenges,
604+ Effective: effective,
605+ })
606+
607+ if err := svc.Load(ctx); err != nil {
608+ log.Warn().Err(err).Msg("failed to load ACME certificates, continuing")
609+ }
610+
611+ if si.startPublicTLS {
612+ if err := svc.Reconcile(ctx); err != nil {
613+ log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing")
614+ }
615+ svc.StartRenewalLoop(ctx, time.Hour)
616+ log.Info().
617+ Str("email", si.cfg.TLS.ACME.Email).
618+ Str("challenge", string(effective.Mode)).
619+ Str("reason", effective.Reason).
620+ Msg("public ACME TLS initialized")
621+ } else {
622+ log.Info().
623+ Str("email", si.cfg.TLS.ACME.Email).
624+ Str("challenge", string(effective.Mode)).
625+ Msg("public ACME TLS initialized (read-only, no renewal loop)")
626+ }
627+
628+ si.svc.publicTLSSvc = svc
629+ return nil
630+}
631+
499632 // initSecrets creates the domain secret store, env loader, and secret service.
500633 func (si *serviceInit) initSecrets() error {
501634 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
@@ -576,6 +709,7 @@ func (si *serviceInit) initHandlers() {
576709 PreviewSvc: si.svc.previewService,
577710 ImageSvc: si.svc.imageSvc,
578711 VolumeSvc: si.svc.volumeSvc,
712+ PublicTLSSvc: si.svc.publicTLSSvc,
579713 })
580714 }
581715
@@ -595,13 +729,7 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
595729 }
596730
597731 // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
598- sweepInterval := previewConfig.TTL / 2
599- if sweepInterval > 1*time.Hour {
600- sweepInterval = 1 * time.Hour
601- }
602- if sweepInterval < 1*time.Minute {
603- sweepInterval = 1 * time.Minute
604- }
732+ sweepInterval := max(min(previewConfig.TTL/2, time.Hour), time.Minute)
605733
606734 svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
607735 teardownTrackedPreview(ctx, svc, p)
@@ -840,8 +968,8 @@ func resolveRuntimeConfig(value string) string {
840968 return "/var/run/docker.sock"
841969 }
842970 // Explicit socket path — strip URI scheme if present.
843- if strings.HasPrefix(value, "unix://") {
844- return strings.TrimPrefix(value, "unix://")
971+ if socketPath, ok := strings.CutPrefix(value, "unix://"); ok {
972+ return socketPath
845973 }
846974 return value
847975 }
@@ -1430,6 +1558,11 @@ type configWatcher interface {
14301558 Watch(ctx context.Context, onChange func()) error
14311559 }
14321560
1561+// publicTLSReconciler is the interface for reconciling public TLS certificates.
1562+type publicTLSReconciler interface {
1563+ Reconcile(context.Context) error
1564+}
1565+
14331566 type configReloader interface {
14341567 Reload(ctx context.Context) error
14351568 }
@@ -1457,13 +1590,14 @@ type reloadCoordinator struct {
14571590 registryLimits interface {
14581591 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
14591592 }
1460- eventBus out.EventPublisher
1461- log zerowrap.Logger
1593+ eventBus out.EventPublisher
1594+ publicTLS publicTLSReconciler
1595+ log zerowrap.Logger
14621596 }
14631597
14641598 func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface {
14651599 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1466-}, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator {
1600+}, eventBus out.EventPublisher, publicTLS publicTLSReconciler, log zerowrap.Logger) *reloadCoordinator {
14671601 return &reloadCoordinator{
14681602 debounce: 500 * time.Millisecond,
14691603 configSvc: configSvc,
@@ -1471,6 +1605,7 @@ func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc pro
14711605 proxySvc: proxySvc,
14721606 registryLimits: registryLimits,
14731607 eventBus: eventBus,
1608+ publicTLS: publicTLS,
14741609 log: log,
14751610 }
14761611 }
@@ -1511,14 +1646,14 @@ func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) e
15111646 }
15121647 }
15131648
1514- if err := c.applyLoadedConfig(now); err != nil {
1649+ if err := c.applyLoadedConfig(ctx, now); err != nil {
15151650 return err
15161651 }
15171652
15181653 return nil
15191654 }
15201655
1521-func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
1656+func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time) error {
15221657 var reloadCfg Config
15231658 if err := c.v.Unmarshal(&reloadCfg); err != nil {
15241659 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
@@ -1545,6 +1680,14 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
15451680
15461681 c.lastRun = now
15471682
1683+ // Reconcile public TLS certificates after config reload.
1684+ if c.publicTLS != nil {
1685+ if err := c.publicTLS.Reconcile(ctx); err != nil {
1686+ c.log.Error().Err(err).Msg("failed to reconcile public TLS certificates after reload")
1687+ return fmt.Errorf("reconcile public TLS: %w", err)
1688+ }
1689+ }
1690+
15481691 c.log.Debug().Msg("config hot reload complete")
15491692 return nil
15501693 }
@@ -1925,7 +2068,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
19252068 // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
19262069 // Direct clients get an onboarding gate (when CA is available) placed BEFORE
19272070 // HTTPSRedirect so force_https_redirect cannot bypass onboarding.
2071+ // ACME HTTP-01 challenge handler is registered before the catch-all "/" so
2072+ // it gets first chance regardless of source IP.
19282073 proxyMux := http.NewServeMux()
2074+
2075+ // Register ACME HTTP-01 challenge handler before all other routes so
2076+ // Let's Encrypt validation always succeeds, even for onboarding clients.
2077+ if svc.publicTLSSvc != nil {
2078+ proxyMux.Handle(acmehttp.Prefix, acmehttp.NewHandler(svc.publicTLSSvc))
2079+ }
2080+
19292081 if proxyCIDRMiddleware != nil && proxyAllowedNets == nil {
19302082 // Invalid proxy_allowed_ips: deny all traffic (fail-closed).
19312083 proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware))
@@ -2300,7 +2452,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
23002452 }
23012453 }
23022454
2303- proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log)
2455+ proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log)
23042456 if err != nil {
23052457 closeStarted(registrySrv)
23062458 return err
@@ -2344,7 +2496,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
23442496
23452497 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
23462498 cleanupHandlers() // Stop debounce timers before draining containers
2347- gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log)
2499+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log)
23482500 return nil
23492501 }
23502502
@@ -2544,7 +2696,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
25442696
25452697 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
25462698 // the container service and cleans up runtime files.
2547-func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, log zerowrap.Logger) {
2699+func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) {
25482700 log.Info().Msg("shutting down Gordon...")
25492701
25502702 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -2565,6 +2717,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
25652717 pkiSvc.Stop()
25662718 }
25672719
2720+ // Stop public ACME TLS renewal loop
2721+ if publicTLS != nil {
2722+ if err := publicTLS.Stop(shutdownCtx); err != nil {
2723+ log.Warn().Err(err).Msg("public TLS stop error")
2724+ }
2725+ }
2726+
25682727 // Phase 2: Drain in-flight registry push sessions before stopping the backend
25692728 if proxySvc != nil {
25702729 log.Info().Msg("draining in-flight registry requests...")
@@ -2592,7 +2751,71 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
25922751
25932752 // startProxyServers sets up the HTTP proxy server and, when tls_port != 0,
25942753 // an HTTPS proxy server with on-demand TLS certificates from the internal CA.
2595-func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
2754+// certificateSelector implements a multi-source TLS certificate lookup.
2755+// Priority: static certs → public ACME TLS → local PKI (internal CA).
2756+type certificateSelector struct {
2757+ staticCerts []tls.Certificate
2758+ publicTLS in.PublicTLSService
2759+ localPKI *pkiusecase.Service
2760+}
2761+
2762+// GetCertificate selects a TLS certificate based on the ClientHello SNI.
2763+//
2764+// Priority:
2765+// 1. Static certs — exact SNI match (leaf VerifyHostname)
2766+// 2. Public ACME TLS — if the host requires ACME coverage
2767+// 3. Local PKI (internal CA) — fallback for all other hosts
2768+// 4. nil, nil — if no source can serve the host
2769+func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
2770+ // 1. Static certs — exact match via leaf VerifyHostname.
2771+ if cert := matchingStaticCert(s.staticCerts, hello.ServerName); cert != nil {
2772+ return cert, nil
2773+ }
2774+
2775+ // 2. Public ACME TLS.
2776+ if s.publicTLS != nil {
2777+ cert, err := s.publicTLS.GetCertificate(hello)
2778+ if err != nil {
2779+ return nil, err
2780+ }
2781+ if cert != nil {
2782+ return cert, nil
2783+ }
2784+ // nil, nil means this host is not an ACME-required route.
2785+ // Fall through to local PKI.
2786+ }
2787+
2788+ // 3. Local PKI (internal CA).
2789+ if s.localPKI != nil {
2790+ return s.localPKI.GetCertificate(hello)
2791+ }
2792+
2793+ return nil, nil
2794+}
2795+
2796+// matchingStaticCert returns a pointer to the first static certificate whose
2797+// leaf verifies the given serverName. Returns nil if no match is found.
2798+func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {
2799+ if serverName == "" {
2800+ return nil
2801+ }
2802+ for i := range certs {
2803+ cert := &certs[i]
2804+ if len(cert.Certificate) == 0 {
2805+ continue
2806+ }
2807+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
2808+ if err != nil {
2809+ continue
2810+ }
2811+ if err := leaf.VerifyHostname(serverName); err == nil {
2812+ return cert
2813+ }
2814+ }
2815+ return nil
2816+}
2817+
2818+func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
25962819 // HTTP listener (Cloudflare proxy + onboarding for direct clients)
25972820 var httpProtos http.Protocols
25982821 httpProtos.SetHTTP1(true)
@@ -2610,23 +2833,33 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
26102833 return httpSrv, httpReady, nil, nil, nil
26112834 }
26122835
2613- // HTTPS listener — static cert (if provided) takes priority by SNI match,
2614- // unmatched domains fall through to on-demand internal CA certs.
2615- tlsConfig := &tls.Config{
2616- MinVersion: tls.VersionTLS12,
2617- GetCertificate: pkiSvc.GetCertificate,
2618- NextProtos: []string{"h2", "http/1.1"},
2619- }
2836+ // Load static cert into a local slice (not tls.Config.Certificates) so the
2837+ // certificate selector has full control over priority ordering.
2838+ var staticCerts []tls.Certificate
26202839 if cfg.Server.TLSCertFile != "" {
26212840 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
26222841 if err != nil {
26232842 return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err)
26242843 }
2625- tlsConfig.Certificates = []tls.Certificate{staticCert}
2844+ staticCerts = []tls.Certificate{staticCert}
26262845 log.Info().
26272846 Str("cert", cfg.Server.TLSCertFile).
26282847 Str("key", cfg.Server.TLSKeyFile).
2629- Msg("loaded static TLS certificate (internal CA handles remaining domains)")
2848+ Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
2849+ }
2850+
2851+ selector := &certificateSelector{
2852+ staticCerts: staticCerts,
2853+ publicTLS: publicTLS,
2854+ localPKI: pkiSvc,
2855+ }
2856+
2857+ // HTTPS listener — static cert (if provided) takes priority by SNI match via
2858+ // certificateSelector, then public ACME TLS, then on-demand internal CA certs.
2859+ tlsConfig := &tls.Config{
2860+ MinVersion: tls.VersionTLS12,
2861+ GetCertificate: selector.GetCertificate,
2862+ NextProtos: []string{"h2", "http/1.1"},
26302863 }
26312864 tlsSrv, tlsReady := startTLSServerWithConfig(
26322865 fmt.Sprintf(":%d", cfg.Server.TLSPort),
@@ -2831,7 +3064,7 @@ func createPidFile(log zerowrap.Logger) string {
28313064 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
28323065 continue
28333066 }
2834- if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
3067+ if err := os.WriteFile(location, fmt.Appendf(nil, "%d", pid), 0600); err == nil {
28353068 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
28363069 return location
28373070 }
@@ -2966,6 +3199,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
29663199 v.SetDefault("server.tls_port", 8443)
29673200 v.SetDefault("server.tls_cert_file", "")
29683201 v.SetDefault("server.tls_key_file", "")
3202+ v.SetDefault("tls.acme.enabled", false)
3203+ v.SetDefault("tls.acme.email", "")
3204+ v.SetDefault("tls.acme.challenge", "auto")
29693205 v.SetDefault("server.force_https_redirect", false)
29703206 v.SetDefault("server.data_dir", DefaultDataDir())
29713207 v.SetDefault("server.runtime", "auto")
M internal/app/run_reload_test.go +5 -5
@@ -185,7 +185,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
185185 events := &eventBusRecorder{}
186186
187187 registryLimits := &registryLimitsRecorder{}
188- coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default())
188+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
189189 require.NoError(t, coord.ApplyLoadedConfig(ctx))
190190
191191 require.Equal(t, 0, reloadSvc.Calls())
@@ -220,7 +220,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
220220 events := &eventBusRecorder{}
221221
222222 registryLimits := &registryLimitsRecorder{}
223- coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default())
223+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
224224 require.NoError(t, coord.Trigger(ctx))
225225 require.NoError(t, coord.Trigger(ctx))
226226
@@ -253,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
253253 reloadErr := errors.New("reload failed")
254254 reloadSvc := &reloadRecorder{err: reloadErr}
255255 proxySvc := &proxyRecorder{}
256- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default())
256+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
257257
258258 err := coord.Trigger(ctx)
259259 require.ErrorIs(t, err, reloadErr)
@@ -282,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
282282 proxySvc := &proxyRecorder{}
283283 events := &eventBusRecorder{err: publishErr}
284284
285- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, zerowrap.Default())
285+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, nil, zerowrap.Default())
286286
287287 err := coord.Trigger(ctx)
288288 require.ErrorIs(t, err, publishErr)
@@ -314,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
314314 <-release
315315 }}
316316 proxySvc := &proxyRecorder{}
317- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default())
317+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
318318
319319 firstDone := make(chan struct{})
320320 go func() {
A internal/boundaries/in/public_tls.go +29 -0
@@ -0,0 +1,29 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+ "crypto/tls"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// PublicTLSService defines the contract for public TLS / ACME certificate management.
14+type PublicTLSService interface {
15+ // GetCertificate returns a TLS certificate for the given ClientHello.
16+ GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
17+
18+ // GetHTTP01Challenge returns the key authorization for an HTTP-01 challenge token.
19+ GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool)
20+
21+ // Status returns the current public TLS status.
22+ Status(ctx context.Context) domain.PublicTLSStatus
23+
24+ // Reconcile ensures the desired certificate state matches the actual state.
25+ Reconcile(ctx context.Context) error
26+
27+ // Stop gracefully shuts down the public TLS service.
28+ Stop(ctx context.Context) error
29+}
A internal/boundaries/in/public_tls_test.go +37 -0
@@ -0,0 +1,37 @@
1+package in
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "testing"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// publicTLSStub is a compile-time stub for PublicTLSService.
12+type publicTLSStub struct{}
13+
14+func (s publicTLSStub) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
15+ return nil, nil
16+}
17+
18+func (s publicTLSStub) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
19+ return "", false
20+}
21+
22+func (s publicTLSStub) Status(ctx context.Context) domain.PublicTLSStatus {
23+ return domain.PublicTLSStatus{}
24+}
25+
26+func (s publicTLSStub) Reconcile(ctx context.Context) error {
27+ return nil
28+}
29+
30+func (s publicTLSStub) Stop(ctx context.Context) error {
31+ return nil
32+}
33+
34+// TestPublicTLSServiceInterface asserts that publicTLSStub satisfies PublicTLSService.
35+func TestPublicTLSServiceInterface(t *testing.T) {
36+ var _ PublicTLSService = publicTLSStub{}
37+}
A internal/boundaries/out/public_tls.go +92 -0
@@ -0,0 +1,92 @@
1+// Package out defines output ports (interfaces) for infrastructure.
2+// These interfaces define the contract between use cases and driven adapters.
3+package out
4+
5+import (
6+ "context"
7+ "crypto/tls"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// CertificateOrder represents a request to obtain or renew a certificate.
14+type CertificateOrder struct {
15+ ID string
16+ Names []string
17+ Challenge domain.ACMEChallengeMode
18+}
19+
20+// StoredCertificate represents a persisted certificate with its metadata.
21+type StoredCertificate struct {
22+ ID string
23+ Names []string
24+ Challenge domain.ACMEChallengeMode
25+ Certificate tls.Certificate
26+ CertPEM []byte
27+ ChainPEM []byte
28+ FullchainPEM []byte
29+ PrivateKeyPEM []byte
30+ NotAfter time.Time
31+ LastError string
32+}
33+
34+// ACMEAccount represents a stored ACME account registration.
35+type ACMEAccount struct {
36+ Email string
37+ PrivateKeyPEM []byte
38+ RegistrationURI string
39+ BodyJSON []byte
40+}
41+
42+// SecretValue holds a resolved secret value and its source.
43+type SecretValue struct {
44+ Value string
45+ Source domain.ACMETokenSource
46+}
47+
48+// CloudflareZone represents a Cloudflare DNS zone.
49+type CloudflareZone struct {
50+ ID string
51+ Name string
52+}
53+
54+// PublicCertificateIssuer defines the contract for obtaining and renewing
55+// TLS certificates via ACME.
56+type PublicCertificateIssuer interface {
57+ // Obtain requests a new certificate for the given order.
58+ Obtain(ctx context.Context, order CertificateOrder) (*StoredCertificate, error)
59+
60+ // Renew renews an existing certificate.
61+ Renew(ctx context.Context, cert StoredCertificate) (*StoredCertificate, error)
62+}
63+
64+// CertificateStore defines the contract for persisting ACME accounts and certificates.
65+type CertificateStore interface {
66+ // LoadAccount retrieves an ACME account.
67+ LoadAccount(ctx context.Context) (*ACMEAccount, error)
68+
69+ // SaveAccount persists an ACME account.
70+ SaveAccount(ctx context.Context, account ACMEAccount) error
71+
72+ // LoadAll returns all stored certificates.
73+ LoadAll(ctx context.Context) ([]StoredCertificate, error)
74+
75+ // Save persists a certificate.
76+ Save(ctx context.Context, cert StoredCertificate) error
77+
78+ // Lock acquires a lock to prevent concurrent certificate operations.
79+ Lock(ctx context.Context) (unlock func() error, err error)
80+}
81+
82+// SecretResolver defines the contract for resolving Cloudflare API tokens.
83+type SecretResolver interface {
84+ // ResolveCloudflareToken retrieves the Cloudflare API token from the configured source.
85+ ResolveCloudflareToken(ctx context.Context) (SecretValue, error)
86+}
87+
88+// CloudflareZoneResolver defines the contract for resolving DNS zones.
89+type CloudflareZoneResolver interface {
90+ // FindZone finds the Cloudflare zone for the given domain.
91+ FindZone(ctx context.Context, domain string) (CloudflareZone, error)
92+}
A internal/boundaries/out/public_tls_test.go +72 -0
@@ -0,0 +1,72 @@
1+package out
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// issuerStub implements PublicCertificateIssuer.
13+type issuerStub struct{}
14+
15+func (s issuerStub) Obtain(ctx context.Context, order CertificateOrder) (*StoredCertificate, error) {
16+ return nil, nil
17+}
18+
19+func (s issuerStub) Renew(ctx context.Context, cert StoredCertificate) (*StoredCertificate, error) {
20+ return nil, nil
21+}
22+
23+// storeStub implements CertificateStore.
24+type storeStub struct{}
25+
26+func (s storeStub) LoadAccount(ctx context.Context) (*ACMEAccount, error) {
27+ return nil, nil
28+}
29+
30+func (s storeStub) SaveAccount(ctx context.Context, account ACMEAccount) error {
31+ return nil
32+}
33+
34+func (s storeStub) LoadAll(ctx context.Context) ([]StoredCertificate, error) {
35+ return nil, nil
36+}
37+
38+func (s storeStub) Save(ctx context.Context, cert StoredCertificate) error {
39+ return nil
40+}
41+
42+func (s storeStub) Lock(ctx context.Context) (func() error, error) {
43+ return func() error { return nil }, nil
44+}
45+
46+// secretStub implements SecretResolver.
47+type secretStub struct{}
48+
49+func (s secretStub) ResolveCloudflareToken(ctx context.Context) (SecretValue, error) {
50+ return SecretValue{}, nil
51+}
52+
53+// zoneStub implements CloudflareZoneResolver.
54+type zoneStub struct{}
55+
56+func (s zoneStub) FindZone(ctx context.Context, domainName string) (CloudflareZone, error) {
57+ return CloudflareZone{}, nil
58+}
59+
60+// TestPublicTLSOutputInterfaces asserts that all stubs satisfy their respective interfaces
61+// and that the required types are importable.
62+func TestPublicTLSOutputInterfaces(t *testing.T) {
63+ var _ PublicCertificateIssuer = issuerStub{}
64+ var _ CertificateStore = storeStub{}
65+ var _ SecretResolver = secretStub{}
66+ var _ CloudflareZoneResolver = zoneStub{}
67+
68+ // Verify that referenced types are importable
69+ var _ tls.Certificate
70+ var _ time.Time
71+ _ = domain.ACMEChallengeHTTP01
72+}
A internal/domain/public_tls.go +124 -0
@@ -0,0 +1,124 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "time"
8+)
9+
10+type ACMEChallengeMode string
11+
12+const (
13+ ACMEChallengeAuto ACMEChallengeMode = "auto"
14+ ACMEChallengeHTTP01 ACMEChallengeMode = "http-01"
15+ ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01"
16+)
17+
18+var (
19+ ErrACMEDisabled = errors.New("acme disabled")
20+ ErrACMEEmailRequired = errors.New("acme email required")
21+ ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
22+ ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
23+ ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
24+)
25+
26+func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
27+ switch strings.TrimSpace(value) {
28+ case "", string(ACMEChallengeAuto):
29+ return ACMEChallengeAuto, nil
30+ case string(ACMEChallengeHTTP01):
31+ return ACMEChallengeHTTP01, nil
32+ case string(ACMEChallengeCloudflareDNS01):
33+ return ACMEChallengeCloudflareDNS01, nil
34+ default:
35+ return "", fmt.Errorf("%w: %q", ErrACMEChallengeInvalid, value)
36+ }
37+}
38+
39+type ACMETokenSource string
40+
41+const (
42+ ACMETokenSourceNone ACMETokenSource = "none"
43+ ACMETokenSourcePass ACMETokenSource = "pass"
44+ ACMETokenSourceFile ACMETokenSource = "file"
45+ ACMETokenSourceEnv ACMETokenSource = "env"
46+)
47+
48+type TLSCertificateStatus string
49+
50+const (
51+ TLSCertificateStatusMissing TLSCertificateStatus = "missing"
52+ TLSCertificateStatusValid TLSCertificateStatus = "valid"
53+ TLSCertificateStatusWarning TLSCertificateStatus = "warning"
54+ TLSCertificateStatusExpired TLSCertificateStatus = "expired"
55+ TLSCertificateStatusError TLSCertificateStatus = "error"
56+)
57+
58+type ManagedCertificate struct {
59+ ID string `json:"id"`
60+ Names []string `json:"names"`
61+ Challenge ACMEChallengeMode `json:"challenge"`
62+ Status TLSCertificateStatus `json:"status"`
63+ NotAfter time.Time `json:"not_after"`
64+ LastError string `json:"last_error,omitempty"`
65+ RenewalPending bool `json:"renewal_pending"`
66+}
67+
68+func (c ManagedCertificate) Covers(host string) bool {
69+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
70+ if host == "" {
71+ return false
72+ }
73+ for _, name := range c.Names {
74+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
75+ if name == host {
76+ return true
77+ }
78+ if strings.HasPrefix(name, "*.") {
79+ suffix := strings.TrimPrefix(name, "*.")
80+ if strings.HasSuffix(host, "."+suffix) {
81+ left := strings.TrimSuffix(host, "."+suffix)
82+ if left != "" && !strings.Contains(left, ".") {
83+ return true
84+ }
85+ }
86+ }
87+ }
88+ return false
89+}
90+
91+func (c ManagedCertificate) Health(now time.Time) TLSCertificateStatus {
92+ if c.LastError != "" {
93+ return TLSCertificateStatusError
94+ }
95+ if c.NotAfter.IsZero() {
96+ return TLSCertificateStatusMissing
97+ }
98+ if !now.Before(c.NotAfter) {
99+ return TLSCertificateStatusExpired
100+ }
101+ if now.Add(30 * 24 * time.Hour).After(c.NotAfter) {
102+ return TLSCertificateStatusWarning
103+ }
104+ return TLSCertificateStatusValid
105+}
106+
107+type TLSRouteCoverage struct {
108+ Domain string `json:"domain"`
109+ Covered bool `json:"covered"`
110+ CoveredBy string `json:"covered_by,omitempty"`
111+ RequiredACME bool `json:"required_acme"`
112+ Error string `json:"error,omitempty"`
113+}
114+
115+type PublicTLSStatus struct {
116+ ACMEEnabled bool `json:"acme_enabled"`
117+ ConfiguredMode ACMEChallengeMode `json:"configured_mode"`
118+ EffectiveMode ACMEChallengeMode `json:"effective_mode"`
119+ SelectionReason string `json:"selection_reason"`
120+ TokenSource ACMETokenSource `json:"token_source"`
121+ Certificates []ManagedCertificate `json:"certificates"`
122+ Routes []TLSRouteCoverage `json:"routes"`
123+ Errors []string `json:"errors,omitempty"`
124+}
A internal/domain/public_tls_test.go +52 -0
@@ -0,0 +1,52 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestACMEChallengeModeParse(t *testing.T) {
12+ tests := []struct {
13+ input string
14+ want ACMEChallengeMode
15+ ok bool
16+ }{
17+ {"", ACMEChallengeAuto, true},
18+ {"auto", ACMEChallengeAuto, true},
19+ {"http-01", ACMEChallengeHTTP01, true},
20+ {"cloudflare-dns-01", ACMEChallengeCloudflareDNS01, true},
21+ {"dns", "", false},
22+ }
23+
24+ for _, tt := range tests {
25+ got, err := ParseACMEChallengeMode(tt.input)
26+ if tt.ok {
27+ require.NoError(t, err)
28+ assert.Equal(t, tt.want, got)
29+ } else {
30+ require.Error(t, err)
31+ }
32+ }
33+}
34+
35+func TestManagedCertificateCovers(t *testing.T) {
36+ apexCert := ManagedCertificate{Names: []string{"example.com", "*.example.com"}}
37+ prodCert := ManagedCertificate{Names: []string{"prod.example.com", "*.prod.example.com"}}
38+
39+ assert.True(t, apexCert.Covers("example.com"))
40+ assert.True(t, apexCert.Covers("app.example.com"))
41+ assert.False(t, apexCert.Covers("api.prod.example.com"), "*.example.com must not cover two labels")
42+ assert.True(t, prodCert.Covers("api.prod.example.com"))
43+ assert.False(t, apexCert.Covers("other.test"))
44+}
45+
46+func TestManagedCertificateHealth(t *testing.T) {
47+ valid := ManagedCertificate{NotAfter: time.Now().Add(60 * 24 * time.Hour)}
48+ expired := ManagedCertificate{NotAfter: time.Now().Add(-time.Hour)}
49+
50+ assert.Equal(t, TLSCertificateStatusValid, valid.Health(time.Now()))
51+ assert.Equal(t, TLSCertificateStatusExpired, expired.Health(time.Now()))
52+}
A internal/usecase/publictls/config.go +147 -0
@@ -0,0 +1,147 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/gordon/internal/boundaries/out"
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// Config holds the resolved configuration for public ACME TLS.
13+type Config struct {
14+ Enabled bool
15+ Email string
16+ Challenge string
17+ HTTPPort int
18+ TLSPort int
19+ DataDir string
20+}
21+
22+// tokenResolver resolves Cloudflare API tokens.
23+type tokenResolver interface {
24+ ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error)
25+}
26+
27+// EffectiveChallenge represents the resolved ACME challenge configuration.
28+type EffectiveChallenge struct {
29+ ConfiguredMode domain.ACMEChallengeMode
30+ Mode domain.ACMEChallengeMode
31+ Token string
32+ TokenSource domain.ACMETokenSource
33+ Reason string
34+}
35+
36+// ResolveEffectiveChallenge resolves the effective ACME challenge configuration
37+// from the given Config and optional token resolver.
38+func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver tokenResolver) (EffectiveChallenge, error) {
39+ if !cfg.Enabled {
40+ return EffectiveChallenge{
41+ ConfiguredMode: domain.ACMEChallengeAuto,
42+ Mode: domain.ACMEChallengeAuto,
43+ TokenSource: domain.ACMETokenSourceNone,
44+ Reason: "ACME disabled",
45+ }, nil
46+ }
47+
48+ if cfg.Email == "" {
49+ return EffectiveChallenge{}, domain.ErrACMEEmailRequired
50+ }
51+
52+ if cfg.TLSPort == 0 {
53+ return EffectiveChallenge{}, fmt.Errorf("%w: tls_port must be set", domain.ErrACMEChallengeInvalid)
54+ }
55+
56+ parsedMode, err := domain.ParseACMEChallengeMode(cfg.Challenge)
57+ if err != nil {
58+ return EffectiveChallenge{}, err
59+ }
60+
61+ switch parsedMode {
62+ case domain.ACMEChallengeHTTP01:
63+ if cfg.HTTPPort == 0 {
64+ return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be set for http-01 challenge", domain.ErrACMEChallengeInvalid)
65+ }
66+ return EffectiveChallenge{
67+ ConfiguredMode: domain.ACMEChallengeHTTP01,
68+ Mode: domain.ACMEChallengeHTTP01,
69+ TokenSource: domain.ACMETokenSourceNone,
70+ Reason: "http-01 challenge selected",
71+ }, nil
72+
73+ case domain.ACMEChallengeCloudflareDNS01:
74+ token, source, err := resolveToken(ctx, resolver)
75+ if err != nil {
76+ return EffectiveChallenge{}, fmt.Errorf("resolve Cloudflare token: %w", err)
77+ }
78+ if token == "" {
79+ return EffectiveChallenge{}, domain.ErrCloudflareTokenMissing
80+ }
81+ return EffectiveChallenge{
82+ ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
83+ Mode: domain.ACMEChallengeCloudflareDNS01,
84+ Token: token,
85+ TokenSource: source,
86+ Reason: "cloudflare dns-01 challenge selected",
87+ }, nil
88+
89+ case domain.ACMEChallengeAuto:
90+ token, source := resolveOptionalToken(ctx, resolver)
91+ if token != "" {
92+ return EffectiveChallenge{
93+ ConfiguredMode: domain.ACMEChallengeAuto,
94+ Mode: domain.ACMEChallengeCloudflareDNS01,
95+ Token: token,
96+ TokenSource: source,
97+ Reason: "auto selected cloudflare dns-01 (token available)",
98+ }, nil
99+ }
100+ if cfg.HTTPPort == 0 {
101+ return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be set for http-01 fallback in auto mode", domain.ErrACMEChallengeInvalid)
102+ }
103+ return EffectiveChallenge{
104+ ConfiguredMode: domain.ACMEChallengeAuto,
105+ Mode: domain.ACMEChallengeHTTP01,
106+ TokenSource: domain.ACMETokenSourceNone,
107+ Reason: "auto selected http-01 (no cloudflare token)",
108+ }, nil
109+
110+ default:
111+ return EffectiveChallenge{}, fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, parsedMode)
112+ }
113+}
114+
115+// resolveOptionalToken resolves the Cloudflare token, ignoring resolver errors.
116+// If the resolver is nil or returns an empty token, the result is empty with source none.
117+func resolveOptionalToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource) {
118+ if resolver == nil {
119+ return "", domain.ACMETokenSourceNone
120+ }
121+ sv, err := resolver.ResolveCloudflareToken(ctx)
122+ if err != nil {
123+ return "", domain.ACMETokenSourceNone
124+ }
125+ token := strings.TrimSpace(sv.Value)
126+ if token == "" {
127+ return "", domain.ACMETokenSourceNone
128+ }
129+ return token, sv.Source
130+}
131+
132+// resolveToken resolves the Cloudflare token via the resolver, propagating errors.
133+// Returns empty token and none source if resolver is nil.
134+func resolveToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource, error) {
135+ if resolver == nil {
136+ return "", domain.ACMETokenSourceNone, nil
137+ }
138+ sv, err := resolver.ResolveCloudflareToken(ctx)
139+ if err != nil {
140+ return "", domain.ACMETokenSourceNone, err
141+ }
142+ token := strings.TrimSpace(sv.Value)
143+ if token == "" {
144+ return "", domain.ACMETokenSourceNone, nil
145+ }
146+ return token, sv.Source, nil
147+}
A internal/usecase/publictls/config_test.go +158 -0
@@ -0,0 +1,158 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+// stubResolver is a simple implementation of tokenResolver for testing.
16+type stubResolver struct {
17+ value out.SecretValue
18+ err error
19+}
20+
21+func (s stubResolver) ResolveCloudflareToken(_ context.Context) (out.SecretValue, error) {
22+ return s.value, s.err
23+}
24+
25+func TestResolveEffectiveChallenge(t *testing.T) {
26+ t.Run("disabled returns auto mode with none source", func(t *testing.T) {
27+ cfg := Config{Enabled: false}
28+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
29+ require.NoError(t, err)
30+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode)
31+ assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode)
32+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
33+ assert.Equal(t, "ACME disabled", ec.Reason)
34+ })
35+
36+ t.Run("enabled requires non-empty email", func(t *testing.T) {
37+ cfg := Config{Enabled: true, Email: "", TLSPort: 8443}
38+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
39+ require.Error(t, err)
40+ assert.ErrorIs(t, err, domain.ErrACMEEmailRequired)
41+ })
42+
43+ t.Run("enabled requires non-zero tls port", func(t *testing.T) {
44+ cfg := Config{Enabled: true, Email: "admin@example.com", TLSPort: 0}
45+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
46+ require.Error(t, err)
47+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
48+ assert.Contains(t, err.Error(), "tls_port")
49+ })
50+
51+ t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) {
52+ cfg := Config{
53+ Enabled: true,
54+ Email: "admin@example.com",
55+ Challenge: "auto",
56+ TLSPort: 8443,
57+ HTTPPort: 8088,
58+ }
59+ resolver := stubResolver{
60+ value: out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass},
61+ }
62+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
63+ require.NoError(t, err)
64+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode)
65+ assert.Equal(t, domain.ACMETokenSourcePass, ec.TokenSource)
66+ assert.Equal(t, "token", ec.Token)
67+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode)
68+ })
69+
70+ t.Run("auto without token returns http-01", func(t *testing.T) {
71+ cfg := Config{
72+ Enabled: true,
73+ Email: "admin@example.com",
74+ Challenge: "auto",
75+ TLSPort: 8443,
76+ HTTPPort: 8088,
77+ }
78+ resolver := stubResolver{
79+ value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone},
80+ }
81+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
82+ require.NoError(t, err)
83+ assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
84+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
85+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode)
86+ })
87+
88+ t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) {
89+ cfg := Config{
90+ Enabled: true,
91+ Email: "admin@example.com",
92+ Challenge: "cloudflare-dns-01",
93+ TLSPort: 8443,
94+ HTTPPort: 8088,
95+ }
96+ resolver := stubResolver{
97+ value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone},
98+ }
99+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
100+ require.Error(t, err)
101+ assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
102+ })
103+
104+ t.Run("http-01 requires http port", func(t *testing.T) {
105+ cfg := Config{
106+ Enabled: true,
107+ Email: "admin@example.com",
108+ Challenge: "http-01",
109+ TLSPort: 8443,
110+ HTTPPort: 0,
111+ }
112+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
113+ require.Error(t, err)
114+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
115+ })
116+
117+ t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) {
118+ cfg := Config{
119+ Enabled: true,
120+ Email: "admin@example.com",
121+ Challenge: "cloudflare-dns-01",
122+ TLSPort: 8443,
123+ }
124+ resolver := stubResolver{
125+ err: errors.New("pass failed"),
126+ }
127+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
128+ require.Error(t, err)
129+ assert.Contains(t, err.Error(), "resolve Cloudflare token")
130+ assert.False(t, errors.Is(err, domain.ErrCloudflareTokenMissing), "must not collapse to missing token")
131+ })
132+
133+ t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) {
134+ cfg := Config{
135+ Enabled: true,
136+ Email: "admin@example.com",
137+ Challenge: "auto",
138+ TLSPort: 8443,
139+ HTTPPort: 8088,
140+ }
141+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
142+ require.NoError(t, err)
143+ assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
144+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
145+ })
146+
147+ t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) {
148+ cfg := Config{
149+ Enabled: true,
150+ Email: "admin@example.com",
151+ Challenge: "cloudflare-dns-01",
152+ TLSPort: 8443,
153+ }
154+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
155+ require.Error(t, err)
156+ assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
157+ })
158+}
A internal/usecase/publictls/http01.go +71 -0
@@ -0,0 +1,71 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+)
8+
9+// HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
10+// authorizations. It is safe for concurrent use.
11+type HTTP01Challenges struct {
12+ mu sync.Mutex
13+ data map[string]string
14+}
15+
16+// NewHTTP01Challenges returns an initialized HTTP01Challenges.
17+func NewHTTP01Challenges() *HTTP01Challenges {
18+ return &HTTP01Challenges{
19+ data: make(map[string]string),
20+ }
21+}
22+
23+// Present stores the key authorization for the given token. It silently
24+// ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values.
25+func (c *HTTP01Challenges) Present(token, keyAuth string) {
26+ if !safeHTTP01Token(token) || keyAuth == "" {
27+ return
28+ }
29+ c.mu.Lock()
30+ defer c.mu.Unlock()
31+ c.data[token] = keyAuth
32+}
33+
34+// CleanUp removes the stored key authorization for the given token.
35+func (c *HTTP01Challenges) CleanUp(token string) {
36+ c.mu.Lock()
37+ defer c.mu.Unlock()
38+ delete(c.data, token)
39+}
40+
41+// Get returns the key authorization for the given token, and a boolean
42+// indicating whether it was found. It returns false for unsafe tokens
43+// (see safeHTTP01Token).
44+func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
45+ if !safeHTTP01Token(token) {
46+ return "", false
47+ }
48+ c.mu.Lock()
49+ defer c.mu.Unlock()
50+ keyAuth, ok := c.data[token]
51+ return keyAuth, ok
52+}
53+
54+// safeHTTP01Token validates an ACME HTTP-01 challenge token.
55+// It rejects empty tokens, tokens containing "/" or "\", and tokens
56+// equal to "..", which could allow path traversal.
57+func safeHTTP01Token(token string) bool {
58+ if token == "" {
59+ return false
60+ }
61+ if strings.Contains(token, "/") {
62+ return false
63+ }
64+ if strings.Contains(token, "\\") {
65+ return false
66+ }
67+ if token == ".." {
68+ return false
69+ }
70+ return true
71+}
A internal/usecase/publictls/http01_test.go +111 -0
@@ -0,0 +1,111 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHTTP01Challenges(t *testing.T) {
12+ ctx := context.Background()
13+
14+ t.Run("full lifecycle", func(t *testing.T) {
15+ ch := NewHTTP01Challenges()
16+ require.NotNil(t, ch)
17+
18+ // Present a valid token
19+ ch.Present("token", "key-auth")
20+ got, ok := ch.Get(ctx, "token")
21+ assert.True(t, ok)
22+ assert.Equal(t, "key-auth", got)
23+
24+ // Clean up
25+ ch.CleanUp("token")
26+ _, ok = ch.Get(ctx, "token")
27+ assert.False(t, ok)
28+ })
29+
30+ t.Run("concurrent access does not panic", func(t *testing.T) {
31+ ch := NewHTTP01Challenges()
32+ done := make(chan struct{})
33+ go func() {
34+ ch.Present("t1", "ka1")
35+ done <- struct{}{}
36+ }()
37+ go func() {
38+ ch.Get(ctx, "t1")
39+ done <- struct{}{}
40+ }()
41+ <-done
42+ <-done
43+ // If we reach here without a race, the test passes.
44+ })
45+}
46+
47+func TestHTTP01ChallengesRejectUnsafeToken(t *testing.T) {
48+ ctx := context.Background()
49+
50+ t.Run("path traversal token ../secret is rejected", func(t *testing.T) {
51+ ch := NewHTTP01Challenges()
52+
53+ ch.Present("../secret", "key-auth")
54+ _, ok := ch.Get(ctx, "../secret")
55+ assert.False(t, ok, "Present should silently ignore ../secret")
56+ })
57+
58+ t.Run("token with slash is rejected", func(t *testing.T) {
59+ ch := NewHTTP01Challenges()
60+
61+ ch.Present("a/b", "key-auth")
62+ _, ok := ch.Get(ctx, "a/b")
63+ assert.False(t, ok, "token containing / should be rejected")
64+
65+ // Get also rejects the unsafe token
66+ _, ok = ch.Get(ctx, "safe/token")
67+ assert.False(t, ok)
68+ })
69+
70+ t.Run("token with backslash is rejected", func(t *testing.T) {
71+ ch := NewHTTP01Challenges()
72+
73+ ch.Present("a\\b", "key-auth")
74+ _, ok := ch.Get(ctx, "a\\b")
75+ assert.False(t, ok, "token containing backslash should be rejected")
76+
77+ _, ok = ch.Get(ctx, "safe\\token")
78+ assert.False(t, ok)
79+ })
80+
81+ t.Run("empty token is rejected", func(t *testing.T) {
82+ ch := NewHTTP01Challenges()
83+
84+ ch.Present("", "key-auth")
85+ _, ok := ch.Get(ctx, "")
86+ assert.False(t, ok, "empty token should be rejected")
87+ })
88+
89+ t.Run("'..' token is rejected", func(t *testing.T) {
90+ ch := NewHTTP01Challenges()
91+
92+ ch.Present("..", "key-auth")
93+ _, ok := ch.Get(ctx, "..")
94+ assert.False(t, ok, "token '..' should be rejected")
95+ })
96+
97+ t.Run("empty keyAuth is silently ignored", func(t *testing.T) {
98+ ch := NewHTTP01Challenges()
99+ ch.Present("valid-token", "")
100+ _, ok := ch.Get(ctx, "valid-token")
101+ assert.False(t, ok, "Present with empty keyAuth should not store anything")
102+ })
103+
104+ t.Run("valid tokens are accepted and retrievable", func(t *testing.T) {
105+ ch := NewHTTP01Challenges()
106+ ch.Present("valid-token", "key-auth-value")
107+ got, ok := ch.Get(ctx, "valid-token")
108+ assert.True(t, ok)
109+ assert.Equal(t, "key-auth-value", got)
110+ })
111+}
A internal/usecase/publictls/integration_test.go +306 -0
@@ -0,0 +1,306 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+// ---------------------------------------------------------------------------
16+// Integration-style tests using fakes (no real ACME / Cloudflare)
17+// ---------------------------------------------------------------------------
18+
19+func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing.T) {
20+ ctx := context.Background()
21+
22+ // Routes: app.example.com, api.prod.example.com, example.com
23+ routes := &fakeRoutes{
24+ routes: []domain.Route{
25+ {Domain: "app.example.com"},
26+ {Domain: "api.prod.example.com"},
27+ {Domain: "example.com"},
28+ },
29+ }
30+
31+ // Fake zone resolver returns "example.com" zone.
32+ zoneResolver := fixedZoneResolver{zone: "example.com"}
33+
34+ // Fake issuer: returns valid stored cert for any order.
35+ issuer := &fakeIssuer{
36+ obtain: func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
37+ return defaultStoredCert(order)
38+ },
39+ }
40+ store := &fakeStore{}
41+
42+ cfg := Config{
43+ Enabled: true,
44+ Email: "admin@example.com",
45+ Challenge: "cloudflare-dns-01",
46+ TLSPort: 8443,
47+ }
48+
49+ svc := NewService(cfg, ServiceDeps{
50+ Config: cfg,
51+ Routes: routes,
52+ Issuer: issuer,
53+ Store: store,
54+ ZoneResolver: zoneResolver,
55+ Challenges: NewHTTP01Challenges(),
56+ Effective: EffectiveChallenge{
57+ ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
58+ Mode: domain.ACMEChallengeCloudflareDNS01,
59+ TokenSource: domain.ACMETokenSourceNone,
60+ Reason: "cloudflare dns-01 challenge selected",
61+ },
62+ })
63+
64+ // Load (nothing stored).
65+ err := svc.Load(ctx)
66+ require.NoError(t, err)
67+
68+ // Reconcile — should obtain DNS wildcard base targets:
69+ // example.com (for app.example.com + example.com) and prod.example.com (for api.prod.example.com).
70+ err = svc.Reconcile(ctx)
71+ require.NoError(t, err)
72+
73+ // Verify issuer received exactly 2 orders.
74+ issuer.mu.Lock()
75+ orders := make([]out.CertificateOrder, len(issuer.orders))
76+ copy(orders, issuer.orders)
77+ issuer.mu.Unlock()
78+
79+ require.Len(t, orders, 2, "expected 2 DNS-01 orders")
80+
81+ // Check order details (orders are sorted alphabetically by target base).
82+ order0 := orders[0]
83+ assert.Equal(t, "dns01-example.com", order0.ID)
84+ assert.ElementsMatch(t, []string{"example.com", "*.example.com"}, order0.Names)
85+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order0.Challenge)
86+
87+ order1 := orders[1]
88+ assert.Equal(t, "dns01-prod.example.com", order1.ID)
89+ assert.ElementsMatch(t, []string{"prod.example.com", "*.prod.example.com"}, order1.Names)
90+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order1.Challenge)
91+
92+ // --- Status reports routes covered ---
93+ status := svc.Status(ctx)
94+ require.True(t, status.ACMEEnabled)
95+ require.Len(t, status.Routes, 3, "expected 3 routes in status")
96+
97+ // Build a map for easier assertions.
98+ routeMap := make(map[string]domain.TLSRouteCoverage)
99+ for _, r := range status.Routes {
100+ routeMap[r.Domain] = r
101+ }
102+
103+ for _, domain := range []string{"app.example.com", "api.prod.example.com", "example.com"} {
104+ rc, ok := routeMap[domain]
105+ require.True(t, ok, "route %s should be present in status", domain)
106+ assert.True(t, rc.Covered, "route %s should be covered", domain)
107+ assert.True(t, rc.RequiredACME)
108+ assert.NotEmpty(t, rc.CoveredBy, "route %s should have CoveredBy set", domain)
109+ assert.Empty(t, rc.Error, "route %s should have no error", domain)
110+ }
111+
112+ // app.example.com should be covered by the wildcard cert (dns01-example.com).
113+ assert.Equal(t, "dns01-example.com", routeMap["app.example.com"].CoveredBy,
114+ "app.example.com should be covered by dns01-example.com (wildcard *.example.com)")
115+
116+ // example.com should be covered by the base cert (dns01-example.com).
117+ assert.Equal(t, "dns01-example.com", routeMap["example.com"].CoveredBy,
118+ "example.com should be covered by dns01-example.com")
119+
120+ // api.prod.example.com should be covered by the wildcard cert (dns01-prod.example.com).
121+ assert.Equal(t, "dns01-prod.example.com", routeMap["api.prod.example.com"].CoveredBy,
122+ "api.prod.example.com should be covered by dns01-prod.example.com (wildcard *.prod.example.com)")
123+
124+ // --- GetCertificate returns non-nil certs ---
125+ t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) {
126+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
127+ require.NoError(t, err)
128+ require.NotNil(t, cert)
129+ require.NotEmpty(t, cert.Certificate)
130+ })
131+
132+ t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) {
133+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "api.prod.example.com"})
134+ require.NoError(t, err)
135+ require.NotNil(t, cert)
136+ require.NotEmpty(t, cert.Certificate)
137+ })
138+
139+ t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) {
140+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "example.com"})
141+ require.NoError(t, err)
142+ require.NotNil(t, cert)
143+ require.NotEmpty(t, cert.Certificate)
144+ })
145+
146+ // Also verify that a host not in the routes returns nil (no required ACME).
147+ // "unrelated.com" is not covered by any wildcard base in requiredHosts.
148+ t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
149+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "unrelated.com"})
150+ require.NoError(t, err)
151+ assert.Nil(t, cert, "unrelated host should not require ACME coverage")
152+ })
153+}
154+
155+func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
156+ ctx := context.Background()
157+
158+ routes := &fakeRoutes{
159+ routes: []domain.Route{
160+ {Domain: "app.example.com"},
161+ },
162+ }
163+
164+ issuer := &fakeIssuer{}
165+ store := &fakeStore{}
166+ challenges := NewHTTP01Challenges()
167+
168+ cfg := Config{
169+ Enabled: true,
170+ Email: "admin@example.com",
171+ Challenge: "http-01",
172+ HTTPPort: 8088,
173+ TLSPort: 8443,
174+ }
175+
176+ svc := NewService(cfg, ServiceDeps{
177+ Config: cfg,
178+ Routes: routes,
179+ Issuer: issuer,
180+ Store: store,
181+ Challenges: challenges,
182+ Effective: EffectiveChallenge{
183+ ConfiguredMode: domain.ACMEChallengeHTTP01,
184+ Mode: domain.ACMEChallengeHTTP01,
185+ TokenSource: domain.ACMETokenSourceNone,
186+ Reason: "http-01 challenge selected",
187+ },
188+ })
189+
190+ // Present an HTTP-01 challenge token/keyAuth.
191+ token := "test-token-abc123"
192+ keyAuth := "test-key-auth-xyz789"
193+ challenges.Present(token, keyAuth)
194+
195+ // GetHTTP01Challenge should return the keyAuth.
196+ gotKeyAuth, ok := svc.GetHTTP01Challenge(ctx, token)
197+ require.True(t, ok, "GetHTTP01Challenge should find the token")
198+ assert.Equal(t, keyAuth, gotKeyAuth)
199+
200+ // Load the service (nothing stored).
201+ err := svc.Load(ctx)
202+ require.NoError(t, err)
203+
204+ // Reconcile — should create per-route target app.example.com.
205+ err = svc.Reconcile(ctx)
206+ require.NoError(t, err)
207+
208+ // Verify the issuer received exactly 1 order.
209+ issuer.mu.Lock()
210+ orders := make([]out.CertificateOrder, len(issuer.orders))
211+ copy(orders, issuer.orders)
212+ issuer.mu.Unlock()
213+
214+ require.Len(t, orders, 1, "expected 1 HTTP-01 order")
215+ assert.Equal(t, "http01-app.example.com", orders[0].ID)
216+ assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
217+ assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
218+
219+ // Status reports covered route.
220+ status := svc.Status(ctx)
221+ require.True(t, status.ACMEEnabled)
222+ require.Len(t, status.Routes, 1, "expected 1 route in status")
223+ assert.Equal(t, "app.example.com", status.Routes[0].Domain)
224+ assert.True(t, status.Routes[0].Covered, "route should be covered")
225+ assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
226+ assert.Empty(t, status.Routes[0].Error)
227+
228+ // GetCertificate returns non-nil cert for app.example.com.
229+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
230+ require.NoError(t, err)
231+ require.NotNil(t, cert)
232+ require.NotEmpty(t, cert.Certificate)
233+
234+ // GetCertificate returns nil for unrelated host.
235+ cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "other.example.com"})
236+ require.NoError(t, err)
237+ assert.Nil(t, cert)
238+}
239+
240+func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing.T) {
241+ ctx := context.Background()
242+
243+ routes := &fakeRoutes{
244+ routes: []domain.Route{
245+ {Domain: "missing.example.com"},
246+ },
247+ }
248+
249+ // Issuer returns error so no cert is obtained.
250+ issuer := &fakeIssuer{
251+ obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
252+ return nil, assert.AnError
253+ },
254+ }
255+ store := &fakeStore{}
256+
257+ cfg := Config{
258+ Enabled: true,
259+ Email: "admin@example.com",
260+ Challenge: "http-01",
261+ HTTPPort: 8088,
262+ TLSPort: 8443,
263+ }
264+
265+ svc := NewService(cfg, ServiceDeps{
266+ Config: cfg,
267+ Routes: routes,
268+ Issuer: issuer,
269+ Store: store,
270+ Challenges: NewHTTP01Challenges(),
271+ Effective: EffectiveChallenge{
272+ ConfiguredMode: domain.ACMEChallengeHTTP01,
273+ Mode: domain.ACMEChallengeHTTP01,
274+ TokenSource: domain.ACMETokenSourceNone,
275+ Reason: "http-01 challenge selected",
276+ },
277+ })
278+
279+ // Load (nothing stored).
280+ err := svc.Load(ctx)
281+ require.NoError(t, err)
282+
283+ // Reconcile — issuer fails, so no cert is cached but requiredHosts is set.
284+ err = svc.Reconcile(ctx)
285+ require.NoError(t, err) // Reconcile swallows per-target obtain errors
286+
287+ // GetCertificate for missing.example.com should return ErrTLSRouteNotCovered.
288+ _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "missing.example.com"})
289+ require.Error(t, err)
290+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
291+ // The error message should contain the hostname.
292+ assert.Contains(t, err.Error(), "missing.example.com")
293+
294+ // Status should show the route is NOT covered and has an error.
295+ status := svc.Status(ctx)
296+ require.True(t, status.ACMEEnabled)
297+ require.Len(t, status.Routes, 1, "expected 1 route in status")
298+ rc := status.Routes[0]
299+ assert.Equal(t, "missing.example.com", rc.Domain)
300+ assert.False(t, rc.Covered, "route should NOT be covered")
301+ assert.True(t, rc.RequiredACME)
302+
303+ // The route error may be empty since not all code paths populate routeErr;
304+ // at minimum the covered flag must be false.
305+ t.Logf("Route error (may be empty): %q", rc.Error)
306+}
A internal/usecase/publictls/renewal.go +131 -0
@@ -0,0 +1,131 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/boundaries/out"
9+)
10+
11+// renewalWindow is the window before certificate expiry when renewal should begin.
12+const renewalWindow = 30 * 24 * time.Hour
13+
14+// ShouldRenew reports whether a certificate should be renewed at the given time.
15+// A certificate with a zero NotAfter is always considered due for renewal.
16+func ShouldRenew(cert out.StoredCertificate, now time.Time) bool {
17+ if cert.NotAfter.IsZero() {
18+ return true
19+ }
20+ return !now.Before(cert.NotAfter.Add(-renewalWindow))
21+}
22+
23+// StartRenewalLoop starts a background goroutine that periodically reconciles
24+// certificates and renews due certificates. Returns a channel that is closed
25+// when the loop exits (ctx is cancelled).
26+//
27+// If interval <= 0, a default of 1 hour is used.
28+func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) <-chan struct{} {
29+ if interval <= 0 {
30+ interval = time.Hour
31+ }
32+
33+ done := make(chan struct{})
34+
35+ // Derive a cancellable context so Stop can terminate the loop.
36+ loopCtx, cancel := context.WithCancel(ctx)
37+
38+ // Cancel any previous loop and wait for it to fully exit before
39+ // overwriting s.done, preventing overlapping loop goroutines.
40+ s.mu.Lock()
41+ oldCancel := s.cancel
42+ oldDone := s.done
43+ s.mu.Unlock()
44+
45+ if oldCancel != nil {
46+ oldCancel()
47+ }
48+ if oldDone != nil {
49+ <-oldDone
50+ }
51+
52+ s.mu.Lock()
53+ s.cancel = cancel
54+ s.done = done
55+ s.mu.Unlock()
56+
57+ go func() {
58+ defer close(done)
59+ defer cancel()
60+
61+ ticker := time.NewTicker(interval)
62+ defer ticker.Stop()
63+
64+ // Run once immediately on start.
65+ _ = s.Reconcile(loopCtx)
66+ _ = s.renewDueCertificates(loopCtx, time.Now())
67+
68+ for {
69+ select {
70+ case <-loopCtx.Done():
71+ return
72+ case <-ticker.C:
73+ _ = s.Reconcile(loopCtx)
74+ _ = s.renewDueCertificates(loopCtx, time.Now())
75+ }
76+ }
77+ }()
78+
79+ return done
80+}
81+
82+// renewDueCertificates renews all certificates in the cache that are due
83+// for renewal according to ShouldRenew.
84+//
85+// Per-certificate errors are recorded in lastErr and do not halt the loop.
86+// Returns nil unless the store or issuer is nil (systemic error).
87+func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error {
88+ if !s.cfg.Enabled {
89+ return nil
90+ }
91+ if s.deps.Store == nil {
92+ return fmt.Errorf("certificate store is nil")
93+ }
94+ if s.deps.Issuer == nil {
95+ return fmt.Errorf("certificate issuer is nil")
96+ }
97+
98+ // Collect due certs under lock, then release before making external calls.
99+ s.mu.Lock()
100+ due := make([]*out.StoredCertificate, 0)
101+ for _, cert := range s.certs {
102+ if ShouldRenew(*cert, now) {
103+ due = append(due, cert)
104+ }
105+ }
106+ s.mu.Unlock()
107+
108+ for _, cert := range due {
109+ renewed, err := s.deps.Issuer.Renew(ctx, *cert)
110+ if err != nil {
111+ s.mu.Lock()
112+ s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)
113+ s.mu.Unlock()
114+ continue
115+ }
116+
117+ if err := s.deps.Store.Save(ctx, *renewed); err != nil {
118+ s.mu.Lock()
119+ s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", err)
120+ s.mu.Unlock()
121+ continue
122+ }
123+
124+ s.mu.Lock()
125+ s.certs[renewed.ID] = renewed
126+ delete(s.lastErr, renewed.ID)
127+ s.mu.Unlock()
128+ }
129+
130+ return nil
131+}
A internal/usecase/publictls/renewal_test.go +196 -0
@@ -0,0 +1,196 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/boundaries/out"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+// ---------------------------------------------------------------------------
17+// ShouldRenew
18+// ---------------------------------------------------------------------------
19+
20+func TestShouldRenew(t *testing.T) {
21+ // Fixed "now" for deterministic tests.
22+ now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
23+
24+ tests := []struct {
25+ name string
26+ cert out.StoredCertificate
27+ want bool
28+ }{
29+ {
30+ name: "cert with NotAfter 45 days from now should not renew",
31+ cert: out.StoredCertificate{NotAfter: now.Add(45 * 24 * time.Hour)},
32+ want: false,
33+ },
34+ {
35+ name: "cert with NotAfter 10 days from now should renew",
36+ cert: out.StoredCertificate{NotAfter: now.Add(10 * 24 * time.Hour)},
37+ want: true,
38+ },
39+ {
40+ name: "expired cert should renew",
41+ cert: out.StoredCertificate{NotAfter: now.Add(-1 * time.Hour)},
42+ want: true,
43+ },
44+ {
45+ name: "cert with zero NotAfter should renew",
46+ cert: out.StoredCertificate{},
47+ want: true,
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ got := ShouldRenew(tt.cert, now)
54+ assert.Equal(t, tt.want, got)
55+ })
56+ }
57+}
58+
59+// ---------------------------------------------------------------------------
60+// RenewalLoopStops
61+// ---------------------------------------------------------------------------
62+
63+func TestRenewalLoopStops(t *testing.T) {
64+ ctx, cancel := context.WithCancel(context.Background())
65+
66+ cfg := Config{
67+ Enabled: true,
68+ Email: "admin@example.com",
69+ Challenge: "http-01",
70+ HTTPPort: 8088,
71+ TLSPort: 8443,
72+ }
73+
74+ svc := NewService(cfg, ServiceDeps{
75+ Config: cfg,
76+ Routes: &fakeRoutes{},
77+ Issuer: &fakeIssuer{},
78+ Store: &fakeStore{},
79+ Challenges: NewHTTP01Challenges(),
80+ Effective: EffectiveChallenge{
81+ ConfiguredMode: domain.ACMEChallengeHTTP01,
82+ Mode: domain.ACMEChallengeHTTP01,
83+ TokenSource: domain.ACMETokenSourceNone,
84+ Reason: "http-01 challenge selected",
85+ },
86+ })
87+
88+ done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
89+ cancel()
90+
91+ require.Eventually(t, func() bool {
92+ select {
93+ case <-done:
94+ return true
95+ default:
96+ return false
97+ }
98+ }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")
99+}
100+
101+// ---------------------------------------------------------------------------
102+// RenewDueCertificates
103+// ---------------------------------------------------------------------------
104+
105+func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
106+ ctx := context.Background()
107+
108+ certID := "http01-app.example.com"
109+ now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
110+ notAfterSoon := now.Add(10 * 24 * time.Hour) // 10 days from now — due for renewal
111+ notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert
112+
113+ // Generate a test certificate.
114+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
115+ require.NoError(t, err)
116+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
117+ require.NoError(t, err)
118+
119+ store := &fakeStore{
120+ certs: []out.StoredCertificate{
121+ {
122+ ID: certID,
123+ Names: []string{"app.example.com"},
124+ Challenge: domain.ACMEChallengeHTTP01,
125+ Certificate: tlsCert,
126+ FullchainPEM: certPEM,
127+ PrivateKeyPEM: keyPEM,
128+ NotAfter: notAfterSoon,
129+ },
130+ },
131+ }
132+
133+ issuer := &fakeIssuer{
134+ renewFn: func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
135+ renewed := cert
136+ renewed.NotAfter = notAfterRenewed
137+ return &renewed, nil
138+ },
139+ }
140+
141+ cfg := Config{
142+ Enabled: true,
143+ Email: "admin@example.com",
144+ Challenge: "http-01",
145+ HTTPPort: 8088,
146+ TLSPort: 8443,
147+ }
148+
149+ svc := NewService(cfg, ServiceDeps{
150+ Config: cfg,
151+ Routes: &fakeRoutes{},
152+ Issuer: issuer,
153+ Store: store,
154+ Challenges: NewHTTP01Challenges(),
155+ Effective: EffectiveChallenge{
156+ ConfiguredMode: domain.ACMEChallengeHTTP01,
157+ Mode: domain.ACMEChallengeHTTP01,
158+ TokenSource: domain.ACMETokenSourceNone,
159+ Reason: "http-01 challenge selected",
160+ },
161+ })
162+
163+ // Load certs into the cache from the store.
164+ err = svc.Load(ctx)
165+ require.NoError(t, err)
166+
167+ // Verify cert is in cache with original NotAfter.
168+ svc.mu.Lock()
169+ cached, ok := svc.certs[certID]
170+ svc.mu.Unlock()
171+ require.True(t, ok, "cert should be in cache after Load")
172+ assert.True(t, cached.NotAfter.Equal(notAfterSoon), "cache should have original NotAfter")
173+
174+ // Run renewDueCertificates.
175+ err = svc.renewDueCertificates(ctx, now)
176+ require.NoError(t, err)
177+
178+ // Verify the store has the updated certificate.
179+ all, err := store.LoadAll(ctx)
180+ require.NoError(t, err)
181+ require.Len(t, all, 1)
182+ assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
183+
184+ // Verify the cache has the updated certificate.
185+ svc.mu.Lock()
186+ cached, ok = svc.certs[certID]
187+ svc.mu.Unlock()
188+ require.True(t, ok, "cert should still be in cache after renewal")
189+ assert.True(t, cached.NotAfter.Equal(notAfterRenewed), "cache should have renewed NotAfter")
190+
191+ // Verify lastErr is cleared for the renewed certificate.
192+ svc.mu.Lock()
193+ _, hasErr := svc.lastErr[certID]
194+ svc.mu.Unlock()
195+ assert.False(t, hasErr, "lastErr should be cleared for renewed cert")
196+}
A internal/usecase/publictls/service.go +356 -0
@@ -0,0 +1,356 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "fmt"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+// RouteSource provides routes from which certificate targets are derived.
16+type RouteSource interface {
17+ GetRoutes(ctx context.Context) []domain.Route
18+ GetExternalRoutes() map[string]string
19+}
20+
21+// ServiceDeps contains the dependencies for the Service.
22+type ServiceDeps struct {
23+ Config Config
24+ Routes RouteSource
25+ Issuer out.PublicCertificateIssuer
26+ Store out.CertificateStore
27+ ZoneResolver zoneResolver
28+ Challenges *HTTP01Challenges
29+ Effective EffectiveChallenge
30+}
31+
32+// Service manages public TLS certificates via ACME.
33+type Service struct {
34+ mu sync.Mutex
35+ cfg Config
36+ deps ServiceDeps
37+ certs map[string]*out.StoredCertificate // indexed by cert ID
38+ lastErr map[string]string // indexed by cert ID
39+ routeErr map[string]string // indexed by host
40+
41+ // requiredHosts is the set of hosts that must be covered by ACME certs.
42+ requiredHosts map[string]struct{}
43+
44+ // cancel cancels the renewal loop context.
45+ cancel context.CancelFunc
46+ // done is closed when the renewal loop exits.
47+ done chan struct{}
48+}
49+
50+// NewService creates a new public TLS Service.
51+func NewService(cfg Config, deps ServiceDeps) *Service {
52+ if deps.Challenges == nil {
53+ deps.Challenges = NewHTTP01Challenges()
54+ }
55+ return &Service{
56+ cfg: cfg,
57+ deps: deps,
58+ certs: make(map[string]*out.StoredCertificate),
59+ lastErr: make(map[string]string),
60+ routeErr: make(map[string]string),
61+ requiredHosts: make(map[string]struct{}),
62+ }
63+}
64+
65+// Load loads all stored certificates from the store into the internal cache.
66+// If the store is nil, this is a no-op.
67+func (s *Service) Load(ctx context.Context) error {
68+ if s.deps.Store == nil {
69+ return nil
70+ }
71+
72+ stored, err := s.deps.Store.LoadAll(ctx)
73+ if err != nil {
74+ return fmt.Errorf("load stored certificates: %w", err)
75+ }
76+
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+
80+ for i := range stored {
81+ cert := &stored[i]
82+ // If the tls.Certificate is empty but PEM data is available, parse it.
83+ if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
84+ parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
85+ if parseErr == nil {
86+ cert.Certificate = parsed
87+ }
88+ }
89+ s.certs[cert.ID] = cert
90+ if cert.LastError != "" {
91+ s.lastErr[cert.ID] = cert.LastError
92+ }
93+ }
94+
95+ return nil
96+}
97+
98+// Reconcile ensures all desired certificates are obtained and cached.
99+// If ACME is disabled, it is a no-op.
100+func (s *Service) Reconcile(ctx context.Context) error {
101+ if !s.cfg.Enabled {
102+ return nil
103+ }
104+
105+ if s.deps.Store == nil {
106+ return fmt.Errorf("certificate store is nil")
107+ }
108+ if s.deps.Issuer == nil {
109+ return fmt.Errorf("certificate issuer is nil")
110+ }
111+
112+ // Determine effective challenge mode.
113+ effective := s.deps.Effective
114+ if effective.Mode == "" {
115+ // Derive conservatively from config.
116+ mode, err := domain.ParseACMEChallengeMode(s.cfg.Challenge)
117+ if err != nil {
118+ mode = domain.ACMEChallengeHTTP01
119+ }
120+ effective.Mode = mode
121+ }
122+
123+ // Get route hosts early to build required hosts set before target derivation.
124+ // This ensures GetCertificate returns ErrTLSRouteNotCovered even if
125+ // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver).
126+ routes := s.deps.Routes.GetRoutes(ctx)
127+ external := s.deps.Routes.GetExternalRoutes()
128+ hosts := routeHosts(routes, external)
129+
130+ // Build required hosts set from route hosts (before target derivation).
131+ required := make(map[string]struct{})
132+ for _, host := range hosts {
133+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
134+ if host != "" {
135+ required[host] = struct{}{}
136+ }
137+ }
138+
139+ // Set requiredHosts before DeriveCertificateTargets so GetCertificate
140+ // returns ErrTLSRouteNotCovered if target derivation/issuer fails.
141+ s.mu.Lock()
142+ s.requiredHosts = required
143+ s.mu.Unlock()
144+
145+ // Derive desired targets.
146+ targets, err := DeriveCertificateTargets(ctx, effective.Mode,
147+ routes, external,
148+ s.deps.ZoneResolver,
149+ )
150+ if err != nil {
151+ return fmt.Errorf("derive certificate targets: %w", err)
152+ }
153+
154+ // Merge target names into required hosts set.
155+ // For DNS-01 wildcard targets, this adds wildcard entries as well;
156+ // the exact route hosts already present suffice for isRequiredHostLocked.
157+ for _, t := range targets {
158+ for _, name := range t.Names {
159+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
160+ if name != "" {
161+ required[name] = struct{}{}
162+ }
163+ }
164+ }
165+
166+ // Acquire store lock to serialise certificate operations.
167+ unlock, err := s.deps.Store.Lock(ctx)
168+ if err != nil {
169+ return fmt.Errorf("acquire store lock: %w", err)
170+ }
171+ defer func() { _ = unlock() }()
172+
173+ // Under mu: update requiredHosts (with target names) and compute missing targets.
174+ s.mu.Lock()
175+ s.requiredHosts = required
176+
177+ missing := make([]CertificateTarget, 0)
178+ for _, target := range targets {
179+ if s.certificateExistsLocked(target) {
180+ continue
181+ }
182+ missing = append(missing, target)
183+ }
184+ s.mu.Unlock()
185+
186+ // Process missing targets without holding s.mu so GetCertificate/Status
187+ // are not blocked during network I/O (Obtain) or storage writes (Save).
188+ s.reconcileMissingTargets(ctx, missing)
189+
190+ return nil
191+}
192+
193+// reconcileMissingTargets obtains and saves certificates for each missing
194+// target. Called without s.mu held so network I/O does not block readers.
195+func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget) {
196+ for _, target := range missing {
197+ order := out.CertificateOrder{
198+ ID: target.ID,
199+ Names: target.Names,
200+ Challenge: target.Challenge,
201+ }
202+
203+ stored, err := s.deps.Issuer.Obtain(ctx, order)
204+ if err != nil {
205+ s.mu.Lock()
206+ s.lastErr[target.ID] = err.Error()
207+ s.mu.Unlock()
208+ continue
209+ }
210+
211+ if err := s.deps.Store.Save(ctx, *stored); err != nil {
212+ s.mu.Lock()
213+ s.lastErr[target.ID] = fmt.Sprintf("save certificate: %v", err)
214+ s.mu.Unlock()
215+ continue
216+ }
217+
218+ s.mu.Lock()
219+ s.certs[stored.ID] = stored
220+ delete(s.lastErr, stored.ID)
221+ s.mu.Unlock()
222+ }
223+}
224+
225+// certificateExistsLocked checks if a valid non-expired cached certificate
226+// covers the target. Must be called with s.mu held.
227+func (s *Service) certificateExistsLocked(target CertificateTarget) bool {
228+ now := time.Now()
229+ for _, cert := range s.certs {
230+ if cert.NotAfter.IsZero() || now.After(cert.NotAfter) {
231+ continue // expired or no expiry
232+ }
233+ if namesCover(cert.Names, target.Names) {
234+ return true
235+ }
236+ }
237+ return false
238+}
239+
240+// namesCover reports whether names covers all required names (exact match, case-insensitive).
241+func namesCover(names, required []string) bool {
242+ covered := make(map[string]bool)
243+ for _, n := range names {
244+ covered[strings.ToLower(strings.TrimSuffix(n, "."))] = true
245+ }
246+ for _, r := range required {
247+ r = strings.ToLower(strings.TrimSuffix(r, "."))
248+ if !covered[r] {
249+ return false
250+ }
251+ }
252+ return true
253+}
254+
255+// hostMatchesCert checks if any name in the given list matches host,
256+// including wildcard matching.
257+func hostMatchesCert(names []string, host string) bool {
258+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
259+ if host == "" {
260+ return false
261+ }
262+ for _, name := range names {
263+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
264+ if name == host {
265+ return true
266+ }
267+ if strings.HasPrefix(name, "*.") {
268+ suffix := strings.TrimPrefix(name, "*.")
269+ if strings.HasSuffix(host, "."+suffix) {
270+ left := strings.TrimSuffix(host, "."+suffix)
271+ if left != "" && !strings.Contains(left, ".") {
272+ return true
273+ }
274+ }
275+ }
276+ }
277+ return false
278+}
279+
280+// GetCertificate returns a TLS certificate for the given ClientHello.
281+//
282+// If the SNI host does not require ACME coverage, returns nil, nil.
283+// If ACME is required but no cert covers the host, returns
284+// ErrTLSRouteNotCovered.
285+// If a valid cert is found, returns a pointer to it.
286+func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
287+ host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hello.ServerName)), ".")
288+ if host == "" {
289+ return nil, nil
290+ }
291+
292+ s.mu.Lock()
293+ defer s.mu.Unlock()
294+
295+ // Check if this host requires ACME coverage.
296+ if !s.isRequiredHostLocked(host) {
297+ return nil, nil
298+ }
299+
300+ // Look for a valid non-expired cached certificate covering this host.
301+ now := time.Now()
302+ for _, cert := range s.certs {
303+ if cert.NotAfter.IsZero() || now.After(cert.NotAfter) {
304+ continue
305+ }
306+ if hostMatchesCert(cert.Names, host) {
307+ return &cert.Certificate, nil
308+ }
309+ }
310+
311+ return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host)
312+}
313+
314+// isRequiredHostLocked checks if host or any wildcard covering it is in
315+// the required hosts set. Must be called with s.mu held.
316+func (s *Service) isRequiredHostLocked(host string) bool {
317+ if _, ok := s.requiredHosts[host]; ok {
318+ return true
319+ }
320+ // Check wildcard: split at the first dot.
321+ parts := strings.SplitN(host, ".", 2)
322+ if len(parts) == 2 {
323+ if _, ok := s.requiredHosts["*."+parts[1]]; ok {
324+ return true
325+ }
326+ }
327+ return false
328+}
329+
330+// GetHTTP01Challenge delegates to the HTTP-01 challenge store.
331+func (s *Service) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
332+ return s.deps.Challenges.Get(ctx, token)
333+}
334+
335+// Stop gracefully stops the service. If a renewal loop is running, it is
336+// cancelled and Stop waits for it to finish (subject to ctx deadline).
337+func (s *Service) Stop(ctx context.Context) error {
338+ s.mu.Lock()
339+ cancel := s.cancel
340+ done := s.done
341+ s.cancel = nil
342+ s.done = nil
343+ s.mu.Unlock()
344+
345+ if cancel != nil {
346+ cancel()
347+ }
348+ if done != nil {
349+ select {
350+ case <-done:
351+ case <-ctx.Done():
352+ return ctx.Err()
353+ }
354+ }
355+ return nil
356+}
A internal/usecase/publictls/service_test.go +580 -0
@@ -0,0 +1,580 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "crypto/rsa"
7+ "crypto/tls"
8+ "crypto/x509"
9+ "crypto/x509/pkix"
10+ "encoding/pem"
11+ "fmt"
12+ "math/big"
13+ "sync"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/gordon/internal/boundaries/out"
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+// ---------------------------------------------------------------------------
25+// Fakes
26+// ---------------------------------------------------------------------------
27+
28+// fakeRoutes implements RouteSource for testing.
29+type fakeRoutes struct {
30+ mu sync.Mutex
31+ routes []domain.Route
32+ external map[string]string
33+}
34+
35+func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route {
36+ f.mu.Lock()
37+ defer f.mu.Unlock()
38+ out := make([]domain.Route, len(f.routes))
39+ copy(out, f.routes)
40+ return out
41+}
42+
43+func (f *fakeRoutes) GetExternalRoutes() map[string]string {
44+ f.mu.Lock()
45+ defer f.mu.Unlock()
46+ out := make(map[string]string, len(f.external))
47+ for k, v := range f.external {
48+ out[k] = v
49+ }
50+ return out
51+}
52+
53+// fakeIssuer implements out.PublicCertificateIssuer for testing.
54+type fakeIssuer struct {
55+ mu sync.Mutex
56+ orders []out.CertificateOrder // recorded orders
57+ obtain func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)
58+ renewFn func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)
59+}
60+
61+func (f *fakeIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
62+ f.mu.Lock()
63+ f.orders = append(f.orders, order)
64+ f.mu.Unlock()
65+ if f.obtain != nil {
66+ return f.obtain(ctx, order)
67+ }
68+ // Default: return a valid certificate.
69+ return defaultStoredCert(order)
70+}
71+
72+func (f *fakeIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
73+ if f.renewFn != nil {
74+ return f.renewFn(ctx, cert)
75+ }
76+ return &cert, nil
77+}
78+
79+// defaultStoredCert creates a StoredCertificate with a self-signed TLS cert
80+// for the given order.
81+func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, error) {
82+ certPEM, keyPEM, err := generateTestCertPEM(order.Names)
83+ if err != nil {
84+ return nil, err
85+ }
86+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
87+ if err != nil {
88+ return nil, err
89+ }
90+ return &out.StoredCertificate{
91+ ID: order.ID,
92+ Names: order.Names,
93+ Challenge: order.Challenge,
94+ Certificate: tlsCert,
95+ FullchainPEM: certPEM,
96+ PrivateKeyPEM: keyPEM,
97+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
98+ }, nil
99+}
100+
101+// fakeStore implements out.CertificateStore for testing.
102+type fakeStore struct {
103+ mu sync.Mutex
104+ lockMu sync.Mutex // separate lock for Lock()/unlock to avoid deadlock with mu
105+ account *out.ACMEAccount
106+ certs []out.StoredCertificate
107+ lockHold bool
108+}
109+
110+func (f *fakeStore) LoadAccount(_ context.Context) (*out.ACMEAccount, error) {
111+ f.mu.Lock()
112+ defer f.mu.Unlock()
113+ if f.account == nil {
114+ return nil, nil
115+ }
116+ acct := *f.account
117+ return &acct, nil
118+}
119+
120+func (f *fakeStore) SaveAccount(_ context.Context, account out.ACMEAccount) error {
121+ f.mu.Lock()
122+ defer f.mu.Unlock()
123+ acct := account
124+ f.account = &acct
125+ return nil
126+}
127+
128+func (f *fakeStore) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
129+ f.mu.Lock()
130+ defer f.mu.Unlock()
131+ out := make([]out.StoredCertificate, len(f.certs))
132+ copy(out, f.certs)
133+ return out, nil
134+}
135+
136+func (f *fakeStore) Save(_ context.Context, cert out.StoredCertificate) error {
137+ f.mu.Lock()
138+ defer f.mu.Unlock()
139+ for i, c := range f.certs {
140+ if c.ID == cert.ID {
141+ f.certs[i] = cert
142+ return nil
143+ }
144+ }
145+ f.certs = append(f.certs, cert)
146+ return nil
147+}
148+
149+func (f *fakeStore) Lock(_ context.Context) (func() error, error) {
150+ f.lockMu.Lock()
151+ f.lockHold = true
152+ return func() error {
153+ f.lockHold = false
154+ f.lockMu.Unlock()
155+ return nil
156+ }, nil
157+}
158+
159+// ---------------------------------------------------------------------------
160+// Test certificate helpers
161+// ---------------------------------------------------------------------------
162+
163+// generateTestCertPEM generates a self-signed certificate for the given names.
164+// Returns PEM-encoded fullchain and private key.
165+func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err error) {
166+ if len(names) == 0 {
167+ names = []string{"localhost"}
168+ }
169+
170+ key, err := rsa.GenerateKey(rand.Reader, 2048)
171+ if err != nil {
172+ return nil, nil, fmt.Errorf("generate key: %w", err)
173+ }
174+
175+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
176+ if err != nil {
177+ return nil, nil, fmt.Errorf("generate serial: %w", err)
178+ }
179+
180+ now := time.Now()
181+ tmpl := &x509.Certificate{
182+ SerialNumber: serial,
183+ Subject: pkix.Name{
184+ CommonName: names[0],
185+ },
186+ NotBefore: now.Add(-1 * time.Hour),
187+ NotAfter: now.Add(90 * 24 * time.Hour),
188+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
189+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
190+ BasicConstraintsValid: true,
191+ DNSNames: names,
192+ }
193+
194+ certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
195+ if err != nil {
196+ return nil, nil, fmt.Errorf("create certificate: %w", err)
197+ }
198+
199+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
200+ if certPEM == nil {
201+ return nil, nil, fmt.Errorf("encode cert PEM")
202+ }
203+
204+ keyDER := x509.MarshalPKCS1PrivateKey(key)
205+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
206+ if keyPEM == nil {
207+ return nil, nil, fmt.Errorf("encode key PEM")
208+ }
209+
210+ return certPEM, keyPEM, nil
211+}
212+
213+// ---------------------------------------------------------------------------
214+// Tests
215+// ---------------------------------------------------------------------------
216+
217+func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
218+ ctx := context.Background()
219+
220+ routes := &fakeRoutes{
221+ routes: []domain.Route{
222+ {Domain: "app.example.com"},
223+ },
224+ }
225+ issuer := &fakeIssuer{}
226+ store := &fakeStore{}
227+
228+ cfg := Config{
229+ Enabled: true,
230+ Email: "admin@example.com",
231+ Challenge: "http-01",
232+ HTTPPort: 8088,
233+ TLSPort: 8443,
234+ }
235+
236+ svc := NewService(cfg, ServiceDeps{
237+ Config: cfg,
238+ Routes: routes,
239+ Issuer: issuer,
240+ Store: store,
241+ Challenges: NewHTTP01Challenges(),
242+ Effective: EffectiveChallenge{
243+ ConfiguredMode: domain.ACMEChallengeHTTP01,
244+ Mode: domain.ACMEChallengeHTTP01,
245+ TokenSource: domain.ACMETokenSourceNone,
246+ Reason: "http-01 challenge selected",
247+ },
248+ })
249+
250+ // Load (nothing stored yet).
251+ err := svc.Load(ctx)
252+ require.NoError(t, err)
253+
254+ // Reconcile — should obtain one certificate for app.example.com.
255+ err = svc.Reconcile(ctx)
256+ require.NoError(t, err)
257+
258+ // Verify the issuer received exactly one order.
259+ issuer.mu.Lock()
260+ orders := make([]out.CertificateOrder, len(issuer.orders))
261+ copy(orders, issuer.orders)
262+ issuer.mu.Unlock()
263+
264+ require.Len(t, orders, 1, "expected one order to be placed")
265+ assert.Equal(t, "http01-app.example.com", orders[0].ID)
266+ assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
267+ assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
268+
269+ // Verify the store has the saved certificate.
270+ stored, err := store.LoadAll(ctx)
271+ require.NoError(t, err)
272+ require.Len(t, stored, 1)
273+ assert.Equal(t, "http01-app.example.com", stored[0].ID)
274+ assert.Equal(t, []string{"app.example.com"}, stored[0].Names)
275+ assert.False(t, stored[0].NotAfter.IsZero())
276+ assert.NotEmpty(t, stored[0].Certificate.Certificate)
277+}
278+
279+func TestServiceStatusReportsCoverage(t *testing.T) {
280+ ctx := context.Background()
281+
282+ // Pre-populate the store with a valid certificate.
283+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
284+ require.NoError(t, err)
285+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
286+ require.NoError(t, err)
287+
288+ store := &fakeStore{
289+ certs: []out.StoredCertificate{
290+ {
291+ ID: "http01-app.example.com",
292+ Names: []string{"app.example.com"},
293+ Challenge: domain.ACMEChallengeHTTP01,
294+ Certificate: tlsCert,
295+ FullchainPEM: certPEM,
296+ PrivateKeyPEM: keyPEM,
297+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
298+ },
299+ },
300+ }
301+
302+ routes := &fakeRoutes{
303+ routes: []domain.Route{
304+ {Domain: "app.example.com"},
305+ },
306+ }
307+ cfg := Config{
308+ Enabled: true,
309+ Email: "admin@example.com",
310+ Challenge: "http-01",
311+ HTTPPort: 8088,
312+ TLSPort: 8443,
313+ }
314+
315+ svc := NewService(cfg, ServiceDeps{
316+ Config: cfg,
317+ Routes: routes,
318+ Store: store,
319+ Effective: EffectiveChallenge{
320+ ConfiguredMode: domain.ACMEChallengeHTTP01,
321+ Mode: domain.ACMEChallengeHTTP01,
322+ TokenSource: domain.ACMETokenSourceNone,
323+ Reason: "http-01 challenge selected",
324+ },
325+ })
326+
327+ // Load the cert into the cache.
328+ err = svc.Load(ctx)
329+ require.NoError(t, err)
330+
331+ // Get status.
332+ status := svc.Status(ctx)
333+
334+ assert.True(t, status.ACMEEnabled)
335+ assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode)
336+ assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode)
337+
338+ require.Len(t, status.Certificates, 1, "expected one managed certificate")
339+ assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID)
340+ assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status)
341+
342+ require.Len(t, status.Routes, 1, "expected one route coverage")
343+ assert.Equal(t, "app.example.com", status.Routes[0].Domain)
344+ assert.True(t, status.Routes[0].Covered, "route should be covered")
345+ assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
346+ assert.True(t, status.Routes[0].RequiredACME)
347+}
348+
349+func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
350+ ctx := context.Background()
351+
352+ routes := &fakeRoutes{
353+ routes: []domain.Route{
354+ {Domain: "app.example.com"},
355+ },
356+ }
357+ issuer := &fakeIssuer{
358+ // Make Obtain fail so no cert is cached.
359+ obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
360+ return nil, fmt.Errorf("acme provider unavailable")
361+ },
362+ }
363+ store := &fakeStore{}
364+
365+ cfg := Config{
366+ Enabled: true,
367+ Email: "admin@example.com",
368+ Challenge: "http-01",
369+ HTTPPort: 8088,
370+ TLSPort: 8443,
371+ }
372+
373+ svc := NewService(cfg, ServiceDeps{
374+ Config: cfg,
375+ Routes: routes,
376+ Issuer: issuer,
377+ Store: store,
378+ Challenges: NewHTTP01Challenges(),
379+ Effective: EffectiveChallenge{
380+ ConfiguredMode: domain.ACMEChallengeHTTP01,
381+ Mode: domain.ACMEChallengeHTTP01,
382+ TokenSource: domain.ACMETokenSourceNone,
383+ Reason: "http-01 challenge selected",
384+ },
385+ })
386+
387+ // Load (no certs stored).
388+ err := svc.Load(ctx)
389+ require.NoError(t, err)
390+
391+ // Reconcile — issuer fails, no cert cached but requiredHosts is set.
392+ err = svc.Reconcile(ctx)
393+ require.NoError(t, err) // Reconcile swallows per-target obtain errors
394+
395+ // GetCertificate for app.example.com should return ErrTLSRouteNotCovered.
396+ _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
397+ require.Error(t, err)
398+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
399+ assert.Contains(t, err.Error(), "app.example.com")
400+}
401+
402+// brokenZoneResolver always returns an error.
403+type brokenZoneResolver struct{}
404+
405+func (brokenZoneResolver) FindZone(_ context.Context, host string) (out.CloudflareZone, error) {
406+ return out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for %s", host)
407+}
408+
409+func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
410+ ctx := context.Background()
411+
412+ routes := &fakeRoutes{
413+ routes: []domain.Route{
414+ {Domain: "app.example.com"},
415+ },
416+ }
417+ issuer := &fakeIssuer{}
418+ store := &fakeStore{}
419+
420+ cfg := Config{
421+ Enabled: true,
422+ Email: "admin@example.com",
423+ Challenge: "dns-01",
424+ HTTPPort: 8088,
425+ TLSPort: 8443,
426+ }
427+
428+ svc := NewService(cfg, ServiceDeps{
429+ Config: cfg,
430+ Routes: routes,
431+ Issuer: issuer,
432+ Store: store,
433+ ZoneResolver: brokenZoneResolver{}, // broken: always fails
434+ Challenges: NewHTTP01Challenges(),
435+ Effective: EffectiveChallenge{
436+ ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
437+ Mode: domain.ACMEChallengeCloudflareDNS01,
438+ TokenSource: domain.ACMETokenSourceNone,
439+ Reason: "dns-01 challenge selected",
440+ },
441+ })
442+
443+ // Load (nothing stored).
444+ err := svc.Load(ctx)
445+ require.NoError(t, err)
446+
447+ // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails.
448+ err = svc.Reconcile(ctx)
449+ require.Error(t, err)
450+ assert.Contains(t, err.Error(), "derive certificate targets")
451+
452+ // GetCertificate for the configured route should return ErrTLSRouteNotCovered,
453+ // not nil,nil, because requiredHosts was set from route hosts before
454+ // the failed DeriveCertificateTargets call.
455+ _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
456+ require.Error(t, err)
457+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
458+ assert.Contains(t, err.Error(), "app.example.com")
459+}
460+
461+func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
462+ ctx := context.Background()
463+
464+ // Generate valid PEM cert data.
465+ certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"})
466+ require.NoError(t, err)
467+
468+ // Store a cert with PEM data but empty tls.Certificate.
469+ store := &fakeStore{
470+ certs: []out.StoredCertificate{
471+ {
472+ ID: "http01-test.example.com",
473+ Names: []string{"test.example.com"},
474+ Challenge: domain.ACMEChallengeHTTP01,
475+ FullchainPEM: certPEM,
476+ PrivateKeyPEM: keyPEM,
477+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
478+ // Certificate field is zero — will be populated by Load.
479+ },
480+ },
481+ }
482+
483+ routes := &fakeRoutes{}
484+ cfg := Config{Enabled: true}
485+
486+ svc := NewService(cfg, ServiceDeps{
487+ Config: cfg,
488+ Routes: routes,
489+ Store: store,
490+ })
491+
492+ // Load — should parse PEM into tls.Certificate.
493+ err = svc.Load(ctx)
494+ require.NoError(t, err)
495+
496+ // Check that the cached cert has a valid tls.Certificate.
497+ //nolint:unused // accessing the cert through the map is sufficient
498+ svc.mu.Lock()
499+ cached, ok := svc.certs["http01-test.example.com"]
500+ svc.mu.Unlock()
501+
502+ require.True(t, ok, "cert should be cached")
503+ require.NotNil(t, cached)
504+ assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated")
505+ assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated")
506+
507+ // Verify it's a valid TLS cert by performing a parse round-trip.
508+ leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0])
509+ require.NoError(t, err)
510+ assert.Contains(t, leaf.DNSNames, "test.example.com")
511+}
512+
513+func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
514+ ctx := context.Background()
515+
516+ // Pre-populate the store with a certificate that has a LastError
517+ // containing sensitive data.
518+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
519+ require.NoError(t, err)
520+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
521+ require.NoError(t, err)
522+
523+ store := &fakeStore{
524+ certs: []out.StoredCertificate{
525+ {
526+ ID: "http01-app.example.com",
527+ Names: []string{"app.example.com"},
528+ Challenge: domain.ACMEChallengeHTTP01,
529+ Certificate: tlsCert,
530+ FullchainPEM: certPEM,
531+ PrivateKeyPEM: keyPEM,
532+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
533+ LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
534+ },
535+ },
536+ }
537+
538+ routes := &fakeRoutes{
539+ routes: []domain.Route{
540+ {Domain: "app.example.com"},
541+ },
542+ }
543+ cfg := Config{
544+ Enabled: true,
545+ Email: "admin@example.com",
546+ Challenge: "http-01",
547+ HTTPPort: 8088,
548+ TLSPort: 8443,
549+ }
550+
551+ svc := NewService(cfg, ServiceDeps{
552+ Config: cfg,
553+ Routes: routes,
554+ Store: store,
555+ Effective: EffectiveChallenge{
556+ ConfiguredMode: domain.ACMEChallengeHTTP01,
557+ Mode: domain.ACMEChallengeHTTP01,
558+ TokenSource: domain.ACMETokenSourceNone,
559+ Reason: "http-01 challenge selected",
560+ },
561+ })
562+
563+ // Load the cert into the cache.
564+ err = svc.Load(ctx)
565+ require.NoError(t, err)
566+
567+ // Get status.
568+ status := svc.Status(ctx)
569+
570+ require.Len(t, status.Certificates, 1)
571+ lastErr := status.Certificates[0].LastError
572+ t.Logf("sanitized LastError: %q", lastErr)
573+
574+ // The original error contained "token=sk-secret-goes-here".
575+ // After sanitization, the secret value should be redacted.
576+ assert.NotContains(t, lastErr, "sk-secret-goes-here",
577+ "LastError must not contain the raw secret value")
578+ assert.Contains(t, lastErr, "token=redacted",
579+ "LastError should contain token=redacted")
580+}
A internal/usecase/publictls/status.go +203 -0
@@ -0,0 +1,203 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "sort"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// Status returns the current public TLS status.
13+func (s *Service) Status(ctx context.Context) domain.PublicTLSStatus {
14+ // Collect route domains outside the lock so route source methods
15+ // (GetRoutes/GetExternalRoutes) are not called while holding s.mu.
16+ routeDomains := collectRouteDomains(ctx, s.deps.Routes)
17+
18+ s.mu.Lock()
19+ defer s.mu.Unlock()
20+
21+ status := domain.PublicTLSStatus{
22+ ACMEEnabled: s.cfg.Enabled,
23+ }
24+
25+ if s.deps.Effective.Mode != "" {
26+ status.ConfiguredMode = s.deps.Effective.ConfiguredMode
27+ status.EffectiveMode = s.deps.Effective.Mode
28+ status.SelectionReason = s.deps.Effective.Reason
29+ status.TokenSource = s.deps.Effective.TokenSource
30+ }
31+
32+ status.Certificates = s.buildManagedCertificatesLocked()
33+ status.Routes = s.buildRouteCoverageLocked(routeDomains, status.Certificates)
34+ return status
35+}
36+
37+// buildManagedCertificatesLocked returns sorted managed certificates.
38+// Must be called with s.mu held.
39+func (s *Service) buildManagedCertificatesLocked() []domain.ManagedCertificate {
40+ certs := make([]domain.ManagedCertificate, 0, len(s.certs))
41+ now := time.Now()
42+ for _, cert := range s.certs {
43+ lastErr := cert.LastError
44+ if e, ok := s.lastErr[cert.ID]; ok && e != "" {
45+ lastErr = e
46+ }
47+ mc := domain.ManagedCertificate{
48+ ID: cert.ID,
49+ Names: cert.Names,
50+ Challenge: cert.Challenge,
51+ NotAfter: cert.NotAfter,
52+ LastError: sanitizeError(lastErr),
53+ }
54+ mc.Status = mc.Health(now)
55+ certs = append(certs, mc)
56+ }
57+ sort.Slice(certs, func(i, j int) bool {
58+ return certs[i].ID < certs[j].ID
59+ })
60+ return certs
61+}
62+
63+// buildRouteCoverageLocked returns route coverage for the given domains.
64+// Must be called with s.mu held.
65+func (s *Service) buildRouteCoverageLocked(routeDomains []string, managedCerts []domain.ManagedCertificate) []domain.TLSRouteCoverage {
66+ sort.Strings(routeDomains)
67+
68+ now := time.Now()
69+ routes := make([]domain.TLSRouteCoverage, 0, len(routeDomains))
70+ for _, d := range routeDomains {
71+ coverage := domain.TLSRouteCoverage{
72+ Domain: d,
73+ RequiredACME: s.cfg.Enabled,
74+ }
75+ coverage.Covered, coverage.CoveredBy = s.findCoverageLocked(d, managedCerts, now)
76+ if err, hasErr := s.routeErr[d]; hasErr {
77+ coverage.Error = sanitizeError(err)
78+ }
79+ routes = append(routes, coverage)
80+ }
81+ return routes
82+}
83+
84+// collectRouteDomains returns all unique canonical route domains.
85+// Does not hold s.mu so route source methods may be called safely.
86+func collectRouteDomains(ctx context.Context, routes RouteSource) []string {
87+ routeSet := make(map[string]struct{})
88+ var domains []string
89+
90+ for _, r := range routes.GetRoutes(ctx) {
91+ canonical, ok := domain.CanonicalRouteDomain(r.Domain)
92+ if ok {
93+ if _, exists := routeSet[canonical]; !exists {
94+ routeSet[canonical] = struct{}{}
95+ domains = append(domains, canonical)
96+ }
97+ }
98+ }
99+ for h := range routes.GetExternalRoutes() {
100+ canonical, ok := domain.CanonicalRouteDomain(h)
101+ if ok {
102+ if _, exists := routeSet[canonical]; !exists {
103+ routeSet[canonical] = struct{}{}
104+ domains = append(domains, canonical)
105+ }
106+ }
107+ }
108+ return domains
109+}
110+
111+// findCoverageLocked checks if any managed certificate covers the given domain.
112+// Must be called with s.mu held.
113+func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.ManagedCertificate, now time.Time) (bool, string) {
114+ for _, mc := range managedCerts {
115+ if !mc.Covers(routeDomain) {
116+ continue
117+ }
118+ switch mc.Status {
119+ case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing, domain.TLSCertificateStatusError:
120+ continue
121+ }
122+ // Double-check expiry from the raw stored cert.
123+ if sc, ok := s.certs[mc.ID]; ok {
124+ if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {
125+ continue
126+ }
127+ }
128+ return true, mc.ID
129+ }
130+ return false, ""
131+}
132+
133+// sanitizeError removes obvious sensitive strings from error messages.
134+// At minimum it ensures that the literal "secret" and patterns like
135+// "token=secret" are not present in the output.
136+func sanitizeError(err string) string {
137+ if err == "" {
138+ return ""
139+ }
140+
141+ result := err
142+
143+ // Remove key=value patterns for well-known sensitive keys.
144+ result = sanitizeKeyEqValue(result, "token")
145+ result = sanitizeKeyEqValue(result, "secret")
146+ result = sanitizeKeyEqValue(result, "key")
147+ result = sanitizeKeyEqValue(result, "password")
148+ result = sanitizeKeyEqValue(result, "pass")
149+ result = sanitizeKeyEqValue(result, "api_key")
150+ result = sanitizeKeyEqValue(result, "apikey")
151+ result = sanitizeKeyEqValue(result, "auth")
152+
153+ // Replace any remaining occurrences of "secret" (case-insensitive).
154+ lower := strings.ToLower(result)
155+ if strings.Contains(lower, "secret") {
156+ result = "redacted"
157+ return result
158+ }
159+
160+ if result == "" {
161+ return "certificate error"
162+ }
163+ return result
164+}
165+
166+// sanitizeKeyEqValue removes patterns like "key=value" from the string.
167+// It replaces the value portion with "redacted".
168+func sanitizeKeyEqValue(s, key string) string {
169+ prefix := key + "="
170+ lowerPrefix := strings.ToLower(prefix)
171+ result := s
172+ pos := 0
173+
174+ for {
175+ lowerResult := strings.ToLower(result)
176+ if pos >= len(lowerResult) {
177+ break
178+ }
179+ idx := strings.Index(lowerResult[pos:], lowerPrefix)
180+ if idx == -1 {
181+ break
182+ }
183+ idx += pos // make absolute
184+
185+ // Find end of value (space, newline, or end of string).
186+ valStart := idx + len(prefix)
187+ rest := result[valStart:]
188+ end := strings.IndexAny(rest, " \n")
189+
190+ replacement := key + "=redacted"
191+ var after string
192+ if end == -1 {
193+ after = ""
194+ } else {
195+ after = rest[end:]
196+ }
197+ result = result[:idx] + replacement + after
198+
199+ // Advance search position past the replacement to avoid re-matching.
200+ pos = idx + len(replacement)
201+ }
202+ return result
203+}
A internal/usecase/publictls/targets.go +144 -0
@@ -0,0 +1,144 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/boundaries/out"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// CertificateTarget represents a single certificate to obtain or renew.
14+type CertificateTarget struct {
15+ ID string
16+ Names []string
17+ Challenge domain.ACMEChallengeMode
18+}
19+
20+// zoneResolver resolves a hostname to its Cloudflare DNS zone.
21+type zoneResolver interface {
22+ FindZone(ctx context.Context, host string) (out.CloudflareZone, error)
23+}
24+
25+// DeriveCertificateTargets derives certificate targets from routes and external
26+// routes based on the given ACME challenge mode.
27+func DeriveCertificateTargets(
28+ ctx context.Context,
29+ mode domain.ACMEChallengeMode,
30+ routes []domain.Route,
31+ external map[string]string,
32+ resolver zoneResolver,
33+) ([]CertificateTarget, error) {
34+ hosts := routeHosts(routes, external)
35+
36+ switch mode {
37+ case domain.ACMEChallengeHTTP01:
38+ return deriveHTTP01Targets(hosts), nil
39+ case domain.ACMEChallengeCloudflareDNS01:
40+ return deriveDNS01Targets(ctx, hosts, resolver)
41+ default:
42+ return nil, fmt.Errorf("unsupported challenge mode: %s", mode)
43+ }
44+}
45+
46+// routeHosts collects all unique canonical hosts from routes and external keys,
47+// sorted alphabetically. Trailing dots are stripped before canonicalization.
48+func routeHosts(routes []domain.Route, external map[string]string) []string {
49+ seen := make(map[string]struct{})
50+ var hosts []string
51+
52+ addHost := func(raw string) {
53+ raw = strings.TrimSuffix(raw, ".")
54+ canonical, ok := domain.CanonicalRouteDomain(raw)
55+ if !ok {
56+ return
57+ }
58+ if _, exists := seen[canonical]; !exists {
59+ seen[canonical] = struct{}{}
60+ hosts = append(hosts, canonical)
61+ }
62+ }
63+
64+ for _, r := range routes {
65+ addHost(r.Domain)
66+ }
67+ for h := range external {
68+ addHost(h)
69+ }
70+
71+ sort.Strings(hosts)
72+ return hosts
73+}
74+
75+// deriveHTTP01Targets creates one target per host for HTTP-01 challenge.
76+func deriveHTTP01Targets(hosts []string) []CertificateTarget {
77+ targets := make([]CertificateTarget, len(hosts))
78+ for i, host := range hosts {
79+ targets[i] = CertificateTarget{
80+ ID: "http01-" + host,
81+ Names: []string{host},
82+ Challenge: domain.ACMEChallengeHTTP01,
83+ }
84+ }
85+ return targets
86+}
87+
88+// deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.
89+func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolver) ([]CertificateTarget, error) {
90+ if resolver == nil {
91+ return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")
92+ }
93+ baseSeen := make(map[string]struct{})
94+ var bases []string
95+
96+ for _, host := range hosts {
97+ zone, err := resolver.FindZone(ctx, host)
98+ if err != nil {
99+ return nil, fmt.Errorf("find zone for %s: %w", host, err)
100+ }
101+ base := wildcardBase(host, zone.Name)
102+ if _, exists := baseSeen[base]; !exists {
103+ baseSeen[base] = struct{}{}
104+ bases = append(bases, base)
105+ }
106+ }
107+
108+ sort.Strings(bases)
109+
110+ targets := make([]CertificateTarget, len(bases))
111+ for i, base := range bases {
112+ targets[i] = CertificateTarget{
113+ ID: "dns01-" + base,
114+ Names: []string{base, "*." + base},
115+ Challenge: domain.ACMEChallengeCloudflareDNS01,
116+ }
117+ }
118+ return targets, nil
119+}
120+
121+// wildcardBase determines the wildcard base for a host within a zone.
122+//
123+// If host == zone, returns zone.
124+// If host is a direct child of zone, returns zone.
125+// If host is deeper (e.g. api.prod.example.com within example.com),
126+// returns the parent label under the zone (prod.example.com).
127+func wildcardBase(host, zone string) string {
128+ if host == zone {
129+ return zone
130+ }
131+
132+ // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.
133+ trimmed := strings.TrimSuffix(host, "."+zone)
134+
135+ if !strings.Contains(trimmed, ".") {
136+ // Direct child of the zone (e.g. app.example.com).
137+ return zone
138+ }
139+
140+ // Deeper than direct child: extract the parent under the zone.
141+ // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"
142+ _, parent, _ := strings.Cut(trimmed, ".")
143+ return parent + "." + zone
144+}
A internal/usecase/publictls/targets_test.go +109 -0
@@ -0,0 +1,109 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// fixedZoneResolver returns a fixed zone name for all lookups.
15+type fixedZoneResolver struct {
16+ zone string
17+}
18+
19+func (f fixedZoneResolver) FindZone(_ context.Context, _ string) (out.CloudflareZone, error) {
20+ return out.CloudflareZone{Name: f.zone}, nil
21+}
22+
23+func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {
24+ routes := []domain.Route{
25+ {Domain: "app.example.com"},
26+ {Domain: "registry.example.com"},
27+ }
28+ mode := domain.ACMEChallengeHTTP01
29+
30+ targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
31+ require.NoError(t, err)
32+ require.Len(t, targets, 2)
33+
34+ assert.Equal(t, "http01-app.example.com", targets[0].ID)
35+ assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
36+ assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge)
37+
38+ assert.Equal(t, "http01-registry.example.com", targets[1].ID)
39+ assert.Equal(t, []string{"registry.example.com"}, targets[1].Names)
40+ assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge)
41+}
42+
43+func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
44+ routes := []domain.Route{
45+ {Domain: "app.example.com"},
46+ {Domain: "api.prod.example.com"},
47+ {Domain: "example.com"},
48+ }
49+ mode := domain.ACMEChallengeCloudflareDNS01
50+ resolver := fixedZoneResolver{zone: "example.com"}
51+
52+ targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, resolver)
53+ require.NoError(t, err)
54+ require.Len(t, targets, 2)
55+
56+ // First target: example.com (includes base and wildcard)
57+ assert.Equal(t, "dns01-example.com", targets[0].ID)
58+ assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names)
59+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge)
60+
61+ // Second target: prod.example.com (includes base and wildcard)
62+ assert.Equal(t, "dns01-prod.example.com", targets[1].ID)
63+ assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names)
64+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)
65+}
66+
67+func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
68+ routes := []domain.Route{
69+ {Domain: "app.example.com"},
70+ }
71+ external := map[string]string{
72+ "external.example.com": "127.0.0.1:8080",
73+ }
74+ mode := domain.ACMEChallengeHTTP01
75+
76+ targets, err := DeriveCertificateTargets(context.Background(), mode, routes, external, nil)
77+ require.NoError(t, err)
78+ require.Len(t, targets, 2)
79+
80+ assert.Equal(t, "http01-app.example.com", targets[0].ID)
81+ assert.Equal(t, "http01-external.example.com", targets[1].ID)
82+ assert.Equal(t, []string{"external.example.com"}, targets[1].Names)
83+}
84+
85+func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {
86+ routes := []domain.Route{
87+ {Domain: "app.example.com"},
88+ }
89+ mode := domain.ACMEChallengeCloudflareDNS01
90+
91+ _, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
92+ require.Error(t, err)
93+ assert.Contains(t, err.Error(), "resolver is nil")
94+}
95+
96+func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
97+ routes := []domain.Route{
98+ {Domain: "App.Example.Com"},
99+ {Domain: "app.example.com"},
100+ }
101+ mode := domain.ACMEChallengeHTTP01
102+
103+ targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
104+ require.NoError(t, err)
105+ require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")
106+
107+ assert.Equal(t, "http01-app.example.com", targets[0].ID)
108+ assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
109+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close