detached 0c0cc56

Merge pull request #182 from bnema/feature/public-acme-tls

Add public ACME TLS support
commit
0c0cc561849f00f3f39b4a495b3be54bc31a6065
author
bnema · · 2026-04-30 04:37 UTC
committer
GitHub · · 2026-04-30 04:37 UTC
merge of
574f9a2 + 67091c0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev11
65 files · +8644 -73
M .mockery.yaml +6 -0
@@ -25,6 +25,11 @@ packages:
2525 RateLimiter:
2626 BackupStorage:
2727 RouteChecker:
28+ HTTPChallengeSink:
29+ PublicCertificateIssuer:
30+ CertificateStore:
31+ SecretResolver:
32+ CloudflareZoneResolver:
2833 github.com/bnema/gordon/internal/boundaries/in:
2934 interfaces:
3035 ContainerService:
@@ -38,6 +43,7 @@ packages:
3843 SecretService:
3944 LogService:
4045 VolumeService:
46+ PublicTLSService:
4147 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
4248 # Mocked here because it abstracts Docker SDK calls that require a running
4349 # daemon, making unit/integration tests impractical without a test double.
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+```text
38+Public TLS / ACME Status
39+
40+ACME: enabled
41+Configured Mode: auto
42+Effective Mode: http-01
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": "auto",
66+ "effective_mode": "http-01",
67+ "selection_reason": "configured",
68+ "token_source": "env",
69+ "certificates": [
70+ {
71+ "id": "cert-abc123",
72+ "names": ["example.com", "www.example.com"],
73+ "challenge": "http-01",
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 docs/concepts.md +1 -1
@@ -79,7 +79,7 @@ When a request comes in for `app.example.com`, Gordon:
7979
8080 ### Route Domains
8181
82-Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, or with static certificates. Gordon does not provide ACME or Let's Encrypt challenge automation.
82+Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, with static certificates, or with public ACME certificates when `[tls.acme]` is enabled. ACME supports HTTP-01 and Cloudflare DNS-01 challenge modes.
8383
8484 ```toml
8585 [routes]
M docs/config/reference.md +11 -0
@@ -33,6 +33,14 @@ token_secret = "" # Path in secrets backend to JWT si
3333 token_expiry = "720h" # Token expiry duration (720h = 30 days)
3434 access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m)
3535
36+# =============================================================================
37+# PUBLIC TLS / ACME
38+# =============================================================================
39+[tls.acme]
40+enabled = false # Enable public ACME certificates (requires server.tls_port > 0)
41+email = "" # ACME account email when enabled
42+challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01"
43+
3644 # =============================================================================
3745 # API (applies to both Registry and Admin endpoints)
3846 # =============================================================================
@@ -195,6 +203,9 @@ keep_last = 3 # Keep N newest tags per repository
195203 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
196204 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
197205 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
206+| `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) |
207+| `tls.acme.email` | `""` | ACME account email when enabled |
208+| `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
198209 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
199210 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
200211 | `auth.token_expiry` | `"720h"` | 30 days |
M docs/config/server.md +22 -1
@@ -84,12 +84,33 @@ tls_key_file = "/etc/gordon/tls/key.pem"
8484
8585 The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA.
8686
87+#### Public ACME certificates
88+
89+Gordon can obtain public certificates through Let's Encrypt-compatible ACME when `[tls.acme]` is enabled. The HTTPS listener must also be enabled (`server.tls_port > 0`).
90+
91+```toml
92+[server]
93+tls_port = 8443
94+
95+[tls.acme]
96+enabled = true
97+email = "admin@example.com"
98+challenge = "auto" # auto, http-01, or cloudflare-dns-01
99+```
100+
101+Challenge behavior:
102+- `http-01` serves `/.well-known/acme-challenge/<token>` on the HTTP proxy port.
103+- `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`.
104+- `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01.
105+
106+Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01.
107+
87108 #### Direct HTTP Onboarding
88109
89110 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
90111
91112 - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads
92-- `/.well-known/acme-challenge/*` — returns `404 Not Found` (reserved for future ACME support)
113+- `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`
93114 - All other paths return `403 Forbidden`
94115
95116 This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected.
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 generated file: collapsed
A internal/adapters/dto/tls.go +77 -0
@@ -0,0 +1,77 @@
1+package dto
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// TLSStatusResponse represents the public TLS/ACME status for the admin API.
10+type TLSStatusResponse struct {
11+ ACMEEnabled bool `json:"acme_enabled"`
12+ ConfiguredMode string `json:"configured_mode"`
13+ EffectiveMode string `json:"effective_mode"`
14+ SelectionReason string `json:"selection_reason"`
15+ TokenSource string `json:"token_source"`
16+ Certificates []TLSCertificateEntry `json:"certificates"`
17+ Routes []TLSRouteCoverage `json:"routes"`
18+ Errors []string `json:"errors,omitempty"`
19+}
20+
21+// TLSCertificateEntry represents a managed certificate entry in the TLS status.
22+type TLSCertificateEntry struct {
23+ ID string `json:"id"`
24+ Names []string `json:"names"`
25+ Challenge string `json:"challenge"`
26+ Status string `json:"status"`
27+ NotAfter time.Time `json:"not_after"`
28+ LastError string `json:"last_error,omitempty"`
29+ RenewalPending bool `json:"renewal_pending"`
30+}
31+
32+// TLSRouteCoverage represents a route's TLS coverage status.
33+type TLSRouteCoverage struct {
34+ Domain string `json:"domain"`
35+ Covered bool `json:"covered"`
36+ CoveredBy string `json:"covered_by,omitempty"`
37+ RequiredACME bool `json:"required_acme"`
38+ Error string `json:"error,omitempty"`
39+}
40+
41+// TLSStatusFromDomain converts a public TLS domain status to its transport DTO.
42+func TLSStatusFromDomain(s domain.PublicTLSStatus) TLSStatusResponse {
43+ certs := make([]TLSCertificateEntry, 0, len(s.Certificates))
44+ for _, c := range s.Certificates {
45+ certs = append(certs, TLSCertificateEntry{
46+ ID: c.ID,
47+ Names: c.Names,
48+ Challenge: string(c.Challenge),
49+ Status: string(c.Status),
50+ NotAfter: c.NotAfter,
51+ LastError: c.LastError,
52+ RenewalPending: c.RenewalPending,
53+ })
54+ }
55+
56+ routes := make([]TLSRouteCoverage, 0, len(s.Routes))
57+ for _, r := range s.Routes {
58+ routes = append(routes, TLSRouteCoverage{
59+ Domain: r.Domain,
60+ Covered: r.Covered,
61+ CoveredBy: r.CoveredBy,
62+ RequiredACME: r.RequiredACME,
63+ Error: r.Error,
64+ })
65+ }
66+
67+ return TLSStatusResponse{
68+ ACMEEnabled: s.ACMEEnabled,
69+ ConfiguredMode: string(s.ConfiguredMode),
70+ EffectiveMode: string(s.EffectiveMode),
71+ SelectionReason: s.SelectionReason,
72+ TokenSource: string(s.TokenSource),
73+ Certificates: certs,
74+ Routes: routes,
75+ Errors: s.Errors,
76+ }
77+}
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 +15 -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,19 @@ 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{
339+ ACMEEnabled: false,
340+ SelectionReason: "public TLS service not configured",
341+ }, nil
342+ }
343+
344+ status := l.publicTLSSvc.Status(ctx)
345+ result := dto.TLSStatusFromDomain(status)
346+ return &result, nil
347+}
348+
334349 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
335350 if l.configSvc == nil {
336351 return nil, fmt.Errorf("local config service unavailable")
M internal/adapters/in/cli/controlplane_local_test.go +13 -0
@@ -5,6 +5,7 @@ import (
55 "testing"
66 "time"
77
8+ "github.com/bnema/gordon/internal/adapters/dto"
89 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
910 "github.com/bnema/gordon/internal/domain"
1011 "github.com/stretchr/testify/mock"
@@ -95,6 +96,18 @@ func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
9596 require.Equal(t, "app.local", result.Domain)
9697 }
9798
99+func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {
100+ t.Parallel()
101+
102+ cp := &localControlPlane{}
103+ status, err := cp.GetTLSStatus(context.Background())
104+ require.NoError(t, err)
105+ require.Equal(t, &dto.TLSStatusResponse{
106+ ACMEEnabled: false,
107+ SelectionReason: "public TLS service not configured",
108+ }, status)
109+}
110+
98111 func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
99112 t.Parallel()
100113
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 +5 -1
@@ -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
@@ -383,7 +387,7 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro
383387 }
384388
385389 // showContainerLogsLocal shows container logs using local Docker access.
386-func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
390+func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error {
387391 // For local container logs, we need Docker access which requires
388392 // the runtime to be initialized. For now, suggest using remote mode
389393 // or direct docker logs command.
A internal/adapters/in/cli/tls.go +202 -0
@@ -0,0 +1,202 @@
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+ if status == nil {
56+ return fmt.Errorf("failed to get TLS status: empty response")
57+ }
58+
59+ if jsonOut {
60+ return writeJSON(out, status)
61+ }
62+
63+ return renderTLSStatusText(out, status)
64+}
65+
66+func renderTLSStatusText(out io.Writer, s *dto.TLSStatusResponse) error {
67+ if err := cliWriteLine(out, cliRenderTitle("Public TLS / ACME Status")); err != nil {
68+ return err
69+ }
70+
71+ if err := renderTLSModeSection(out, s); err != nil {
72+ return err
73+ }
74+
75+ if err := renderTLSCertificatesSection(out, s); err != nil {
76+ return err
77+ }
78+
79+ if err := renderTLSRouteCoverageSection(out, s); err != nil {
80+ return err
81+ }
82+
83+ return renderTLSErrorsSection(out, s)
84+}
85+
86+func renderTLSModeSection(out io.Writer, s *dto.TLSStatusResponse) error {
87+ acmeStatus := "disabled"
88+ if s.ACMEEnabled {
89+ acmeStatus = "enabled"
90+ }
91+ if err := cliWriteLine(out, cliRenderMeta("ACME:", acmeStatus)); err != nil {
92+ return err
93+ }
94+ if err := cliWriteLine(out, cliRenderMeta("Configured Mode:", s.ConfiguredMode)); err != nil {
95+ return err
96+ }
97+ if err := cliWriteLine(out, cliRenderMeta("Effective Mode:", s.EffectiveMode)); err != nil {
98+ return err
99+ }
100+ if s.SelectionReason != "" {
101+ if err := cliWriteLine(out, cliRenderMeta("Reason:", s.SelectionReason)); err != nil {
102+ return err
103+ }
104+ }
105+ return cliWriteLine(out, cliRenderMeta("Token Source:", s.TokenSource))
106+}
107+
108+func renderTLSCertificatesSection(out io.Writer, s *dto.TLSStatusResponse) error {
109+ if len(s.Certificates) == 0 {
110+ return nil
111+ }
112+
113+ if err := cliWriteLine(out, ""); err != nil {
114+ return err
115+ }
116+ if err := cliWriteLine(out, cliRenderTitle("Certificates")); err != nil {
117+ return err
118+ }
119+
120+ for _, cert := range s.Certificates {
121+ if err := cliWritef(out, " %s\n", cliRenderMeta("ID:", cert.ID)); err != nil {
122+ return err
123+ }
124+ if err := cliWriteLine(out, cliRenderMeta(" Names:", strings.Join(cert.Names, ", "))); err != nil {
125+ return err
126+ }
127+ if err := cliWriteLine(out, cliRenderMeta(" Status:", cert.Status)); err != nil {
128+ return err
129+ }
130+ if !cert.NotAfter.IsZero() {
131+ if err := cliWriteLine(out, cliRenderMeta(" Not After:", cert.NotAfter.Format(time.DateTime))); err != nil {
132+ return err
133+ }
134+ }
135+ if cert.LastError != "" {
136+ if err := cliWriteLine(out, cliRenderMeta(" Last Error:", cert.LastError)); err != nil {
137+ return err
138+ }
139+ }
140+ }
141+
142+ return nil
143+}
144+
145+func renderTLSRouteCoverageSection(out io.Writer, s *dto.TLSStatusResponse) error {
146+ if len(s.Routes) == 0 {
147+ return nil
148+ }
149+
150+ if err := cliWriteLine(out, ""); err != nil {
151+ return err
152+ }
153+ if err := cliWriteLine(out, cliRenderTitle("Route Coverage")); err != nil {
154+ return err
155+ }
156+
157+ for _, route := range s.Routes {
158+ covered := "no"
159+ if route.Covered {
160+ covered = "yes"
161+ }
162+ if err := cliWritef(out, " %s covered=%s", route.Domain, covered); err != nil {
163+ return err
164+ }
165+ if route.CoveredBy != "" {
166+ if err := cliWritef(out, " covered_by=%s", route.CoveredBy); err != nil {
167+ return err
168+ }
169+ }
170+ if route.Error != "" {
171+ if err := cliWritef(out, " error=%s", route.Error); err != nil {
172+ return err
173+ }
174+ }
175+ if err := cliWriteLine(out, ""); err != nil {
176+ return err
177+ }
178+ }
179+
180+ return nil
181+}
182+
183+func renderTLSErrorsSection(out io.Writer, s *dto.TLSStatusResponse) error {
184+ if len(s.Errors) == 0 {
185+ return nil
186+ }
187+
188+ if err := cliWriteLine(out, ""); err != nil {
189+ return err
190+ }
191+ if err := cliWriteLine(out, cliRenderTitle("Errors")); err != nil {
192+ return err
193+ }
194+
195+ for _, e := range s.Errors {
196+ if err := cliWriteLine(out, " "+e); err != nil {
197+ return err
198+ }
199+ }
200+
201+ return nil
202+}
A internal/adapters/in/cli/tls_test.go +305 -0
@@ -0,0 +1,305 @@
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+ Certificates: []dto.TLSCertificateEntry{
276+ {
277+ ID: "cert-abc123",
278+ Names: []string{"example.com", "www.example.com"},
279+ Status: "valid",
280+ NotAfter: notAfter,
281+ LastError: "",
282+ },
283+ },
284+ }, nil
285+ },
286+ }
287+
288+ var buf bytes.Buffer
289+ err := runTLSStatusCmd(context.Background(), cp, &buf, true)
290+ require.NoError(t, err)
291+
292+ var result dto.TLSStatusResponse
293+ err = json.Unmarshal(buf.Bytes(), &result)
294+ require.NoError(t, err)
295+
296+ assert.True(t, result.ACMEEnabled)
297+ assert.Equal(t, "acme", result.ConfiguredMode)
298+ assert.Equal(t, "acme-staging", result.EffectiveMode)
299+ assert.Equal(t, "env", result.TokenSource)
300+ require.Len(t, result.Certificates, 1)
301+ assert.Equal(t, "cert-abc123", result.Certificates[0].ID)
302+ assert.Equal(t, []string{"example.com", "www.example.com"}, result.Certificates[0].Names)
303+ assert.Equal(t, "valid", result.Certificates[0].Status)
304+ assert.Equal(t, notAfter.Format(time.RFC3339), result.Certificates[0].NotAfter.Format(time.RFC3339))
305+}
A internal/adapters/in/http/acme/handler.go +71 -0
@@ -0,0 +1,71 @@
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+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// Prefix is the path prefix for ACME HTTP-01 challenge requests.
14+const Prefix = "/.well-known/acme-challenge/"
15+
16+// ChallengeService defines the interface for retrieving HTTP-01 challenge tokens.
17+type ChallengeService interface {
18+ GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool)
19+}
20+
21+// Handler serves ACME HTTP-01 challenge responses.
22+type Handler struct {
23+ svc ChallengeService
24+}
25+
26+// NewHandler creates a new Handler with the given ChallengeService.
27+func NewHandler(svc ChallengeService) *Handler {
28+ return &Handler{svc: svc}
29+}
30+
31+// ServeHTTP handles incoming HTTP requests for ACME HTTP-01 challenges.
32+// Only GET and HEAD methods are allowed; all others return 405 Method Not Allowed.
33+// The token must be a safe path component and must exist in the challenge store.
34+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
35+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
36+ w.Header().Set("Allow", "GET, HEAD")
37+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
38+ return
39+ }
40+
41+ if !strings.HasPrefix(r.URL.Path, Prefix) {
42+ http.NotFound(w, r)
43+ return
44+ }
45+ token := strings.TrimPrefix(r.URL.Path, Prefix)
46+ if !domain.IsValidHTTP01Token(token) {
47+ http.NotFound(w, r)
48+ return
49+ }
50+
51+ if h.svc == nil {
52+ http.NotFound(w, r)
53+ return
54+ }
55+
56+ keyAuth, ok := h.svc.GetHTTP01Challenge(r.Context(), token)
57+ if !ok {
58+ http.NotFound(w, r)
59+ return
60+ }
61+
62+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
63+ w.Header().Set("Cache-Control", "no-store")
64+ w.WriteHeader(http.StatusOK)
65+
66+ if r.Method == http.MethodHead {
67+ return
68+ }
69+
70+ _, _ = w.Write([]byte(keyAuth))
71+}
A internal/adapters/in/http/acme/handler_test.go +123 -0
@@ -0,0 +1,123 @@
1+package acme
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// challengeSvc is a simple map-backed implementation of ChallengeService for testing.
15+type challengeSvc map[string]string
16+
17+func (s challengeSvc) GetHTTP01Challenge(_ context.Context, token string) (string, bool) {
18+ keyAuth, ok := s[token]
19+ return keyAuth, ok
20+}
21+
22+func newTestServer(t *testing.T, svc ChallengeService) *httptest.Server {
23+ t.Helper()
24+ server := httptest.NewServer(NewHandler(svc))
25+ t.Cleanup(server.Close)
26+ return server
27+}
28+
29+func readBody(t *testing.T, resp *http.Response) string {
30+ t.Helper()
31+ defer resp.Body.Close()
32+ body, err := io.ReadAll(resp.Body)
33+ require.NoError(t, err)
34+ return string(body)
35+}
36+
37+func TestHandlerServesGETChallenge(t *testing.T) {
38+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
39+
40+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc")
41+ require.NoError(t, err)
42+
43+ assert.Equal(t, http.StatusOK, resp.StatusCode)
44+ assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
45+ assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
46+ assert.Equal(t, "key-auth", readBody(t, resp))
47+}
48+
49+func TestHandlerServesHEADChallenge(t *testing.T) {
50+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
51+
52+ resp, err := http.Head(server.URL + "/.well-known/acme-challenge/abc")
53+ require.NoError(t, err)
54+ defer resp.Body.Close()
55+
56+ assert.Equal(t, http.StatusOK, resp.StatusCode)
57+ assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
58+ assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
59+}
60+
61+func TestHandlerRejectsUnsafeToken(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ path string
65+ }{
66+ {name: "path traversal", path: "/.well-known/acme-challenge/../secret"},
67+ {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"},
68+ {name: "empty token", path: "/.well-known/acme-challenge/"},
69+ {name: "backslash in token", path: "/.well-known/acme-challenge/foo%5Cbar"},
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
75+ resp, err := http.Get(server.URL + tt.path)
76+ require.NoError(t, err)
77+ defer resp.Body.Close()
78+
79+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "path: %s", tt.path)
80+ })
81+ }
82+}
83+
84+func TestHandlerRejectsPOST(t *testing.T) {
85+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
86+
87+ resp, err := http.Post(server.URL+"/.well-known/acme-challenge/abc", "text/plain", nil)
88+ require.NoError(t, err)
89+ defer resp.Body.Close()
90+
91+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
92+ assert.Equal(t, "GET, HEAD", resp.Header.Get("Allow"))
93+}
94+
95+func TestHandlerRejectsMissingPrefix(t *testing.T) {
96+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
97+
98+ resp, err := http.Get(server.URL + "/some-other-path")
99+ require.NoError(t, err)
100+ defer resp.Body.Close()
101+
102+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
103+}
104+
105+func TestHandlerRejectsUnknownToken(t *testing.T) {
106+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
107+
108+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/unknown")
109+ require.NoError(t, err)
110+ defer resp.Body.Close()
111+
112+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
113+}
114+
115+func TestHandlerRejectsNilService(t *testing.T) {
116+ server := newTestServer(t, nil)
117+
118+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc")
119+ require.NoError(t, err)
120+ defer resp.Body.Close()
121+
122+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
123+}
M internal/adapters/in/http/admin/handler.go +32 -4
@@ -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
@@ -541,8 +545,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
541545 return
542546 }
543547
544- if strings.HasSuffix(routeDomain, "/attachments") {
545- parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
548+ if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {
546549 if parentDomain == "" {
547550 h.sendError(w, http.StatusBadRequest, "domain required in path")
548551 return
@@ -1321,8 +1324,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
13211324 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
13221325 if err != nil {
13231326 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1324- var deployErr *domain.DeployFailureError
1325- if errors.As(err, &deployErr) {
1327+ if deployErr, ok := errors.AsType[*domain.DeployFailureError](err); ok {
13261328 response := dto.DeployErrorResponse{
13271329 Error: deployErr.Error(),
13281330 Cause: deployErr.Cause,
@@ -1906,3 +1908,29 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
19061908
19071909 h.sendJSON(w, http.StatusOK, response)
19081910 }
1911+
1912+// handleTLSStatus handles GET /admin/tls/status.
1913+func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
1914+ if r.Method != http.MethodGet {
1915+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1916+ return
1917+ }
1918+
1919+ ctx := r.Context()
1920+
1921+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1922+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1923+ return
1924+ }
1925+
1926+ if h.publicTLSSvc == nil {
1927+ h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{
1928+ ACMEEnabled: false,
1929+ SelectionReason: "public TLS service not configured",
1930+ })
1931+ return
1932+ }
1933+
1934+ status := h.publicTLSSvc.Status(ctx)
1935+ h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status))
1936+}
M internal/adapters/in/http/admin/handler_test.go +88 -0
@@ -2585,3 +2585,91 @@ func TestHandler_Images_Authorization(t *testing.T) {
25852585 assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
25862586 })
25872587 }
2588+
2589+func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2590+ handler := newTestHandler(t, func(d *HandlerDeps) {
2591+ d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2592+ })
2593+
2594+ server := newScopedTestServer(t, handler, "admin:config:write")
2595+ resp, err := http.Get(server.URL + "/admin/tls/status")
2596+ require.NoError(t, err)
2597+ defer resp.Body.Close()
2598+
2599+ body, err := io.ReadAll(resp.Body)
2600+ require.NoError(t, err)
2601+
2602+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2603+ assert.Contains(t, string(body), "insufficient permissions for status:read")
2604+}
2605+
2606+func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
2607+ now := time.Now()
2608+ tlsStatus := domain.PublicTLSStatus{
2609+ ACMEEnabled: true,
2610+ ConfiguredMode: domain.ACMEChallengeAuto,
2611+ EffectiveMode: domain.ACMEChallengeHTTP01,
2612+ SelectionReason: "auto-selected",
2613+ TokenSource: domain.ACMETokenSourcePass,
2614+ Certificates: []domain.ManagedCertificate{
2615+ {
2616+ ID: "cert-1",
2617+ Names: []string{"example.com"},
2618+ Challenge: domain.ACMEChallengeHTTP01,
2619+ Status: domain.TLSCertificateStatusValid,
2620+ NotAfter: now.Add(60 * 24 * time.Hour),
2621+ },
2622+ },
2623+ Routes: []domain.TLSRouteCoverage{
2624+ {
2625+ Domain: "example.com",
2626+ Covered: true,
2627+ CoveredBy: "cert-1",
2628+ RequiredACME: true,
2629+ },
2630+ },
2631+ }
2632+
2633+ handler := newTestHandler(t, func(d *HandlerDeps) {
2634+ fakeSvc := inmocks.NewMockPublicTLSService(t)
2635+ fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
2636+ d.PublicTLSSvc = fakeSvc
2637+ })
2638+
2639+ server := newScopedTestServer(t, handler, "admin:status:read")
2640+ resp, err := http.Get(server.URL + "/admin/tls/status")
2641+ require.NoError(t, err)
2642+ defer resp.Body.Close()
2643+
2644+ assert.Equal(t, http.StatusOK, resp.StatusCode)
2645+
2646+ var body dto.TLSStatusResponse
2647+ err = json.NewDecoder(resp.Body).Decode(&body)
2648+ require.NoError(t, err)
2649+
2650+ assert.True(t, body.ACMEEnabled)
2651+ assert.Equal(t, "auto", body.ConfiguredMode)
2652+ assert.Equal(t, "http-01", body.EffectiveMode)
2653+ assert.Equal(t, "auto-selected", body.SelectionReason)
2654+ assert.Equal(t, "pass", body.TokenSource)
2655+ require.Len(t, body.Certificates, 1)
2656+ assert.Equal(t, "cert-1", body.Certificates[0].ID)
2657+ assert.Equal(t, "valid", body.Certificates[0].Status)
2658+ assert.Equal(t, "http-01", body.Certificates[0].Challenge)
2659+ require.Len(t, body.Routes, 1)
2660+ assert.Equal(t, "example.com", body.Routes[0].Domain)
2661+ assert.True(t, body.Routes[0].Covered)
2662+}
2663+
2664+func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
2665+ handler := newTestHandler(t, func(d *HandlerDeps) {
2666+ d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2667+ })
2668+
2669+ server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
2670+ resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil)
2671+ require.NoError(t, err)
2672+ defer resp.Body.Close()
2673+
2674+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
2675+}
A internal/adapters/out/acmelego/cloudflare.go +261 -0
@@ -0,0 +1,261 @@
1+package acmelego
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "github.com/bnema/gordon/internal/boundaries/out"
16+)
17+
18+// zoneAPIResponse is a minimal Cloudflare API response for zone listing.
19+type zoneAPIResponse struct {
20+ Success bool `json:"success"`
21+ Errors []zoneAPIError `json:"errors"`
22+ Result []zoneAPIResult `json:"result"`
23+}
24+
25+type zoneAPIError struct {
26+ Code int `json:"code"`
27+ Message string `json:"message"`
28+}
29+
30+type zoneAPIResult struct {
31+ ID string `json:"id"`
32+ Name string `json:"name"`
33+ Status string `json:"status"`
34+}
35+
36+const defaultCloudflareBaseURL = "https://api.cloudflare.com/client/v4"
37+
38+// CloudflareZoneResolver resolves a hostname to its Cloudflare DNS zone by
39+// searching from the full hostname down to the TLD.
40+type CloudflareZoneResolver struct {
41+ token string
42+ baseURL string
43+ client *http.Client
44+
45+ mu sync.RWMutex
46+ // cache uses a small fixed maximum size; eviction scans the map to keep the
47+ // resolver simple because defaultCacheMaxSize is intentionally low.
48+ cache map[string]zoneCacheEntry
49+}
50+
51+type zoneCacheEntry struct {
52+ zone out.CloudflareZone
53+ err error
54+ createdAt time.Time
55+ ttl time.Duration
56+}
57+
58+const (
59+ defaultCacheTTL = 10 * time.Minute
60+ defaultErrorCacheTTL = time.Minute
61+ defaultCloudflareTimeout = 10 * time.Second
62+ defaultCacheMaxSize = 100
63+ defaultMaxResponseSize = 1 << 20 // 1 MB
64+)
65+
66+// CloudflareZoneResolverOption configures a CloudflareZoneResolver.
67+type CloudflareZoneResolverOption func(*CloudflareZoneResolver)
68+
69+// WithCloudflareBaseURL sets the base URL for the Cloudflare API.
70+func WithCloudflareBaseURL(baseURL string) CloudflareZoneResolverOption {
71+ return func(r *CloudflareZoneResolver) {
72+ r.baseURL = strings.TrimRight(baseURL, "/")
73+ }
74+}
75+
76+// WithCloudflareHTTPClient sets the HTTP client for the resolver.
77+func WithCloudflareHTTPClient(client *http.Client) CloudflareZoneResolverOption {
78+ return func(r *CloudflareZoneResolver) {
79+ if client != nil {
80+ r.client = client
81+ }
82+ }
83+}
84+
85+// NewCloudflareZoneResolver creates a new CloudflareZoneResolver.
86+func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOption) *CloudflareZoneResolver {
87+ r := &CloudflareZoneResolver{
88+ token: token,
89+ baseURL: defaultCloudflareBaseURL,
90+ client: &http.Client{Timeout: defaultCloudflareTimeout},
91+ cache: make(map[string]zoneCacheEntry),
92+ }
93+ for _, opt := range opts {
94+ opt(r)
95+ }
96+ return r
97+}
98+
99+// compile-time interface check
100+var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil)
101+
102+// FindZone finds the most specific Cloudflare zone for the given domain.
103+// It searches from the full hostname down to the TLD (e.g. for
104+// api.prod.example.com it tries api.prod.example.com, prod.example.com,
105+// example.com, com) and returns the first active zone found.
106+func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string) (out.CloudflareZone, error) {
107+ domainName = strings.TrimSuffix(strings.TrimSpace(domainName), ".")
108+ domainName = strings.ToLower(domainName)
109+ if domainName == "" {
110+ return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain")
111+ }
112+
113+ if cached, ok := r.cached(domainName); ok {
114+ return cached.zone, cached.err
115+ }
116+
117+ parts := strings.Split(domainName, ".")
118+
119+ // candidateCount is the total possible candidates (full domain down to TLD).
120+ // For "api.prod.example.com" -> 4 candidates; for "example.com" -> 2.
121+ candidateCount := len(parts)
122+ candidates := make([]string, 0, candidateCount)
123+
124+ // Build candidates from full hostname down to the TLD.
125+ // For parts ["api","prod","example","com"]:
126+ // "api.prod.example.com", "prod.example.com", "example.com", "com"
127+ for i := range parts {
128+ candidate := strings.Join(parts[i:], ".")
129+ candidates = append(candidates, candidate)
130+ }
131+
132+ var candidateErrs []error
133+ for _, candidate := range candidates {
134+ if cached, ok := r.cached(candidate); ok {
135+ if cached.err == nil {
136+ r.storeCache(domainName, cached.zone, nil)
137+ return cached.zone, nil
138+ }
139+ candidateErrs = append(candidateErrs, fmt.Errorf("%s: %w", candidate, cached.err))
140+ continue
141+ }
142+ zone, err := r.findZoneByName(ctx, candidate)
143+ r.storeCache(candidate, zone, err)
144+ if err != nil {
145+ candidateErrs = append(candidateErrs, fmt.Errorf("%s: %w", candidate, err))
146+ continue
147+ }
148+ r.storeCache(domainName, zone, nil)
149+ return zone, nil
150+ }
151+
152+ err := fmt.Errorf("cloudflare zone resolver: no active zone found for %q: %w", domainName, errors.Join(candidateErrs...))
153+ r.storeCache(domainName, out.CloudflareZone{}, err)
154+ return out.CloudflareZone{}, err
155+}
156+
157+func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {
158+ r.mu.RLock()
159+ entry, ok := r.cache[name]
160+ if !ok {
161+ r.mu.RUnlock()
162+ return entry, false
163+ }
164+ if time.Since(entry.createdAt) > entry.ttl {
165+ r.mu.RUnlock()
166+ r.mu.Lock()
167+ if current, ok := r.cache[name]; ok && current.createdAt.Equal(entry.createdAt) {
168+ delete(r.cache, name)
169+ }
170+ r.mu.Unlock()
171+ return zoneCacheEntry{}, false
172+ }
173+ r.mu.RUnlock()
174+ return entry, true
175+}
176+
177+func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone, err error) {
178+ r.mu.Lock()
179+ defer r.mu.Unlock()
180+ if len(r.cache) >= defaultCacheMaxSize {
181+ // Evict oldest entry with an O(n) scan. The cache is deliberately small
182+ // (defaultCacheMaxSize), so this stays simple without an LRU list.
183+ var oldestKey string
184+ var oldestTime time.Time
185+ for k, v := range r.cache {
186+ if oldestKey == "" || v.createdAt.Before(oldestTime) {
187+ oldestKey = k
188+ oldestTime = v.createdAt
189+ }
190+ }
191+ delete(r.cache, oldestKey)
192+ }
193+ ttl := defaultCacheTTL
194+ if err != nil {
195+ ttl = defaultErrorCacheTTL
196+ }
197+ r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now(), ttl: ttl}
198+}
199+
200+// findZoneByName queries the Cloudflare API for an active zone with the exact given name.
201+func (r *CloudflareZoneResolver) findZoneByName(ctx context.Context, name string) (out.CloudflareZone, error) {
202+ u, err := url.Parse(r.baseURL + "/zones")
203+ if err != nil {
204+ return out.CloudflareZone{}, fmt.Errorf("parse url: %w", err)
205+ }
206+
207+ q := u.Query()
208+ q.Set("name", name)
209+ q.Set("status", "active")
210+ u.RawQuery = q.Encode()
211+
212+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
213+ if err != nil {
214+ return out.CloudflareZone{}, fmt.Errorf("create request: %w", err)
215+ }
216+ req.Header.Set("Authorization", "Bearer "+r.token)
217+ req.Header.Set("Accept", "application/json")
218+
219+ resp, err := r.client.Do(req)
220+ if err != nil {
221+ return out.CloudflareZone{}, fmt.Errorf("request failed: %w", err)
222+ }
223+ defer resp.Body.Close()
224+
225+ lr := io.LimitReader(resp.Body, defaultMaxResponseSize+1)
226+ body, err := io.ReadAll(lr)
227+ if err != nil {
228+ return out.CloudflareZone{}, fmt.Errorf("read response: %w", err)
229+ }
230+ if len(body) > defaultMaxResponseSize {
231+ return out.CloudflareZone{}, fmt.Errorf("cloudflare api: response body too large (%d bytes)", len(body))
232+ }
233+
234+ if resp.StatusCode != http.StatusOK {
235+ return out.CloudflareZone{}, fmt.Errorf("cloudflare api: status %d", resp.StatusCode)
236+ }
237+
238+ var apiResp zoneAPIResponse
239+ if err := json.Unmarshal(body, &apiResp); err != nil {
240+ return out.CloudflareZone{}, fmt.Errorf("parse response: %w", err)
241+ }
242+
243+ if !apiResp.Success {
244+ errMsg := "cloudflare api returned unsuccessfully"
245+ if len(apiResp.Errors) > 0 {
246+ errMsg = apiResp.Errors[0].Message
247+ }
248+ return out.CloudflareZone{}, fmt.Errorf("cloudflare api: %s", errMsg)
249+ }
250+
251+ for _, z := range apiResp.Result {
252+ if z.Name == name && z.Status == "active" {
253+ return out.CloudflareZone{
254+ ID: z.ID,
255+ Name: z.Name,
256+ }, nil
257+ }
258+ }
259+
260+ return out.CloudflareZone{}, fmt.Errorf("no active zone found for %q", name)
261+}
A internal/adapters/out/acmelego/cloudflare_test.go +261 -0
@@ -0,0 +1,261 @@
1+package acmelego
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/boundaries/out"
15+)
16+
17+func TestCloudflareZoneResolverFindsParentZone(t *testing.T) {
18+ var requestCount int
19+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ requestCount++
21+
22+ // Verify auth header
23+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
24+
25+ // Verify query parameters
26+ name := r.URL.Query().Get("name")
27+ status := r.URL.Query().Get("status")
28+ assert.Equal(t, "active", status)
29+
30+ switch name {
31+ case "api.prod.example.com":
32+ // Return empty - no zone found
33+ w.Header().Set("Content-Type", "application/json")
34+ json.NewEncoder(w).Encode(zoneAPIResponse{
35+ Success: true,
36+ Result: []zoneAPIResult{},
37+ })
38+ case "prod.example.com":
39+ // Return empty - no zone found
40+ w.Header().Set("Content-Type", "application/json")
41+ json.NewEncoder(w).Encode(zoneAPIResponse{
42+ Success: true,
43+ Result: []zoneAPIResult{},
44+ })
45+ case "example.com":
46+ // Return active zone
47+ w.Header().Set("Content-Type", "application/json")
48+ json.NewEncoder(w).Encode(zoneAPIResponse{
49+ Success: true,
50+ Result: []zoneAPIResult{
51+ {
52+ ID: "zone-id-123",
53+ Name: "example.com",
54+ Status: "active",
55+ },
56+ },
57+ })
58+ default:
59+ t.Errorf("unexpected zone name query: %s", name)
60+ }
61+ }))
62+ defer server.Close()
63+
64+ resolver := NewCloudflareZoneResolver("test-token",
65+ WithCloudflareBaseURL(server.URL))
66+
67+ zone, err := resolver.FindZone(context.Background(), "api.prod.example.com")
68+ require.NoError(t, err)
69+ assert.Equal(t, "zone-id-123", zone.ID)
70+ assert.Equal(t, "example.com", zone.Name)
71+ assert.Equal(t, 3, requestCount, "should have made 3 API calls")
72+}
73+
74+func TestCloudflareZoneResolverDoesNotLeakTokenInError(t *testing.T) {
75+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+ // Return a 500 error
77+ w.WriteHeader(http.StatusInternalServerError)
78+ w.Write([]byte(`{"success":false,"errors":[]}`))
79+ }))
80+ defer server.Close()
81+
82+ resolver := NewCloudflareZoneResolver("super-secret-token-abc123",
83+ WithCloudflareBaseURL(server.URL))
84+
85+ _, err := resolver.FindZone(context.Background(), "example.com")
86+ require.Error(t, err)
87+ errStr := err.Error()
88+
89+ // The error must NOT contain the token
90+ assert.NotContains(t, errStr, "super-secret-token-abc123")
91+ assert.NotContains(t, errStr, "secret-token")
92+}
93+
94+func TestCloudflareZoneResolverSimpleDomain(t *testing.T) {
95+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+ name := r.URL.Query().Get("name")
97+ assert.Equal(t, "example.com", name)
98+
99+ w.Header().Set("Content-Type", "application/json")
100+ json.NewEncoder(w).Encode(zoneAPIResponse{
101+ Success: true,
102+ Result: []zoneAPIResult{
103+ {
104+ ID: "zone-1",
105+ Name: "example.com",
106+ Status: "active",
107+ },
108+ },
109+ })
110+ }))
111+ defer server.Close()
112+
113+ resolver := NewCloudflareZoneResolver("test-token",
114+ WithCloudflareBaseURL(server.URL))
115+
116+ zone, err := resolver.FindZone(context.Background(), "example.com")
117+ require.NoError(t, err)
118+ assert.Equal(t, "zone-1", zone.ID)
119+ assert.Equal(t, "example.com", zone.Name)
120+}
121+
122+func TestCloudflareZoneResolverNoZoneFound(t *testing.T) {
123+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+ w.Header().Set("Content-Type", "application/json")
125+ json.NewEncoder(w).Encode(zoneAPIResponse{
126+ Success: true,
127+ Result: []zoneAPIResult{},
128+ })
129+ }))
130+ defer server.Close()
131+
132+ resolver := NewCloudflareZoneResolver("test-token",
133+ WithCloudflareBaseURL(server.URL))
134+
135+ _, err := resolver.FindZone(context.Background(), "missing.example.com")
136+ require.Error(t, err)
137+ assert.Contains(t, err.Error(), "no active zone found")
138+}
139+
140+func TestCloudflareZoneResolverCachesErrorsWithShortTTL(t *testing.T) {
141+ resolver := NewCloudflareZoneResolver("token")
142+ resolver.storeCache("example.com", out.CloudflareZone{}, errors.New("temporary failure"))
143+
144+ entry, ok := resolver.cached("example.com")
145+ require.True(t, ok)
146+ assert.Error(t, entry.err)
147+ assert.Equal(t, defaultErrorCacheTTL, entry.ttl)
148+}
149+
150+func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) {
151+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+ w.Header().Set("Content-Type", "application/json")
153+ json.NewEncoder(w).Encode(zoneAPIResponse{
154+ Success: false,
155+ Errors: []zoneAPIError{
156+ {Code: 1000, Message: "authentication error"},
157+ },
158+ Result: []zoneAPIResult{},
159+ })
160+ }))
161+ defer server.Close()
162+
163+ resolver := NewCloudflareZoneResolver("bad-token",
164+ WithCloudflareBaseURL(server.URL))
165+
166+ _, err := resolver.FindZone(context.Background(), "example.com")
167+ require.Error(t, err)
168+ // The resolver iterates all candidates; the final error is the generic one.
169+ assert.Contains(t, err.Error(), "no active zone found")
170+ // Token must never leak in error messages.
171+ assert.NotContains(t, err.Error(), "bad-token")
172+}
173+
174+// Ensure the interface compliance.
175+func TestCloudflareZoneResolverImplementsInterface(t *testing.T) {
176+ var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil)
177+
178+ // Test NewCloudflareZoneResolver returns non-nil
179+ r := NewCloudflareZoneResolver("token")
180+ require.NotNil(t, r)
181+
182+ // Check defaults
183+ assert.Equal(t, "https://api.cloudflare.com/client/v4", r.baseURL)
184+ assert.NotNil(t, r.client)
185+ assert.Equal(t, defaultCloudflareTimeout, r.client.Timeout)
186+}
187+
188+func TestWithCloudflareHTTPClientNilLeavesDefault(t *testing.T) {
189+ // Passing nil must not replace the default HTTP client.
190+ r := NewCloudflareZoneResolver("token", WithCloudflareHTTPClient(nil))
191+ require.NotNil(t, r.client)
192+ assert.Equal(t, defaultCloudflareTimeout, r.client.Timeout,
193+ "default timeout should be preserved when nil is passed")
194+}
195+
196+func TestCloudflareZoneResolverFindZoneLowercasesInput(t *testing.T) {
197+ // The server expects lowercase names; uppercase input must be normalized.
198+ callCount := 0
199+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200+ callCount++
201+ name := r.URL.Query().Get("name")
202+ assert.Equal(t, "example.com", name, "should receive lowercased domain, got %q", name)
203+
204+ w.Header().Set("Content-Type", "application/json")
205+ json.NewEncoder(w).Encode(zoneAPIResponse{
206+ Success: true,
207+ Result: []zoneAPIResult{
208+ {ID: "zone-1", Name: "example.com", Status: "active"},
209+ },
210+ })
211+ }))
212+ defer server.Close()
213+
214+ resolver := NewCloudflareZoneResolver("test-token",
215+ WithCloudflareBaseURL(server.URL))
216+
217+ // Uppercase input — should lowercase to EXAMPLE.COM -> example.com
218+ zone, err := resolver.FindZone(context.Background(), "EXAMPLE.COM")
219+ require.NoError(t, err)
220+ assert.Equal(t, "zone-1", zone.ID)
221+ assert.Equal(t, 1, callCount)
222+}
223+
224+func TestCloudflareZoneResolverFindZoneMixedCaseWithTrailingDot(t *testing.T) {
225+ // Mixed case with trailing dot should be lowercased and dot-stripped.
226+ // The search chain tries "mixed.example.com" (empty), then "example.com" (found).
227+ callCount := 0
228+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229+ callCount++
230+ name := r.URL.Query().Get("name")
231+ switch name {
232+ case "mixed.example.com":
233+ // No zone found for subdomain
234+ w.Header().Set("Content-Type", "application/json")
235+ json.NewEncoder(w).Encode(zoneAPIResponse{
236+ Success: true,
237+ Result: []zoneAPIResult{},
238+ })
239+ case "example.com":
240+ w.Header().Set("Content-Type", "application/json")
241+ json.NewEncoder(w).Encode(zoneAPIResponse{
242+ Success: true,
243+ Result: []zoneAPIResult{
244+ {ID: "zone-1", Name: "example.com", Status: "active"},
245+ },
246+ })
247+ default:
248+ t.Errorf("unexpected zone name query: %s", name)
249+ }
250+ }))
251+ defer server.Close()
252+
253+ resolver := NewCloudflareZoneResolver("test-token",
254+ WithCloudflareBaseURL(server.URL))
255+
256+ zone, err := resolver.FindZone(context.Background(), "Mixed.EXAMPLE.Com.")
257+ require.NoError(t, err)
258+ assert.Equal(t, "zone-1", zone.ID)
259+ // First call: mixed.example.com (empty), second: example.com (found)
260+ assert.Equal(t, 2, callCount, "should try mixed.example.com then find example.com")
261+}
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+import (
5+ "fmt"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+)
9+
10+// HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider
11+// interface for HTTP-01 challenge solving.
12+type HTTPProvider struct {
13+ sink out.HTTPChallengeSink
14+}
15+
16+// NewHTTPProvider creates a new HTTPProvider.
17+func NewHTTPProvider(sink out.HTTPChallengeSink) *HTTPProvider {
18+ return &HTTPProvider{sink: sink}
19+}
20+
21+// Present stores the key authorization for the given token via the sink.
22+func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {
23+ if p.sink == nil {
24+ return fmt.Errorf("http-01 sink is nil: cannot Present")
25+ }
26+ if err := p.sink.Present(token, keyAuth); err != nil {
27+ return fmt.Errorf("present http-01 challenge: %w", err)
28+ }
29+ return nil
30+}
31+
32+// CleanUp removes the key authorization for the given token via the sink.
33+func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error {
34+ if p.sink == nil {
35+ return fmt.Errorf("http-01 sink is nil: cannot CleanUp")
36+ }
37+ if err := p.sink.CleanUp(token); err != nil {
38+ return fmt.Errorf("cleanup http-01 challenge: %w", err)
39+ }
40+ return nil
41+}
A internal/adapters/out/acmelego/http_provider_test.go +35 -0
@@ -0,0 +1,35 @@
1+package acmelego
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
9+)
10+
11+func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) {
12+ mockSink := outmocks.NewMockHTTPChallengeSink(t)
13+ mockSink.EXPECT().Present("token", "key-auth").Return(nil)
14+ mockSink.EXPECT().CleanUp("token").Return(nil)
15+
16+ provider := NewHTTPProvider(mockSink)
17+
18+ err := provider.Present("example.com", "token", "key-auth")
19+ assert.NoError(t, err)
20+
21+ err = provider.CleanUp("example.com", "token", "key-auth")
22+ assert.NoError(t, err)
23+}
24+
25+func TestHTTPProviderNilSink(t *testing.T) {
26+ provider := NewHTTPProvider(nil)
27+
28+ err := provider.Present("example.com", "token", "key-auth")
29+ assert.Error(t, err)
30+ assert.Contains(t, err.Error(), "sink is nil")
31+
32+ err = provider.CleanUp("example.com", "token", "key-auth")
33+ assert.Error(t, err)
34+ assert.Contains(t, err.Error(), "sink is nil")
35+}
A internal/adapters/out/acmelego/issuer.go +356 -0
@@ -0,0 +1,356 @@
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+ "fmt"
10+ "net/http"
11+ "strings"
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 out.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+ initializing bool
57+ initDone chan struct{}
58+}
59+
60+// NewIssuer creates a new Issuer. Config is validated; the ACME account is
61+// loaded or created lazily on first Obtain or Renew call.
62+func NewIssuer(cfg Config) (*Issuer, error) {
63+ if cfg.Email == "" {
64+ return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired)
65+ }
66+ if cfg.Store == nil {
67+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)
68+ }
69+ if cfg.CADirectoryURL != "" && !strings.HasPrefix(cfg.CADirectoryURL, "https://") {
70+ return nil, fmt.Errorf("acmelego: CADirectoryURL must use HTTPS, got %q", cfg.CADirectoryURL)
71+ }
72+ if cfg.Challenge == "" {
73+ cfg.Challenge = domain.ACMEChallengeHTTP01
74+ }
75+ switch cfg.Challenge {
76+ case domain.ACMEChallengeHTTP01:
77+ if cfg.HTTPChallengeSink == nil {
78+ return nil, fmt.Errorf("acmelego: %w", domain.ErrHTTPChallengeSinkRequired)
79+ }
80+ case domain.ACMEChallengeCloudflareDNS01:
81+ if cfg.Token == "" {
82+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
83+ }
84+ default:
85+ return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
86+ }
87+
88+ return &Issuer{cfg: cfg}, nil
89+}
90+
91+// compile-time interface check
92+var _ out.PublicCertificateIssuer = (*Issuer)(nil)
93+
94+// Obtain obtains a new certificate for the given order. The ACME client is
95+// initialized lazily on the first call.
96+func (i *Issuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
97+ if err := i.ensureClient(ctx); err != nil {
98+ return nil, err
99+ }
100+
101+ resource, err := i.client.Certificate.Obtain(certificate.ObtainRequest{
102+ Domains: order.Names,
103+ Bundle: true,
104+ })
105+ if err != nil {
106+ return nil, fmt.Errorf("obtain certificate: %w", err)
107+ }
108+
109+ return resourceToStored(order, resource)
110+}
111+
112+// Renew renews an existing certificate. The ACME client is initialized lazily
113+// on the first call. Bundle and MustStaple are set to true/false respectively.
114+func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
115+ if err := i.ensureClient(ctx); err != nil {
116+ return nil, err
117+ }
118+
119+ domainName := ""
120+ if len(cert.Names) > 0 {
121+ domainName = cert.Names[0]
122+ }
123+
124+ legoCert := certificate.Resource{
125+ Domain: domainName,
126+ PrivateKey: cert.PrivateKeyPEM,
127+ Certificate: cert.CertPEM,
128+ IssuerCertificate: cert.ChainPEM,
129+ }
130+
131+ // If FullchainPEM is available but CertPEM is empty, use FullchainPEM.
132+ if len(legoCert.Certificate) == 0 && len(cert.FullchainPEM) > 0 {
133+ legoCert.Certificate = cert.FullchainPEM
134+ }
135+
136+ renewed, err := i.client.Certificate.RenewWithOptions(legoCert, &certificate.RenewOptions{
137+ Bundle: true,
138+ })
139+ if err != nil {
140+ return nil, fmt.Errorf("renew certificate: %w", err)
141+ }
142+
143+ // Preserve the original order info
144+ order := out.CertificateOrder{
145+ ID: cert.ID,
146+ Names: cert.Names,
147+ Challenge: cert.Challenge,
148+ }
149+
150+ return resourceToStored(order, renewed)
151+}
152+
153+// ensureClient initializes the lego client and ACME account if not yet done.
154+func (i *Issuer) ensureClient(ctx context.Context) error {
155+ for {
156+ i.mu.Lock()
157+ if i.client != nil {
158+ i.mu.Unlock()
159+ return nil
160+ }
161+ if i.initializing {
162+ done := i.initDone
163+ i.mu.Unlock()
164+ select {
165+ case <-done:
166+ continue
167+ case <-ctx.Done():
168+ return ctx.Err()
169+ }
170+ }
171+ i.initializing = true
172+ i.initDone = make(chan struct{})
173+ done := i.initDone
174+ i.mu.Unlock()
175+
176+ user, client, err := i.buildClient(ctx)
177+
178+ i.mu.Lock()
179+ if err == nil {
180+ i.user = user
181+ i.client = client
182+ }
183+ i.initializing = false
184+ close(done)
185+ i.initDone = nil
186+ i.mu.Unlock()
187+
188+ return err
189+ }
190+}
191+
192+func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, error) {
193+ // Load or create ACME account
194+ user, err := i.loadOrCreateAccount(ctx)
195+ if err != nil {
196+ return nil, nil, fmt.Errorf("acme account: %w", err)
197+ }
198+
199+ client, err := lego.NewClient(i.newLegoConfig(user))
200+ if err != nil {
201+ return nil, nil, fmt.Errorf("create lego client: %w", err)
202+ }
203+
204+ // Set up challenge providers
205+ switch i.cfg.Challenge {
206+ case domain.ACMEChallengeHTTP01:
207+ if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {
208+ return nil, nil, fmt.Errorf("set http-01 provider: %w", err)
209+ }
210+ case domain.ACMEChallengeCloudflareDNS01:
211+ cfCfg := cloudflare.NewDefaultConfig()
212+ cfCfg.AuthToken = i.cfg.Token
213+ cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg)
214+ if err != nil {
215+ return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
216+ }
217+ if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {
218+ return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)
219+ }
220+ }
221+
222+ return user, client, nil
223+}
224+
225+// loadOrCreateAccount loads an existing ACME account from the store or creates
226+// a new one. If creating, it registers the account with the ACME server.
227+func (i *Issuer) loadOrCreateAccount(ctx context.Context) (*AccountUser, error) {
228+ stored, err := i.cfg.Store.LoadAccount(ctx)
229+ if err != nil {
230+ return nil, fmt.Errorf("load account: %w", err)
231+ }
232+
233+ if stored != nil && len(stored.PrivateKeyPEM) > 0 {
234+ return restoreAccount(stored)
235+ }
236+
237+ // Generate new ECDSA P-256 private key for the ACME account
238+ privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
239+ if err != nil {
240+ return nil, fmt.Errorf("generate account key: %w", err)
241+ }
242+
243+ user := NewAccountUser(i.cfg.Email, privateKey, nil)
244+
245+ // We need a temporary client to register the account
246+ tmpClient, err := lego.NewClient(i.newLegoConfig(user))
247+ if err != nil {
248+ return nil, fmt.Errorf("create temporary lego client: %w", err)
249+ }
250+
251+ reg, err := tmpClient.Registration.Register(registration.RegisterOptions{
252+ TermsOfServiceAgreed: true,
253+ })
254+ if err != nil {
255+ return nil, fmt.Errorf("register acme account: %w", err)
256+ }
257+
258+ user = NewAccountUser(i.cfg.Email, privateKey, reg)
259+
260+ // Persist the account
261+ bodyJSON, err := json.Marshal(reg.Body)
262+ if err != nil {
263+ return nil, fmt.Errorf("marshal account body: %w", err)
264+ }
265+
266+ privateKeyPEM := certcrypto.PEMEncode(privateKey)
267+
268+ if err := i.cfg.Store.SaveAccount(ctx, out.ACMEAccount{
269+ Email: i.cfg.Email,
270+ PrivateKeyPEM: privateKeyPEM,
271+ RegistrationURI: reg.URI,
272+ BodyJSON: bodyJSON,
273+ }); err != nil {
274+ return nil, fmt.Errorf("save account: %w", err)
275+ }
276+
277+ return user, nil
278+}
279+
280+// restoreAccount reconstructs an AccountUser from a stored ACMEAccount.
281+func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) {
282+ privateKey, err := certcrypto.ParsePEMPrivateKey(stored.PrivateKeyPEM)
283+ if err != nil {
284+ return nil, fmt.Errorf("parse account private key: %w", err)
285+ }
286+
287+ reg := &registration.Resource{
288+ URI: stored.RegistrationURI,
289+ }
290+
291+ if len(stored.BodyJSON) > 0 {
292+ var body acme.Account
293+ if err := json.Unmarshal(stored.BodyJSON, &body); err != nil {
294+ return nil, fmt.Errorf("unmarshal account body: %w", err)
295+ }
296+ reg.Body = body
297+ }
298+
299+ return NewAccountUser(stored.Email, privateKey, reg), nil
300+}
301+
302+// newLegoConfig creates a lego.Config with common settings from the Issuer config.
303+func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config {
304+ legoCfg := lego.NewConfig(user)
305+ legoCfg.Certificate.KeyType = certcrypto.EC256 // ECDSA P-256
306+ if i.cfg.CADirectoryURL != "" {
307+ legoCfg.CADirURL = i.cfg.CADirectoryURL
308+ }
309+ if i.cfg.HTTPClient != nil {
310+ legoCfg.HTTPClient = i.cfg.HTTPClient
311+ }
312+ return legoCfg
313+}
314+
315+// resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
316+func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {
317+ var fullchainPEM []byte
318+ if len(res.IssuerCertificate) > 0 {
319+ fullchainPEM = append(append([]byte{}, res.Certificate...), res.IssuerCertificate...)
320+ } else {
321+ fullchainPEM = res.Certificate
322+ }
323+
324+ // Parse the certificate to extract NotAfter and populate tls.Certificate.
325+ var tlsCert tls.Certificate
326+ notAfter := time.Time{}
327+ parsedTLSCert, tlsErr := tls.X509KeyPair(fullchainPEM, res.PrivateKey)
328+ if tlsErr != nil {
329+ return nil, fmt.Errorf("parse tls certificate: %w", tlsErr)
330+ }
331+ tlsCert = parsedTLSCert
332+ if len(parsedTLSCert.Certificate) > 0 {
333+ parsed, parseErr := x509.ParseCertificate(parsedTLSCert.Certificate[0])
334+ if parseErr != nil {
335+ return nil, fmt.Errorf("parse x509 certificate: %w", parseErr)
336+ }
337+ notAfter = parsed.NotAfter
338+ }
339+
340+ names := order.Names
341+ if names == nil {
342+ names = []string{}
343+ }
344+
345+ return &out.StoredCertificate{
346+ ID: order.ID,
347+ Names: names,
348+ Challenge: order.Challenge,
349+ Certificate: tlsCert,
350+ CertPEM: res.Certificate,
351+ ChainPEM: res.IssuerCertificate,
352+ FullchainPEM: fullchainPEM,
353+ PrivateKeyPEM: res.PrivateKey,
354+ NotAfter: notAfter,
355+ }, nil
356+}
A internal/adapters/out/acmelego/issuer_test.go +312 -0
@@ -0,0 +1,312 @@
1+package acmelego
2+
3+import (
4+ "crypto/ecdsa"
5+ "crypto/elliptic"
6+ "crypto/rand"
7+ "crypto/rsa"
8+ "crypto/tls"
9+ "crypto/x509"
10+ "crypto/x509/pkix"
11+ "encoding/json"
12+ "encoding/pem"
13+ "errors"
14+ "math/big"
15+ "testing"
16+ "time"
17+
18+ "github.com/go-acme/lego/v4/acme"
19+ "github.com/go-acme/lego/v4/certcrypto"
20+ "github.com/go-acme/lego/v4/certificate"
21+ "github.com/stretchr/testify/assert"
22+ "github.com/stretchr/testify/require"
23+
24+ "github.com/bnema/gordon/internal/boundaries/out"
25+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
26+ "github.com/bnema/gordon/internal/domain"
27+)
28+
29+func TestNewIssuerValidatesEmail(t *testing.T) {
30+ _, err := NewIssuer(Config{})
31+ assert.Error(t, err)
32+ assert.ErrorIs(t, err, domain.ErrACMEEmailRequired)
33+}
34+
35+func TestNewIssuerValidatesStore(t *testing.T) {
36+ _, err := NewIssuer(Config{Email: "test@example.com"})
37+ assert.Error(t, err)
38+ assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
39+}
40+
41+func TestNewIssuerValidatesHTTPChallengeSink(t *testing.T) {
42+ _, err := NewIssuer(Config{
43+ Email: "test@example.com",
44+ Challenge: domain.ACMEChallengeHTTP01,
45+ Store: outmocks.NewMockCertificateStore(t),
46+ })
47+ assert.Error(t, err)
48+ assert.ErrorIs(t, err, domain.ErrHTTPChallengeSinkRequired)
49+}
50+
51+func TestNewIssuerValidatesChallengeMode(t *testing.T) {
52+ _, err := NewIssuer(Config{
53+ Email: "test@example.com",
54+ Challenge: domain.ACMEChallengeMode("tls-alpn-01"),
55+ Store: outmocks.NewMockCertificateStore(t),
56+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
57+ })
58+ assert.Error(t, err)
59+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
60+}
61+
62+func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
63+ _, err := NewIssuer(Config{
64+ Email: "test@example.com",
65+ Challenge: domain.ACMEChallengeCloudflareDNS01,
66+ Store: outmocks.NewMockCertificateStore(t),
67+ })
68+ assert.Error(t, err)
69+ assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
70+}
71+
72+func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
73+ _, err := NewIssuer(Config{
74+ Email: "test@example.com",
75+ Challenge: domain.ACMEChallengeHTTP01,
76+ Store: outmocks.NewMockCertificateStore(t),
77+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
78+ CADirectoryURL: "http://acme.test/directory",
79+ })
80+ require.Error(t, err)
81+ assert.Contains(t, err.Error(), "must use HTTPS")
82+
83+ // HTTPS should be accepted
84+ _, err = NewIssuer(Config{
85+ Email: "test@example.com",
86+ Challenge: domain.ACMEChallengeHTTP01,
87+ Store: outmocks.NewMockCertificateStore(t),
88+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
89+ CADirectoryURL: "https://acme.test/directory",
90+ })
91+ require.NoError(t, err)
92+}
93+
94+func TestNewIssuerValidWithDefaults(t *testing.T) {
95+ issuer, err := NewIssuer(Config{
96+ Email: "test@example.com",
97+ Store: outmocks.NewMockCertificateStore(t),
98+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
99+ })
100+ require.NoError(t, err)
101+ require.NotNil(t, issuer)
102+ assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
103+}
104+
105+func TestPrivateKeyRoundTrip(t *testing.T) {
106+ // Generate an ECDSA P-256 private key
107+ privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
108+ require.NoError(t, err)
109+ require.NotNil(t, privateKey)
110+
111+ // PEM encode
112+ pemBytes := certcrypto.PEMEncode(privateKey)
113+ require.NotEmpty(t, pemBytes)
114+
115+ // Parse back
116+ parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes)
117+ require.NoError(t, err)
118+ require.NotNil(t, parsedKey)
119+
120+ // Verify it's the same curve (ECDSA P-256)
121+ ecKey, ok := parsedKey.(*ecdsa.PrivateKey)
122+ require.True(t, ok, "expected *ecdsa.PrivateKey")
123+ assert.Equal(t, elliptic.P256(), ecKey.Curve)
124+
125+ // Verify we can sign with the original and verify with the parsed
126+ digest := []byte("test message")
127+ sig, err := ecdsa.SignASN1(rand.Reader, privateKey.(*ecdsa.PrivateKey), digest[:])
128+ require.NoError(t, err)
129+ assert.True(t, ecdsa.VerifyASN1(&ecKey.PublicKey, digest[:], sig))
130+}
131+
132+func TestPrivateKeyRSARoundTrip(t *testing.T) {
133+ // Test RSA private key round-trip via certcrypto
134+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
135+ require.NoError(t, err)
136+
137+ pemBytes := certcrypto.PEMEncode(privateKey)
138+ require.NotEmpty(t, pemBytes)
139+
140+ parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes)
141+ require.NoError(t, err)
142+ require.NotNil(t, parsedKey)
143+
144+ _, ok := parsedKey.(*rsa.PrivateKey)
145+ require.True(t, ok)
146+}
147+
148+func TestResourceConversionToStoredCertificate(t *testing.T) {
149+ // Generate a self-signed certificate for testing
150+ certPEM, keyPEM, err := generateSelfSignedCert("example.com")
151+ require.NoError(t, err)
152+
153+ // Generate a fake issuer certificate
154+ issuerPEM, _, err := generateSelfSignedCert("fake-ca.example.com")
155+ require.NoError(t, err)
156+
157+ resource := &certificate.Resource{
158+ Domain: "example.com",
159+ CertURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123",
160+ CertStableURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123",
161+ PrivateKey: keyPEM,
162+ Certificate: certPEM,
163+ IssuerCertificate: issuerPEM,
164+ }
165+
166+ order := out.CertificateOrder{
167+ ID: "test-cert",
168+ Names: []string{"example.com"},
169+ Challenge: domain.ACMEChallengeHTTP01,
170+ }
171+
172+ stored, err := resourceToStored(order, resource)
173+ require.NoError(t, err)
174+ require.NotNil(t, stored)
175+ assert.Equal(t, "test-cert", stored.ID)
176+ assert.Equal(t, []string{"example.com"}, stored.Names)
177+ assert.Equal(t, domain.ACMEChallengeHTTP01, stored.Challenge)
178+ assert.Equal(t, certPEM, stored.CertPEM)
179+ assert.Equal(t, issuerPEM, stored.ChainPEM)
180+
181+ // FullchainPEM should be cert + issuer
182+ expectedFullchain := append(append([]byte{}, certPEM...), issuerPEM...)
183+ assert.Equal(t, expectedFullchain, stored.FullchainPEM)
184+ assert.Equal(t, keyPEM, stored.PrivateKeyPEM)
185+
186+ // NotAfter should be set
187+ assert.False(t, stored.NotAfter.IsZero(), "NotAfter should be set")
188+
189+ // Verify tls.Certificate is populated from the PEM data
190+ assert.NotEmpty(t, stored.Certificate.Certificate, "tls.Certificate should be populated from PEM")
191+ assert.NotNil(t, stored.Certificate.PrivateKey, "private key should be populated")
192+}
193+
194+func TestResourceConversionNoIssuer(t *testing.T) {
195+ certPEM, keyPEM, err := generateSelfSignedCert("example.com")
196+ require.NoError(t, err)
197+
198+ resource := &certificate.Resource{
199+ Domain: "example.com",
200+ PrivateKey: keyPEM,
201+ Certificate: certPEM,
202+ }
203+
204+ order := out.CertificateOrder{
205+ ID: "test-cert-no-issuer",
206+ Names: []string{"example.com"},
207+ }
208+
209+ stored, err := resourceToStored(order, resource)
210+ require.NoError(t, err)
211+ require.NotNil(t, stored)
212+ assert.Equal(t, certPEM, stored.FullchainPEM, "fullchain should equal cert when no issuer")
213+ assert.Empty(t, stored.ChainPEM)
214+}
215+
216+func TestAccountJSONRoundTrip(t *testing.T) {
217+ // Simulate an ACME account with BodyJSON containing the serialized acme.Account
218+ body := acme.Account{
219+ Status: "valid",
220+ Contact: []string{"mailto:test@example.com"},
221+ TermsOfServiceAgreed: true,
222+ Orders: "https://acme-v02.api.letsencrypt.org/acme/orders/abc123",
223+ }
224+
225+ bodyJSON, err := json.Marshal(body)
226+ require.NoError(t, err)
227+
228+ var restored acme.Account
229+ err = json.Unmarshal(bodyJSON, &restored)
230+ require.NoError(t, err)
231+ assert.Equal(t, body.Status, restored.Status)
232+ assert.Equal(t, body.Contact, restored.Contact)
233+ assert.Equal(t, body.TermsOfServiceAgreed, restored.TermsOfServiceAgreed)
234+ assert.Equal(t, body.Orders, restored.Orders)
235+}
236+
237+// generateSelfSignedCert creates a self-signed certificate and its private key
238+// for testing purposes.
239+func generateSelfSignedCert(domain string) (certPEM, keyPEM []byte, err error) {
240+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
241+ if err != nil {
242+ return nil, nil, err
243+ }
244+
245+ serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
246+ if err != nil {
247+ return nil, nil, err
248+ }
249+
250+ template := &x509.Certificate{
251+ SerialNumber: serialNumber,
252+ Subject: pkix.Name{
253+ CommonName: domain,
254+ Organization: []string{"Test"},
255+ },
256+ DNSNames: []string{domain},
257+ NotBefore: time.Now().Add(-1 * time.Hour),
258+ NotAfter: time.Now().Add(24 * time.Hour),
259+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
260+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
261+ BasicConstraintsValid: true,
262+ }
263+
264+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
265+ if err != nil {
266+ return nil, nil, err
267+ }
268+
269+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
270+ if certPEM == nil {
271+ return nil, nil, errors.New("pem encode certificate returned nil")
272+ }
273+
274+ keyDER, err := x509.MarshalECPrivateKey(privateKey)
275+ if err != nil {
276+ return nil, nil, err
277+ }
278+
279+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
280+ if keyPEM == nil {
281+ return nil, nil, errors.New("pem encode private key returned nil")
282+ }
283+
284+ return certPEM, keyPEM, nil
285+}
286+
287+// Ensure TestResourceConversion also validates the tls.X509KeyPair parse works.
288+func TestParsedCertificateNotAfter(t *testing.T) {
289+ certPEM, keyPEM, err := generateSelfSignedCert("test.example.com")
290+ require.NoError(t, err)
291+
292+ resource := &certificate.Resource{
293+ Domain: "test.example.com",
294+ PrivateKey: keyPEM,
295+ Certificate: certPEM,
296+ }
297+
298+ order := out.CertificateOrder{
299+ ID: "parse-test",
300+ Names: []string{"test.example.com"},
301+ }
302+
303+ stored, err := resourceToStored(order, resource)
304+ require.NoError(t, err)
305+ require.NotNil(t, stored)
306+ assert.False(t, stored.NotAfter.IsZero())
307+ assert.True(t, stored.NotAfter.After(time.Now()))
308+
309+ // Confirm tls.X509KeyPair succeeds on the stored PEMs
310+ _, parseErr := tls.X509KeyPair(stored.FullchainPEM, stored.PrivateKeyPEM)
311+ assert.NoError(t, parseErr, "tls.X509KeyPair should succeed")
312+}
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 +511 -0
@@ -0,0 +1,511 @@
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+ "syscall"
13+ "time"
14+
15+ "github.com/bnema/gordon/internal/boundaries/out"
16+ "github.com/bnema/gordon/internal/domain"
17+)
18+
19+// certMetadata is the JSON structure persisted in each certificate directory.
20+type certMetadata struct {
21+ ID string `json:"id"`
22+ Names []string `json:"names"`
23+ Challenge string `json:"challenge"`
24+ NotAfter time.Time `json:"not_after"`
25+ LastError string `json:"last_error,omitempty"`
26+}
27+
28+const (
29+ lockFile = ".lock"
30+ accountFile = "account.json"
31+ certDir = "certs"
32+ backupSuffix = ".old"
33+ tempInfix = ".tmp-"
34+ certFile = "cert.pem"
35+ chainFile = "chain.pem"
36+ fullchainFile = "fullchain.pem"
37+ privkeyFile = "privkey.pem"
38+ metaFile = "metadata.json"
39+
40+ dirMode os.FileMode = 0700
41+ privKeyMode os.FileMode = 0600
42+ metaMode os.FileMode = 0600
43+ accountMode os.FileMode = 0600
44+ lockMode os.FileMode = 0600
45+ pemMode os.FileMode = 0644
46+)
47+
48+var ErrLockHeld = errors.New("acmestore: lock already held")
49+
50+// Store implements out.CertificateStore using the local filesystem.
51+type Store struct {
52+ root string
53+}
54+
55+// New creates a Store rooted at root. root must be non-empty and is created
56+// along with the certs subdirectory if they do not exist.
57+func New(root string) (*Store, error) {
58+ if root == "" {
59+ return nil, errors.New("acmestore: root is required")
60+ }
61+ if err := os.MkdirAll(root, dirMode); err != nil {
62+ return nil, fmt.Errorf("acmestore: mkdir root: %w", err)
63+ }
64+ if _, err := ensureCertsDir(root); err != nil {
65+ return nil, err
66+ }
67+ return &Store{root: root}, nil
68+}
69+
70+// LoadAccount reads the account from <root>/account.json.
71+// It returns nil, nil when the file does not exist.
72+func (s *Store) LoadAccount(_ context.Context) (*out.ACMEAccount, error) {
73+ path := filepath.Join(s.root, accountFile)
74+ data, err := os.ReadFile(path)
75+ if err != nil {
76+ if os.IsNotExist(err) {
77+ return nil, nil
78+ }
79+ return nil, fmt.Errorf("acmestore: read account: %w", err)
80+ }
81+ var acct out.ACMEAccount
82+ if err := json.Unmarshal(data, &acct); err != nil {
83+ return nil, fmt.Errorf("acmestore: unmarshal account: %w", err)
84+ }
85+ return &acct, nil
86+}
87+
88+// SaveAccount writes the account to <root>/account.json atomically with mode 0600.
89+func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error {
90+ data, err := json.MarshalIndent(account, "", " ")
91+ if err != nil {
92+ return fmt.Errorf("acmestore: marshal account: %w", err)
93+ }
94+ if err := writeAtomic(filepath.Join(s.root, accountFile), data, accountMode); err != nil {
95+ return fmt.Errorf("acmestore: save account: %w", err)
96+ }
97+ return nil
98+}
99+
100+// LoadAll reads all certificate directories under <root>/certs/ and returns
101+// the stored certificates. Directories with unsafe IDs are skipped.
102+func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
103+ certsPath := filepath.Join(s.root, certDir)
104+ entries, err := os.ReadDir(certsPath)
105+ if err != nil {
106+ if os.IsNotExist(err) {
107+ return nil, nil
108+ }
109+ return nil, fmt.Errorf("acmestore: list certs: %w", err)
110+ }
111+
112+ var result []out.StoredCertificate
113+ for _, entry := range entries {
114+ if !entry.IsDir() {
115+ continue
116+ }
117+ id := entry.Name()
118+ if !safeID(id) {
119+ continue
120+ }
121+ // Skip temporary and backup directories created by Save.
122+ if isStoreInternalCertDir(id) {
123+ continue
124+ }
125+
126+ cert, err := s.loadOne(id)
127+ if err != nil {
128+ return nil, fmt.Errorf("acmestore: load %s: %w", id, err)
129+ }
130+ result = append(result, *cert)
131+ }
132+ return result, nil
133+}
134+
135+// loadOne reads a single certificate directory and returns the stored
136+// certificate.
137+func (s *Store) loadOne(id string) (*out.StoredCertificate, error) {
138+ dir := filepath.Join(s.root, certDir, id)
139+
140+ meta, err := readMetadata(filepath.Join(dir, metaFile))
141+ if err != nil {
142+ return nil, fmt.Errorf("acmestore: metadata %s: %w", id, err)
143+ }
144+
145+ certPEM, err := readPEM(filepath.Join(dir, certFile))
146+ if err != nil {
147+ return nil, fmt.Errorf("acmestore: read %s cert.pem: %w", id, err)
148+ }
149+ chainPEM, err := readPEM(filepath.Join(dir, chainFile))
150+ if err != nil {
151+ return nil, fmt.Errorf("acmestore: read %s chain.pem: %w", id, err)
152+ }
153+ fullchainPEM, err := readPEM(filepath.Join(dir, fullchainFile))
154+ if err != nil {
155+ return nil, fmt.Errorf("acmestore: read %s fullchain.pem: %w", id, err)
156+ }
157+ privKeyPEM, err := readPEM(filepath.Join(dir, privkeyFile))
158+ if err != nil {
159+ return nil, fmt.Errorf("acmestore: read %s privkey.pem: %w", id, err)
160+ }
161+
162+ var tlsCert tls.Certificate
163+ if len(fullchainPEM) > 0 && len(privKeyPEM) > 0 {
164+ parsed, err := tls.X509KeyPair(fullchainPEM, privKeyPEM)
165+ if err != nil {
166+ return nil, fmt.Errorf("acmestore: parse key pair %s: %w", id, err)
167+ }
168+ tlsCert = parsed
169+ }
170+
171+ challenge, err := domain.ParseACMEChallengeMode(meta.Challenge)
172+ if err != nil {
173+ return nil, fmt.Errorf("acmestore: challenge %s: %w", id, err)
174+ }
175+
176+ return &out.StoredCertificate{
177+ ID: id,
178+ Names: meta.Names,
179+ Challenge: challenge,
180+ Certificate: tlsCert,
181+ CertPEM: certPEM,
182+ ChainPEM: chainPEM,
183+ FullchainPEM: fullchainPEM,
184+ PrivateKeyPEM: privKeyPEM,
185+ NotAfter: meta.NotAfter,
186+ LastError: meta.LastError,
187+ }, nil
188+}
189+
190+// Save persists a certificate to <root>/certs/<safe-id>/.
191+// All PEM files and metadata are written atomically.
192+func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
193+ if !safeID(cert.ID) {
194+ return fmt.Errorf("acmestore: unsafe certificate id %q: %w", cert.ID, domain.ErrPathTraversal)
195+ }
196+
197+ parent := filepath.Join(s.root, certDir)
198+ if err := os.MkdirAll(parent, dirMode); err != nil {
199+ return fmt.Errorf("acmestore: mkdir certs: %w", err)
200+ }
201+ if err := recoverCertificateDir(parent, cert.ID); err != nil {
202+ return fmt.Errorf("acmestore: recover cert %s: %w", cert.ID, err)
203+ }
204+
205+ dir := filepath.Join(parent, cert.ID)
206+ tmpDir, err := os.MkdirTemp(parent, cert.ID+".tmp-*")
207+ if err != nil {
208+ return fmt.Errorf("acmestore: create temp dir: %w", err)
209+ }
210+ committed := false
211+ defer func() {
212+ if !committed {
213+ os.RemoveAll(tmpDir)
214+ }
215+ }()
216+
217+ if err := writeCertificateFiles(tmpDir, cert); err != nil {
218+ return err
219+ }
220+
221+ backupDir := dir + backupSuffix
222+ _ = os.RemoveAll(backupDir)
223+ hadExisting := false
224+ if err := os.Rename(dir, backupDir); err != nil {
225+ if !os.IsNotExist(err) {
226+ return fmt.Errorf("acmestore: backup existing cert dir: %w", err)
227+ }
228+ } else {
229+ hadExisting = true
230+ }
231+
232+ if err := os.Rename(tmpDir, dir); err != nil {
233+ if hadExisting {
234+ _ = os.Rename(backupDir, dir)
235+ }
236+ return fmt.Errorf("acmestore: commit cert dir: %w", err)
237+ }
238+ committed = true
239+ if hadExisting {
240+ _ = os.RemoveAll(backupDir)
241+ }
242+ return nil
243+}
244+
245+func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
246+ meta := certMetadata{
247+ ID: cert.ID,
248+ Names: cert.Names,
249+ Challenge: string(cert.Challenge),
250+ NotAfter: cert.NotAfter,
251+ LastError: cert.LastError,
252+ }
253+
254+ if err := writeFileIfNonNil(filepath.Join(dir, certFile), cert.CertPEM, pemMode); err != nil {
255+ return fmt.Errorf("acmestore: write cert.pem: %w", err)
256+ }
257+ if err := writeFileIfNonNil(filepath.Join(dir, chainFile), cert.ChainPEM, pemMode); err != nil {
258+ return fmt.Errorf("acmestore: write chain.pem: %w", err)
259+ }
260+ if err := writeFileIfNonNil(filepath.Join(dir, fullchainFile), cert.FullchainPEM, pemMode); err != nil {
261+ return fmt.Errorf("acmestore: write fullchain.pem: %w", err)
262+ }
263+ if err := writeFileIfNonNil(filepath.Join(dir, privkeyFile), cert.PrivateKeyPEM, privKeyMode); err != nil {
264+ return fmt.Errorf("acmestore: write privkey.pem: %w", err)
265+ }
266+
267+ metaData, err := json.MarshalIndent(meta, "", " ")
268+ if err != nil {
269+ return fmt.Errorf("acmestore: marshal metadata: %w", err)
270+ }
271+ if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil {
272+ return fmt.Errorf("acmestore: write metadata: %w", err)
273+ }
274+ return nil
275+}
276+
277+// Lock acquires an exclusive advisory lock using a lock file at <root>/.lock.
278+// It is fail-fast and non-blocking: if another live process holds the lock,
279+// Lock returns an error immediately without waiting. Context cancellation is
280+// respected before attempting acquisition. The returned unlock function releases
281+// the OS advisory lock, closes the file descriptor, and removes the lock file.
282+func (s *Store) Lock(ctx context.Context) (func() error, error) {
283+ select {
284+ case <-ctx.Done():
285+ return nil, ctx.Err()
286+ default:
287+ }
288+
289+ path := filepath.Join(s.root, lockFile)
290+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, lockMode)
291+ if err != nil {
292+ return nil, fmt.Errorf("acmestore: open lock file: %w", err)
293+ }
294+
295+ fd, err := fileDescriptor(f)
296+ if err != nil {
297+ _ = f.Close()
298+ return nil, err
299+ }
300+ if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
301+ _ = f.Close()
302+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
303+ return nil, ErrLockHeld
304+ }
305+ return nil, fmt.Errorf("acmestore: acquire lock: %w", err)
306+ }
307+
308+ if err := writeLockMetadata(f); err != nil {
309+ _ = syscall.Flock(fd, syscall.LOCK_UN)
310+ _ = f.Close()
311+ return nil, err
312+ }
313+
314+ return func() error {
315+ return releaseLockFile(f, fd)
316+ }, nil
317+}
318+
319+func writeLockMetadata(f *os.File) error {
320+ if err := f.Truncate(0); err != nil {
321+ return fmt.Errorf("acmestore: truncate lock file: %w", err)
322+ }
323+ if _, err := f.Seek(0, 0); err != nil {
324+ return fmt.Errorf("acmestore: seek lock file: %w", err)
325+ }
326+ metadata := fmt.Sprintf("pid=%d\nhost=%s\ncreated_at=%s\n", os.Getpid(), hostname(), time.Now().UTC().Format(time.RFC3339Nano))
327+ if _, err := f.WriteString(metadata); err != nil {
328+ return fmt.Errorf("acmestore: write lock metadata: %w", err)
329+ }
330+ return nil
331+}
332+
333+func releaseLockFile(f *os.File, fd int) error {
334+ var errs []error
335+ if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
336+ errs = append(errs, fmt.Errorf("unlock: %w", err))
337+ }
338+ if err := f.Close(); err != nil {
339+ errs = append(errs, fmt.Errorf("close: %w", err))
340+ }
341+ if err := errors.Join(errs...); err != nil {
342+ return fmt.Errorf("acmestore: release lock: %w", err)
343+ }
344+ return nil
345+}
346+
347+func fileDescriptor(f *os.File) (int, error) {
348+ fd := f.Fd()
349+ if fd > uintptr(^uint(0)>>1) {
350+ return 0, fmt.Errorf("acmestore: lock file descriptor overflows int")
351+ }
352+ return int(fd), nil
353+}
354+
355+func hostname() string {
356+ host, err := os.Hostname()
357+ if err != nil {
358+ return "unknown"
359+ }
360+ return host
361+}
362+
363+func ensureCertsDir(root string) (string, error) {
364+ certsPath := filepath.Join(root, certDir)
365+ if err := os.MkdirAll(certsPath, dirMode); err != nil {
366+ return "", fmt.Errorf("acmestore: mkdir certs: %w", err)
367+ }
368+ if err := recoverCertificateDirs(certsPath); err != nil {
369+ return "", fmt.Errorf("acmestore: recover certs: %w", err)
370+ }
371+ return certsPath, nil
372+}
373+
374+func isStoreInternalTempDir(id string) bool {
375+ return strings.Contains(id, tempInfix)
376+}
377+
378+func isStoreInternalCertDir(id string) bool {
379+ return isStoreInternalTempDir(id) || strings.HasSuffix(id, backupSuffix)
380+}
381+
382+func recoverCertificateDir(certsPath, id string) error {
383+ if !safeID(id) {
384+ return nil
385+ }
386+ backupDir := filepath.Join(certsPath, id+backupSuffix)
387+ dir := filepath.Join(certsPath, id)
388+ if _, err := os.Stat(dir); err == nil {
389+ return nil
390+ } else if !os.IsNotExist(err) {
391+ return err
392+ }
393+ if _, err := os.Stat(backupDir); err != nil {
394+ if os.IsNotExist(err) {
395+ return nil
396+ }
397+ return err
398+ }
399+ if err := os.Rename(backupDir, dir); err != nil {
400+ return fmt.Errorf("restore backup %s: %w", filepath.Base(backupDir), err)
401+ }
402+ return nil
403+}
404+
405+func recoverCertificateDirs(certsPath string) error {
406+ entries, err := os.ReadDir(certsPath)
407+ if err != nil {
408+ if os.IsNotExist(err) {
409+ return nil
410+ }
411+ return err
412+ }
413+ for _, entry := range entries {
414+ if !entry.IsDir() {
415+ continue
416+ }
417+ name := entry.Name()
418+ if isStoreInternalTempDir(name) {
419+ if err := os.RemoveAll(filepath.Join(certsPath, name)); err != nil {
420+ return fmt.Errorf("remove temp %s: %w", name, err)
421+ }
422+ continue
423+ }
424+ if base, ok := strings.CutSuffix(name, backupSuffix); ok {
425+ if err := recoverCertificateDir(certsPath, base); err != nil {
426+ return err
427+ }
428+ }
429+ }
430+ return nil
431+}
432+
433+// safeID rejects IDs that could cause path traversal or collide with store-internal artifacts.
434+func safeID(id string) bool {
435+ return id != "" && !strings.HasPrefix(id, ".") && !isStoreInternalCertDir(id) && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
436+}
437+
438+// writeAtomic writes data to path atomically by writing to a temporary file
439+// in the same directory and then renaming.
440+func writeAtomic(path string, data []byte, mode os.FileMode) error {
441+ dir := filepath.Dir(path)
442+ tmp, err := os.CreateTemp(dir, ".tmp-*")
443+ if err != nil {
444+ return fmt.Errorf("create temp: %w", err)
445+ }
446+ tmpName := tmp.Name()
447+
448+ // Clean up temp file on any failure
449+ removeOnErr := true
450+ defer func() {
451+ if removeOnErr {
452+ os.Remove(tmpName)
453+ }
454+ }()
455+
456+ if err := tmp.Chmod(mode); err != nil {
457+ tmp.Close()
458+ return fmt.Errorf("chmod temp: %w", err)
459+ }
460+ if _, err := tmp.Write(data); err != nil {
461+ tmp.Close()
462+ return fmt.Errorf("write temp: %w", err)
463+ }
464+ // Sync before close to ensure data is on disk.
465+ if err := tmp.Sync(); err != nil {
466+ tmp.Close()
467+ return fmt.Errorf("sync temp: %w", err)
468+ }
469+ if err := tmp.Close(); err != nil {
470+ return fmt.Errorf("close temp: %w", err)
471+ }
472+ if err := os.Rename(tmpName, path); err != nil {
473+ return fmt.Errorf("rename temp: %w", err)
474+ }
475+ removeOnErr = false
476+ return nil
477+}
478+
479+// writeFileIfNonNil writes data to path atomically only when data is non-nil.
480+func writeFileIfNonNil(path string, data []byte, mode os.FileMode) error {
481+ if data == nil {
482+ return nil
483+ }
484+ return writeAtomic(path, data, mode)
485+}
486+
487+// readPEM reads a PEM file from path. If the file does not exist it returns
488+// nil, nil without error. Other read errors are returned.
489+func readPEM(path string) ([]byte, error) {
490+ data, err := os.ReadFile(path)
491+ if err != nil {
492+ if os.IsNotExist(err) {
493+ return nil, nil
494+ }
495+ return nil, err
496+ }
497+ return data, nil
498+}
499+
500+// readMetadata reads and unmarshals a metadata JSON file.
501+func readMetadata(path string) (*certMetadata, error) {
502+ data, err := os.ReadFile(path)
503+ if err != nil {
504+ return nil, fmt.Errorf("read: %w", err)
505+ }
506+ var meta certMetadata
507+ if err := json.Unmarshal(data, &meta); err != nil {
508+ return nil, fmt.Errorf("unmarshal: %w", err)
509+ }
510+ return &meta, nil
511+}
A internal/adapters/out/acmestore/store_test.go +417 -0
@@ -0,0 +1,417 @@
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+ for _, id := range []string{"../escape", ".", ".hidden", "tenant.old", "tenant.tmp-123"} {
132+ t.Run(id, func(t *testing.T) {
133+ cert := out.StoredCertificate{
134+ ID: id,
135+ PrivateKeyPEM: []byte("key"),
136+ }
137+
138+ err = store.Save(ctx, cert)
139+ require.Error(t, err)
140+ assert.Contains(t, err.Error(), "unsafe certificate id")
141+ assert.ErrorIs(t, err, domain.ErrPathTraversal)
142+ })
143+ }
144+}
145+
146+func TestStoreLockAcquireRelease(t *testing.T) {
147+ root := t.TempDir()
148+ ctx := context.Background()
149+
150+ store, err := New(root)
151+ require.NoError(t, err)
152+
153+ unlock, err := store.Lock(ctx)
154+ require.NoError(t, err)
155+ assert.FileExists(t, filepath.Join(root, lockFile))
156+
157+ _, err = store.Lock(ctx)
158+ require.Error(t, err)
159+ assert.Contains(t, err.Error(), "lock already held")
160+
161+ require.NoError(t, unlock())
162+ assert.FileExists(t, filepath.Join(root, lockFile))
163+
164+ unlock, err = store.Lock(ctx)
165+ require.NoError(t, err)
166+ require.NoError(t, unlock())
167+}
168+
169+func TestStoreLockContextCanceled(t *testing.T) {
170+ root := t.TempDir()
171+ store, err := New(root)
172+ require.NoError(t, err)
173+
174+ ctx, cancel := context.WithCancel(context.Background())
175+ cancel()
176+
177+ _, err = store.Lock(ctx)
178+ require.ErrorIs(t, err, context.Canceled)
179+ assert.NoFileExists(t, filepath.Join(root, lockFile))
180+}
181+
182+func TestStoreNewRestoresBackupDir(t *testing.T) {
183+ root := t.TempDir()
184+ ctx := context.Background()
185+
186+ store, err := New(root)
187+ require.NoError(t, err)
188+
189+ fullchainPEM, privKeyPEM := generateTestCertPEM(t)
190+ cert := out.StoredCertificate{
191+ ID: "restore.example.com",
192+ Names: []string{"restore.example.com"},
193+ Challenge: domain.ACMEChallengeHTTP01,
194+ CertPEM: fullchainPEM,
195+ FullchainPEM: fullchainPEM,
196+ PrivateKeyPEM: privKeyPEM,
197+ }
198+ require.NoError(t, store.Save(ctx, cert))
199+
200+ certPath := filepath.Join(root, certDir, cert.ID)
201+ backupPath := certPath + ".old"
202+ require.NoError(t, os.Rename(certPath, backupPath))
203+ require.NoDirExists(t, certPath)
204+
205+ store, err = New(root)
206+ require.NoError(t, err)
207+ assert.DirExists(t, certPath)
208+
209+ certs, err := store.LoadAll(ctx)
210+ require.NoError(t, err)
211+ require.Len(t, certs, 1)
212+ assert.Equal(t, cert.ID, certs[0].ID)
213+}
214+
215+func TestStoreSaveLoadAccount(t *testing.T) {
216+ root := t.TempDir()
217+ ctx := context.Background()
218+
219+ store, err := New(root)
220+ require.NoError(t, err)
221+
222+ acct := out.ACMEAccount{
223+ Email: "admin@example.com",
224+ PrivateKeyPEM: []byte("key"),
225+ RegistrationURI: "https://acme.test/acct/1",
226+ BodyJSON: []byte(`{"status":"valid"}`),
227+ }
228+
229+ err = store.SaveAccount(ctx, acct)
230+ require.NoError(t, err)
231+
232+ loaded, err := store.LoadAccount(ctx)
233+ require.NoError(t, err)
234+ require.NotNil(t, loaded)
235+
236+ assert.Equal(t, "admin@example.com", loaded.Email)
237+ assert.Equal(t, []byte("key"), loaded.PrivateKeyPEM)
238+ assert.Equal(t, "https://acme.test/acct/1", loaded.RegistrationURI)
239+ assert.Equal(t, []byte(`{"status":"valid"}`), loaded.BodyJSON)
240+}
241+
242+func TestStoreAccountFileMode(t *testing.T) {
243+ root := t.TempDir()
244+ ctx := context.Background()
245+
246+ store, err := New(root)
247+ require.NoError(t, err)
248+
249+ acct := out.ACMEAccount{
250+ Email: "test@example.com",
251+ PrivateKeyPEM: []byte("test-key"),
252+ RegistrationURI: "https://acme.test/acct/99",
253+ BodyJSON: []byte(`{"status":"valid"}`),
254+ }
255+
256+ err = store.SaveAccount(ctx, acct)
257+ require.NoError(t, err)
258+
259+ accountPath := filepath.Join(root, accountFile)
260+ info, err := os.Stat(accountPath)
261+ require.NoError(t, err)
262+
263+ assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
264+}
265+
266+func TestStoreLoadAllInvalidChallenge(t *testing.T) {
267+ root := t.TempDir()
268+ ctx := context.Background()
269+
270+ store, err := New(root)
271+ require.NoError(t, err)
272+
273+ // Create a cert dir manually with invalid challenge in metadata
274+ certDirPath := filepath.Join(root, certDir, "bad-challenge.example.com")
275+ err = os.MkdirAll(certDirPath, dirMode)
276+ require.NoError(t, err)
277+
278+ meta := certMetadata{
279+ ID: "bad-challenge.example.com",
280+ Names: []string{"bad-challenge.example.com"},
281+ Challenge: "invalid-challenge-mode",
282+ }
283+ metaData, err := json.MarshalIndent(meta, "", " ")
284+ require.NoError(t, err)
285+ err = os.WriteFile(filepath.Join(certDirPath, metaFile), metaData, metaMode)
286+ require.NoError(t, err)
287+
288+ // LoadAll should return an error wrapping domain.ErrACMEChallengeInvalid
289+ _, err = store.LoadAll(ctx)
290+ require.Error(t, err)
291+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
292+}
293+
294+func TestStoreLoadAllCorruptPrivkey(t *testing.T) {
295+ root := t.TempDir()
296+ ctx := context.Background()
297+
298+ store, err := New(root)
299+ require.NoError(t, err)
300+
301+ // Save a valid cert first
302+ cert := out.StoredCertificate{
303+ ID: "corrupt-privkey.example.com",
304+ Names: []string{"corrupt-privkey.example.com"},
305+ Challenge: domain.ACMEChallengeHTTP01,
306+ FullchainPEM: []byte("fullchain-data"),
307+ PrivateKeyPEM: []byte("privkey-data"),
308+ }
309+ err = store.Save(ctx, cert)
310+ require.NoError(t, err)
311+
312+ // Replace privkey.pem with an unreadable directory (simulate corruption)
313+ privkeyPath := filepath.Join(root, certDir, "corrupt-privkey.example.com", privkeyFile)
314+ err = os.Remove(privkeyPath)
315+ require.NoError(t, err)
316+ // Create a directory in its place to cause a read error
317+ err = os.MkdirAll(privkeyPath, 0700)
318+ require.NoError(t, err)
319+
320+ // LoadAll should return an error
321+ _, err = store.LoadAll(ctx)
322+ require.Error(t, err)
323+ assert.Contains(t, err.Error(), "privkey.pem")
324+}
325+
326+func TestStoreLoadAllCorruptFullchain(t *testing.T) {
327+ root := t.TempDir()
328+ ctx := context.Background()
329+
330+ store, err := New(root)
331+ require.NoError(t, err)
332+
333+ // Save a valid cert first
334+ cert := out.StoredCertificate{
335+ ID: "corrupt-fullchain.example.com",
336+ Names: []string{"corrupt-fullchain.example.com"},
337+ Challenge: domain.ACMEChallengeHTTP01,
338+ FullchainPEM: []byte("fullchain-data"),
339+ PrivateKeyPEM: []byte("privkey-data"),
340+ }
341+ err = store.Save(ctx, cert)
342+ require.NoError(t, err)
343+
344+ // Replace fullchain.pem with an unreadable directory
345+ fullchainPath := filepath.Join(root, certDir, "corrupt-fullchain.example.com", fullchainFile)
346+ err = os.Remove(fullchainPath)
347+ require.NoError(t, err)
348+ err = os.MkdirAll(fullchainPath, 0700)
349+ require.NoError(t, err)
350+
351+ // LoadAll should return an error
352+ _, err = store.LoadAll(ctx)
353+ require.Error(t, err)
354+ assert.Contains(t, err.Error(), "fullchain.pem")
355+}
356+
357+func TestStoreLoadAllSkipsTempAndBackupDirs(t *testing.T) {
358+ root := t.TempDir()
359+ ctx := context.Background()
360+
361+ store, err := New(root)
362+ require.NoError(t, err)
363+
364+ fullchainPEM, privKeyPEM := generateTestCertPEM(t)
365+
366+ // Save a valid certificate first.
367+ cert := out.StoredCertificate{
368+ ID: "valid.example.com",
369+ Names: []string{"valid.example.com"},
370+ Challenge: domain.ACMEChallengeHTTP01,
371+ CertPEM: fullchainPEM,
372+ FullchainPEM: fullchainPEM,
373+ PrivateKeyPEM: privKeyPEM,
374+ }
375+ err = store.Save(ctx, cert)
376+ require.NoError(t, err)
377+
378+ // Create a tmp-* directory that Save would have left behind.
379+ tmpDir := filepath.Join(root, certDir, "orphan.tmp-abc123")
380+ err = os.MkdirAll(tmpDir, dirMode)
381+ require.NoError(t, err)
382+
383+ // Create a .old directory that Save would have left behind.
384+ oldDir := filepath.Join(root, certDir, "old-cert.example.com.old")
385+ err = os.MkdirAll(oldDir, dirMode)
386+ require.NoError(t, err)
387+
388+ // LoadAll should return only the valid cert, skipping both temp and backup dirs.
389+ certs, err := store.LoadAll(ctx)
390+ require.NoError(t, err)
391+ require.Len(t, certs, 1, "should skip tmp-* and *.old directories")
392+ assert.Equal(t, "valid.example.com", certs[0].ID)
393+}
394+
395+func TestStoreLoadAllInvalidPEM(t *testing.T) {
396+ root := t.TempDir()
397+ ctx := context.Background()
398+
399+ store, err := New(root)
400+ require.NoError(t, err)
401+
402+ // Save a cert with non-empty but invalid PEM bytes (mismatched key)
403+ cert := out.StoredCertificate{
404+ ID: "invalid-pem.example.com",
405+ Names: []string{"invalid-pem.example.com"},
406+ Challenge: domain.ACMEChallengeHTTP01,
407+ FullchainPEM: []byte("invalid-fullchain"),
408+ PrivateKeyPEM: []byte("invalid-privkey"),
409+ }
410+ err = store.Save(ctx, cert)
411+ require.NoError(t, err)
412+
413+ // LoadAll should return an error because tls.X509KeyPair fails
414+ _, err = store.LoadAll(ctx)
415+ require.Error(t, err)
416+ assert.Contains(t, err.Error(), "parse key pair")
417+}
A internal/adapters/out/secrets/public_tls_resolver.go +142 -0
@@ -0,0 +1,142 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+const defaultCloudflareTokenPassName = "gordon/cloudflare/api-token"
18+
19+// Package-level disabled logger and pass provider reused across calls.
20+var disabledLogger = zerowrap.New(zerowrap.Config{Level: "disabled"})
21+var defaultPassProvider = NewPassProvider(disabledLogger)
22+
23+// PublicTLSResolverConfig holds optional overrides for PublicTLSResolver.
24+type PublicTLSResolverConfig struct {
25+ PassLookup func(ctx context.Context, name string) (string, error)
26+ EnvLookup func(key string) string
27+}
28+
29+// PublicTLSResolver resolves Cloudflare API tokens using pass, file, or env.
30+type PublicTLSResolver struct {
31+ passLookup func(ctx context.Context, name string) (string, error)
32+ envLookup func(key string) string
33+}
34+
35+var _ out.SecretResolver = (*PublicTLSResolver)(nil)
36+
37+// NewPublicTLSResolver creates a new PublicTLSResolver with the given config.
38+// Defaults PassLookup to defaultPassLookup and EnvLookup to os.Getenv.
39+func NewPublicTLSResolver(cfg PublicTLSResolverConfig) *PublicTLSResolver {
40+ r := &PublicTLSResolver{
41+ passLookup: defaultPassLookup,
42+ envLookup: os.Getenv,
43+ }
44+ if cfg.PassLookup != nil {
45+ r.passLookup = cfg.PassLookup
46+ }
47+ if cfg.EnvLookup != nil {
48+ r.envLookup = cfg.EnvLookup
49+ }
50+ return r
51+}
52+
53+// ResolveCloudflareToken resolves the Cloudflare API token.
54+// Lookup order: pass, token file env, token env.
55+func (r *PublicTLSResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) {
56+ var passErr error
57+ var fileErr error
58+
59+ // 1. Try pass
60+ if token, err := r.passLookup(ctx, defaultCloudflareTokenPassName); err == nil {
61+ if trimmed := strings.TrimSpace(token); trimmed != "" {
62+ return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourcePass}, nil
63+ }
64+ } else if !errors.Is(err, os.ErrNotExist) {
65+ passErr = err
66+ }
67+
68+ // 2. Try token file env
69+ if filePath := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN_FILE"); filePath != "" {
70+ if data, err := readFileWithTimeout(ctx, filePath, 5*time.Second); err == nil {
71+ if trimmed := strings.TrimSpace(string(data)); trimmed != "" {
72+ return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceFile}, nil
73+ }
74+ } else {
75+ fileErr = fmt.Errorf("read token file: %w", err)
76+ }
77+ }
78+
79+ // 3. Try token env
80+ if token := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN"); token != "" {
81+ if trimmed := strings.TrimSpace(token); trimmed != "" {
82+ return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceEnv}, nil
83+ }
84+ }
85+
86+ // 4. No token found — return remembered errors if any
87+ if passErr != nil {
88+ return out.SecretValue{Source: domain.ACMETokenSourceNone}, fmt.Errorf("resolve pass token: %w", passErr)
89+ }
90+ if fileErr != nil {
91+ return out.SecretValue{Source: domain.ACMETokenSourceNone}, fileErr
92+ }
93+ return out.SecretValue{Source: domain.ACMETokenSourceNone}, nil
94+}
95+
96+// defaultPassLookup executes "pass show <name>" with a 2-second timeout.
97+// Returns the raw output with trailing newline. Never logs token values.
98+func defaultPassLookup(ctx context.Context, name string) (string, error) {
99+ // Only allow the known Cloudflare token pass name.
100+ if name != defaultCloudflareTokenPassName {
101+ return "", fmt.Errorf("pass lookup: disallowed name %q", name)
102+ }
103+
104+ return defaultPassProvider.GetSecret(ctx, name)
105+}
106+
107+// readFileWithTimeout reads a file with a context-aware deadline.
108+// It runs os.ReadFile in a goroutine and selects on context expiry.
109+// If the context has a deadline, it is used; otherwise a timeout is applied.
110+func readFileWithTimeout(ctx context.Context, path string, timeout time.Duration) ([]byte, error) {
111+ readCtx := ctx
112+ if _, hasDeadline := ctx.Deadline(); !hasDeadline {
113+ var cancel context.CancelFunc
114+ readCtx, cancel = context.WithTimeout(ctx, timeout)
115+ defer cancel()
116+ }
117+
118+ type result struct {
119+ data []byte
120+ err error
121+ }
122+
123+ ch := make(chan result, 1)
124+ go func() {
125+ // os.ReadFile is not context-cancellable. The buffered channel ensures
126+ // this goroutine can finish without blocking if readCtx expires first;
127+ // for local config files this trade-off is acceptable, though reads on
128+ // slow NFS/FUSE mounts may continue after cancellation.
129+ data, err := os.ReadFile(path)
130+ select {
131+ case ch <- result{data: data, err: err}:
132+ case <-readCtx.Done():
133+ }
134+ }()
135+
136+ select {
137+ case <-readCtx.Done():
138+ return nil, readCtx.Err()
139+ case r := <-ch:
140+ return r.data, r.err
141+ }
142+}
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 +3 -3
@@ -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(domainName string, keys []string) {
3636 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
3737 if err != nil {
3838 return
@@ -60,7 +60,7 @@ func TestMigrateEnvFile(t *testing.T) {
6060
6161 domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
6262 keys := []string{"API_KEY", "DB_PASSWORD"}
63- defer cleanupPassDomain(t, domainName, keys)
63+ defer cleanupPassDomain(domainName, keys)
6464
6565 envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n"
6666 envFileName := domainName + ".env"
@@ -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 +151 -0
@@ -0,0 +1,151 @@
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/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
19+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
20+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
21+ "github.com/bnema/gordon/internal/domain"
22+ pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
23+ "github.com/bnema/zerowrap"
24+)
25+
26+// generateTestCert creates a self-signed certificate for the given hostnames.
27+func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
28+ t.Helper()
29+
30+ privKey, err := rsa.GenerateKey(rand.Reader, 2048)
31+ require.NoError(t, err)
32+
33+ template := &x509.Certificate{
34+ SerialNumber: big.NewInt(1),
35+ NotBefore: time.Now().Add(-1 * time.Hour),
36+ NotAfter: time.Now().Add(24 * time.Hour),
37+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
38+ }
39+ for _, host := range hosts {
40+ if ip := net.ParseIP(host); ip != nil {
41+ template.IPAddresses = append(template.IPAddresses, ip)
42+ } else {
43+ template.DNSNames = append(template.DNSNames, host)
44+ }
45+ }
46+
47+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
48+ require.NoError(t, err)
49+
50+ return tls.Certificate{
51+ Certificate: [][]byte{certDER},
52+ PrivateKey: privKey,
53+ }
54+}
55+
56+// TestCertificateSelector_StaticCertWins tests that a static certificate matching
57+// the server name is returned before public TLS.
58+func TestCertificateSelector_StaticCertWins(t *testing.T) {
59+ staticCert := generateTestCert(t, "static.example.com")
60+ selector := &certificateSelector{
61+ staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
62+ publicTLS: inmocks.NewMockPublicTLSService(t),
63+ }
64+
65+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})
66+ require.NoError(t, err)
67+ require.NotNil(t, got)
68+ assert.Equal(t, staticCert.Certificate[0], got.Certificate[0],
69+ "static cert should be returned, not public TLS")
70+}
71+
72+func TestCertificateSelector_PublicTLSErrorFallsThroughWithoutLocalPKI(t *testing.T) {
73+ publicTLS := inmocks.NewMockPublicTLSService(t)
74+ publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
75+ selector := &certificateSelector{publicTLS: publicTLS}
76+
77+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
78+ require.NoError(t, err)
79+ assert.Nil(t, got, "public TLS error with no local PKI returns nil,nil")
80+}
81+
82+// TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
83+// nil,nil and no local PKI, the selector returns nil,nil.
84+func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T) {
85+ publicTLS := inmocks.NewMockPublicTLSService(t)
86+ publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
87+
88+ routes := outmocks.NewMockRouteChecker(t)
89+ routes.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "acme-required.example.com"}}).Maybe()
90+ routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
91+ ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
92+ require.NoError(t, err)
93+ pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())
94+ defer pkiSvc.Stop()
95+
96+ selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}
97+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
98+ require.NoError(t, err)
99+ require.NotNil(t, got, "public TLS error should fall through to local PKI")
100+}
101+
102+func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
103+ publicTLS := inmocks.NewMockPublicTLSService(t)
104+ publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil)
105+ selector := &certificateSelector{publicTLS: publicTLS}
106+
107+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
108+ require.NoError(t, err)
109+ assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil")
110+}
111+
112+// TestCertificateSelector_NoSource tests that when no source has a matching cert,
113+// nil, nil is returned.
114+func TestCertificateSelector_NoSource(t *testing.T) {
115+ selector := &certificateSelector{}
116+
117+ got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"})
118+ assert.NoError(t, err)
119+ assert.Nil(t, got)
120+}
121+
122+// TestMatchingStaticCert tests the matchingStaticCert helper.
123+func TestMatchingStaticCert(t *testing.T) {
124+ certA := generateTestCert(t, "app.example.com")
125+ certB := generateTestCert(t, "api.example.com")
126+
127+ certs := []tls.Certificate{certA, certB}
128+
129+ // Exact match
130+ got := matchingStaticCert(certs, "app.example.com")
131+ require.NotNil(t, got)
132+ assert.Equal(t, certA.Certificate[0], got.Certificate[0])
133+
134+ // Second cert match
135+ got = matchingStaticCert(certs, "api.example.com")
136+ require.NotNil(t, got)
137+ assert.Equal(t, certB.Certificate[0], got.Certificate[0])
138+
139+ // No match
140+ got = matchingStaticCert(certs, "other.example.com")
141+ assert.Nil(t, got)
142+
143+ // Empty server name returns the first configured static certificate.
144+ got = matchingStaticCert(certs, "")
145+ require.NotNil(t, got)
146+ assert.Equal(t, certA.Certificate[0], got.Certificate[0])
147+
148+ // Empty cert list
149+ got = matchingStaticCert(nil, "app.example.com")
150+ assert.Nil(t, got)
151+}
M internal/app/run.go +295 -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"
@@ -45,6 +48,7 @@ import (
4548
4649 // Adapters - Input
4750 "github.com/bnema/gordon/internal/adapters/dto"
51+ acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme"
4852 "github.com/bnema/gordon/internal/adapters/in/http/admin"
4953 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
5054 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
@@ -54,6 +58,7 @@ import (
5458 "github.com/bnema/gordon/internal/adapters/in/http/registry"
5559
5660 // Boundaries
61+ "github.com/bnema/gordon/internal/boundaries/in"
5762 "github.com/bnema/gordon/internal/boundaries/out"
5863
5964 // Domain
@@ -75,6 +80,7 @@ import (
7580 "github.com/bnema/gordon/internal/usecase/logs"
7681 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
7782 "github.com/bnema/gordon/internal/usecase/proxy"
83+ "github.com/bnema/gordon/internal/usecase/publictls"
7884 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
7985 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
8086 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
@@ -189,6 +195,14 @@ type Config struct {
189195 } `mapstructure:"containers"`
190196
191197 Telemetry telemetry.Config `mapstructure:"telemetry"`
198+
199+ TLS struct {
200+ ACME struct {
201+ Enabled bool `mapstructure:"enabled"`
202+ Email string `mapstructure:"email"`
203+ Challenge string `mapstructure:"challenge"`
204+ } `mapstructure:"acme"`
205+ } `mapstructure:"tls"`
192206 }
193207
194208 // services holds all the services used by the application.
@@ -224,6 +238,7 @@ type services struct {
224238 caAdapter *pkiadapter.CA
225239 pkiSvc *pkiusecase.Service
226240 reloadCoordinator *reloadCoordinator
241+ publicTLSSvc in.PublicTLSService
227242 registryHandler interface {
228243 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
229244 }
@@ -401,27 +416,53 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
401416 return writer, nil
402417 }
403418
419+// serviceOptions controls optional behavior for createServicesWithOptions.
420+type serviceOptions struct {
421+ // StartPublicTLS controls whether ACME Reconcile and renewal loop are started.
422+ // Server runtime sets true; Kernel/local CLI sets false (read-only status).
423+ StartPublicTLS bool
424+}
425+
404426 // serviceInit holds the shared context for service initialization helpers.
405427 type serviceInit struct {
406- ctx context.Context
407- v *viper.Viper
408- cfg Config
409- log zerowrap.Logger
410- svc *services
428+ ctx context.Context
429+ v *viper.Viper
430+ cfg Config
431+ log zerowrap.Logger
432+ svc *services
433+ startPublicTLS bool
411434 }
412435
413-// createServices creates all the application services.
436+// createServices creates all the application services for server runtime.
437+// It calls createServicesWithOptions with StartPublicTLS: true.
414438 func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
439+ return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: true})
440+}
441+
442+// createServicesWithOptions creates all the application services with the given options.
443+// When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that
444+// read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects.
445+func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) {
415446 si := &serviceInit{
416- ctx: ctx,
417- v: v,
418- cfg: cfg,
419- log: log,
420- svc: &services{},
447+ ctx: ctx,
448+ v: v,
449+ cfg: cfg,
450+ log: log,
451+ svc: &services{},
452+ startPublicTLS: opts.StartPublicTLS,
421453 }
422454 defer func() {
423- if retErr != nil && si.svc.pkiSvc != nil {
424- si.svc.pkiSvc.Stop()
455+ if retErr != nil {
456+ if si.svc.pkiSvc != nil {
457+ si.svc.pkiSvc.Stop()
458+ }
459+ if si.svc.publicTLSSvc != nil {
460+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
461+ defer cancel()
462+ if err := si.svc.publicTLSSvc.Stop(ctx); err != nil {
463+ si.log.Warn().Err(err).Msg("failed to stop public TLS service during createServices cleanup")
464+ }
465+ }
425466 }
426467 }()
427468 var err error
@@ -465,11 +506,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
465506 return nil, err
466507 }
467508
509+ if err := si.initPublicTLS(); err != nil {
510+ return nil, err
511+ }
512+
468513 if err := si.initRuntimeAndProxy(); err != nil {
469514 return nil, err
470515 }
471516
472- si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log)
517+ si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log)
473518
474519 si.initHandlers()
475520
@@ -496,6 +541,92 @@ func (si *serviceInit) initPKI() error {
496541 return nil
497542 }
498543
544+// initPublicTLS initializes the public ACME TLS service if enabled.
545+func (si *serviceInit) initPublicTLS() error {
546+ if !si.cfg.TLS.ACME.Enabled {
547+ return nil
548+ }
549+
550+ if si.cfg.Server.TLSPort == 0 {
551+ return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)
552+ }
553+
554+ ctx := si.ctx
555+ log := si.log
556+
557+ publicTLSCfg := publictls.Config{
558+ Enabled: si.cfg.TLS.ACME.Enabled,
559+ Email: si.cfg.TLS.ACME.Email,
560+ Challenge: si.cfg.TLS.ACME.Challenge,
561+ HTTPPort: si.cfg.Server.Port,
562+ TLSPort: si.cfg.Server.TLSPort,
563+ DataDir: resolveDataDir(si.cfg.Server.DataDir),
564+ }
565+
566+ tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
567+
568+ effective, err := publictls.ResolveEffectiveChallenge(ctx, publicTLSCfg, tokenResolver)
569+ if err != nil {
570+ return log.WrapErr(err, "resolve ACME challenge")
571+ }
572+
573+ store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
574+ if err != nil {
575+ return log.WrapErr(err, "create ACME store")
576+ }
577+
578+ challenges := publictls.NewHTTP01Challenges()
579+
580+ var zoneResolver *acmelego.CloudflareZoneResolver
581+ if effective.Mode == domain.ACMEChallengeCloudflareDNS01 {
582+ zoneResolver = acmelego.NewCloudflareZoneResolver(effective.Token)
583+ }
584+
585+ issuer, err := acmelego.NewIssuer(acmelego.Config{
586+ Email: si.cfg.TLS.ACME.Email,
587+ Challenge: effective.Mode,
588+ Token: effective.Token,
589+ Store: store,
590+ HTTPChallengeSink: challenges,
591+ })
592+ if err != nil {
593+ return log.WrapErr(err, "create ACME issuer")
594+ }
595+
596+ svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{
597+ Routes: si.svc.configSvc,
598+ Issuer: issuer,
599+ Store: store,
600+ ZoneResolver: zoneResolver,
601+ Challenges: challenges,
602+ Effective: effective,
603+ })
604+
605+ if err := svc.Load(ctx); err != nil {
606+ log.Warn().Err(err).Msg("failed to load ACME certificates, continuing")
607+ }
608+
609+ if si.startPublicTLS {
610+ if err := svc.Reconcile(ctx); err != nil {
611+ log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing")
612+ }
613+ svc.StartRenewalLoop(ctx, time.Hour)
614+ log.Info().
615+ Str("email", si.cfg.TLS.ACME.Email).
616+ Str("challenge", string(effective.Mode)).
617+ Str("reason", effective.Reason).
618+ Msg("public ACME TLS initialized")
619+ } else {
620+ log.Info().
621+ Str("email", si.cfg.TLS.ACME.Email).
622+ Str("challenge", string(effective.Mode)).
623+ Msg("public ACME TLS initialized (read-only, no renewal loop)")
624+ }
625+
626+ si.svc.publicTLSSvc = svc
627+ return nil
628+}
629+
499630 // initSecrets creates the domain secret store, env loader, and secret service.
500631 func (si *serviceInit) initSecrets() error {
501632 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
@@ -576,6 +707,7 @@ func (si *serviceInit) initHandlers() {
576707 PreviewSvc: si.svc.previewService,
577708 ImageSvc: si.svc.imageSvc,
578709 VolumeSvc: si.svc.volumeSvc,
710+ PublicTLSSvc: si.svc.publicTLSSvc,
579711 })
580712 }
581713
@@ -595,13 +727,7 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
595727 }
596728
597729 // 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- }
730+ sweepInterval := max(min(previewConfig.TTL/2, time.Hour), time.Minute)
605731
606732 svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
607733 teardownTrackedPreview(ctx, svc, p)
@@ -840,8 +966,8 @@ func resolveRuntimeConfig(value string) string {
840966 return "/var/run/docker.sock"
841967 }
842968 // Explicit socket path — strip URI scheme if present.
843- if strings.HasPrefix(value, "unix://") {
844- return strings.TrimPrefix(value, "unix://")
969+ if socketPath, ok := strings.CutPrefix(value, "unix://"); ok {
970+ return socketPath
845971 }
846972 return value
847973 }
@@ -1430,6 +1556,11 @@ type configWatcher interface {
14301556 Watch(ctx context.Context, onChange func()) error
14311557 }
14321558
1559+// publicTLSReconciler is the interface for reconciling public TLS certificates.
1560+type publicTLSReconciler interface {
1561+ Reconcile(context.Context) error
1562+}
1563+
14331564 type configReloader interface {
14341565 Reload(ctx context.Context) error
14351566 }
@@ -1457,13 +1588,14 @@ type reloadCoordinator struct {
14571588 registryLimits interface {
14581589 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
14591590 }
1460- eventBus out.EventPublisher
1461- log zerowrap.Logger
1591+ eventBus out.EventPublisher
1592+ publicTLS publicTLSReconciler
1593+ log zerowrap.Logger
14621594 }
14631595
14641596 func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface {
14651597 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1466-}, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator {
1598+}, eventBus out.EventPublisher, publicTLS publicTLSReconciler, log zerowrap.Logger) *reloadCoordinator {
14671599 return &reloadCoordinator{
14681600 debounce: 500 * time.Millisecond,
14691601 configSvc: configSvc,
@@ -1471,6 +1603,7 @@ func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc pro
14711603 proxySvc: proxySvc,
14721604 registryLimits: registryLimits,
14731605 eventBus: eventBus,
1606+ publicTLS: publicTLS,
14741607 log: log,
14751608 }
14761609 }
@@ -1511,14 +1644,14 @@ func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) e
15111644 }
15121645 }
15131646
1514- if err := c.applyLoadedConfig(now); err != nil {
1647+ if err := c.applyLoadedConfig(ctx, now); err != nil {
15151648 return err
15161649 }
15171650
15181651 return nil
15191652 }
15201653
1521-func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
1654+func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time) error {
15221655 var reloadCfg Config
15231656 if err := c.v.Unmarshal(&reloadCfg); err != nil {
15241657 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
@@ -1545,6 +1678,14 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
15451678
15461679 c.lastRun = now
15471680
1681+ // Reconcile public TLS certificates after config reload.
1682+ // Log and continue on failure — a transient ACME issue should not abort the entire reload.
1683+ if c.publicTLS != nil {
1684+ if err := c.publicTLS.Reconcile(ctx); err != nil {
1685+ c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
1686+ }
1687+ }
1688+
15481689 c.log.Debug().Msg("config hot reload complete")
15491690 return nil
15501691 }
@@ -1925,7 +2066,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
19252066 // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
19262067 // Direct clients get an onboarding gate (when CA is available) placed BEFORE
19272068 // HTTPSRedirect so force_https_redirect cannot bypass onboarding.
2069+ // ACME HTTP-01 challenge handler is registered before the catch-all "/" so
2070+ // it gets first chance regardless of source IP.
19282071 proxyMux := http.NewServeMux()
2072+
2073+ // Register ACME HTTP-01 challenge handler before all other routes so
2074+ // Let's Encrypt validation always succeeds, even for onboarding clients.
2075+ if svc.publicTLSSvc != nil {
2076+ proxyMux.Handle(acmehttp.Prefix, acmehttp.NewHandler(svc.publicTLSSvc))
2077+ }
2078+
19292079 if proxyCIDRMiddleware != nil && proxyAllowedNets == nil {
19302080 // Invalid proxy_allowed_ips: deny all traffic (fail-closed).
19312081 proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware))
@@ -2300,7 +2450,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
23002450 }
23012451 }
23022452
2303- proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log)
2453+ proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log)
23042454 if err != nil {
23052455 closeStarted(registrySrv)
23062456 return err
@@ -2344,7 +2494,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
23442494
23452495 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
23462496 cleanupHandlers() // Stop debounce timers before draining containers
2347- gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log)
2497+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log)
23482498 return nil
23492499 }
23502500
@@ -2544,7 +2694,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
25442694
25452695 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
25462696 // 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) {
2697+func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) {
25482698 log.Info().Msg("shutting down Gordon...")
25492699
25502700 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -2565,6 +2715,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
25652715 pkiSvc.Stop()
25662716 }
25672717
2718+ // Stop public ACME TLS renewal loop
2719+ if publicTLS != nil {
2720+ if err := publicTLS.Stop(shutdownCtx); err != nil {
2721+ log.Warn().Err(err).Msg("public TLS stop error")
2722+ }
2723+ }
2724+
25682725 // Phase 2: Drain in-flight registry push sessions before stopping the backend
25692726 if proxySvc != nil {
25702727 log.Info().Msg("draining in-flight registry requests...")
@@ -2592,7 +2749,90 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
25922749
25932750 // startProxyServers sets up the HTTP proxy server and, when tls_port != 0,
25942751 // 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) {
2752+// certificateSelector implements a multi-source TLS certificate lookup.
2753+// Priority: static certs → public ACME TLS → local PKI (internal CA).
2754+type certificateSelector struct {
2755+ staticCerts []staticTLSCertificate
2756+ publicTLS in.PublicTLSService
2757+ localPKI *pkiusecase.Service
2758+}
2759+
2760+type staticTLSCertificate struct {
2761+ cert tls.Certificate
2762+ leaf *x509.Certificate
2763+}
2764+
2765+// GetCertificate selects a TLS certificate based on the ClientHello SNI.
2766+//
2767+// Priority:
2768+// 1. Static certs — exact SNI match (leaf VerifyHostname)
2769+// 2. Public ACME TLS — if the host requires ACME coverage
2770+// 3. Local PKI (internal CA) — fallback for all other hosts
2771+// 4. nil, nil — if no source can serve the host
2772+func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
2773+ // 1. Static certs — exact match via leaf VerifyHostname.
2774+ if cert := matchingPreparedStaticCert(s.staticCerts, hello.ServerName); cert != nil {
2775+ return cert, nil
2776+ }
2777+
2778+ // 2. Public ACME TLS.
2779+ if s.publicTLS != nil {
2780+ cert, err := s.publicTLS.GetCertificateForHost(hello.ServerName)
2781+ if err == nil && cert != nil {
2782+ return cert, nil
2783+ }
2784+ // nil, nil means this host is not an ACME-required route. Errors mean
2785+ // public ACME cannot currently serve this host. In both cases, fall
2786+ // through to local PKI instead of aborting the TLS handshake.
2787+ }
2788+
2789+ // 3. Local PKI (internal CA).
2790+ if s.localPKI != nil {
2791+ return s.localPKI.GetCertificate(hello)
2792+ }
2793+
2794+ return nil, nil
2795+}
2796+
2797+func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificate {
2798+ prepared := make([]staticTLSCertificate, 0, len(certs))
2799+ for _, cert := range certs {
2800+ if cert.Leaf == nil && len(cert.Certificate) > 0 {
2801+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
2802+ if err == nil {
2803+ cert.Leaf = leaf
2804+ }
2805+ }
2806+ prepared = append(prepared, staticTLSCertificate{cert: cert, leaf: cert.Leaf})
2807+ }
2808+ return prepared
2809+}
2810+
2811+func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {
2812+ if serverName == "" {
2813+ if len(certs) == 0 {
2814+ return nil
2815+ }
2816+ return &certs[0].cert
2817+ }
2818+ for i := range certs {
2819+ if certs[i].leaf == nil {
2820+ continue
2821+ }
2822+ if err := certs[i].leaf.VerifyHostname(serverName); err == nil {
2823+ return &certs[i].cert
2824+ }
2825+ }
2826+ return nil
2827+}
2828+
2829+// matchingStaticCert returns a pointer to the first static certificate whose
2830+// leaf verifies the given serverName. Returns nil if no match is found.
2831+func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {
2832+ return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
2833+}
2834+
2835+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) {
25962836 // HTTP listener (Cloudflare proxy + onboarding for direct clients)
25972837 var httpProtos http.Protocols
25982838 httpProtos.SetHTTP1(true)
@@ -2610,23 +2850,33 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
26102850 return httpSrv, httpReady, nil, nil, nil
26112851 }
26122852
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- }
2853+ // Load static cert into a local slice (not tls.Config.Certificates) so the
2854+ // certificate selector has full control over priority ordering.
2855+ var staticCerts []tls.Certificate
26202856 if cfg.Server.TLSCertFile != "" {
26212857 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
26222858 if err != nil {
26232859 return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err)
26242860 }
2625- tlsConfig.Certificates = []tls.Certificate{staticCert}
2861+ staticCerts = []tls.Certificate{staticCert}
26262862 log.Info().
26272863 Str("cert", cfg.Server.TLSCertFile).
26282864 Str("key", cfg.Server.TLSKeyFile).
2629- Msg("loaded static TLS certificate (internal CA handles remaining domains)")
2865+ Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
2866+ }
2867+
2868+ selector := &certificateSelector{
2869+ staticCerts: prepareStaticTLSCertificates(staticCerts),
2870+ publicTLS: publicTLS,
2871+ localPKI: pkiSvc,
2872+ }
2873+
2874+ // HTTPS listener — static cert (if provided) takes priority by SNI match via
2875+ // certificateSelector, then public ACME TLS, then on-demand internal CA certs.
2876+ tlsConfig := &tls.Config{
2877+ MinVersion: tls.VersionTLS12,
2878+ GetCertificate: selector.GetCertificate,
2879+ NextProtos: []string{"h2", "http/1.1"},
26302880 }
26312881 tlsSrv, tlsReady := startTLSServerWithConfig(
26322882 fmt.Sprintf(":%d", cfg.Server.TLSPort),
@@ -2831,7 +3081,7 @@ func createPidFile(log zerowrap.Logger) string {
28313081 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
28323082 continue
28333083 }
2834- if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
3084+ if err := os.WriteFile(location, fmt.Appendf(nil, "%d", pid), 0600); err == nil {
28353085 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
28363086 return location
28373087 }
@@ -2966,6 +3216,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
29663216 v.SetDefault("server.tls_port", 8443)
29673217 v.SetDefault("server.tls_cert_file", "")
29683218 v.SetDefault("server.tls_key_file", "")
3219+ v.SetDefault("tls.acme.enabled", false)
3220+ v.SetDefault("tls.acme.email", "")
3221+ v.SetDefault("tls.acme.challenge", "auto")
29693222 v.SetDefault("server.force_https_redirect", false)
29703223 v.SetDefault("server.data_dir", DefaultDataDir())
29713224 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/mocks/mock_public_tls_service.go +321 -0
@@ -0,0 +1,321 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "crypto/tls"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockPublicTLSService creates a new instance of MockPublicTLSService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockPublicTLSService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockPublicTLSService {
21+ mock := &MockPublicTLSService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockPublicTLSService is an autogenerated mock type for the PublicTLSService type
30+type MockPublicTLSService struct {
31+ mock.Mock
32+}
33+
34+type MockPublicTLSService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockPublicTLSService) EXPECT() *MockPublicTLSService_Expecter {
39+ return &MockPublicTLSService_Expecter{mock: &_m.Mock}
40+}
41+
42+// GetCertificateForHost provides a mock function for the type MockPublicTLSService
43+func (_mock *MockPublicTLSService) GetCertificateForHost(host string) (*tls.Certificate, error) {
44+ ret := _mock.Called(host)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GetCertificateForHost")
48+ }
49+
50+ var r0 *tls.Certificate
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(string) (*tls.Certificate, error)); ok {
53+ return returnFunc(host)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(string) *tls.Certificate); ok {
56+ r0 = returnFunc(host)
57+ } else {
58+ if ret.Get(0) != nil {
59+ r0 = ret.Get(0).(*tls.Certificate)
60+ }
61+ }
62+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
63+ r1 = returnFunc(host)
64+ } else {
65+ r1 = ret.Error(1)
66+ }
67+ return r0, r1
68+}
69+
70+// MockPublicTLSService_GetCertificateForHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCertificateForHost'
71+type MockPublicTLSService_GetCertificateForHost_Call struct {
72+ *mock.Call
73+}
74+
75+// GetCertificateForHost is a helper method to define mock.On call
76+// - host string
77+func (_e *MockPublicTLSService_Expecter) GetCertificateForHost(host any) *MockPublicTLSService_GetCertificateForHost_Call {
78+ return &MockPublicTLSService_GetCertificateForHost_Call{Call: _e.mock.On("GetCertificateForHost", host)}
79+}
80+
81+func (_c *MockPublicTLSService_GetCertificateForHost_Call) Run(run func(host string)) *MockPublicTLSService_GetCertificateForHost_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 string
84+ if args[0] != nil {
85+ arg0 = args[0].(string)
86+ }
87+ run(
88+ arg0,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockPublicTLSService_GetCertificateForHost_Call) Return(certificate *tls.Certificate, err error) *MockPublicTLSService_GetCertificateForHost_Call {
95+ _c.Call.Return(certificate, err)
96+ return _c
97+}
98+
99+func (_c *MockPublicTLSService_GetCertificateForHost_Call) RunAndReturn(run func(host string) (*tls.Certificate, error)) *MockPublicTLSService_GetCertificateForHost_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
104+// GetHTTP01Challenge provides a mock function for the type MockPublicTLSService
105+func (_mock *MockPublicTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
106+ ret := _mock.Called(ctx, token)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for GetHTTP01Challenge")
110+ }
111+
112+ var r0 string
113+ var r1 bool
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool)); ok {
115+ return returnFunc(ctx, token)
116+ }
117+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
118+ r0 = returnFunc(ctx, token)
119+ } else {
120+ r0 = ret.Get(0).(string)
121+ }
122+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
123+ r1 = returnFunc(ctx, token)
124+ } else {
125+ r1 = ret.Get(1).(bool)
126+ }
127+ return r0, r1
128+}
129+
130+// MockPublicTLSService_GetHTTP01Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHTTP01Challenge'
131+type MockPublicTLSService_GetHTTP01Challenge_Call struct {
132+ *mock.Call
133+}
134+
135+// GetHTTP01Challenge is a helper method to define mock.On call
136+// - ctx context.Context
137+// - token string
138+func (_e *MockPublicTLSService_Expecter) GetHTTP01Challenge(ctx any, token any) *MockPublicTLSService_GetHTTP01Challenge_Call {
139+ return &MockPublicTLSService_GetHTTP01Challenge_Call{Call: _e.mock.On("GetHTTP01Challenge", ctx, token)}
140+}
141+
142+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Run(run func(ctx context.Context, token string)) *MockPublicTLSService_GetHTTP01Challenge_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 string
149+ if args[1] != nil {
150+ arg1 = args[1].(string)
151+ }
152+ run(
153+ arg0,
154+ arg1,
155+ )
156+ })
157+ return _c
158+}
159+
160+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Return(keyAuth string, ok bool) *MockPublicTLSService_GetHTTP01Challenge_Call {
161+ _c.Call.Return(keyAuth, ok)
162+ return _c
163+}
164+
165+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) RunAndReturn(run func(ctx context.Context, token string) (string, bool)) *MockPublicTLSService_GetHTTP01Challenge_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// Reconcile provides a mock function for the type MockPublicTLSService
171+func (_mock *MockPublicTLSService) Reconcile(ctx context.Context) error {
172+ ret := _mock.Called(ctx)
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for Reconcile")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
180+ r0 = returnFunc(ctx)
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockPublicTLSService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
188+type MockPublicTLSService_Reconcile_Call struct {
189+ *mock.Call
190+}
191+
192+// Reconcile is a helper method to define mock.On call
193+// - ctx context.Context
194+func (_e *MockPublicTLSService_Expecter) Reconcile(ctx any) *MockPublicTLSService_Reconcile_Call {
195+ return &MockPublicTLSService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx)}
196+}
197+
198+func (_c *MockPublicTLSService_Reconcile_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Reconcile_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 context.Context
201+ if args[0] != nil {
202+ arg0 = args[0].(context.Context)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockPublicTLSService_Reconcile_Call) Return(err error) *MockPublicTLSService_Reconcile_Call {
212+ _c.Call.Return(err)
213+ return _c
214+}
215+
216+func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Reconcile_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
221+// Status provides a mock function for the type MockPublicTLSService
222+func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
223+ ret := _mock.Called(ctx)
224+
225+ if len(ret) == 0 {
226+ panic("no return value specified for Status")
227+ }
228+
229+ var r0 domain.PublicTLSStatus
230+ if returnFunc, ok := ret.Get(0).(func(context.Context) domain.PublicTLSStatus); ok {
231+ r0 = returnFunc(ctx)
232+ } else {
233+ r0 = ret.Get(0).(domain.PublicTLSStatus)
234+ }
235+ return r0
236+}
237+
238+// MockPublicTLSService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
239+type MockPublicTLSService_Status_Call struct {
240+ *mock.Call
241+}
242+
243+// Status is a helper method to define mock.On call
244+// - ctx context.Context
245+func (_e *MockPublicTLSService_Expecter) Status(ctx any) *MockPublicTLSService_Status_Call {
246+ return &MockPublicTLSService_Status_Call{Call: _e.mock.On("Status", ctx)}
247+}
248+
249+func (_c *MockPublicTLSService_Status_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Status_Call {
250+ _c.Call.Run(func(args mock.Arguments) {
251+ var arg0 context.Context
252+ if args[0] != nil {
253+ arg0 = args[0].(context.Context)
254+ }
255+ run(
256+ arg0,
257+ )
258+ })
259+ return _c
260+}
261+
262+func (_c *MockPublicTLSService_Status_Call) Return(publicTLSStatus domain.PublicTLSStatus) *MockPublicTLSService_Status_Call {
263+ _c.Call.Return(publicTLSStatus)
264+ return _c
265+}
266+
267+func (_c *MockPublicTLSService_Status_Call) RunAndReturn(run func(ctx context.Context) domain.PublicTLSStatus) *MockPublicTLSService_Status_Call {
268+ _c.Call.Return(run)
269+ return _c
270+}
271+
272+// Stop provides a mock function for the type MockPublicTLSService
273+func (_mock *MockPublicTLSService) Stop(ctx context.Context) error {
274+ ret := _mock.Called(ctx)
275+
276+ if len(ret) == 0 {
277+ panic("no return value specified for Stop")
278+ }
279+
280+ var r0 error
281+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
282+ r0 = returnFunc(ctx)
283+ } else {
284+ r0 = ret.Error(0)
285+ }
286+ return r0
287+}
288+
289+// MockPublicTLSService_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
290+type MockPublicTLSService_Stop_Call struct {
291+ *mock.Call
292+}
293+
294+// Stop is a helper method to define mock.On call
295+// - ctx context.Context
296+func (_e *MockPublicTLSService_Expecter) Stop(ctx any) *MockPublicTLSService_Stop_Call {
297+ return &MockPublicTLSService_Stop_Call{Call: _e.mock.On("Stop", ctx)}
298+}
299+
300+func (_c *MockPublicTLSService_Stop_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Stop_Call {
301+ _c.Call.Run(func(args mock.Arguments) {
302+ var arg0 context.Context
303+ if args[0] != nil {
304+ arg0 = args[0].(context.Context)
305+ }
306+ run(
307+ arg0,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *MockPublicTLSService_Stop_Call) Return(err error) *MockPublicTLSService_Stop_Call {
314+ _c.Call.Return(err)
315+ return _c
316+}
317+
318+func (_c *MockPublicTLSService_Stop_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Stop_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
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+ // GetCertificateForHost returns a TLS certificate for the given SNI host.
16+ GetCertificateForHost(host string) (*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/out/mocks/mock_certificate_store.go +339 -0
@@ -0,0 +1,339 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockCertificateStore creates a new instance of MockCertificateStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockCertificateStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockCertificateStore {
20+ mock := &MockCertificateStore{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockCertificateStore is an autogenerated mock type for the CertificateStore type
29+type MockCertificateStore struct {
30+ mock.Mock
31+}
32+
33+type MockCertificateStore_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockCertificateStore) EXPECT() *MockCertificateStore_Expecter {
38+ return &MockCertificateStore_Expecter{mock: &_m.Mock}
39+}
40+
41+// LoadAccount provides a mock function for the type MockCertificateStore
42+func (_mock *MockCertificateStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for LoadAccount")
47+ }
48+
49+ var r0 *out.ACMEAccount
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*out.ACMEAccount, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) *out.ACMEAccount); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(*out.ACMEAccount)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62+ r1 = returnFunc(ctx)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockCertificateStore_LoadAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAccount'
70+type MockCertificateStore_LoadAccount_Call struct {
71+ *mock.Call
72+}
73+
74+// LoadAccount is a helper method to define mock.On call
75+// - ctx context.Context
76+func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call {
77+ return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
78+}
79+
80+func (_c *MockCertificateStore_LoadAccount_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAccount_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ run(
87+ arg0,
88+ )
89+ })
90+ return _c
91+}
92+
93+func (_c *MockCertificateStore_LoadAccount_Call) Return(aCMEAccount *out.ACMEAccount, err error) *MockCertificateStore_LoadAccount_Call {
94+ _c.Call.Return(aCMEAccount, err)
95+ return _c
96+}
97+
98+func (_c *MockCertificateStore_LoadAccount_Call) RunAndReturn(run func(ctx context.Context) (*out.ACMEAccount, error)) *MockCertificateStore_LoadAccount_Call {
99+ _c.Call.Return(run)
100+ return _c
101+}
102+
103+// LoadAll provides a mock function for the type MockCertificateStore
104+func (_mock *MockCertificateStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) {
105+ ret := _mock.Called(ctx)
106+
107+ if len(ret) == 0 {
108+ panic("no return value specified for LoadAll")
109+ }
110+
111+ var r0 []out.StoredCertificate
112+ var r1 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]out.StoredCertificate, error)); ok {
114+ return returnFunc(ctx)
115+ }
116+ if returnFunc, ok := ret.Get(0).(func(context.Context) []out.StoredCertificate); ok {
117+ r0 = returnFunc(ctx)
118+ } else {
119+ if ret.Get(0) != nil {
120+ r0 = ret.Get(0).([]out.StoredCertificate)
121+ }
122+ }
123+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
124+ r1 = returnFunc(ctx)
125+ } else {
126+ r1 = ret.Error(1)
127+ }
128+ return r0, r1
129+}
130+
131+// MockCertificateStore_LoadAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAll'
132+type MockCertificateStore_LoadAll_Call struct {
133+ *mock.Call
134+}
135+
136+// LoadAll is a helper method to define mock.On call
137+// - ctx context.Context
138+func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call {
139+ return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140+}
141+
142+func (_c *MockCertificateStore_LoadAll_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAll_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ run(
149+ arg0,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *MockCertificateStore_LoadAll_Call) Return(storedCertificates []out.StoredCertificate, err error) *MockCertificateStore_LoadAll_Call {
156+ _c.Call.Return(storedCertificates, err)
157+ return _c
158+}
159+
160+func (_c *MockCertificateStore_LoadAll_Call) RunAndReturn(run func(ctx context.Context) ([]out.StoredCertificate, error)) *MockCertificateStore_LoadAll_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Lock provides a mock function for the type MockCertificateStore
166+func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) {
167+ ret := _mock.Called(ctx)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Lock")
171+ }
172+
173+ var r0 func() error
174+ var r1 error
175+ if returnFunc, ok := ret.Get(0).(func(context.Context) (func() error, error)); ok {
176+ return returnFunc(ctx)
177+ }
178+ if returnFunc, ok := ret.Get(0).(func(context.Context) func() error); ok {
179+ r0 = returnFunc(ctx)
180+ } else {
181+ if ret.Get(0) != nil {
182+ r0 = ret.Get(0).(func() error)
183+ }
184+ }
185+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
186+ r1 = returnFunc(ctx)
187+ } else {
188+ r1 = ret.Error(1)
189+ }
190+ return r0, r1
191+}
192+
193+// MockCertificateStore_Lock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lock'
194+type MockCertificateStore_Lock_Call struct {
195+ *mock.Call
196+}
197+
198+// Lock is a helper method to define mock.On call
199+// - ctx context.Context
200+func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call {
201+ return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
202+}
203+
204+func (_c *MockCertificateStore_Lock_Call) Run(run func(ctx context.Context)) *MockCertificateStore_Lock_Call {
205+ _c.Call.Run(func(args mock.Arguments) {
206+ var arg0 context.Context
207+ if args[0] != nil {
208+ arg0 = args[0].(context.Context)
209+ }
210+ run(
211+ arg0,
212+ )
213+ })
214+ return _c
215+}
216+
217+func (_c *MockCertificateStore_Lock_Call) Return(unlock func() error, err error) *MockCertificateStore_Lock_Call {
218+ _c.Call.Return(unlock, err)
219+ return _c
220+}
221+
222+func (_c *MockCertificateStore_Lock_Call) RunAndReturn(run func(ctx context.Context) (func() error, error)) *MockCertificateStore_Lock_Call {
223+ _c.Call.Return(run)
224+ return _c
225+}
226+
227+// Save provides a mock function for the type MockCertificateStore
228+func (_mock *MockCertificateStore) Save(ctx context.Context, cert out.StoredCertificate) error {
229+ ret := _mock.Called(ctx, cert)
230+
231+ if len(ret) == 0 {
232+ panic("no return value specified for Save")
233+ }
234+
235+ var r0 error
236+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) error); ok {
237+ r0 = returnFunc(ctx, cert)
238+ } else {
239+ r0 = ret.Error(0)
240+ }
241+ return r0
242+}
243+
244+// MockCertificateStore_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
245+type MockCertificateStore_Save_Call struct {
246+ *mock.Call
247+}
248+
249+// Save is a helper method to define mock.On call
250+// - ctx context.Context
251+// - cert out.StoredCertificate
252+func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call {
253+ return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
254+}
255+
256+func (_c *MockCertificateStore_Save_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockCertificateStore_Save_Call {
257+ _c.Call.Run(func(args mock.Arguments) {
258+ var arg0 context.Context
259+ if args[0] != nil {
260+ arg0 = args[0].(context.Context)
261+ }
262+ var arg1 out.StoredCertificate
263+ if args[1] != nil {
264+ arg1 = args[1].(out.StoredCertificate)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ )
270+ })
271+ return _c
272+}
273+
274+func (_c *MockCertificateStore_Save_Call) Return(err error) *MockCertificateStore_Save_Call {
275+ _c.Call.Return(err)
276+ return _c
277+}
278+
279+func (_c *MockCertificateStore_Save_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) error) *MockCertificateStore_Save_Call {
280+ _c.Call.Return(run)
281+ return _c
282+}
283+
284+// SaveAccount provides a mock function for the type MockCertificateStore
285+func (_mock *MockCertificateStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error {
286+ ret := _mock.Called(ctx, account)
287+
288+ if len(ret) == 0 {
289+ panic("no return value specified for SaveAccount")
290+ }
291+
292+ var r0 error
293+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.ACMEAccount) error); ok {
294+ r0 = returnFunc(ctx, account)
295+ } else {
296+ r0 = ret.Error(0)
297+ }
298+ return r0
299+}
300+
301+// MockCertificateStore_SaveAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveAccount'
302+type MockCertificateStore_SaveAccount_Call struct {
303+ *mock.Call
304+}
305+
306+// SaveAccount is a helper method to define mock.On call
307+// - ctx context.Context
308+// - account out.ACMEAccount
309+func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call {
310+ return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
311+}
312+
313+func (_c *MockCertificateStore_SaveAccount_Call) Run(run func(ctx context.Context, account out.ACMEAccount)) *MockCertificateStore_SaveAccount_Call {
314+ _c.Call.Run(func(args mock.Arguments) {
315+ var arg0 context.Context
316+ if args[0] != nil {
317+ arg0 = args[0].(context.Context)
318+ }
319+ var arg1 out.ACMEAccount
320+ if args[1] != nil {
321+ arg1 = args[1].(out.ACMEAccount)
322+ }
323+ run(
324+ arg0,
325+ arg1,
326+ )
327+ })
328+ return _c
329+}
330+
331+func (_c *MockCertificateStore_SaveAccount_Call) Return(err error) *MockCertificateStore_SaveAccount_Call {
332+ _c.Call.Return(err)
333+ return _c
334+}
335+
336+func (_c *MockCertificateStore_SaveAccount_Call) RunAndReturn(run func(ctx context.Context, account out.ACMEAccount) error) *MockCertificateStore_SaveAccount_Call {
337+ _c.Call.Return(run)
338+ return _c
339+}
A internal/boundaries/out/mocks/mock_cloudflare_zone_resolver.go +105 -0
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockCloudflareZoneResolver creates a new instance of MockCloudflareZoneResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockCloudflareZoneResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockCloudflareZoneResolver {
20+ mock := &MockCloudflareZoneResolver{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockCloudflareZoneResolver is an autogenerated mock type for the CloudflareZoneResolver type
29+type MockCloudflareZoneResolver struct {
30+ mock.Mock
31+}
32+
33+type MockCloudflareZoneResolver_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockCloudflareZoneResolver) EXPECT() *MockCloudflareZoneResolver_Expecter {
38+ return &MockCloudflareZoneResolver_Expecter{mock: &_m.Mock}
39+}
40+
41+// FindZone provides a mock function for the type MockCloudflareZoneResolver
42+func (_mock *MockCloudflareZoneResolver) FindZone(ctx context.Context, domain string) (out.CloudflareZone, error) {
43+ ret := _mock.Called(ctx, domain)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for FindZone")
47+ }
48+
49+ var r0 out.CloudflareZone
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (out.CloudflareZone, error)); ok {
52+ return returnFunc(ctx, domain)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) out.CloudflareZone); ok {
55+ r0 = returnFunc(ctx, domain)
56+ } else {
57+ r0 = ret.Get(0).(out.CloudflareZone)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
60+ r1 = returnFunc(ctx, domain)
61+ } else {
62+ r1 = ret.Error(1)
63+ }
64+ return r0, r1
65+}
66+
67+// MockCloudflareZoneResolver_FindZone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindZone'
68+type MockCloudflareZoneResolver_FindZone_Call struct {
69+ *mock.Call
70+}
71+
72+// FindZone is a helper method to define mock.On call
73+// - ctx context.Context
74+// - domain string
75+func (_e *MockCloudflareZoneResolver_Expecter) FindZone(ctx any, domain any) *MockCloudflareZoneResolver_FindZone_Call {
76+ return &MockCloudflareZoneResolver_FindZone_Call{Call: _e.mock.On("FindZone", ctx, domain)}
77+}
78+
79+func (_c *MockCloudflareZoneResolver_FindZone_Call) Run(run func(ctx context.Context, domain string)) *MockCloudflareZoneResolver_FindZone_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 string
86+ if args[1] != nil {
87+ arg1 = args[1].(string)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *MockCloudflareZoneResolver_FindZone_Call) Return(cloudflareZone out.CloudflareZone, err error) *MockCloudflareZoneResolver_FindZone_Call {
98+ _c.Call.Return(cloudflareZone, err)
99+ return _c
100+}
101+
102+func (_c *MockCloudflareZoneResolver_FindZone_Call) RunAndReturn(run func(ctx context.Context, domain string) (out.CloudflareZone, error)) *MockCloudflareZoneResolver_FindZone_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
A internal/boundaries/out/mocks/mock_http_challenge_sink.go +144 -0
@@ -0,0 +1,144 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockHTTPChallengeSink creates a new instance of MockHTTPChallengeSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockHTTPChallengeSink(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockHTTPChallengeSink {
17+ mock := &MockHTTPChallengeSink{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockHTTPChallengeSink is an autogenerated mock type for the HTTPChallengeSink type
26+type MockHTTPChallengeSink struct {
27+ mock.Mock
28+}
29+
30+type MockHTTPChallengeSink_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockHTTPChallengeSink) EXPECT() *MockHTTPChallengeSink_Expecter {
35+ return &MockHTTPChallengeSink_Expecter{mock: &_m.Mock}
36+}
37+
38+// CleanUp provides a mock function for the type MockHTTPChallengeSink
39+func (_mock *MockHTTPChallengeSink) CleanUp(token string) error {
40+ ret := _mock.Called(token)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for CleanUp")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
48+ r0 = returnFunc(token)
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockHTTPChallengeSink_CleanUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanUp'
56+type MockHTTPChallengeSink_CleanUp_Call struct {
57+ *mock.Call
58+}
59+
60+// CleanUp is a helper method to define mock.On call
61+// - token string
62+func (_e *MockHTTPChallengeSink_Expecter) CleanUp(token any) *MockHTTPChallengeSink_CleanUp_Call {
63+ return &MockHTTPChallengeSink_CleanUp_Call{Call: _e.mock.On("CleanUp", token)}
64+}
65+
66+func (_c *MockHTTPChallengeSink_CleanUp_Call) Run(run func(token string)) *MockHTTPChallengeSink_CleanUp_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 string
69+ if args[0] != nil {
70+ arg0 = args[0].(string)
71+ }
72+ run(
73+ arg0,
74+ )
75+ })
76+ return _c
77+}
78+
79+func (_c *MockHTTPChallengeSink_CleanUp_Call) Return(err error) *MockHTTPChallengeSink_CleanUp_Call {
80+ _c.Call.Return(err)
81+ return _c
82+}
83+
84+func (_c *MockHTTPChallengeSink_CleanUp_Call) RunAndReturn(run func(token string) error) *MockHTTPChallengeSink_CleanUp_Call {
85+ _c.Call.Return(run)
86+ return _c
87+}
88+
89+// Present provides a mock function for the type MockHTTPChallengeSink
90+func (_mock *MockHTTPChallengeSink) Present(token string, keyAuth string) error {
91+ ret := _mock.Called(token, keyAuth)
92+
93+ if len(ret) == 0 {
94+ panic("no return value specified for Present")
95+ }
96+
97+ var r0 error
98+ if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
99+ r0 = returnFunc(token, keyAuth)
100+ } else {
101+ r0 = ret.Error(0)
102+ }
103+ return r0
104+}
105+
106+// MockHTTPChallengeSink_Present_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Present'
107+type MockHTTPChallengeSink_Present_Call struct {
108+ *mock.Call
109+}
110+
111+// Present is a helper method to define mock.On call
112+// - token string
113+// - keyAuth string
114+func (_e *MockHTTPChallengeSink_Expecter) Present(token any, keyAuth any) *MockHTTPChallengeSink_Present_Call {
115+ return &MockHTTPChallengeSink_Present_Call{Call: _e.mock.On("Present", token, keyAuth)}
116+}
117+
118+func (_c *MockHTTPChallengeSink_Present_Call) Run(run func(token string, keyAuth string)) *MockHTTPChallengeSink_Present_Call {
119+ _c.Call.Run(func(args mock.Arguments) {
120+ var arg0 string
121+ if args[0] != nil {
122+ arg0 = args[0].(string)
123+ }
124+ var arg1 string
125+ if args[1] != nil {
126+ arg1 = args[1].(string)
127+ }
128+ run(
129+ arg0,
130+ arg1,
131+ )
132+ })
133+ return _c
134+}
135+
136+func (_c *MockHTTPChallengeSink_Present_Call) Return(err error) *MockHTTPChallengeSink_Present_Call {
137+ _c.Call.Return(err)
138+ return _c
139+}
140+
141+func (_c *MockHTTPChallengeSink_Present_Call) RunAndReturn(run func(token string, keyAuth string) error) *MockHTTPChallengeSink_Present_Call {
142+ _c.Call.Return(run)
143+ return _c
144+}
A internal/boundaries/out/mocks/mock_public_certificate_issuer.go +175 -0
@@ -0,0 +1,175 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockPublicCertificateIssuer creates a new instance of MockPublicCertificateIssuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockPublicCertificateIssuer(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockPublicCertificateIssuer {
20+ mock := &MockPublicCertificateIssuer{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockPublicCertificateIssuer is an autogenerated mock type for the PublicCertificateIssuer type
29+type MockPublicCertificateIssuer struct {
30+ mock.Mock
31+}
32+
33+type MockPublicCertificateIssuer_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockPublicCertificateIssuer) EXPECT() *MockPublicCertificateIssuer_Expecter {
38+ return &MockPublicCertificateIssuer_Expecter{mock: &_m.Mock}
39+}
40+
41+// Obtain provides a mock function for the type MockPublicCertificateIssuer
42+func (_mock *MockPublicCertificateIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
43+ ret := _mock.Called(ctx, order)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Obtain")
47+ }
48+
49+ var r0 *out.StoredCertificate
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error)); ok {
52+ return returnFunc(ctx, order)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) *out.StoredCertificate); ok {
55+ r0 = returnFunc(ctx, order)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(*out.StoredCertificate)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, out.CertificateOrder) error); ok {
62+ r1 = returnFunc(ctx, order)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockPublicCertificateIssuer_Obtain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Obtain'
70+type MockPublicCertificateIssuer_Obtain_Call struct {
71+ *mock.Call
72+}
73+
74+// Obtain is a helper method to define mock.On call
75+// - ctx context.Context
76+// - order out.CertificateOrder
77+func (_e *MockPublicCertificateIssuer_Expecter) Obtain(ctx any, order any) *MockPublicCertificateIssuer_Obtain_Call {
78+ return &MockPublicCertificateIssuer_Obtain_Call{Call: _e.mock.On("Obtain", ctx, order)}
79+}
80+
81+func (_c *MockPublicCertificateIssuer_Obtain_Call) Run(run func(ctx context.Context, order out.CertificateOrder)) *MockPublicCertificateIssuer_Obtain_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 out.CertificateOrder
88+ if args[1] != nil {
89+ arg1 = args[1].(out.CertificateOrder)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockPublicCertificateIssuer_Obtain_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Obtain_Call {
100+ _c.Call.Return(storedCertificate, err)
101+ return _c
102+}
103+
104+func (_c *MockPublicCertificateIssuer_Obtain_Call) RunAndReturn(run func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Obtain_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// Renew provides a mock function for the type MockPublicCertificateIssuer
110+func (_mock *MockPublicCertificateIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
111+ ret := _mock.Called(ctx, cert)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for Renew")
115+ }
116+
117+ var r0 *out.StoredCertificate
118+ var r1 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error)); ok {
120+ return returnFunc(ctx, cert)
121+ }
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) *out.StoredCertificate); ok {
123+ r0 = returnFunc(ctx, cert)
124+ } else {
125+ if ret.Get(0) != nil {
126+ r0 = ret.Get(0).(*out.StoredCertificate)
127+ }
128+ }
129+ if returnFunc, ok := ret.Get(1).(func(context.Context, out.StoredCertificate) error); ok {
130+ r1 = returnFunc(ctx, cert)
131+ } else {
132+ r1 = ret.Error(1)
133+ }
134+ return r0, r1
135+}
136+
137+// MockPublicCertificateIssuer_Renew_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Renew'
138+type MockPublicCertificateIssuer_Renew_Call struct {
139+ *mock.Call
140+}
141+
142+// Renew is a helper method to define mock.On call
143+// - ctx context.Context
144+// - cert out.StoredCertificate
145+func (_e *MockPublicCertificateIssuer_Expecter) Renew(ctx any, cert any) *MockPublicCertificateIssuer_Renew_Call {
146+ return &MockPublicCertificateIssuer_Renew_Call{Call: _e.mock.On("Renew", ctx, cert)}
147+}
148+
149+func (_c *MockPublicCertificateIssuer_Renew_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockPublicCertificateIssuer_Renew_Call {
150+ _c.Call.Run(func(args mock.Arguments) {
151+ var arg0 context.Context
152+ if args[0] != nil {
153+ arg0 = args[0].(context.Context)
154+ }
155+ var arg1 out.StoredCertificate
156+ if args[1] != nil {
157+ arg1 = args[1].(out.StoredCertificate)
158+ }
159+ run(
160+ arg0,
161+ arg1,
162+ )
163+ })
164+ return _c
165+}
166+
167+func (_c *MockPublicCertificateIssuer_Renew_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Renew_Call {
168+ _c.Call.Return(storedCertificate, err)
169+ return _c
170+}
171+
172+func (_c *MockPublicCertificateIssuer_Renew_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Renew_Call {
173+ _c.Call.Return(run)
174+ return _c
175+}
A internal/boundaries/out/mocks/mock_secret_resolver.go +99 -0
@@ -0,0 +1,99 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockSecretResolver creates a new instance of MockSecretResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockSecretResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockSecretResolver {
20+ mock := &MockSecretResolver{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockSecretResolver is an autogenerated mock type for the SecretResolver type
29+type MockSecretResolver struct {
30+ mock.Mock
31+}
32+
33+type MockSecretResolver_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockSecretResolver) EXPECT() *MockSecretResolver_Expecter {
38+ return &MockSecretResolver_Expecter{mock: &_m.Mock}
39+}
40+
41+// ResolveCloudflareToken provides a mock function for the type MockSecretResolver
42+func (_mock *MockSecretResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ResolveCloudflareToken")
47+ }
48+
49+ var r0 out.SecretValue
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (out.SecretValue, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) out.SecretValue); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ r0 = ret.Get(0).(out.SecretValue)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
60+ r1 = returnFunc(ctx)
61+ } else {
62+ r1 = ret.Error(1)
63+ }
64+ return r0, r1
65+}
66+
67+// MockSecretResolver_ResolveCloudflareToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveCloudflareToken'
68+type MockSecretResolver_ResolveCloudflareToken_Call struct {
69+ *mock.Call
70+}
71+
72+// ResolveCloudflareToken is a helper method to define mock.On call
73+// - ctx context.Context
74+func (_e *MockSecretResolver_Expecter) ResolveCloudflareToken(ctx any) *MockSecretResolver_ResolveCloudflareToken_Call {
75+ return &MockSecretResolver_ResolveCloudflareToken_Call{Call: _e.mock.On("ResolveCloudflareToken", ctx)}
76+}
77+
78+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Run(run func(ctx context.Context)) *MockSecretResolver_ResolveCloudflareToken_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ run(
85+ arg0,
86+ )
87+ })
88+ return _c
89+}
90+
91+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Return(secretValue out.SecretValue, err error) *MockSecretResolver_ResolveCloudflareToken_Call {
92+ _c.Call.Return(secretValue, err)
93+ return _c
94+}
95+
96+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) RunAndReturn(run func(ctx context.Context) (out.SecretValue, error)) *MockSecretResolver_ResolveCloudflareToken_Call {
97+ _c.Call.Return(run)
98+ return _c
99+}
A internal/boundaries/out/public_tls.go +102 -0
@@ -0,0 +1,102 @@
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+// HTTPChallengeSink defines the contract for storing HTTP-01 challenge tokens
55+// while an ACME issuer completes domain validation.
56+type HTTPChallengeSink interface {
57+ // Present stores the key authorization for a challenge token.
58+ Present(token, keyAuth string) error
59+
60+ // CleanUp removes the key authorization for a challenge token.
61+ CleanUp(token string) error
62+}
63+
64+// PublicCertificateIssuer defines the contract for obtaining and renewing
65+// TLS certificates via ACME.
66+type PublicCertificateIssuer interface {
67+ // Obtain requests a new certificate for the given order.
68+ Obtain(ctx context.Context, order CertificateOrder) (*StoredCertificate, error)
69+
70+ // Renew renews an existing certificate.
71+ Renew(ctx context.Context, cert StoredCertificate) (*StoredCertificate, error)
72+}
73+
74+// CertificateStore defines the contract for persisting ACME accounts and certificates.
75+type CertificateStore interface {
76+ // LoadAccount retrieves an ACME account.
77+ LoadAccount(ctx context.Context) (*ACMEAccount, error)
78+
79+ // SaveAccount persists an ACME account.
80+ SaveAccount(ctx context.Context, account ACMEAccount) error
81+
82+ // LoadAll returns all stored certificates.
83+ LoadAll(ctx context.Context) ([]StoredCertificate, error)
84+
85+ // Save persists a certificate.
86+ Save(ctx context.Context, cert StoredCertificate) error
87+
88+ // Lock acquires a lock to prevent concurrent certificate operations.
89+ Lock(ctx context.Context) (unlock func() error, err error)
90+}
91+
92+// SecretResolver defines the contract for resolving Cloudflare API tokens.
93+type SecretResolver interface {
94+ // ResolveCloudflareToken retrieves the Cloudflare API token from the configured source.
95+ ResolveCloudflareToken(ctx context.Context) (SecretValue, error)
96+}
97+
98+// CloudflareZoneResolver defines the contract for resolving DNS zones.
99+type CloudflareZoneResolver interface {
100+ // FindZone finds the Cloudflare zone for the given domain.
101+ FindZone(ctx context.Context, domain string) (CloudflareZone, error)
102+}
M internal/domain/errors.go +11 -0
@@ -91,4 +91,15 @@ var (
9191 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
9292 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
9393 ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it")
94+
95+ // TLS / ACME errors
96+ ErrACMEDisabled = errors.New("acme disabled")
97+ ErrACMEEmailRequired = errors.New("acme email required")
98+ ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100+ ErrCertificateStoreRequired = errors.New("certificate store required")
101+ ErrCertificateIssuerRequired = errors.New("certificate issuer required")
102+ ErrRouteSourceRequired = errors.New("route source required")
103+ ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
104+ ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
94105 )
A internal/domain/public_tls.go +124 -0
@@ -0,0 +1,124 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+)
8+
9+type ACMEChallengeMode string
10+
11+const (
12+ ACMEChallengeAuto ACMEChallengeMode = "auto"
13+ ACMEChallengeHTTP01 ACMEChallengeMode = "http-01"
14+ ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01"
15+
16+ MaxHTTP01TokenLength = 256
17+ TLSRenewalWindow = 30 * 24 * time.Hour
18+)
19+
20+func IsValidHTTP01Token(token string) bool {
21+ return token != "" && len(token) <= MaxHTTP01TokenLength && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00")
22+}
23+
24+func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
25+ switch strings.TrimSpace(value) {
26+ case "", string(ACMEChallengeAuto):
27+ return ACMEChallengeAuto, nil
28+ case string(ACMEChallengeHTTP01):
29+ return ACMEChallengeHTTP01, nil
30+ case string(ACMEChallengeCloudflareDNS01):
31+ return ACMEChallengeCloudflareDNS01, nil
32+ default:
33+ return "", fmt.Errorf("%w: %q", ErrACMEChallengeInvalid, value)
34+ }
35+}
36+
37+type ACMETokenSource string
38+
39+const (
40+ ACMETokenSourceNone ACMETokenSource = "none"
41+ ACMETokenSourcePass ACMETokenSource = "pass"
42+ ACMETokenSourceFile ACMETokenSource = "file"
43+ ACMETokenSourceEnv ACMETokenSource = "env"
44+)
45+
46+type TLSCertificateStatus string
47+
48+const (
49+ TLSCertificateStatusMissing TLSCertificateStatus = "missing"
50+ TLSCertificateStatusValid TLSCertificateStatus = "valid"
51+ TLSCertificateStatusWarning TLSCertificateStatus = "warning"
52+ TLSCertificateStatusExpired TLSCertificateStatus = "expired"
53+ TLSCertificateStatusError TLSCertificateStatus = "error"
54+)
55+
56+type ManagedCertificate struct {
57+ ID string
58+ Names []string
59+ Challenge ACMEChallengeMode
60+ Status TLSCertificateStatus
61+ NotAfter time.Time
62+ LastError string
63+ RenewalPending bool
64+}
65+
66+func (c ManagedCertificate) Covers(host string) bool {
67+ return CertificateNamesCoverHost(c.Names, host)
68+}
69+
70+// CertificateNamesCoverHost reports whether any certificate name covers host.
71+func CertificateNamesCoverHost(names []string, host string) bool {
72+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
73+ if host == "" {
74+ return false
75+ }
76+ for _, name := range names {
77+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
78+ if name == host {
79+ return true
80+ }
81+ if suffix, ok := strings.CutPrefix(name, "*."); ok && strings.HasSuffix(host, "."+suffix) {
82+ left := strings.TrimSuffix(host, "."+suffix)
83+ if left != "" && !strings.Contains(left, ".") {
84+ return true
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(TLSRenewalWindow).Before(c.NotAfter) {
102+ return TLSCertificateStatusWarning
103+ }
104+ return TLSCertificateStatusValid
105+}
106+
107+type TLSRouteCoverage struct {
108+ Domain string
109+ Covered bool
110+ CoveredBy string
111+ RequiredACME bool
112+ Error string
113+}
114+
115+type PublicTLSStatus struct {
116+ ACMEEnabled bool
117+ ConfiguredMode ACMEChallengeMode
118+ EffectiveMode ACMEChallengeMode
119+ SelectionReason string
120+ TokenSource ACMETokenSource
121+ Certificates []ManagedCertificate
122+ Routes []TLSRouteCoverage
123+ Errors []string
124+}
A internal/domain/public_tls_test.go +135 -0
@@ -0,0 +1,135 @@
1+package domain
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestIsValidHTTP01Token(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ token string
16+ want bool
17+ }{
18+ {name: "valid", token: "abc123_-token", want: true},
19+ {name: "empty", token: "", want: false},
20+ {name: "slash", token: "abc/def", want: false},
21+ {name: "backslash", token: `abc\def`, want: false},
22+ {name: "dot dot", token: "abc..def", want: false},
23+ {name: "nul", token: "abc\x00def", want: false},
24+ {name: "too long", token: strings.Repeat("a", MaxHTTP01TokenLength+1), want: false},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ assert.Equal(t, tt.want, IsValidHTTP01Token(tt.token))
30+ })
31+ }
32+}
33+
34+func TestACMEChallengeModeParse(t *testing.T) {
35+ tests := []struct {
36+ input string
37+ want ACMEChallengeMode
38+ ok bool
39+ }{
40+ {"", ACMEChallengeAuto, true},
41+ {"auto", ACMEChallengeAuto, true},
42+ {"http-01", ACMEChallengeHTTP01, true},
43+ {"cloudflare-dns-01", ACMEChallengeCloudflareDNS01, true},
44+ {"dns", "", false},
45+ }
46+
47+ for _, tt := range tests {
48+ got, err := ParseACMEChallengeMode(tt.input)
49+ if tt.ok {
50+ require.NoError(t, err)
51+ assert.Equal(t, tt.want, got)
52+ } else {
53+ require.Error(t, err)
54+ assert.ErrorIs(t, err, ErrACMEChallengeInvalid)
55+ }
56+ }
57+}
58+
59+func TestManagedCertificateCovers(t *testing.T) {
60+ apexCert := ManagedCertificate{Names: []string{"example.com", "*.example.com"}}
61+ prodCert := ManagedCertificate{Names: []string{"prod.example.com", "*.prod.example.com"}}
62+
63+ assert.True(t, apexCert.Covers("example.com"))
64+ assert.True(t, apexCert.Covers("app.example.com"))
65+ assert.False(t, apexCert.Covers("api.prod.example.com"), "*.example.com must not cover two labels")
66+ assert.True(t, prodCert.Covers("api.prod.example.com"))
67+ assert.False(t, apexCert.Covers("other.test"))
68+}
69+
70+func TestManagedCertificateHealth(t *testing.T) {
71+ // Fixed reference time for deterministic tests.
72+ now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
73+
74+ tests := []struct {
75+ name string
76+ cert ManagedCertificate
77+ want TLSCertificateStatus
78+ }{
79+ {
80+ name: "valid certificate far from expiry",
81+ cert: ManagedCertificate{NotAfter: now.Add(60 * 24 * time.Hour)},
82+ want: TLSCertificateStatusValid,
83+ },
84+ {
85+ name: "expired certificate",
86+ cert: ManagedCertificate{NotAfter: now.Add(-time.Hour)},
87+ want: TLSCertificateStatusExpired,
88+ },
89+ {
90+ name: "warning when less than 30 days remain",
91+ cert: ManagedCertificate{NotAfter: now.Add(15 * 24 * time.Hour)},
92+ want: TLSCertificateStatusWarning,
93+ },
94+ {
95+ name: "warning exactly at 30 day boundary",
96+ cert: ManagedCertificate{NotAfter: now.Add(30 * 24 * time.Hour)},
97+ want: TLSCertificateStatusWarning,
98+ },
99+ {
100+ name: "missing when NotAfter is zero",
101+ cert: ManagedCertificate{},
102+ want: TLSCertificateStatusMissing,
103+ },
104+ {
105+ name: "error when LastError is set",
106+ cert: ManagedCertificate{
107+ NotAfter: now.Add(60 * 24 * time.Hour),
108+ LastError: "issuer unavailable",
109+ },
110+ want: TLSCertificateStatusError,
111+ },
112+ {
113+ name: "error takes priority over expired",
114+ cert: ManagedCertificate{
115+ NotAfter: now.Add(-time.Hour),
116+ LastError: "something went wrong",
117+ },
118+ want: TLSCertificateStatusError,
119+ },
120+ {
121+ name: "error takes priority over zero NotAfter",
122+ cert: ManagedCertificate{
123+ LastError: "something went wrong",
124+ },
125+ want: TLSCertificateStatusError,
126+ },
127+ }
128+
129+ for _, tt := range tests {
130+ t.Run(tt.name, func(t *testing.T) {
131+ got := tt.cert.Health(now)
132+ assert.Equal(t, tt.want, got)
133+ })
134+ }
135+}
A internal/usecase/publictls/config.go +135 -0
@@ -0,0 +1,135 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// Config holds the resolved configuration for public ACME TLS.
12+type Config struct {
13+ Enabled bool
14+ Email string
15+ Challenge string
16+ HTTPPort int
17+ TLSPort int
18+ DataDir string
19+}
20+
21+// EffectiveChallenge represents the resolved ACME challenge configuration.
22+type EffectiveChallenge struct {
23+ ConfiguredMode domain.ACMEChallengeMode
24+ Mode domain.ACMEChallengeMode
25+ Token string
26+ TokenSource domain.ACMETokenSource
27+ Reason string
28+}
29+
30+// validPort checks that port is in the valid TCP port range 1..65535.
31+func validPort(p int) bool {
32+ return p >= 1 && p <= 65535
33+}
34+
35+// ResolveEffectiveChallenge resolves the effective ACME challenge configuration
36+// from the given Config and optional token resolver.
37+func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver out.SecretResolver) (EffectiveChallenge, error) {
38+ if !cfg.Enabled {
39+ configuredMode := domain.ACMEChallengeAuto
40+ if parsed, err := domain.ParseACMEChallengeMode(cfg.Challenge); err == nil {
41+ configuredMode = parsed
42+ }
43+ return EffectiveChallenge{
44+ ConfiguredMode: configuredMode,
45+ Mode: domain.ACMEChallengeAuto,
46+ TokenSource: domain.ACMETokenSourceNone,
47+ Reason: "ACME disabled",
48+ }, nil
49+ }
50+
51+ if cfg.Email == "" {
52+ return EffectiveChallenge{}, domain.ErrACMEEmailRequired
53+ }
54+
55+ if !validPort(cfg.TLSPort) {
56+ return EffectiveChallenge{}, fmt.Errorf("%w: tls_port must be in range 1..65535", domain.ErrACMEChallengeInvalid)
57+ }
58+
59+ parsedMode, err := domain.ParseACMEChallengeMode(cfg.Challenge)
60+ if err != nil {
61+ return EffectiveChallenge{}, err
62+ }
63+
64+ switch parsedMode {
65+ case domain.ACMEChallengeHTTP01:
66+ if !validPort(cfg.HTTPPort) {
67+ return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be in range 1..65535 for http-01 challenge", domain.ErrACMEChallengeInvalid)
68+ }
69+ return EffectiveChallenge{
70+ ConfiguredMode: domain.ACMEChallengeHTTP01,
71+ Mode: domain.ACMEChallengeHTTP01,
72+ TokenSource: domain.ACMETokenSourceNone,
73+ Reason: "http-01 challenge selected",
74+ }, nil
75+
76+ case domain.ACMEChallengeCloudflareDNS01:
77+ token, source, err := resolveToken(ctx, resolver, true)
78+ if err != nil {
79+ return EffectiveChallenge{}, fmt.Errorf("resolve Cloudflare token: %w", err)
80+ }
81+ if token == "" {
82+ return EffectiveChallenge{}, domain.ErrCloudflareTokenMissing
83+ }
84+ return EffectiveChallenge{
85+ ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
86+ Mode: domain.ACMEChallengeCloudflareDNS01,
87+ Token: token,
88+ TokenSource: source,
89+ Reason: "cloudflare dns-01 challenge selected",
90+ }, nil
91+
92+ case domain.ACMEChallengeAuto:
93+ token, source, _ := resolveToken(ctx, resolver, false)
94+ if token != "" {
95+ return EffectiveChallenge{
96+ ConfiguredMode: domain.ACMEChallengeAuto,
97+ Mode: domain.ACMEChallengeCloudflareDNS01,
98+ Token: token,
99+ TokenSource: source,
100+ Reason: "auto selected cloudflare dns-01 (token available)",
101+ }, nil
102+ }
103+ if !validPort(cfg.HTTPPort) {
104+ return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be in range 1..65535 for http-01 fallback in auto mode", domain.ErrACMEChallengeInvalid)
105+ }
106+ return EffectiveChallenge{
107+ ConfiguredMode: domain.ACMEChallengeAuto,
108+ Mode: domain.ACMEChallengeHTTP01,
109+ TokenSource: domain.ACMETokenSourceNone,
110+ Reason: "auto selected http-01 (no cloudflare token)",
111+ }, nil
112+
113+ default:
114+ return EffectiveChallenge{}, fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, parsedMode)
115+ }
116+}
117+
118+// resolveToken resolves the Cloudflare token via the resolver. When strict is
119+// false, resolver errors are ignored so auto mode can fall back to HTTP-01.
120+func resolveToken(ctx context.Context, resolver out.SecretResolver, strict bool) (string, domain.ACMETokenSource, error) {
121+ if resolver == nil {
122+ return "", domain.ACMETokenSourceNone, nil
123+ }
124+ sv, err := resolver.ResolveCloudflareToken(ctx)
125+ if err != nil {
126+ if strict {
127+ return "", domain.ACMETokenSourceNone, err
128+ }
129+ return "", domain.ACMETokenSourceNone, nil
130+ }
131+ if sv.Value == "" {
132+ return "", domain.ACMETokenSourceNone, nil
133+ }
134+ return sv.Value, sv.Source, nil
135+}
A internal/usecase/publictls/config_test.go +151 -0
@@ -0,0 +1,151 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/boundaries/out"
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func newSecretResolverMock(t *testing.T, value out.SecretValue, err error) *outmocks.MockSecretResolver {
18+ t.Helper()
19+ resolver := outmocks.NewMockSecretResolver(t)
20+ resolver.EXPECT().ResolveCloudflareToken(mock.Anything).Return(value, err)
21+ return resolver
22+}
23+
24+func TestResolveEffectiveChallenge(t *testing.T) {
25+ t.Run("disabled returns auto mode with none source", func(t *testing.T) {
26+ cfg := Config{Enabled: false}
27+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
28+ require.NoError(t, err)
29+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode)
30+ assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode)
31+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
32+ assert.Equal(t, "ACME disabled", ec.Reason)
33+ })
34+
35+ t.Run("disabled preserves configured challenge in ConfiguredMode", func(t *testing.T) {
36+ cfg := Config{Enabled: false, Challenge: "http-01"}
37+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
38+ require.NoError(t, err)
39+ assert.Equal(t, domain.ACMEChallengeHTTP01, ec.ConfiguredMode, "should preserve http-01")
40+ assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode, "Mode should remain auto")
41+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
42+ assert.Equal(t, "ACME disabled", ec.Reason)
43+ })
44+
45+ t.Run("disabled preserves cloudflare-dns-01 in ConfiguredMode", func(t *testing.T) {
46+ cfg := Config{Enabled: false, Challenge: "cloudflare-dns-01"}
47+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
48+ require.NoError(t, err)
49+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.ConfiguredMode, "should preserve cloudflare-dns-01")
50+ assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode, "Mode should remain auto")
51+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
52+ assert.Equal(t, "ACME disabled", ec.Reason)
53+ })
54+
55+ t.Run("disabled with unparseable challenge defaults ConfiguredMode to auto", func(t *testing.T) {
56+ cfg := Config{Enabled: false, Challenge: "unknown-challenge"}
57+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
58+ require.NoError(t, err)
59+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode, "unparseable defaults to auto")
60+ assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode)
61+ assert.Equal(t, "ACME disabled", ec.Reason)
62+ })
63+
64+ t.Run("enabled requires non-empty email", func(t *testing.T) {
65+ cfg := Config{Enabled: true, Email: "", TLSPort: 8443}
66+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
67+ require.Error(t, err)
68+ assert.ErrorIs(t, err, domain.ErrACMEEmailRequired)
69+ })
70+
71+ t.Run("enabled requires valid tls port in 1..65535", func(t *testing.T) {
72+ cfg := Config{Enabled: true, Email: "admin@example.com", TLSPort: 0}
73+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
74+ require.Error(t, err)
75+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
76+ assert.Contains(t, err.Error(), "tls_port")
77+
78+ cfg2 := Config{Enabled: true, Email: "admin@example.com", TLSPort: 65536}
79+ _, err2 := ResolveEffectiveChallenge(context.Background(), cfg2, nil)
80+ require.Error(t, err2)
81+ assert.ErrorIs(t, err2, domain.ErrACMEChallengeInvalid)
82+ assert.Contains(t, err2.Error(), "tls_port")
83+ })
84+
85+ t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) {
86+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
87+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass}, nil)
88+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
89+ require.NoError(t, err)
90+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode)
91+ assert.Equal(t, domain.ACMETokenSourcePass, ec.TokenSource)
92+ assert.Equal(t, "token", ec.Token)
93+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode)
94+ })
95+
96+ t.Run("auto without token returns http-01", func(t *testing.T) {
97+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
98+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil)
99+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
100+ require.NoError(t, err)
101+ assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
102+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
103+ assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode)
104+ })
105+
106+ t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) {
107+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443, HTTPPort: 8088}
108+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil)
109+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
110+ require.Error(t, err)
111+ assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
112+ })
113+
114+ t.Run("http-01 requires valid http port in 1..65535", func(t *testing.T) {
115+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", TLSPort: 8443, HTTPPort: 0}
116+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
117+ require.Error(t, err)
118+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
119+ assert.Contains(t, err.Error(), "http_port")
120+
121+ cfg2 := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", TLSPort: 8443, HTTPPort: 65536}
122+ _, err2 := ResolveEffectiveChallenge(context.Background(), cfg2, nil)
123+ require.Error(t, err2)
124+ assert.ErrorIs(t, err2, domain.ErrACMEChallengeInvalid)
125+ assert.Contains(t, err2.Error(), "http_port")
126+ })
127+
128+ t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) {
129+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443}
130+ resolver := newSecretResolverMock(t, out.SecretValue{}, errors.New("pass failed"))
131+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
132+ require.Error(t, err)
133+ assert.Contains(t, err.Error(), "resolve Cloudflare token")
134+ assert.False(t, errors.Is(err, domain.ErrCloudflareTokenMissing), "must not collapse to missing token")
135+ })
136+
137+ t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) {
138+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
139+ ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
140+ require.NoError(t, err)
141+ assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
142+ assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource)
143+ })
144+
145+ t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) {
146+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443}
147+ _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
148+ require.Error(t, err)
149+ assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
150+ })
151+}
A internal/usecase/publictls/http01.go +58 -0
@@ -0,0 +1,58 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
11+// authorizations. It is safe for concurrent use.
12+type HTTP01Challenges struct {
13+ mu sync.RWMutex
14+ data map[string]string
15+}
16+
17+// NewHTTP01Challenges returns an initialized HTTP01Challenges.
18+func NewHTTP01Challenges() *HTTP01Challenges {
19+ return &HTTP01Challenges{
20+ data: make(map[string]string),
21+ }
22+}
23+
24+// Present stores the key authorization for the given token. It silently
25+// ignores unsafe tokens and empty keyAuth values.
26+func (c *HTTP01Challenges) Present(token, keyAuth string) error {
27+ if !domain.IsValidHTTP01Token(token) || keyAuth == "" {
28+ return nil
29+ }
30+ c.mu.Lock()
31+ defer c.mu.Unlock()
32+ c.data[token] = keyAuth
33+ return nil
34+}
35+
36+// CleanUp removes the stored key authorization for the given token. It silently
37+// ignores unsafe tokens.
38+func (c *HTTP01Challenges) CleanUp(token string) error {
39+ if !domain.IsValidHTTP01Token(token) {
40+ return nil
41+ }
42+ c.mu.Lock()
43+ defer c.mu.Unlock()
44+ delete(c.data, token)
45+ return nil
46+}
47+
48+// Get returns the key authorization for the given token, and a boolean
49+// indicating whether it was found. It returns false for unsafe tokens.
50+func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
51+ if !domain.IsValidHTTP01Token(token) {
52+ return "", false
53+ }
54+ c.mu.RLock()
55+ defer c.mu.RUnlock()
56+ keyAuth, ok := c.data[token]
57+ return keyAuth, ok
58+}
A internal/usecase/publictls/http01_test.go +164 -0
@@ -0,0 +1,164 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestHTTP01Challenges(t *testing.T) {
14+ ctx := context.Background()
15+
16+ t.Run("full lifecycle", func(t *testing.T) {
17+ ch := NewHTTP01Challenges()
18+ require.NotNil(t, ch)
19+
20+ // Present a valid token
21+ ch.Present("token", "key-auth")
22+ got, ok := ch.Get(ctx, "token")
23+ assert.True(t, ok)
24+ assert.Equal(t, "key-auth", got)
25+
26+ // Clean up
27+ ch.CleanUp("token")
28+ _, ok = ch.Get(ctx, "token")
29+ assert.False(t, ok)
30+ })
31+
32+ t.Run("concurrent access is correct", func(t *testing.T) {
33+ ch := NewHTTP01Challenges()
34+
35+ // Pre-populate some tokens.
36+ ch.Present("existing-token", "existing-keyauth")
37+
38+ const numGoroutines = 20
39+ var wg sync.WaitGroup
40+ errCh := make(chan error, numGoroutines)
41+
42+ // Spawn concurrent readers and writers.
43+ for i := range numGoroutines {
44+ wg.Add(1)
45+ go func(id int) {
46+ defer wg.Done()
47+ token := fmt.Sprintf("token-%d", id)
48+ keyAuth := fmt.Sprintf("ka-%d", id)
49+
50+ // Every goroutine presents its own token.
51+ ch.Present(token, keyAuth)
52+
53+ // Every goroutine reads back its own token.
54+ got, ok := ch.Get(ctx, token)
55+ if !ok {
56+ errCh <- fmt.Errorf("token %q not found after Present", token)
57+ return
58+ }
59+ if got != keyAuth {
60+ errCh <- fmt.Errorf("token %q: got %q, want %q", token, got, keyAuth)
61+ return
62+ }
63+
64+ // Every goroutine reads the pre-populated existing token.
65+ existing, ok := ch.Get(ctx, "existing-token")
66+ if !ok {
67+ errCh <- fmt.Errorf("existing-token not found by goroutine %d", id)
68+ return
69+ }
70+ if existing != "existing-keyauth" {
71+ errCh <- fmt.Errorf("existing-token: got %q, want existing-keyauth", existing)
72+ return
73+ }
74+
75+ // Every goroutine cleans up its own token.
76+ ch.CleanUp(token)
77+ _, ok = ch.Get(ctx, token)
78+ if ok {
79+ errCh <- fmt.Errorf("token %q should be gone after CleanUp", token)
80+ return
81+ }
82+ }(i)
83+ }
84+
85+ wg.Wait()
86+ close(errCh)
87+
88+ // Collect any errors from goroutines.
89+ for err := range errCh {
90+ t.Error(err)
91+ }
92+
93+ // Verify that pre-populated token is still present after all concurrent access.
94+ existing, ok := ch.Get(ctx, "existing-token")
95+ require.True(t, ok, "existing-token should survive concurrent access")
96+ assert.Equal(t, "existing-keyauth", existing)
97+ })
98+}
99+
100+func TestHTTP01ChallengesRejectUnsafeToken(t *testing.T) {
101+ ctx := context.Background()
102+
103+ t.Run("path traversal token ../secret is rejected", func(t *testing.T) {
104+ ch := NewHTTP01Challenges()
105+
106+ ch.Present("../secret", "key-auth")
107+ _, ok := ch.Get(ctx, "../secret")
108+ assert.False(t, ok, "Present should silently ignore ../secret")
109+ })
110+
111+ t.Run("token with slash is rejected", func(t *testing.T) {
112+ ch := NewHTTP01Challenges()
113+
114+ ch.Present("a/b", "key-auth")
115+ _, ok := ch.Get(ctx, "a/b")
116+ assert.False(t, ok, "token containing / should be rejected")
117+
118+ // Get also rejects the unsafe token
119+ _, ok = ch.Get(ctx, "safe/token")
120+ assert.False(t, ok)
121+ })
122+
123+ t.Run("token with backslash is rejected", func(t *testing.T) {
124+ ch := NewHTTP01Challenges()
125+
126+ ch.Present("a\\b", "key-auth")
127+ _, ok := ch.Get(ctx, "a\\b")
128+ assert.False(t, ok, "token containing backslash should be rejected")
129+
130+ _, ok = ch.Get(ctx, "safe\\token")
131+ assert.False(t, ok)
132+ })
133+
134+ t.Run("empty token is rejected", func(t *testing.T) {
135+ ch := NewHTTP01Challenges()
136+
137+ ch.Present("", "key-auth")
138+ _, ok := ch.Get(ctx, "")
139+ assert.False(t, ok, "empty token should be rejected")
140+ })
141+
142+ t.Run("'..' token is rejected", func(t *testing.T) {
143+ ch := NewHTTP01Challenges()
144+
145+ ch.Present("..", "key-auth")
146+ _, ok := ch.Get(ctx, "..")
147+ assert.False(t, ok, "token '..' should be rejected")
148+ })
149+
150+ t.Run("empty keyAuth is silently ignored", func(t *testing.T) {
151+ ch := NewHTTP01Challenges()
152+ ch.Present("valid-token", "")
153+ _, ok := ch.Get(ctx, "valid-token")
154+ assert.False(t, ok, "Present with empty keyAuth should not store anything")
155+ })
156+
157+ t.Run("valid tokens are accepted and retrievable", func(t *testing.T) {
158+ ch := NewHTTP01Challenges()
159+ ch.Present("valid-token", "key-auth-value")
160+ got, ok := ch.Get(ctx, "valid-token")
161+ assert.True(t, ok)
162+ assert.Equal(t, "key-auth-value", got)
163+ })
164+}
A internal/usecase/publictls/integration_test.go +296 -0
@@ -0,0 +1,296 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+// ---------------------------------------------------------------------------
17+// Integration-style tests using fakes (no real ACME / Cloudflare)
18+// ---------------------------------------------------------------------------
19+
20+func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing.T) {
21+ ctx := context.Background()
22+
23+ // Routes: app.example.com, api.prod.example.com, example.com
24+ routes := &fakeRoutes{
25+ routes: []domain.Route{
26+ {Domain: "app.example.com"},
27+ {Domain: "api.prod.example.com"},
28+ {Domain: "example.com"},
29+ },
30+ }
31+
32+ // Mock zone resolver returns "example.com" zone for 3 routes.
33+ zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
34+ zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)
35+
36+ // Mock issuer: returns valid stored cert for any order.
37+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
38+ return defaultStoredCert(order)
39+ }, nil)
40+ store, _ := newMockCertificateStore(t)
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+ orders := recorder.Orders()
75+
76+ require.Len(t, orders, 2, "expected 2 DNS-01 orders")
77+
78+ // Check order details (orders are sorted alphabetically by target base).
79+ order0 := orders[0]
80+ assert.Equal(t, "dns01-example.com", order0.ID)
81+ assert.ElementsMatch(t, []string{"example.com", "*.example.com"}, order0.Names)
82+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order0.Challenge)
83+
84+ order1 := orders[1]
85+ assert.Equal(t, "dns01-prod.example.com", order1.ID)
86+ assert.ElementsMatch(t, []string{"prod.example.com", "*.prod.example.com"}, order1.Names)
87+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order1.Challenge)
88+
89+ // --- Status reports routes covered ---
90+ status := svc.Status(ctx)
91+ require.True(t, status.ACMEEnabled)
92+ require.Len(t, status.Routes, 3, "expected 3 routes in status")
93+
94+ // Build a map for easier assertions.
95+ routeMap := make(map[string]domain.TLSRouteCoverage)
96+ for _, r := range status.Routes {
97+ routeMap[r.Domain] = r
98+ }
99+
100+ for _, routeDomain := range []string{"app.example.com", "api.prod.example.com", "example.com"} {
101+ rc, ok := routeMap[routeDomain]
102+ require.True(t, ok, "route %s should be present in status", routeDomain)
103+ assert.True(t, rc.Covered, "route %s should be covered", routeDomain)
104+ assert.True(t, rc.RequiredACME)
105+ assert.NotEmpty(t, rc.CoveredBy, "route %s should have CoveredBy set", routeDomain)
106+ assert.Empty(t, rc.Error, "route %s should have no error", routeDomain)
107+ }
108+
109+ // app.example.com should be covered by the wildcard cert (dns01-example.com).
110+ assert.Equal(t, "dns01-example.com", routeMap["app.example.com"].CoveredBy,
111+ "app.example.com should be covered by dns01-example.com (wildcard *.example.com)")
112+
113+ // example.com should be covered by the base cert (dns01-example.com).
114+ assert.Equal(t, "dns01-example.com", routeMap["example.com"].CoveredBy,
115+ "example.com should be covered by dns01-example.com")
116+
117+ // api.prod.example.com should be covered by the wildcard cert (dns01-prod.example.com).
118+ assert.Equal(t, "dns01-prod.example.com", routeMap["api.prod.example.com"].CoveredBy,
119+ "api.prod.example.com should be covered by dns01-prod.example.com (wildcard *.prod.example.com)")
120+
121+ // --- GetCertificate returns non-nil certs ---
122+ t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) {
123+ cert, err := svc.GetCertificateForHost("app.example.com")
124+ require.NoError(t, err)
125+ require.NotNil(t, cert)
126+ require.NotEmpty(t, cert.Certificate)
127+ })
128+
129+ t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) {
130+ cert, err := svc.GetCertificateForHost("api.prod.example.com")
131+ require.NoError(t, err)
132+ require.NotNil(t, cert)
133+ require.NotEmpty(t, cert.Certificate)
134+ })
135+
136+ t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) {
137+ cert, err := svc.GetCertificateForHost("example.com")
138+ require.NoError(t, err)
139+ require.NotNil(t, cert)
140+ require.NotEmpty(t, cert.Certificate)
141+ })
142+
143+ // Also verify that a host not in the routes returns nil (no required ACME).
144+ // "unrelated.com" is not covered by any wildcard base in requiredHosts.
145+ t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
146+ cert, err := svc.GetCertificateForHost("unrelated.com")
147+ require.NoError(t, err)
148+ assert.Nil(t, cert, "unrelated host should not require ACME coverage")
149+ })
150+}
151+
152+func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
153+ ctx := context.Background()
154+
155+ routes := &fakeRoutes{
156+ routes: []domain.Route{
157+ {Domain: "app.example.com"},
158+ },
159+ }
160+
161+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
162+ store, _ := newMockCertificateStore(t)
163+ challenges := NewHTTP01Challenges()
164+
165+ cfg := Config{
166+ Enabled: true,
167+ Email: "admin@example.com",
168+ Challenge: "http-01",
169+ HTTPPort: 8088,
170+ TLSPort: 8443,
171+ }
172+
173+ svc := NewService(cfg, ServiceDeps{
174+ Config: cfg,
175+ Routes: routes,
176+ Issuer: issuer,
177+ Store: store,
178+ Challenges: challenges,
179+ Effective: EffectiveChallenge{
180+ ConfiguredMode: domain.ACMEChallengeHTTP01,
181+ Mode: domain.ACMEChallengeHTTP01,
182+ TokenSource: domain.ACMETokenSourceNone,
183+ Reason: "http-01 challenge selected",
184+ },
185+ })
186+
187+ // Present an HTTP-01 challenge token/keyAuth.
188+ token := "test-token-abc123"
189+ keyAuth := "test-key-auth-xyz789"
190+ challenges.Present(token, keyAuth)
191+
192+ // GetHTTP01Challenge should return the keyAuth.
193+ gotKeyAuth, ok := svc.GetHTTP01Challenge(ctx, token)
194+ require.True(t, ok, "GetHTTP01Challenge should find the token")
195+ assert.Equal(t, keyAuth, gotKeyAuth)
196+
197+ // Load the service (nothing stored).
198+ err := svc.Load(ctx)
199+ require.NoError(t, err)
200+
201+ // Reconcile — should create per-route target app.example.com.
202+ err = svc.Reconcile(ctx)
203+ require.NoError(t, err)
204+
205+ // Verify the issuer received exactly 1 order.
206+ orders := recorder.Orders()
207+
208+ require.Len(t, orders, 1, "expected 1 HTTP-01 order")
209+ assert.Equal(t, "http01-app.example.com", orders[0].ID)
210+ assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
211+ assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
212+
213+ // Status reports covered route.
214+ status := svc.Status(ctx)
215+ require.True(t, status.ACMEEnabled)
216+ require.Len(t, status.Routes, 1, "expected 1 route in status")
217+ assert.Equal(t, "app.example.com", status.Routes[0].Domain)
218+ assert.True(t, status.Routes[0].Covered, "route should be covered")
219+ assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
220+ assert.Empty(t, status.Routes[0].Error)
221+
222+ // GetCertificateForHost returns non-nil cert for app.example.com.
223+ cert, err := svc.GetCertificateForHost("app.example.com")
224+ require.NoError(t, err)
225+ require.NotNil(t, cert)
226+ require.NotEmpty(t, cert.Certificate)
227+
228+ // GetCertificateForHost returns nil for unrelated host.
229+ cert, err = svc.GetCertificateForHost("other.example.com")
230+ require.NoError(t, err)
231+ assert.Nil(t, cert)
232+}
233+
234+func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing.T) {
235+ ctx := context.Background()
236+
237+ routes := &fakeRoutes{
238+ routes: []domain.Route{
239+ {Domain: "missing.example.com"},
240+ },
241+ }
242+
243+ // Issuer returns error so no cert is obtained.
244+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
245+ return nil, assert.AnError
246+ }, nil)
247+ store, _ := newMockCertificateStore(t)
248+
249+ cfg := Config{
250+ Enabled: true,
251+ Email: "admin@example.com",
252+ Challenge: "http-01",
253+ HTTPPort: 8088,
254+ TLSPort: 8443,
255+ }
256+
257+ svc := NewService(cfg, ServiceDeps{
258+ Config: cfg,
259+ Routes: routes,
260+ Issuer: issuer,
261+ Store: store,
262+ Challenges: NewHTTP01Challenges(),
263+ Effective: EffectiveChallenge{
264+ ConfiguredMode: domain.ACMEChallengeHTTP01,
265+ Mode: domain.ACMEChallengeHTTP01,
266+ TokenSource: domain.ACMETokenSourceNone,
267+ Reason: "http-01 challenge selected",
268+ },
269+ })
270+
271+ // Load (nothing stored).
272+ err := svc.Load(ctx)
273+ require.NoError(t, err)
274+
275+ // Reconcile — issuer fails, so no cert is cached but requiredHosts is set.
276+ err = svc.Reconcile(ctx)
277+ require.NoError(t, err) // Reconcile swallows per-target obtain errors
278+
279+ // GetCertificateForHost for missing.example.com should return ErrTLSRouteNotCovered.
280+ _, err = svc.GetCertificateForHost("missing.example.com")
281+ require.Error(t, err)
282+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
283+ // The error message should contain the hostname.
284+ assert.Contains(t, err.Error(), "missing.example.com")
285+
286+ // Status should show the route is NOT covered and has an error.
287+ status := svc.Status(ctx)
288+ require.True(t, status.ACMEEnabled)
289+ require.Len(t, status.Routes, 1, "expected 1 route in status")
290+ rc := status.Routes[0]
291+ assert.Equal(t, "missing.example.com", rc.Domain)
292+ assert.False(t, rc.Covered, "route should NOT be covered")
293+ assert.True(t, rc.RequiredACME)
294+
295+ assert.Contains(t, rc.Error, "obtain certificate")
296+}
A internal/usecase/publictls/renewal.go +174 -0
@@ -0,0 +1,174 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
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(-domain.TLSRenewalWindow))
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+ loopCtx = zerowrap.CtxWithFields(loopCtx, map[string]any{
38+ zerowrap.FieldLayer: "usecase",
39+ zerowrap.FieldUseCase: "RenewDueCertificates",
40+ })
41+
42+ // Atomically swap loop state under lock before cancelling the old loop,
43+ // preventing overlapping loop goroutines and race conditions.
44+ s.mu.Lock()
45+ oldCancel := s.cancel
46+ oldDone := s.done
47+ s.cancel = cancel
48+ s.done = done
49+ s.mu.Unlock()
50+
51+ if oldCancel != nil {
52+ oldCancel()
53+ }
54+ if oldDone != nil {
55+ select {
56+ case <-oldDone:
57+ case <-ctx.Done():
58+ case <-time.After(30 * time.Second):
59+ }
60+ }
61+
62+ go func() {
63+ defer close(done)
64+ defer cancel()
65+
66+ ticker := time.NewTicker(interval)
67+ defer ticker.Stop()
68+
69+ log := zerowrap.FromCtx(loopCtx)
70+
71+ // Startup and reload paths reconcile routes; the periodic loop only renews
72+ // due certificates to avoid repeated zone lookups and ACME ordering work.
73+ if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
74+ log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
75+ }
76+
77+ for {
78+ select {
79+ case <-loopCtx.Done():
80+ return
81+ case <-ticker.C:
82+ if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
83+ log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
84+ }
85+ }
86+ }
87+ }()
88+
89+ return done
90+}
91+
92+// renewDueCertificates renews all certificates in the cache that are due
93+// for renewal according to ShouldRenew.
94+//
95+// Per-certificate errors are recorded in lastErr and do not halt the loop.
96+// Returns nil unless the store or issuer is nil (systemic error).
97+func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error {
98+ log := zerowrap.FromCtx(ctx)
99+ if !s.cfg.Enabled {
100+ return nil
101+ }
102+ if s.deps.Store == nil {
103+ return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
104+ }
105+ if s.deps.Issuer == nil {
106+ return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
107+ }
108+
109+ // Collect due certs from in-memory cache under s.mu (no store lock needed).
110+ s.mu.Lock()
111+ due := make([]out.StoredCertificate, 0, len(s.certs))
112+ for _, cert := range s.certs {
113+ if ShouldRenew(*cert, now) {
114+ due = append(due, *cert)
115+ }
116+ }
117+ s.mu.Unlock()
118+
119+ for _, cert := range due {
120+ if err := ctx.Err(); err != nil {
121+ return err
122+ }
123+ renewed, err := s.deps.Issuer.Renew(ctx, cert)
124+ if err != nil {
125+ s.mu.Lock()
126+ s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)
127+ s.mu.Unlock()
128+ continue
129+ }
130+
131+ if renewed == nil {
132+ s.mu.Lock()
133+ s.lastErr[cert.ID] = "renew returned nil certificate"
134+ s.mu.Unlock()
135+ continue
136+ }
137+ if err := populateStoredCertificate(renewed); err != nil {
138+ s.mu.Lock()
139+ s.lastErr[cert.ID] = err.Error()
140+ s.mu.Unlock()
141+ continue
142+ }
143+ renewed.LastError = ""
144+
145+ // Acquire store lock only around Save to avoid holding it across
146+ // the potentially long-running Issuer.Renew call.
147+ unlock, err := s.deps.Store.Lock(ctx)
148+ if err != nil {
149+ s.mu.Lock()
150+ s.lastErr[cert.ID] = fmt.Sprintf("acquire store lock: %v", err)
151+ s.mu.Unlock()
152+ continue
153+ }
154+
155+ saveErr := s.deps.Store.Save(ctx, *renewed)
156+ if unlockErr := unlock(); unlockErr != nil {
157+ log.Warn().Err(unlockErr).Msg("failed to release store lock")
158+ }
159+
160+ if saveErr != nil {
161+ s.mu.Lock()
162+ s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", saveErr)
163+ s.mu.Unlock()
164+ continue
165+ }
166+
167+ s.mu.Lock()
168+ s.certs[renewed.ID] = renewed
169+ delete(s.lastErr, renewed.ID)
170+ s.mu.Unlock()
171+ }
172+
173+ return nil
174+}
A internal/usecase/publictls/renewal_test.go +208 -0
@@ -0,0 +1,208 @@
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+func TestRenewDueCertificatesRequiresDependencies(t *testing.T) {
60+ cfg := Config{Enabled: true}
61+ svc := NewService(cfg, ServiceDeps{})
62+
63+ err := svc.renewDueCertificates(context.Background(), time.Now())
64+ require.Error(t, err)
65+ assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
66+
67+ svc.deps.Store, _ = newMockCertificateStore(t)
68+ err = svc.renewDueCertificates(context.Background(), time.Now())
69+ require.Error(t, err)
70+ assert.ErrorIs(t, err, domain.ErrCertificateIssuerRequired)
71+}
72+
73+// ---------------------------------------------------------------------------
74+// RenewalLoopStops
75+// ---------------------------------------------------------------------------
76+
77+func TestRenewalLoopStops(t *testing.T) {
78+ ctx, cancel := context.WithCancel(context.Background())
79+
80+ cfg := Config{
81+ Enabled: true,
82+ Email: "admin@example.com",
83+ Challenge: "http-01",
84+ HTTPPort: 8088,
85+ TLSPort: 8443,
86+ }
87+
88+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
89+ store, _ := newMockCertificateStore(t)
90+
91+ svc := NewService(cfg, ServiceDeps{
92+ Config: cfg,
93+ Routes: &fakeRoutes{},
94+ Issuer: issuer,
95+ Store: store,
96+ Challenges: NewHTTP01Challenges(),
97+ Effective: EffectiveChallenge{
98+ ConfiguredMode: domain.ACMEChallengeHTTP01,
99+ Mode: domain.ACMEChallengeHTTP01,
100+ TokenSource: domain.ACMETokenSourceNone,
101+ Reason: "http-01 challenge selected",
102+ },
103+ })
104+
105+ done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
106+ cancel()
107+
108+ require.Eventually(t, func() bool {
109+ select {
110+ case <-done:
111+ return true
112+ default:
113+ return false
114+ }
115+ }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")
116+}
117+
118+// ---------------------------------------------------------------------------
119+// RenewDueCertificates
120+// ---------------------------------------------------------------------------
121+
122+func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
123+ ctx := context.Background()
124+
125+ certID := "http01-app.example.com"
126+ now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
127+ notAfterSoon := now.Add(10 * 24 * time.Hour) // 10 days from now — due for renewal
128+ notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert
129+
130+ // Generate a test certificate.
131+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
132+ require.NoError(t, err)
133+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
134+ require.NoError(t, err)
135+
136+ store, storeState := newMockCertificateStore(t, out.StoredCertificate{
137+ ID: certID,
138+ Names: []string{"app.example.com"},
139+ Challenge: domain.ACMEChallengeHTTP01,
140+ Certificate: tlsCert,
141+ FullchainPEM: certPEM,
142+ PrivateKeyPEM: keyPEM,
143+ NotAfter: notAfterSoon,
144+ })
145+
146+ issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
147+ renewed := cert
148+ renewed.Certificate = tls.Certificate{}
149+ renewed.LastError = "previous renewal error"
150+ renewed.NotAfter = notAfterRenewed
151+ return &renewed, nil
152+ })
153+
154+ cfg := Config{
155+ Enabled: true,
156+ Email: "admin@example.com",
157+ Challenge: "http-01",
158+ HTTPPort: 8088,
159+ TLSPort: 8443,
160+ }
161+
162+ svc := NewService(cfg, ServiceDeps{
163+ Config: cfg,
164+ Routes: &fakeRoutes{},
165+ Issuer: issuer,
166+ Store: store,
167+ Challenges: NewHTTP01Challenges(),
168+ Effective: EffectiveChallenge{
169+ ConfiguredMode: domain.ACMEChallengeHTTP01,
170+ Mode: domain.ACMEChallengeHTTP01,
171+ TokenSource: domain.ACMETokenSourceNone,
172+ Reason: "http-01 challenge selected",
173+ },
174+ })
175+
176+ // Load certs into the cache from the store.
177+ err = svc.Load(ctx)
178+ require.NoError(t, err)
179+
180+ // Verify cert is in cache with original NotAfter via Status.
181+ status := svc.Status(ctx)
182+ require.Len(t, status.Certificates, 1, "expected 1 managed certificate after Load")
183+ assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterSoon),
184+ "cache should have original NotAfter (got %v, want %v)",
185+ status.Certificates[0].NotAfter, notAfterSoon)
186+
187+ // Run renewDueCertificates.
188+ err = svc.renewDueCertificates(ctx, now)
189+ require.NoError(t, err)
190+
191+ // Verify the store has the updated certificate.
192+ all := storeState.All()
193+ require.Len(t, all, 1)
194+ assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
195+ assert.NotEmpty(t, all[0].Certificate.Certificate, "store should have normalized tls.Certificate")
196+ assert.Empty(t, all[0].LastError, "store should not persist stale LastError")
197+
198+ // Verify the cache has the updated certificate via Status.
199+ status = svc.Status(ctx)
200+ require.Len(t, status.Certificates, 1, "expected 1 managed certificate after renewal")
201+ assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterRenewed),
202+ "cache should have renewed NotAfter (got %v, want %v)",
203+ status.Certificates[0].NotAfter, notAfterRenewed)
204+
205+ // Verify lastErr is empty for the renewed certificate.
206+ assert.Empty(t, status.Certificates[0].LastError,
207+ "LastError should be empty for renewed cert")
208+}
A internal/usecase/publictls/service.go +465 -0
@@ -0,0 +1,465 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "fmt"
7+ "reflect"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+// RouteSource provides routes from which certificate targets are derived.
19+type RouteSource interface {
20+ GetRoutes(ctx context.Context) []domain.Route
21+ GetExternalRoutes() map[string]string
22+}
23+
24+// ServiceDeps contains the dependencies for the Service.
25+type ServiceDeps struct {
26+ Config Config
27+ Routes RouteSource
28+ Issuer out.PublicCertificateIssuer
29+ Store out.CertificateStore
30+ ZoneResolver out.CloudflareZoneResolver
31+ Challenges *HTTP01Challenges
32+ Effective EffectiveChallenge
33+ Log zerowrap.Logger
34+}
35+
36+// Service manages public TLS certificates via ACME.
37+type Service struct {
38+ mu sync.RWMutex
39+ cfg Config
40+ deps ServiceDeps
41+ log zerowrap.Logger
42+ certs map[string]*out.StoredCertificate // indexed by cert ID
43+ lastErr map[string]string // indexed by cert ID
44+ routeErr map[string]string // indexed by host
45+
46+ // requiredHosts is the set of hosts that must be covered by ACME certs.
47+ requiredHosts map[string]struct{}
48+
49+ // cancel cancels the renewal loop context.
50+ cancel context.CancelFunc
51+ // done is closed when the renewal loop exits.
52+ done chan struct{}
53+}
54+
55+// NewService creates a new public TLS Service.
56+func NewService(cfg Config, deps ServiceDeps) *Service {
57+ if deps.Challenges == nil {
58+ deps.Challenges = NewHTTP01Challenges()
59+ }
60+ if reflect.ValueOf(deps.Log).IsZero() {
61+ deps.Log = zerowrap.Default()
62+ }
63+ return &Service{
64+ cfg: cfg,
65+ deps: deps,
66+ log: deps.Log,
67+ certs: make(map[string]*out.StoredCertificate),
68+ lastErr: make(map[string]string),
69+ routeErr: make(map[string]string),
70+ requiredHosts: make(map[string]struct{}),
71+ }
72+}
73+
74+// Load loads all stored certificates from the store into the internal cache.
75+// If the store is nil, this is a no-op.
76+func (s *Service) Load(ctx context.Context) error {
77+ if s.deps.Store == nil {
78+ return nil
79+ }
80+
81+ stored, err := s.deps.Store.LoadAll(ctx)
82+ if err != nil {
83+ return fmt.Errorf("load stored certificates: %w", err)
84+ }
85+
86+ s.mu.Lock()
87+ defer s.mu.Unlock()
88+
89+ s.certs = make(map[string]*out.StoredCertificate, len(stored))
90+ s.lastErr = make(map[string]string)
91+ s.requiredHosts = make(map[string]struct{})
92+
93+ for i := range stored {
94+ cert := &stored[i]
95+ if parseErr := populateStoredCertificate(cert); parseErr != nil {
96+ s.lastErr[cert.ID] = parseErr.Error()
97+ continue
98+ }
99+ s.certs[cert.ID] = cert
100+ addNamesToRequiredHosts(s.requiredHosts, cert.Names)
101+ if cert.LastError != "" {
102+ s.lastErr[cert.ID] = cert.LastError
103+ }
104+ }
105+
106+ return nil
107+}
108+
109+// Reconcile ensures all desired certificates are obtained and cached.
110+// If ACME is disabled, it is a no-op.
111+func (s *Service) Reconcile(ctx context.Context) error {
112+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
113+ zerowrap.FieldLayer: "usecase",
114+ zerowrap.FieldUseCase: "Reconcile",
115+ })
116+ log := zerowrap.FromCtx(ctx)
117+
118+ if !s.cfg.Enabled {
119+ log.Debug().Msg("acme disabled, skipping reconcile")
120+ return nil
121+ }
122+ log.Debug().Msg("starting certificate reconciliation")
123+
124+ if s.deps.Store == nil {
125+ log.Warn().Msg("certificate store is nil, cannot reconcile")
126+ return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
127+ }
128+ if s.deps.Issuer == nil {
129+ log.Warn().Msg("certificate issuer is nil, cannot reconcile")
130+ return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
131+ }
132+ if s.deps.Routes == nil {
133+ log.Warn().Msg("route source is nil, cannot reconcile")
134+ return fmt.Errorf("%w: route source is nil", domain.ErrRouteSourceRequired)
135+ }
136+
137+ // Determine effective challenge mode.
138+ effective := s.deps.Effective
139+ if effective.Mode == "" {
140+ resolved, err := ResolveEffectiveChallenge(ctx, s.cfg, nil)
141+ if err != nil {
142+ return fmt.Errorf("resolve effective challenge: %w", err)
143+ }
144+ effective = resolved
145+ }
146+
147+ // Get route hosts early to build required hosts set before target derivation.
148+ // This ensures GetCertificate returns ErrTLSRouteNotCovered even if
149+ // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver).
150+ routes := s.deps.Routes.GetRoutes(ctx)
151+ external := s.deps.Routes.GetExternalRoutes()
152+ hosts := routeHosts(routes, external)
153+
154+ // Build required hosts set from route hosts (before target derivation).
155+ required := canonicalHostSet(hosts)
156+
157+ // Set requiredHosts before DeriveCertificateTargets so GetCertificate
158+ // returns ErrTLSRouteNotCovered if target derivation/issuer fails.
159+ s.mu.Lock()
160+ s.requiredHosts = required
161+ clearRouteErrorsLocked(s.routeErr, required)
162+ s.mu.Unlock()
163+
164+ // Derive desired targets.
165+ targets, err := DeriveCertificateTargets(ctx, effective.Mode,
166+ routes, external,
167+ s.deps.ZoneResolver,
168+ )
169+ if err != nil {
170+ s.mu.Lock()
171+ setRouteErrorsLocked(s.routeErr, required, fmt.Sprintf("derive certificate targets: %v", err))
172+ s.mu.Unlock()
173+ return fmt.Errorf("derive certificate targets: %w", err)
174+ }
175+
176+ // Merge target names into required hosts set.
177+ // For DNS-01 wildcard targets, this adds wildcard entries as well;
178+ // the exact route hosts already present suffice for isRequiredHostLocked.
179+ addTargetNames(required, targets)
180+
181+ // Under mu: update requiredHosts (with target names) and compute missing targets.
182+ s.mu.Lock()
183+ s.requiredHosts = required
184+
185+ missing := make([]CertificateTarget, 0)
186+ for _, target := range targets {
187+ if s.certificateExistsLocked(target) {
188+ continue
189+ }
190+ missing = append(missing, target)
191+ }
192+ s.mu.Unlock()
193+
194+ // Process missing targets without holding s.mu or the store lock so
195+ // GetCertificate/Status are not blocked during network I/O (Obtain).
196+ s.reconcileMissingTargets(ctx, missing, required)
197+
198+ log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets")
199+ return nil
200+}
201+
202+func populateStoredCertificate(cert *out.StoredCertificate) error {
203+ if cert == nil {
204+ return fmt.Errorf("stored certificate is nil")
205+ }
206+ if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
207+ parsed, err := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
208+ if err != nil {
209+ return fmt.Errorf("parse stored certificate: %w", err)
210+ }
211+ cert.Certificate = parsed
212+ }
213+ if len(cert.Certificate.Certificate) == 0 {
214+ return fmt.Errorf("stored certificate is empty")
215+ }
216+ return nil
217+}
218+
219+func canonicalHostSet(hosts []string) map[string]struct{} {
220+ required := make(map[string]struct{}, len(hosts))
221+ for _, host := range hosts {
222+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
223+ if host != "" {
224+ required[host] = struct{}{}
225+ }
226+ }
227+ return required
228+}
229+
230+func addNamesToRequiredHosts(required map[string]struct{}, names []string) {
231+ for _, name := range names {
232+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
233+ if name != "" {
234+ required[name] = struct{}{}
235+ }
236+ }
237+}
238+
239+func addTargetNames(required map[string]struct{}, targets []CertificateTarget) {
240+ for _, t := range targets {
241+ addNamesToRequiredHosts(required, t.Names)
242+ }
243+}
244+
245+func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) {
246+ for host := range hosts {
247+ delete(routeErr, host)
248+ }
249+}
250+
251+func setRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}, message string) {
252+ for host := range hosts {
253+ routeErr[host] = message
254+ }
255+}
256+
257+func setTargetRouteErrorLocked(routeErr map[string]string, required map[string]struct{}, target CertificateTarget, message string) {
258+ for host := range required {
259+ if hostMatchesCert(target.Names, host) {
260+ routeErr[host] = message
261+ }
262+ }
263+}
264+
265+func clearTargetRouteErrorLocked(routeErr map[string]string, required map[string]struct{}, target CertificateTarget) {
266+ for host := range required {
267+ if hostMatchesCert(target.Names, host) {
268+ delete(routeErr, host)
269+ }
270+ }
271+}
272+
273+// reconcileMissingTargets obtains and saves certificates for each missing
274+// target. Called without s.mu held so network I/O does not block readers.
275+func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget, required map[string]struct{}) {
276+ log := zerowrap.FromCtx(ctx)
277+ for _, target := range missing {
278+ order := out.CertificateOrder{
279+ ID: target.ID,
280+ Names: target.Names,
281+ Challenge: target.Challenge,
282+ }
283+
284+ stored, err := s.deps.Issuer.Obtain(ctx, order)
285+ if err != nil {
286+ errorMessage := fmt.Sprintf("obtain certificate: %v", err)
287+ s.mu.Lock()
288+ s.lastErr[target.ID] = errorMessage
289+ setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
290+ s.mu.Unlock()
291+ continue
292+ }
293+ if stored == nil {
294+ errorMessage := "obtain certificate returned nil"
295+ s.mu.Lock()
296+ s.lastErr[target.ID] = errorMessage
297+ setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
298+ s.mu.Unlock()
299+ continue
300+ }
301+ if err := populateStoredCertificate(stored); err != nil {
302+ errorMessage := err.Error()
303+ s.mu.Lock()
304+ s.lastErr[target.ID] = errorMessage
305+ setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
306+ s.mu.Unlock()
307+ continue
308+ }
309+
310+ unlock, err := s.deps.Store.Lock(ctx)
311+ if err != nil {
312+ errorMessage := fmt.Sprintf("acquire store lock: %v", err)
313+ s.mu.Lock()
314+ s.lastErr[target.ID] = errorMessage
315+ setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
316+ s.mu.Unlock()
317+ continue
318+ }
319+ saveErr := s.deps.Store.Save(ctx, *stored)
320+ if unlockErr := unlock(); unlockErr != nil {
321+ log.Warn().Err(unlockErr).Msg("failed to release store lock")
322+ }
323+ if saveErr != nil {
324+ errorMessage := fmt.Sprintf("save certificate: %v", saveErr)
325+ s.mu.Lock()
326+ s.lastErr[target.ID] = errorMessage
327+ setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
328+ s.mu.Unlock()
329+ continue
330+ }
331+
332+ s.mu.Lock()
333+ s.certs[stored.ID] = stored
334+ delete(s.lastErr, stored.ID)
335+ clearTargetRouteErrorLocked(s.routeErr, required, target)
336+ s.mu.Unlock()
337+ }
338+}
339+
340+// certificateExistsLocked checks if a valid non-expired cached certificate
341+// covers the target. Must be called with s.mu held.
342+func (s *Service) certificateExistsLocked(target CertificateTarget) bool {
343+ now := time.Now()
344+ for _, cert := range s.certs {
345+ if cert.NotAfter.IsZero() || now.After(cert.NotAfter) {
346+ continue // expired or no expiry
347+ }
348+ if exactNamesCoverAll(cert.Names, target.Names) {
349+ return true
350+ }
351+ }
352+ return false
353+}
354+
355+// exactNamesCoverAll reports whether names contains every required name exactly, case-insensitively.
356+func exactNamesCoverAll(names, required []string) bool {
357+ covered := make(map[string]bool)
358+ for _, n := range names {
359+ covered[strings.ToLower(strings.TrimSuffix(n, "."))] = true
360+ }
361+ for _, r := range required {
362+ r = strings.ToLower(strings.TrimSuffix(r, "."))
363+ if !covered[r] {
364+ return false
365+ }
366+ }
367+ return true
368+}
369+
370+// hostMatchesCert checks if any name in the given list matches host,
371+// including wildcard matching.
372+func hostMatchesCert(names []string, host string) bool {
373+ return domain.CertificateNamesCoverHost(names, host)
374+}
375+
376+// GetCertificateForHost returns a TLS certificate for the given SNI host.
377+//
378+// If the SNI host does not require ACME coverage, returns nil, nil.
379+// If ACME is required but no cert covers the host, returns
380+// ErrTLSRouteNotCovered.
381+// If a valid cert is found, returns a pointer to it.
382+func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) {
383+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
384+ if host == "" {
385+ return nil, nil
386+ }
387+
388+ s.mu.RLock()
389+ defer s.mu.RUnlock()
390+
391+ // Check if this host requires ACME coverage.
392+ if !s.isRequiredHostLocked(host) {
393+ return nil, nil
394+ }
395+
396+ // Look for a valid non-expired cached certificate covering this host.
397+ now := time.Now()
398+ for _, cert := range s.certs {
399+ if cert.NotAfter.IsZero() || now.After(cert.NotAfter) {
400+ continue
401+ }
402+ if hostMatchesCert(cert.Names, host) {
403+ return &cert.Certificate, nil
404+ }
405+ }
406+
407+ return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host)
408+}
409+
410+// isRequiredHostLocked checks if host or any wildcard covering it is in
411+// the required hosts set. Must be called with s.mu held.
412+func (s *Service) isRequiredHostLocked(host string) bool {
413+ if _, ok := s.requiredHosts[host]; ok {
414+ return true
415+ }
416+ // Check wildcard: split at the first dot.
417+ parts := strings.SplitN(host, ".", 2)
418+ if len(parts) == 2 {
419+ if _, ok := s.requiredHosts["*."+parts[1]]; ok {
420+ return true
421+ }
422+ }
423+ return false
424+}
425+
426+// GetHTTP01Challenge delegates to the HTTP-01 challenge store.
427+func (s *Service) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
428+ return s.deps.Challenges.Get(ctx, token)
429+}
430+
431+// getStoredCertificate returns a copy of the stored certificate for the given
432+// ID, or nil if not found.
433+func (s *Service) getStoredCertificate(id string) *out.StoredCertificate {
434+ s.mu.RLock()
435+ defer s.mu.RUnlock()
436+ cert := s.certs[id]
437+ if cert == nil {
438+ return nil
439+ }
440+ cpy := *cert
441+ return &cpy
442+}
443+
444+// Stop gracefully stops the service. If a renewal loop is running, it is
445+// cancelled and Stop waits for it to finish (subject to ctx deadline).
446+func (s *Service) Stop(ctx context.Context) error {
447+ s.mu.Lock()
448+ cancel := s.cancel
449+ done := s.done
450+ s.cancel = nil
451+ s.done = nil
452+ s.mu.Unlock()
453+
454+ if cancel != nil {
455+ cancel()
456+ }
457+ if done != nil {
458+ select {
459+ case <-done:
460+ case <-ctx.Done():
461+ return ctx.Err()
462+ }
463+ }
464+ return nil
465+}
A internal/usecase/publictls/service_test.go +687 -0
@@ -0,0 +1,687 @@
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+ "maps"
13+ "math/big"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "github.com/bnema/gordon/internal/boundaries/out"
23+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
24+ "github.com/bnema/gordon/internal/domain"
25+)
26+
27+// ---------------------------------------------------------------------------
28+// Fakes
29+// ---------------------------------------------------------------------------
30+
31+// fakeRoutes models a stateful RouteSource using an internal mutex and
32+// copy-on-read semantics. Generated mocks cannot easily replicate this
33+// pattern because the interface methods return slices/maps by value and
34+// callers may modify the returned data; a hand-rolled fake ensures each
35+// GetRoutes/GetExternalRoutes call returns a defensive copy.
36+type fakeRoutes struct {
37+ mu sync.Mutex
38+ routes []domain.Route
39+ external map[string]string
40+}
41+
42+func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route {
43+ f.mu.Lock()
44+ defer f.mu.Unlock()
45+ routesCopy := make([]domain.Route, len(f.routes))
46+ copy(routesCopy, f.routes)
47+ return routesCopy
48+}
49+
50+func (f *fakeRoutes) GetExternalRoutes() map[string]string {
51+ f.mu.Lock()
52+ defer f.mu.Unlock()
53+ externalCopy := make(map[string]string, len(f.external))
54+ maps.Copy(externalCopy, f.external)
55+ return externalCopy
56+}
57+
58+type mockIssuerRecorder struct {
59+ mu sync.Mutex
60+ orders []out.CertificateOrder
61+}
62+
63+func (r *mockIssuerRecorder) record(order out.CertificateOrder) {
64+ r.mu.Lock()
65+ defer r.mu.Unlock()
66+ r.orders = append(r.orders, order)
67+}
68+
69+func (r *mockIssuerRecorder) Orders() []out.CertificateOrder {
70+ r.mu.Lock()
71+ defer r.mu.Unlock()
72+ orders := make([]out.CertificateOrder, len(r.orders))
73+ copy(orders, r.orders)
74+ return orders
75+}
76+
77+func newMockPublicCertificateIssuer(
78+ t *testing.T,
79+ obtain func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error),
80+ renew func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error),
81+) (*outmocks.MockPublicCertificateIssuer, *mockIssuerRecorder) {
82+ t.Helper()
83+ recorder := &mockIssuerRecorder{}
84+ issuer := outmocks.NewMockPublicCertificateIssuer(t)
85+
86+ obtainCall := issuer.EXPECT().Obtain(mock.Anything, mock.Anything).RunAndReturn(
87+ func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
88+ recorder.record(order)
89+ if obtain != nil {
90+ return obtain(ctx, order)
91+ }
92+ return defaultStoredCert(order)
93+ },
94+ )
95+ obtainCall.Maybe()
96+
97+ renewCall := issuer.EXPECT().Renew(mock.Anything, mock.Anything).RunAndReturn(
98+ func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
99+ if renew != nil {
100+ return renew(ctx, cert)
101+ }
102+ return &cert, nil
103+ },
104+ )
105+ renewCall.Maybe()
106+
107+ return issuer, recorder
108+}
109+
110+// defaultStoredCert creates a StoredCertificate with a self-signed TLS cert
111+// for the given order.
112+func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, error) {
113+ certPEM, keyPEM, err := generateTestCertPEM(order.Names)
114+ if err != nil {
115+ return nil, err
116+ }
117+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
118+ if err != nil {
119+ return nil, err
120+ }
121+ return &out.StoredCertificate{
122+ ID: order.ID,
123+ Names: order.Names,
124+ Challenge: order.Challenge,
125+ Certificate: tlsCert,
126+ FullchainPEM: certPEM,
127+ PrivateKeyPEM: keyPEM,
128+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
129+ }, nil
130+}
131+
132+type mockCertificateStoreState struct {
133+ mu sync.Mutex
134+ account *out.ACMEAccount
135+ certs []out.StoredCertificate
136+}
137+
138+func (s *mockCertificateStoreState) All() []out.StoredCertificate {
139+ s.mu.Lock()
140+ defer s.mu.Unlock()
141+ certs := make([]out.StoredCertificate, len(s.certs))
142+ copy(certs, s.certs)
143+ return certs
144+}
145+
146+func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
147+ t.Helper()
148+ state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
149+ store := outmocks.NewMockCertificateStore(t)
150+
151+ loadAccountCall := store.EXPECT().LoadAccount(mock.Anything).RunAndReturn(func(context.Context) (*out.ACMEAccount, error) {
152+ state.mu.Lock()
153+ defer state.mu.Unlock()
154+ if state.account == nil {
155+ return nil, nil
156+ }
157+ account := *state.account
158+ return &account, nil
159+ })
160+ loadAccountCall.Maybe()
161+
162+ saveAccountCall := store.EXPECT().SaveAccount(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, account out.ACMEAccount) error {
163+ state.mu.Lock()
164+ defer state.mu.Unlock()
165+ accountCopy := account
166+ state.account = &accountCopy
167+ return nil
168+ })
169+ saveAccountCall.Maybe()
170+
171+ loadAllCall := store.EXPECT().LoadAll(mock.Anything).RunAndReturn(func(context.Context) ([]out.StoredCertificate, error) {
172+ return state.All(), nil
173+ })
174+ loadAllCall.Maybe()
175+
176+ saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177+ state.mu.Lock()
178+ defer state.mu.Unlock()
179+ for i, existing := range state.certs {
180+ if existing.ID == cert.ID {
181+ state.certs[i] = cert
182+ return nil
183+ }
184+ }
185+ state.certs = append(state.certs, cert)
186+ return nil
187+ })
188+ saveCall.Maybe()
189+
190+ lockCall := store.EXPECT().Lock(mock.Anything).Return(func() error { return nil }, nil)
191+ lockCall.Maybe()
192+
193+ return store, state
194+}
195+
196+// ---------------------------------------------------------------------------
197+// Test certificate helpers
198+// ---------------------------------------------------------------------------
199+
200+// generateTestCertPEM generates a self-signed certificate for the given names.
201+// Returns PEM-encoded fullchain and private key.
202+func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err error) {
203+ if len(names) == 0 {
204+ names = []string{"localhost"}
205+ }
206+
207+ key, err := rsa.GenerateKey(rand.Reader, 2048)
208+ if err != nil {
209+ return nil, nil, fmt.Errorf("generate key: %w", err)
210+ }
211+
212+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
213+ if err != nil {
214+ return nil, nil, fmt.Errorf("generate serial: %w", err)
215+ }
216+
217+ now := time.Now()
218+ tmpl := &x509.Certificate{
219+ SerialNumber: serial,
220+ Subject: pkix.Name{
221+ CommonName: names[0],
222+ },
223+ NotBefore: now.Add(-1 * time.Hour),
224+ NotAfter: now.Add(90 * 24 * time.Hour),
225+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
226+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
227+ BasicConstraintsValid: true,
228+ DNSNames: names,
229+ }
230+
231+ certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
232+ if err != nil {
233+ return nil, nil, fmt.Errorf("create certificate: %w", err)
234+ }
235+
236+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
237+ if certPEM == nil {
238+ return nil, nil, fmt.Errorf("encode cert PEM")
239+ }
240+
241+ keyDER := x509.MarshalPKCS1PrivateKey(key)
242+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
243+ if keyPEM == nil {
244+ return nil, nil, fmt.Errorf("encode key PEM")
245+ }
246+
247+ return certPEM, keyPEM, nil
248+}
249+
250+// ---------------------------------------------------------------------------
251+// Tests
252+// ---------------------------------------------------------------------------
253+
254+func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
255+ ctx := context.Background()
256+
257+ routes := &fakeRoutes{
258+ routes: []domain.Route{
259+ {Domain: "app.example.com"},
260+ },
261+ }
262+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
263+ store, storeState := newMockCertificateStore(t)
264+
265+ cfg := Config{
266+ Enabled: true,
267+ Email: "admin@example.com",
268+ Challenge: "http-01",
269+ HTTPPort: 8088,
270+ TLSPort: 8443,
271+ }
272+
273+ svc := NewService(cfg, ServiceDeps{
274+ Config: cfg,
275+ Routes: routes,
276+ Issuer: issuer,
277+ Store: store,
278+ Challenges: NewHTTP01Challenges(),
279+ Effective: EffectiveChallenge{
280+ ConfiguredMode: domain.ACMEChallengeHTTP01,
281+ Mode: domain.ACMEChallengeHTTP01,
282+ TokenSource: domain.ACMETokenSourceNone,
283+ Reason: "http-01 challenge selected",
284+ },
285+ })
286+
287+ // Load (nothing stored yet).
288+ err := svc.Load(ctx)
289+ require.NoError(t, err)
290+
291+ // Reconcile — should obtain one certificate for app.example.com.
292+ err = svc.Reconcile(ctx)
293+ require.NoError(t, err)
294+
295+ // Verify the issuer received exactly one order.
296+ orders := recorder.Orders()
297+
298+ require.Len(t, orders, 1, "expected one order to be placed")
299+ assert.Equal(t, "http01-app.example.com", orders[0].ID)
300+ assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
301+ assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
302+
303+ // Verify the store has the saved certificate.
304+ stored := storeState.All()
305+ require.Len(t, stored, 1)
306+ assert.Equal(t, "http01-app.example.com", stored[0].ID)
307+ assert.Equal(t, []string{"app.example.com"}, stored[0].Names)
308+ assert.False(t, stored[0].NotAfter.IsZero())
309+ assert.NotEmpty(t, stored[0].Certificate.Certificate)
310+}
311+
312+func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
313+ ctx := context.Background()
314+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
315+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
316+ store, _ := newMockCertificateStore(t)
317+ cfg := Config{
318+ Enabled: true,
319+ Email: "admin@example.com",
320+ Challenge: "http-01",
321+ HTTPPort: 8088,
322+ // Missing TLSPort should be caught by ResolveEffectiveChallenge rather
323+ // than silently defaulting an effective mode.
324+ }
325+
326+ svc := NewService(cfg, ServiceDeps{
327+ Config: cfg,
328+ Routes: routes,
329+ Issuer: issuer,
330+ Store: store,
331+ })
332+
333+ err := svc.Reconcile(ctx)
334+ require.Error(t, err)
335+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
336+}
337+
338+func TestServiceStatusReportsCoverage(t *testing.T) {
339+ ctx := context.Background()
340+
341+ // Pre-populate the store with a valid certificate.
342+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
343+ require.NoError(t, err)
344+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
345+ require.NoError(t, err)
346+
347+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
348+ ID: "http01-app.example.com",
349+ Names: []string{"app.example.com"},
350+ Challenge: domain.ACMEChallengeHTTP01,
351+ Certificate: tlsCert,
352+ FullchainPEM: certPEM,
353+ PrivateKeyPEM: keyPEM,
354+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
355+ })
356+
357+ routes := &fakeRoutes{
358+ routes: []domain.Route{
359+ {Domain: "app.example.com"},
360+ },
361+ }
362+ cfg := Config{
363+ Enabled: true,
364+ Email: "admin@example.com",
365+ Challenge: "http-01",
366+ HTTPPort: 8088,
367+ TLSPort: 8443,
368+ }
369+
370+ svc := NewService(cfg, ServiceDeps{
371+ Config: cfg,
372+ Routes: routes,
373+ Store: store,
374+ Effective: EffectiveChallenge{
375+ ConfiguredMode: domain.ACMEChallengeHTTP01,
376+ Mode: domain.ACMEChallengeHTTP01,
377+ TokenSource: domain.ACMETokenSourceNone,
378+ Reason: "http-01 challenge selected",
379+ },
380+ })
381+
382+ // Load the cert into the cache.
383+ err = svc.Load(ctx)
384+ require.NoError(t, err)
385+
386+ // Get status.
387+ status := svc.Status(ctx)
388+
389+ assert.True(t, status.ACMEEnabled)
390+ assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode)
391+ assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode)
392+
393+ require.Len(t, status.Certificates, 1, "expected one managed certificate")
394+ assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID)
395+ assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status)
396+
397+ require.Len(t, status.Routes, 1, "expected one route coverage")
398+ assert.Equal(t, "app.example.com", status.Routes[0].Domain)
399+ assert.True(t, status.Routes[0].Covered, "route should be covered")
400+ assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
401+ assert.True(t, status.Routes[0].RequiredACME)
402+}
403+
404+func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
405+ ctx := context.Background()
406+
407+ routes := &fakeRoutes{
408+ routes: []domain.Route{
409+ {Domain: "app.example.com"},
410+ },
411+ }
412+ // Make Obtain fail so no cert is cached.
413+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
414+ return nil, fmt.Errorf("acme provider unavailable")
415+ }, nil)
416+ store, _ := newMockCertificateStore(t)
417+
418+ cfg := Config{
419+ Enabled: true,
420+ Email: "admin@example.com",
421+ Challenge: "http-01",
422+ HTTPPort: 8088,
423+ TLSPort: 8443,
424+ }
425+
426+ svc := NewService(cfg, ServiceDeps{
427+ Config: cfg,
428+ Routes: routes,
429+ Issuer: issuer,
430+ Store: store,
431+ Challenges: NewHTTP01Challenges(),
432+ Effective: EffectiveChallenge{
433+ ConfiguredMode: domain.ACMEChallengeHTTP01,
434+ Mode: domain.ACMEChallengeHTTP01,
435+ TokenSource: domain.ACMETokenSourceNone,
436+ Reason: "http-01 challenge selected",
437+ },
438+ })
439+
440+ // Load (no certs stored).
441+ err := svc.Load(ctx)
442+ require.NoError(t, err)
443+
444+ // Reconcile — issuer fails, no cert cached but requiredHosts is set.
445+ err = svc.Reconcile(ctx)
446+ require.NoError(t, err) // Reconcile swallows per-target obtain errors
447+
448+ // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered.
449+ _, err = svc.GetCertificateForHost("app.example.com")
450+ require.Error(t, err)
451+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
452+ assert.Contains(t, err.Error(), "app.example.com")
453+}
454+
455+func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
456+ ctx := context.Background()
457+
458+ routes := &fakeRoutes{
459+ routes: []domain.Route{
460+ {Domain: "app.example.com"},
461+ },
462+ }
463+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
464+ store, _ := newMockCertificateStore(t)
465+
466+ // Use generated mock that always returns an error.
467+ zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
468+ zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return(
469+ out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"),
470+ )
471+
472+ cfg := Config{
473+ Enabled: true,
474+ Email: "admin@example.com",
475+ Challenge: "dns-01",
476+ HTTPPort: 8088,
477+ TLSPort: 8443,
478+ }
479+
480+ svc := NewService(cfg, ServiceDeps{
481+ Config: cfg,
482+ Routes: routes,
483+ Issuer: issuer,
484+ Store: store,
485+ ZoneResolver: zoneResolver, // generated mock that always fails
486+ Challenges: NewHTTP01Challenges(),
487+ Effective: EffectiveChallenge{
488+ ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
489+ Mode: domain.ACMEChallengeCloudflareDNS01,
490+ TokenSource: domain.ACMETokenSourceNone,
491+ Reason: "dns-01 challenge selected",
492+ },
493+ })
494+
495+ // Load (nothing stored).
496+ err := svc.Load(ctx)
497+ require.NoError(t, err)
498+
499+ // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails.
500+ err = svc.Reconcile(ctx)
501+ require.Error(t, err)
502+ assert.Contains(t, err.Error(), "derive certificate targets")
503+
504+ // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered,
505+ // not nil,nil, because requiredHosts was set from route hosts before
506+ // the failed DeriveCertificateTargets call.
507+ _, err = svc.GetCertificateForHost("app.example.com")
508+ require.Error(t, err)
509+ assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
510+ assert.Contains(t, err.Error(), "app.example.com")
511+}
512+
513+func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
514+ ctx := context.Background()
515+
516+ // Generate valid PEM cert data.
517+ certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"})
518+ require.NoError(t, err)
519+
520+ // Store a cert with PEM data but empty tls.Certificate.
521+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
522+ ID: "http01-test.example.com",
523+ Names: []string{"test.example.com"},
524+ Challenge: domain.ACMEChallengeHTTP01,
525+ FullchainPEM: certPEM,
526+ PrivateKeyPEM: keyPEM,
527+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
528+ // Certificate field is zero — will be populated by Load.
529+ })
530+
531+ routes := &fakeRoutes{}
532+ cfg := Config{Enabled: true}
533+
534+ svc := NewService(cfg, ServiceDeps{
535+ Config: cfg,
536+ Routes: routes,
537+ Store: store,
538+ })
539+
540+ // Load — should parse PEM into tls.Certificate.
541+ err = svc.Load(ctx)
542+ require.NoError(t, err)
543+
544+ // Verify the cached cert has a valid tls.Certificate via the accessor.
545+ cached := svc.getStoredCertificate("http01-test.example.com")
546+ require.NotNil(t, cached, "cert should be cached")
547+ assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated")
548+ assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated")
549+
550+ // Verify it's a valid TLS cert by performing a parse round-trip.
551+ leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0])
552+ require.NoError(t, err)
553+ assert.Contains(t, leaf.DNSNames, "test.example.com")
554+
555+ served, err := svc.GetCertificateForHost("test.example.com")
556+ require.NoError(t, err)
557+ require.NotNil(t, served, "loaded certificates should seed required hosts")
558+}
559+
560+func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {
561+ ctx := context.Background()
562+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
563+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
564+ return nil, fmt.Errorf("acme unavailable token=sk-secret")
565+ }, nil)
566+ store, _ := newMockCertificateStore(t)
567+ cfg := Config{
568+ Enabled: true,
569+ Email: "admin@example.com",
570+ Challenge: "http-01",
571+ HTTPPort: 8088,
572+ TLSPort: 8443,
573+ }
574+ svc := NewService(cfg, ServiceDeps{
575+ Config: cfg,
576+ Routes: routes,
577+ Issuer: issuer,
578+ Store: store,
579+ Effective: EffectiveChallenge{
580+ ConfiguredMode: domain.ACMEChallengeHTTP01,
581+ Mode: domain.ACMEChallengeHTTP01,
582+ TokenSource: domain.ACMETokenSourceNone,
583+ },
584+ })
585+
586+ require.NoError(t, svc.Reconcile(ctx))
587+ status := svc.Status(ctx)
588+ require.Len(t, status.Routes, 1)
589+ assert.False(t, status.Routes[0].Covered)
590+ assert.Contains(t, status.Routes[0].Error, "token=redacted")
591+ assert.NotContains(t, status.Routes[0].Error, "sk-secret")
592+}
593+
594+func TestServiceStatusCoverageUsesServingCertDespiteTransientError(t *testing.T) {
595+ ctx := context.Background()
596+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
597+ require.NoError(t, err)
598+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
599+ require.NoError(t, err)
600+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
601+ ID: "http01-app.example.com",
602+ Names: []string{"app.example.com"},
603+ Challenge: domain.ACMEChallengeHTTP01,
604+ Certificate: tlsCert,
605+ FullchainPEM: certPEM,
606+ PrivateKeyPEM: keyPEM,
607+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
608+ LastError: "transient renewal error",
609+ })
610+ cfg := Config{Enabled: true}
611+ svc := NewService(cfg, ServiceDeps{
612+ Config: cfg,
613+ Routes: &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}},
614+ Store: store,
615+ })
616+ require.NoError(t, svc.Load(ctx))
617+
618+ status := svc.Status(ctx)
619+ require.Len(t, status.Routes, 1)
620+ assert.True(t, status.Routes[0].Covered)
621+ assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
622+}
623+
624+func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
625+ ctx := context.Background()
626+
627+ // Pre-populate the store with a certificate that has a LastError
628+ // containing sensitive data.
629+ certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
630+ require.NoError(t, err)
631+ tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
632+ require.NoError(t, err)
633+
634+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
635+ ID: "http01-app.example.com",
636+ Names: []string{"app.example.com"},
637+ Challenge: domain.ACMEChallengeHTTP01,
638+ Certificate: tlsCert,
639+ FullchainPEM: certPEM,
640+ PrivateKeyPEM: keyPEM,
641+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
642+ LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
643+ })
644+
645+ routes := &fakeRoutes{
646+ routes: []domain.Route{
647+ {Domain: "app.example.com"},
648+ },
649+ }
650+ cfg := Config{
651+ Enabled: true,
652+ Email: "admin@example.com",
653+ Challenge: "http-01",
654+ HTTPPort: 8088,
655+ TLSPort: 8443,
656+ }
657+
658+ svc := NewService(cfg, ServiceDeps{
659+ Config: cfg,
660+ Routes: routes,
661+ Store: store,
662+ Effective: EffectiveChallenge{
663+ ConfiguredMode: domain.ACMEChallengeHTTP01,
664+ Mode: domain.ACMEChallengeHTTP01,
665+ TokenSource: domain.ACMETokenSourceNone,
666+ Reason: "http-01 challenge selected",
667+ },
668+ })
669+
670+ // Load the cert into the cache.
671+ err = svc.Load(ctx)
672+ require.NoError(t, err)
673+
674+ // Get status.
675+ status := svc.Status(ctx)
676+
677+ require.Len(t, status.Certificates, 1)
678+ lastErr := status.Certificates[0].LastError
679+ t.Logf("sanitized LastError: %q", lastErr)
680+
681+ // The original error contained "token=sk-secret-goes-here".
682+ // After sanitization, the secret value should be redacted.
683+ assert.NotContains(t, lastErr, "sk-secret-goes-here",
684+ "LastError must not contain the raw secret value")
685+ assert.Contains(t, lastErr, "token=redacted",
686+ "LastError should contain token=redacted")
687+}
A internal/usecase/publictls/status.go +191 -0
@@ -0,0 +1,191 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "regexp"
6+ "sort"
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+ status.Errors = buildStatusErrors(status.Certificates, status.Routes)
35+ return status
36+}
37+
38+// buildManagedCertificatesLocked returns sorted managed certificates.
39+// Must be called with s.mu held.
40+func (s *Service) buildManagedCertificatesLocked() []domain.ManagedCertificate {
41+ certs := make([]domain.ManagedCertificate, 0, len(s.certs))
42+ now := time.Now()
43+ for _, cert := range s.certs {
44+ lastErr := cert.LastError
45+ if e, ok := s.lastErr[cert.ID]; ok && e != "" {
46+ lastErr = e
47+ }
48+ mc := domain.ManagedCertificate{
49+ ID: cert.ID,
50+ Names: cert.Names,
51+ Challenge: cert.Challenge,
52+ NotAfter: cert.NotAfter,
53+ LastError: sanitizeError(lastErr),
54+ }
55+ mc.Status = mc.Health(now)
56+ certs = append(certs, mc)
57+ }
58+ sort.Slice(certs, func(i, j int) bool {
59+ return certs[i].ID < certs[j].ID
60+ })
61+ return certs
62+}
63+
64+func buildStatusErrors(certs []domain.ManagedCertificate, routes []domain.TLSRouteCoverage) []string {
65+ seen := make(map[string]struct{})
66+ var errorsList []string
67+ add := func(message string) {
68+ message = sanitizeError(message)
69+ if message == "" {
70+ return
71+ }
72+ if _, ok := seen[message]; ok {
73+ return
74+ }
75+ seen[message] = struct{}{}
76+ errorsList = append(errorsList, message)
77+ }
78+ for _, cert := range certs {
79+ add(cert.LastError)
80+ }
81+ for _, route := range routes {
82+ add(route.Error)
83+ }
84+ return errorsList
85+}
86+
87+// buildRouteCoverageLocked returns route coverage for the given domains.
88+// Must be called with s.mu held.
89+func (s *Service) buildRouteCoverageLocked(routeDomains []string, managedCerts []domain.ManagedCertificate) []domain.TLSRouteCoverage {
90+ sort.Strings(routeDomains)
91+
92+ now := time.Now()
93+ routes := make([]domain.TLSRouteCoverage, 0, len(routeDomains))
94+ for _, d := range routeDomains {
95+ coverage := domain.TLSRouteCoverage{
96+ Domain: d,
97+ RequiredACME: s.cfg.Enabled,
98+ }
99+ coverage.Covered, coverage.CoveredBy = s.findCoverageLocked(d, managedCerts, now)
100+ if err, hasErr := s.routeErr[d]; hasErr {
101+ coverage.Error = sanitizeError(err)
102+ }
103+ routes = append(routes, coverage)
104+ }
105+ return routes
106+}
107+
108+// collectRouteDomains returns all unique canonical route domains.
109+// Does not hold s.mu so route source methods may be called safely.
110+func collectRouteDomains(ctx context.Context, routes RouteSource) []string {
111+ routeSet := make(map[string]struct{})
112+ var domains []string
113+
114+ for _, r := range routes.GetRoutes(ctx) {
115+ canonical, ok := domain.CanonicalRouteDomain(r.Domain)
116+ if ok {
117+ if _, exists := routeSet[canonical]; !exists {
118+ routeSet[canonical] = struct{}{}
119+ domains = append(domains, canonical)
120+ }
121+ }
122+ }
123+ for h := range routes.GetExternalRoutes() {
124+ canonical, ok := domain.CanonicalRouteDomain(h)
125+ if ok {
126+ if _, exists := routeSet[canonical]; !exists {
127+ routeSet[canonical] = struct{}{}
128+ domains = append(domains, canonical)
129+ }
130+ }
131+ }
132+ return domains
133+}
134+
135+// findCoverageLocked checks if any managed certificate covers the given domain.
136+// Must be called with s.mu held.
137+func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.ManagedCertificate, now time.Time) (bool, string) {
138+ for _, mc := range managedCerts {
139+ if !mc.Covers(routeDomain) {
140+ continue
141+ }
142+ switch mc.Status {
143+ case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing:
144+ continue
145+ }
146+ // Double-check the raw stored cert expiry to guard against stale
147+ // ManagedCertificate.Status values that may not reflect real expiry.
148+ if sc, ok := s.certs[mc.ID]; ok {
149+ if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {
150+ continue
151+ }
152+ }
153+ return true, mc.ID
154+ }
155+ return false, ""
156+}
157+
158+// Sensitive key names that may appear in error messages.
159+var sensitiveKeys = `(token|secret|key|password|pass|api_key|apikey|auth)`
160+
161+// Patterns for redacting sensitive values in common formats.
162+var (
163+ // key=value (unquoted, case-insensitive)
164+ keyEqValueRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `=[^\s"']+`)
165+ // key="value" (double-quoted)
166+ keyEqQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `="[^"]*"`)
167+ // key='value' (single-quoted)
168+ keyEqSingleQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `='[^']*'`)
169+ // JSON "key": "value"
170+ jsonKeyValueRe = regexp.MustCompile(`(?i)"` + sensitiveKeys + `"\s*:\s*"[^"]*"`)
171+ // YAML key: value (start of line or after indent)
172+ yamlKeyValueRe = regexp.MustCompile(`(?i)(?m)^\s*` + sensitiveKeys + `\s*:\s+\S+`)
173+)
174+
175+// sanitizeError removes obvious sensitive strings from error messages.
176+// It redacts key=value, key="value", key='value', JSON "key": "value",
177+// and YAML key: value patterns for known sensitive keys while preserving
178+// safe context. It does not blanket-redact messages just because they
179+// contain the word "secret" or similar.
180+func sanitizeError(err string) string {
181+ if err == "" {
182+ return ""
183+ }
184+
185+ result := keyEqValueRe.ReplaceAllString(err, "$1=redacted")
186+ result = keyEqQuotedRe.ReplaceAllString(result, `$1="redacted"`)
187+ result = keyEqSingleQuotedRe.ReplaceAllString(result, "$1='redacted'")
188+ result = jsonKeyValueRe.ReplaceAllString(result, `"$1":"redacted"`)
189+ result = yamlKeyValueRe.ReplaceAllString(result, "$1: redacted")
190+ return result
191+}
A internal/usecase/publictls/status_test.go +77 -0
@@ -0,0 +1,77 @@
1+package publictls
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeError_Empty(t *testing.T) {
10+ assert.Equal(t, "", sanitizeError(""))
11+}
12+
13+func TestSanitizeError_NoSensitiveData(t *testing.T) {
14+ msg := "failed to obtain certificate: acme server returned 429"
15+ assert.Equal(t, msg, sanitizeError(msg))
16+}
17+
18+func TestSanitizeError_KeyEqValue(t *testing.T) {
19+ result := sanitizeError("token=sk-secret-goes-here provider said invalid")
20+ assert.NotContains(t, result, "sk-secret-goes-here")
21+ assert.Contains(t, result, "token=redacted")
22+}
23+
24+func TestSanitizeError_KeyEqQuotedValue(t *testing.T) {
25+ result := sanitizeError(`secret="my-secret-value" is invalid`)
26+ assert.NotContains(t, result, "my-secret-value")
27+ assert.Contains(t, result, `secret="redacted"`)
28+}
29+
30+func TestSanitizeError_KeyEqSingleQuotedValue(t *testing.T) {
31+ result := sanitizeError(`key='my-api-key-12345' not found`)
32+ assert.NotContains(t, result, "my-api-key-12345")
33+ assert.Contains(t, result, "key='redacted'")
34+}
35+
36+func TestSanitizeError_JSONKeyValue(t *testing.T) {
37+ result := sanitizeError(`{"token":"s3kr1t","domain":"example.com"}`)
38+ assert.NotContains(t, result, "s3kr1t")
39+ assert.Contains(t, result, `"token":"redacted"`)
40+ // Non-sensitive fields should be preserved
41+ assert.Contains(t, result, `"domain":"example.com"`)
42+}
43+
44+func TestSanitizeError_YAMLKeyValue(t *testing.T) {
45+ result := sanitizeError("configuration error:\n api_key: abcdef123456\n password: hunter2")
46+ assert.NotContains(t, result, "abcdef123456")
47+ assert.NotContains(t, result, "hunter2")
48+ assert.Contains(t, result, "api_key: redacted")
49+ assert.Contains(t, result, "password: redacted")
50+ // Non-sensitive context preserved
51+ assert.Contains(t, result, "configuration error:")
52+}
53+
54+func TestSanitizeError_CaseInsensitive(t *testing.T) {
55+ result := sanitizeError("Token=sk-live-abc123 and SECRET=top-secret-value")
56+ assert.NotContains(t, result, "sk-live-abc123")
57+ assert.NotContains(t, result, "top-secret-value")
58+ assert.Contains(t, result, "Token=redacted")
59+ assert.Contains(t, result, "SECRET=redacted")
60+}
61+
62+func TestSanitizeError_MixedFormats(t *testing.T) {
63+ result := sanitizeError(`token=plain, secret="quoted", auth='single-quoted', and json {"password":"p4ss"}`)
64+ assert.NotContains(t, result, "plain")
65+ assert.NotContains(t, result, "quoted")
66+ assert.NotContains(t, result, "single-quoted")
67+ assert.NotContains(t, result, "p4ss")
68+ assert.Contains(t, result, "token=redacted")
69+ assert.Contains(t, result, `secret="redacted"`)
70+ assert.Contains(t, result, "auth='redacted'")
71+ assert.Contains(t, result, `"password":"redacted"`)
72+}
73+
74+func TestSanitizeError_PreservesSafeContext(t *testing.T) {
75+ msg := "certificate for example.com failed: acme error 403"
76+ assert.Equal(t, msg, sanitizeError(msg))
77+}
A internal/usecase/publictls/targets.go +153 -0
@@ -0,0 +1,153 @@
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+// DeriveCertificateTargets derives certificate targets from routes and external
21+// routes based on the given ACME challenge mode.
22+func DeriveCertificateTargets(
23+ ctx context.Context,
24+ mode domain.ACMEChallengeMode,
25+ routes []domain.Route,
26+ external map[string]string,
27+ resolver out.CloudflareZoneResolver,
28+) ([]CertificateTarget, error) {
29+ hosts := routeHosts(routes, external)
30+
31+ switch mode {
32+ case domain.ACMEChallengeHTTP01:
33+ return deriveHTTP01Targets(hosts), nil
34+ case domain.ACMEChallengeCloudflareDNS01:
35+ return deriveDNS01Targets(ctx, hosts, resolver)
36+ default:
37+ return nil, fmt.Errorf("unsupported challenge mode: %s", mode)
38+ }
39+}
40+
41+// routeHosts collects all unique canonical hosts from routes and external keys,
42+// sorted alphabetically. Trailing dots are stripped before canonicalization.
43+func routeHosts(routes []domain.Route, external map[string]string) []string {
44+ seen := make(map[string]struct{})
45+ var hosts []string
46+
47+ addHost := func(raw string) {
48+ raw = strings.TrimSuffix(raw, ".")
49+ canonical, ok := domain.CanonicalRouteDomain(raw)
50+ if !ok {
51+ return
52+ }
53+ if _, exists := seen[canonical]; !exists {
54+ seen[canonical] = struct{}{}
55+ hosts = append(hosts, canonical)
56+ }
57+ }
58+
59+ for _, r := range routes {
60+ addHost(r.Domain)
61+ }
62+ for h := range external {
63+ addHost(h)
64+ }
65+
66+ sort.Strings(hosts)
67+ return hosts
68+}
69+
70+// deriveHTTP01Targets creates one target per host for HTTP-01 challenge.
71+func deriveHTTP01Targets(hosts []string) []CertificateTarget {
72+ targets := make([]CertificateTarget, len(hosts))
73+ for i, host := range hosts {
74+ targets[i] = CertificateTarget{
75+ ID: "http01-" + host,
76+ Names: []string{host},
77+ Challenge: domain.ACMEChallengeHTTP01,
78+ }
79+ }
80+ return targets
81+}
82+
83+// deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.
84+func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.CloudflareZoneResolver) ([]CertificateTarget, error) {
85+ if resolver == nil {
86+ return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")
87+ }
88+ baseSeen := make(map[string]struct{})
89+ var bases []string
90+
91+ for _, host := range hosts {
92+ zone, err := resolver.FindZone(ctx, host)
93+ if err != nil {
94+ return nil, fmt.Errorf("find zone for %s: %w", host, err)
95+ }
96+ base, err := wildcardBase(host, zone.Name)
97+ if err != nil {
98+ return nil, fmt.Errorf("find wildcard base for %s: %w", host, err)
99+ }
100+ if _, exists := baseSeen[base]; !exists {
101+ baseSeen[base] = struct{}{}
102+ bases = append(bases, base)
103+ }
104+ }
105+
106+ sort.Strings(bases)
107+
108+ targets := make([]CertificateTarget, len(bases))
109+ for i, base := range bases {
110+ targets[i] = CertificateTarget{
111+ ID: "dns01-" + base,
112+ Names: []string{base, "*." + base},
113+ Challenge: domain.ACMEChallengeCloudflareDNS01,
114+ }
115+ }
116+ return targets, nil
117+}
118+
119+// wildcardBase determines the wildcard base for a host within a zone.
120+// This is used by deriveDNS01Targets to group hosts under a common
121+// wildcard certificate (e.g. example.com + *.example.com). Hosts that
122+// share the same wildcard base are combined into a single DNS-01 target.
123+//
124+// If host == zone, returns zone.
125+// If host is a direct child of zone, returns zone.
126+// If host is deeper (e.g. api.prod.example.com within example.com),
127+// returns the parent label under the zone (prod.example.com).
128+func wildcardBase(host, zone string) (string, error) {
129+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
130+ zone = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zone)), ".")
131+ if host == "" || zone == "" {
132+ return "", fmt.Errorf("host and zone must be non-empty")
133+ }
134+ if host == zone {
135+ return zone, nil
136+ }
137+ if !strings.HasSuffix(host, "."+zone) {
138+ return "", fmt.Errorf("zone %q does not match host %q", zone, host)
139+ }
140+
141+ // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.
142+ trimmed := strings.TrimSuffix(host, "."+zone)
143+
144+ if !strings.Contains(trimmed, ".") {
145+ // Direct child of the zone (e.g. app.example.com).
146+ return zone, nil
147+ }
148+
149+ // Deeper than direct child: extract the parent under the zone.
150+ // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"
151+ _, parent, _ := strings.Cut(trimmed, ".")
152+ return parent + "." + zone, nil
153+}
A internal/usecase/publictls/targets_test.go +81 -0
@@ -0,0 +1,81 @@
1+package publictls
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {
17+ routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}}
18+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
19+ require.NoError(t, err)
20+ require.Len(t, targets, 2)
21+
22+ assert.Equal(t, "http01-app.example.com", targets[0].ID)
23+ assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
24+ assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge)
25+ assert.Equal(t, "http01-registry.example.com", targets[1].ID)
26+ assert.Equal(t, []string{"registry.example.com"}, targets[1].Names)
27+ assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge)
28+}
29+
30+func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
31+ routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}}
32+ resolver := outmocks.NewMockCloudflareZoneResolver(t)
33+ resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)
34+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
35+ require.NoError(t, err)
36+ require.Len(t, targets, 2)
37+
38+ assert.Equal(t, "dns01-example.com", targets[0].ID)
39+ assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names)
40+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge)
41+ assert.Equal(t, "dns01-prod.example.com", targets[1].ID)
42+ assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names)
43+ assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)
44+}
45+
46+func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
47+ routes := []domain.Route{{Domain: "app.example.com"}}
48+ external := map[string]string{"external.example.com": "127.0.0.1:8080"}
49+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil)
50+ require.NoError(t, err)
51+ require.Len(t, targets, 2)
52+
53+ assert.Equal(t, "http01-app.example.com", targets[0].ID)
54+ assert.Equal(t, "http01-external.example.com", targets[1].ID)
55+ assert.Equal(t, []string{"external.example.com"}, targets[1].Names)
56+}
57+
58+func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {
59+ routes := []domain.Route{{Domain: "app.example.com"}}
60+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil)
61+ require.Error(t, err)
62+ assert.Contains(t, err.Error(), "resolver is nil")
63+}
64+
65+func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) {
66+ routes := []domain.Route{{Domain: "app.example.com"}}
67+ resolver := outmocks.NewMockCloudflareZoneResolver(t)
68+ resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "other.test"}, nil).Once()
69+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
70+ require.Error(t, err)
71+ assert.Contains(t, err.Error(), "does not match host")
72+}
73+
74+func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
75+ routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}}
76+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
77+ require.NoError(t, err)
78+ require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")
79+ assert.Equal(t, "http01-app.example.com", targets[0].ID)
80+ assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
81+}

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