add public ACME TLS support
- commit
- ed49430b8fb59d75cc43e50be5c78aee7e93fed5
- author
- brice · · 2026-04-29 07:22 UTC
- parent
- 574f9a2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
54 files · +6652 -65
A docs/cli/tls.md +106 -0
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | # TLS Commands | |||
| 2 | + | ||||
| 3 | + | Inspect public TLS/ACME certificate status. | |||
| 4 | + | ||||
| 5 | + | Remote targeting uses client config or an active remote by default. | |||
| 6 | + | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 7 | + | ||||
| 8 | + | ## gordon tls | |||
| 9 | + | ||||
| 10 | + | ### Subcommands | |||
| 11 | + | ||||
| 12 | + | | Subcommand | Description | | |||
| 13 | + | |------------|-------------| | |||
| 14 | + | | `status` | Show public TLS certificate status | | |||
| 15 | + | ||||
| 16 | + | --- | |||
| 17 | + | ||||
| 18 | + | ## gordon tls status | |||
| 19 | + | ||||
| 20 | + | Display the current public TLS/ACME certificate status, including ACME mode, | |||
| 21 | + | certificate details, route coverage, and any errors. | |||
| 22 | + | ||||
| 23 | + | ```bash | |||
| 24 | + | gordon tls status | |||
| 25 | + | gordon tls status --json | |||
| 26 | + | gordon tls status --remote https://gordon.mydomain.com --token $TOKEN | |||
| 27 | + | ``` | |||
| 28 | + | ||||
| 29 | + | ### Flags | |||
| 30 | + | ||||
| 31 | + | | Flag | Description | | |||
| 32 | + | |------|-------------| | |||
| 33 | + | | `--json` | Output as JSON | | |||
| 34 | + | ||||
| 35 | + | ### Human Output | |||
| 36 | + | ||||
| 37 | + | ``` | |||
| 38 | + | Public TLS / ACME Status | |||
| 39 | + | ||||
| 40 | + | ACME: enabled | |||
| 41 | + | Configured Mode: acme | |||
| 42 | + | Effective Mode: acme-staging | |||
| 43 | + | Reason: configured | |||
| 44 | + | Token Source: env | |||
| 45 | + | ||||
| 46 | + | Certificates | |||
| 47 | + | ID: cert-abc123 | |||
| 48 | + | Names: example.com, www.example.com | |||
| 49 | + | Status: valid | |||
| 50 | + | Not After: 2026-05-29 12:00:00 | |||
| 51 | + | ||||
| 52 | + | Route Coverage | |||
| 53 | + | example.com covered=yes covered_by=cert-abc123 | |||
| 54 | + | internal.local covered=no error=self-signed cert | |||
| 55 | + | ||||
| 56 | + | Errors | |||
| 57 | + | route internal.local has no ACME cert | |||
| 58 | + | ``` | |||
| 59 | + | ||||
| 60 | + | ### JSON Output | |||
| 61 | + | ||||
| 62 | + | ```json | |||
| 63 | + | { | |||
| 64 | + | "acme_enabled": true, | |||
| 65 | + | "configured_mode": "acme", | |||
| 66 | + | "effective_mode": "acme-staging", | |||
| 67 | + | "selection_reason": "configured", | |||
| 68 | + | "token_source": "env", | |||
| 69 | + | "certificates": [ | |||
| 70 | + | { | |||
| 71 | + | "id": "cert-abc123", | |||
| 72 | + | "names": ["example.com", "www.example.com"], | |||
| 73 | + | "challenge": "", | |||
| 74 | + | "status": "valid", | |||
| 75 | + | "not_after": "2026-05-29T12:00:00Z", | |||
| 76 | + | "renewal_pending": false | |||
| 77 | + | } | |||
| 78 | + | ], | |||
| 79 | + | "routes": [ | |||
| 80 | + | { | |||
| 81 | + | "domain": "example.com", | |||
| 82 | + | "covered": true, | |||
| 83 | + | "covered_by": "cert-abc123", | |||
| 84 | + | "required_acme": true | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | "domain": "internal.local", | |||
| 88 | + | "covered": false, | |||
| 89 | + | "required_acme": false, | |||
| 90 | + | "error": "self-signed cert" | |||
| 91 | + | } | |||
| 92 | + | ], | |||
| 93 | + | "errors": ["route internal.local has no ACME cert"] | |||
| 94 | + | } | |||
| 95 | + | ``` | |||
| 96 | + | ||||
| 97 | + | ### Token Source | |||
| 98 | + | ||||
| 99 | + | The `token_source` field indicates where the ACME token was sourced from | |||
| 100 | + | (e.g., `env`, `file`, `config`). The token value is never displayed. | |||
| 101 | + | ||||
| 102 | + | ## Related | |||
| 103 | + | ||||
| 104 | + | - [CLI Overview](./index.md) | |||
| 105 | + | - [Status Command](./status.md) | |||
| 106 | + | - [Routes Command](./routes.md) | |||
M go.mod +9 -4
| @@ -10,6 +10,7 @@ require ( | |||||
| 10 | github.com/coreos/go-systemd/v22 v22.7.0 | 10 | github.com/coreos/go-systemd/v22 v22.7.0 | ||
| 11 | github.com/docker/docker v28.5.2+incompatible | 11 | github.com/docker/docker v28.5.2+incompatible | ||
| 12 | github.com/docker/go-connections v0.6.0 | 12 | github.com/docker/go-connections v0.6.0 | ||
| 13 | + | github.com/go-acme/lego/v4 v4.35.2 | |||
| 13 | github.com/golang-jwt/jwt/v5 v5.3.1 | 14 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 14 | github.com/google/go-containerregistry v0.21.5 | 15 | github.com/google/go-containerregistry v0.21.5 | ||
| 15 | github.com/google/uuid v1.6.0 | 16 | github.com/google/uuid v1.6.0 | ||
| @@ -46,10 +47,12 @@ require ( | |||||
| 46 | github.com/docker/cli v29.4.0+incompatible // indirect | 47 | github.com/docker/cli v29.4.0+incompatible // indirect | ||
| 47 | github.com/docker/docker-credential-helpers v0.9.5 // indirect | 48 | github.com/docker/docker-credential-helpers v0.9.5 // indirect | ||
| 48 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 50 | + | github.com/go-jose/go-jose/v4 v4.1.4 // indirect | |||
| 49 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | 51 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | ||
| 50 | github.com/klauspost/compress v1.18.5 // indirect | 52 | github.com/klauspost/compress v1.18.5 // indirect | ||
| 51 | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect | 53 | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect | ||
| 52 | github.com/mattn/go-localereader v0.0.1 // indirect | 54 | github.com/mattn/go-localereader v0.0.1 // indirect | ||
| 55 | + | github.com/miekg/dns v1.1.72 // indirect | |||
| 53 | github.com/mitchellh/go-homedir v1.1.0 // indirect | 56 | github.com/mitchellh/go-homedir v1.1.0 // indirect | ||
| 54 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | 57 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | ||
| 55 | github.com/morikuni/aec v1.1.0 // indirect | 58 | github.com/morikuni/aec v1.1.0 // indirect | ||
| @@ -63,7 +66,9 @@ require ( | |||||
| 63 | go.opentelemetry.io/otel/log v0.19.0 // indirect | 66 | go.opentelemetry.io/otel/log v0.19.0 // indirect | ||
| 64 | go.opentelemetry.io/proto/otlp v1.10.0 // indirect | 67 | go.opentelemetry.io/proto/otlp v1.10.0 // indirect | ||
| 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 68 | 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 | |||
| 67 | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect | 72 | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect | ||
| 68 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect | 73 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect | ||
| 69 | google.golang.org/grpc v1.80.0 // indirect | 74 | google.golang.org/grpc v1.80.0 // indirect | ||
| @@ -72,8 +77,8 @@ require ( | |||||
| 72 | ) | 77 | ) | ||
| 73 | 78 | ||||
| 74 | require ( | 79 | require ( | ||
| 75 | - | github.com/davecgh/go-spew v1.1.1 // indirect | 80 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect |
| 76 | - | github.com/pmezard/go-difflib v1.0.0 // indirect | 81 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect |
| 77 | github.com/stretchr/objx v0.5.3 // indirect | 82 | github.com/stretchr/objx v0.5.3 // indirect | ||
| 78 | go.uber.org/mock v0.6.0 // direct | 83 | go.uber.org/mock v0.6.0 // direct | ||
| 79 | golang.org/x/time v0.15.0 | 84 | golang.org/x/time v0.15.0 | ||
| @@ -111,7 +116,7 @@ require ( | |||||
| 111 | go.opentelemetry.io/otel/metric v1.43.0 | 116 | go.opentelemetry.io/otel/metric v1.43.0 | ||
| 112 | go.opentelemetry.io/otel/trace v1.43.0 | 117 | go.opentelemetry.io/otel/trace v1.43.0 | ||
| 113 | golang.org/x/sys v0.43.0 | 118 | 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 |
| 115 | gopkg.in/yaml.v3 v3.0.1 // indirect | 120 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| 116 | gotest.tools/v3 v3.5.2 // indirect | 121 | gotest.tools/v3 v3.5.2 // indirect | ||
| 117 | ) | 122 | ) | ||
M go.sum +20 -10 generated file: collapsed
A internal/adapters/dto/tls.go +35 -0
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // TLSStatusResponse represents the public TLS/ACME status for the admin API. | |||
| 6 | + | type TLSStatusResponse struct { | |||
| 7 | + | ACMEEnabled bool `json:"acme_enabled"` | |||
| 8 | + | ConfiguredMode string `json:"configured_mode"` | |||
| 9 | + | EffectiveMode string `json:"effective_mode"` | |||
| 10 | + | SelectionReason string `json:"selection_reason"` | |||
| 11 | + | TokenSource string `json:"token_source"` | |||
| 12 | + | Certificates []TLSCertificateEntry `json:"certificates"` | |||
| 13 | + | Routes []TLSRouteCoverage `json:"routes"` | |||
| 14 | + | Errors []string `json:"errors,omitempty"` | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // TLSCertificateEntry represents a managed certificate entry in the TLS status. | |||
| 18 | + | type TLSCertificateEntry struct { | |||
| 19 | + | ID string `json:"id"` | |||
| 20 | + | Names []string `json:"names"` | |||
| 21 | + | Challenge string `json:"challenge"` | |||
| 22 | + | Status string `json:"status"` | |||
| 23 | + | NotAfter time.Time `json:"not_after"` | |||
| 24 | + | LastError string `json:"last_error,omitempty"` | |||
| 25 | + | RenewalPending bool `json:"renewal_pending"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // TLSRouteCoverage represents a route's TLS coverage status. | |||
| 29 | + | type TLSRouteCoverage struct { | |||
| 30 | + | Domain string `json:"domain"` | |||
| 31 | + | Covered bool `json:"covered"` | |||
| 32 | + | CoveredBy string `json:"covered_by,omitempty"` | |||
| 33 | + | RequiredACME bool `json:"required_acme"` | |||
| 34 | + | Error string `json:"error,omitempty"` | |||
| 35 | + | } | |||
M internal/adapters/in/cli/attachments_push_test.go +4 -0
| @@ -108,6 +108,10 @@ func (c *attachmentPushTestControlPlane) RemoveAutoRouteAllowedDomain(context.Co | |||||
| 108 | panic("unexpected call") | 108 | panic("unexpected call") | ||
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | + | func (c *attachmentPushTestControlPlane) GetTLSStatus(context.Context) (*dto.TLSStatusResponse, error) { | |||
| 112 | + | return &dto.TLSStatusResponse{}, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 111 | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | 115 | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | ||
| 112 | if c.getStatus != nil { | 116 | if c.getStatus != nil { | ||
| 113 | return c.getStatus(ctx) | 117 | return c.getStatus(ctx) | ||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -38,6 +38,7 @@ type ControlPlane interface { | |||||
| 38 | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | 38 | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | ||
| 39 | 39 | ||||
| 40 | GetStatus(ctx context.Context) (*remote.Status, error) | 40 | GetStatus(ctx context.Context) (*remote.Status, error) | ||
| 41 | + | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |||
| 41 | Reload(ctx context.Context) error | 42 | Reload(ctx context.Context) error | ||
| 42 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | 43 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | ||
| 43 | GetConfig(ctx context.Context) (*remote.Config, error) | 44 | GetConfig(ctx context.Context) (*remote.Config, error) | ||
M internal/adapters/in/cli/controlplane_local.go +50 -0
| @@ -25,6 +25,7 @@ type localControlPlane struct { | |||||
| 25 | healthSvc in.HealthService | 25 | healthSvc in.HealthService | ||
| 26 | logSvc in.LogService | 26 | logSvc in.LogService | ||
| 27 | volumeSvc in.VolumeService | 27 | volumeSvc in.VolumeService | ||
| 28 | + | publicTLSSvc in.PublicTLSService | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | 31 | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | ||
| @@ -50,6 +51,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||||
| 50 | healthSvc: kernel.Health(), | 51 | healthSvc: kernel.Health(), | ||
| 51 | logSvc: kernel.Logs(), | 52 | logSvc: kernel.Logs(), | ||
| 52 | volumeSvc: kernel.Volumes(), | 53 | volumeSvc: kernel.Volumes(), | ||
| 54 | + | publicTLSSvc: kernel.PublicTLS(), | |||
| 53 | } | 55 | } | ||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| @@ -331,6 +333,16 @@ func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pa | |||||
| 331 | return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern) | 333 | return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern) | ||
| 332 | } | 334 | } | ||
| 333 | 335 | ||||
| 336 | + | func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |||
| 337 | + | if l.publicTLSSvc == nil { | |||
| 338 | + | return &dto.TLSStatusResponse{}, nil | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | status := l.publicTLSSvc.Status(ctx) | |||
| 342 | + | result := toLocalTLSStatusResponse(status) | |||
| 343 | + | return &result, nil | |||
| 344 | + | } | |||
| 345 | + | ||||
| 334 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | 346 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | ||
| 335 | if l.configSvc == nil { | 347 | if l.configSvc == nil { | ||
| 336 | return nil, fmt.Errorf("local config service unavailable") | 348 | return nil, fmt.Errorf("local config service unavailable") | ||
| @@ -625,6 +637,44 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |||||
| 625 | return out | 637 | return out | ||
| 626 | } | 638 | } | ||
| 627 | 639 | ||||
| 640 | + | // toLocalTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse. | |||
| 641 | + | func toLocalTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse { | |||
| 642 | + | certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates)) | |||
| 643 | + | for _, c := range s.Certificates { | |||
| 644 | + | certs = append(certs, dto.TLSCertificateEntry{ | |||
| 645 | + | ID: c.ID, | |||
| 646 | + | Names: c.Names, | |||
| 647 | + | Challenge: string(c.Challenge), | |||
| 648 | + | Status: string(c.Status), | |||
| 649 | + | NotAfter: c.NotAfter, | |||
| 650 | + | LastError: c.LastError, | |||
| 651 | + | RenewalPending: c.RenewalPending, | |||
| 652 | + | }) | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes)) | |||
| 656 | + | for _, r := range s.Routes { | |||
| 657 | + | routes = append(routes, dto.TLSRouteCoverage{ | |||
| 658 | + | Domain: r.Domain, | |||
| 659 | + | Covered: r.Covered, | |||
| 660 | + | CoveredBy: r.CoveredBy, | |||
| 661 | + | RequiredACME: r.RequiredACME, | |||
| 662 | + | Error: r.Error, | |||
| 663 | + | }) | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | return dto.TLSStatusResponse{ | |||
| 667 | + | ACMEEnabled: s.ACMEEnabled, | |||
| 668 | + | ConfiguredMode: string(s.ConfiguredMode), | |||
| 669 | + | EffectiveMode: string(s.EffectiveMode), | |||
| 670 | + | SelectionReason: s.SelectionReason, | |||
| 671 | + | TokenSource: string(s.TokenSource), | |||
| 672 | + | Certificates: certs, | |||
| 673 | + | Routes: routes, | |||
| 674 | + | Errors: s.Errors, | |||
| 675 | + | } | |||
| 676 | + | } | |||
| 677 | + | ||||
| 628 | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | 678 | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | ||
| 629 | var startedAt *time.Time | 679 | var startedAt *time.Time | ||
| 630 | if !job.StartedAt.IsZero() { | 680 | if !job.StartedAt.IsZero() { | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -104,6 +104,10 @@ func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, err | |||||
| 104 | return r.client.GetStatus(ctx) | 104 | return r.client.GetStatus(ctx) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | + | func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |||
| 108 | + | return r.client.GetTLSStatus(ctx) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 107 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | 111 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | ||
| 108 | return r.client.Reload(ctx) | 112 | return r.client.Reload(ctx) | ||
| 109 | } | 113 | } | ||
M internal/adapters/in/cli/json_parity_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||||
| 25 | {"routes show", newRoutesShowCmd}, | 25 | {"routes show", newRoutesShowCmd}, | ||
| 26 | {"networks list", newNetworksListCmd}, | 26 | {"networks list", newNetworksListCmd}, | ||
| 27 | {"images tags", newImagesTagsCmd}, | 27 | {"images tags", newImagesTagsCmd}, | ||
| 28 | + | {"tls status", newTLSStatusCmd}, | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | for _, tc := range commands { | 31 | for _, tc := range commands { | ||
M internal/adapters/in/cli/push_test.go +4 -0
| @@ -114,6 +114,10 @@ func (c *resolveFromImageTestControlPlane) RemoveAutoRouteAllowedDomain(context. | |||||
| 114 | panic("unexpected call") | 114 | panic("unexpected call") | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | func (c *resolveFromImageTestControlPlane) GetTLSStatus(context.Context) (*dto.TLSStatusResponse, error) { | |||
| 118 | + | return &dto.TLSStatusResponse{}, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 117 | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | 121 | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | ||
| 118 | panic("unexpected call") | 122 | panic("unexpected call") | ||
| 119 | } | 123 | } | ||
M internal/adapters/in/cli/remote/client.go +15 -0
| @@ -657,6 +657,21 @@ type Status struct { | |||||
| 657 | ContainerStatus map[string]string `json:"container_status"` | 657 | ContainerStatus map[string]string `json:"container_status"` | ||
| 658 | } | 658 | } | ||
| 659 | 659 | ||||
| 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 | + | ||||
| 660 | // GetStatus returns the Gordon server status. | 675 | // GetStatus returns the Gordon server status. | ||
| 661 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | 676 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | ||
| 662 | resp, err := c.request(ctx, http.MethodGet, "/status", nil) | 677 | 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) { | |||||
| 116 | require.NoError(t, err) | 116 | require.NoError(t, err) | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 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 | + | ||||
| 119 | func TestClientListImages(t *testing.T) { | 136 | func TestClientListImages(t *testing.T) { | ||
| 120 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 137 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 121 | require.Equal(t, "/admin/images", r.URL.Path) | 138 | require.Equal(t, "/admin/images", r.URL.Path) | ||
M internal/adapters/in/cli/root.go +4 -0
| @@ -149,6 +149,10 @@ Commands are organized by where they run: | |||||
| 149 | previewCmd.GroupID = groupManage | 149 | previewCmd.GroupID = groupManage | ||
| 150 | rootCmd.AddCommand(previewCmd) | 150 | rootCmd.AddCommand(previewCmd) | ||
| 151 | 151 | ||||
| 152 | + | tlsCmd := newTLSCmd() | |||
| 153 | + | tlsCmd.GroupID = groupManage | |||
| 154 | + | rootCmd.AddCommand(tlsCmd) | |||
| 155 | + | ||||
| 152 | // Client-only commands (no server needed) | 156 | // Client-only commands (no server needed) | ||
| 153 | remotesCmd := newRemotesCmd() | 157 | remotesCmd := newRemotesCmd() | ||
| 154 | remotesCmd.GroupID = groupClient | 158 | remotesCmd.GroupID = groupClient | ||
A internal/adapters/in/cli/tls.go +199 -0
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // tlsResolveControlPlane allows tests to override control-plane resolution. | |||
| 16 | + | var tlsResolveControlPlane = resolveControlPlane | |||
| 17 | + | ||||
| 18 | + | func newTLSCmd() *cobra.Command { | |||
| 19 | + | cmd := &cobra.Command{ | |||
| 20 | + | Use: "tls", | |||
| 21 | + | Short: "Inspect TLS certificate status", | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | cmd.AddCommand(newTLSStatusCmd()) | |||
| 25 | + | ||||
| 26 | + | return cmd | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func newTLSStatusCmd() *cobra.Command { | |||
| 30 | + | var jsonOut bool | |||
| 31 | + | ||||
| 32 | + | cmd := &cobra.Command{ | |||
| 33 | + | Use: "status", | |||
| 34 | + | Short: "Show public TLS certificate status", | |||
| 35 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 36 | + | handle, err := tlsResolveControlPlane(configPath) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return err | |||
| 39 | + | } | |||
| 40 | + | defer handle.close() | |||
| 41 | + | return runTLSStatusCmd(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut) | |||
| 42 | + | }, | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 46 | + | ||||
| 47 | + | return cmd | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func runTLSStatusCmd(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error { | |||
| 51 | + | status, err := cp.GetTLSStatus(ctx) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return fmt.Errorf("failed to get TLS status: %w", err) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | if jsonOut { | |||
| 57 | + | return writeJSON(out, status) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | return renderTLSStatusText(out, status) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func renderTLSStatusText(out io.Writer, s *dto.TLSStatusResponse) error { | |||
| 64 | + | if err := cliWriteLine(out, cliRenderTitle("Public TLS / ACME Status")); err != nil { | |||
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if err := renderTLSModeSection(out, s); err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | if err := renderTLSCertificatesSection(out, s); err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if err := renderTLSRouteCoverageSection(out, s); err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | return renderTLSErrorsSection(out, s) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func renderTLSModeSection(out io.Writer, s *dto.TLSStatusResponse) error { | |||
| 84 | + | acmeStatus := "disabled" | |||
| 85 | + | if s.ACMEEnabled { | |||
| 86 | + | acmeStatus = "enabled" | |||
| 87 | + | } | |||
| 88 | + | if err := cliWriteLine(out, cliRenderMeta("ACME:", acmeStatus)); err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | if err := cliWriteLine(out, cliRenderMeta("Configured Mode:", s.ConfiguredMode)); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | if err := cliWriteLine(out, cliRenderMeta("Effective Mode:", s.EffectiveMode)); err != nil { | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | if s.SelectionReason != "" { | |||
| 98 | + | if err := cliWriteLine(out, cliRenderMeta("Reason:", s.SelectionReason)); err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | return cliWriteLine(out, cliRenderMeta("Token Source:", s.TokenSource)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func renderTLSCertificatesSection(out io.Writer, s *dto.TLSStatusResponse) error { | |||
| 106 | + | if len(s.Certificates) == 0 { | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | if err := cliWriteLine(out, cliRenderTitle("Certificates")); err != nil { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | for _, cert := range s.Certificates { | |||
| 118 | + | if err := cliWritef(out, " %s\n", cliRenderMeta("ID:", cert.ID)); err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | if err := cliWriteLine(out, cliRenderMeta(" Names:", strings.Join(cert.Names, ", "))); err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | if err := cliWriteLine(out, cliRenderMeta(" Status:", cert.Status)); err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | if !cert.NotAfter.IsZero() { | |||
| 128 | + | if err := cliWriteLine(out, cliRenderMeta(" Not After:", cert.NotAfter.Format(time.DateTime))); err != nil { | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | if cert.LastError != "" { | |||
| 133 | + | if err := cliWriteLine(out, cliRenderMeta(" Last Error:", cert.LastError)); err != nil { | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func renderTLSRouteCoverageSection(out io.Writer, s *dto.TLSStatusResponse) error { | |||
| 143 | + | if len(s.Routes) == 0 { | |||
| 144 | + | return nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 148 | + | return err | |||
| 149 | + | } | |||
| 150 | + | if err := cliWriteLine(out, cliRenderTitle("Route Coverage")); err != nil { | |||
| 151 | + | return err | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | for _, route := range s.Routes { | |||
| 155 | + | covered := "no" | |||
| 156 | + | if route.Covered { | |||
| 157 | + | covered = "yes" | |||
| 158 | + | } | |||
| 159 | + | if err := cliWritef(out, " %s covered=%s", route.Domain, covered); err != nil { | |||
| 160 | + | return err | |||
| 161 | + | } | |||
| 162 | + | if route.CoveredBy != "" { | |||
| 163 | + | if err := cliWritef(out, " covered_by=%s", route.CoveredBy); err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | if route.Error != "" { | |||
| 168 | + | if err := cliWritef(out, " error=%s", route.Error); err != nil { | |||
| 169 | + | return err | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | return nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func renderTLSErrorsSection(out io.Writer, s *dto.TLSStatusResponse) error { | |||
| 181 | + | if len(s.Errors) == 0 { | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | if err := cliWriteLine(out, cliRenderTitle("Errors")); err != nil { | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | for _, e := range s.Errors { | |||
| 193 | + | if err := cliWriteLine(out, " "+e); err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | return nil | |||
| 199 | + | } | |||
A internal/adapters/in/cli/tls_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // tlsTestControlPlane implements ControlPlane for TLS tests. | |||
| 19 | + | type tlsTestControlPlane struct { | |||
| 20 | + | getTLSStatus func(context.Context) (*dto.TLSStatusResponse, error) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | var _ ControlPlane = (*tlsTestControlPlane)(nil) | |||
| 24 | + | ||||
| 25 | + | func (c *tlsTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |||
| 26 | + | panic("unexpected call") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (c *tlsTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 30 | + | panic("unexpected call") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (c *tlsTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) { | |||
| 34 | + | panic("unexpected call") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (c *tlsTestControlPlane) FindRoutesByImage(context.Context, string) ([]domain.Route, error) { | |||
| 38 | + | panic("unexpected call") | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (c *tlsTestControlPlane) AddRoute(context.Context, domain.Route) error { | |||
| 42 | + | panic("unexpected call") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (c *tlsTestControlPlane) UpdateRoute(context.Context, domain.Route) error { | |||
| 46 | + | panic("unexpected call") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (c *tlsTestControlPlane) RemoveRoute(context.Context, string) error { | |||
| 50 | + | panic("unexpected call") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (c *tlsTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 54 | + | panic("unexpected call") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (c *tlsTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |||
| 58 | + | panic("unexpected call") | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (c *tlsTestControlPlane) SetSecrets(context.Context, string, map[string]string) error { | |||
| 62 | + | panic("unexpected call") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (c *tlsTestControlPlane) DeleteSecret(context.Context, string, string) error { | |||
| 66 | + | panic("unexpected call") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (c *tlsTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error { | |||
| 70 | + | panic("unexpected call") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (c *tlsTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error { | |||
| 74 | + | panic("unexpected call") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (c *tlsTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) { | |||
| 78 | + | panic("unexpected call") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (c *tlsTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) { | |||
| 82 | + | panic("unexpected call") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | |||
| 86 | + | panic("unexpected call") | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (c *tlsTestControlPlane) AddAttachment(context.Context, string, string) error { | |||
| 90 | + | panic("unexpected call") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (c *tlsTestControlPlane) RemoveAttachment(context.Context, string, string) error { | |||
| 94 | + | panic("unexpected call") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (c *tlsTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) { | |||
| 98 | + | panic("unexpected call") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (c *tlsTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error { | |||
| 102 | + | panic("unexpected call") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (c *tlsTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error { | |||
| 106 | + | panic("unexpected call") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (c *tlsTestControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |||
| 110 | + | if c.getTLSStatus != nil { | |||
| 111 | + | return c.getTLSStatus(ctx) | |||
| 112 | + | } | |||
| 113 | + | return nil, nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (c *tlsTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |||
| 117 | + | panic("unexpected call") | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (c *tlsTestControlPlane) Reload(context.Context) error { | |||
| 121 | + | panic("unexpected call") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (c *tlsTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) { | |||
| 125 | + | panic("unexpected call") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (c *tlsTestControlPlane) GetConfig(context.Context) (*remote.Config, error) { | |||
| 129 | + | panic("unexpected call") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (c *tlsTestControlPlane) DeployIntent(context.Context, string) error { | |||
| 133 | + | panic("unexpected call") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (c *tlsTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) { | |||
| 137 | + | panic("unexpected call") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (c *tlsTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) { | |||
| 141 | + | panic("unexpected call") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (c *tlsTestControlPlane) ListTags(context.Context, string) ([]string, error) { | |||
| 145 | + | panic("unexpected call") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (c *tlsTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) { | |||
| 149 | + | panic("unexpected call") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (c *tlsTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) { | |||
| 153 | + | panic("unexpected call") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (c *tlsTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) { | |||
| 157 | + | panic("unexpected call") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (c *tlsTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) { | |||
| 161 | + | panic("unexpected call") | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (c *tlsTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) { | |||
| 165 | + | panic("unexpected call") | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (c *tlsTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) { | |||
| 169 | + | panic("unexpected call") | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (c *tlsTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) { | |||
| 173 | + | panic("unexpected call") | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (c *tlsTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) { | |||
| 177 | + | panic("unexpected call") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (c *tlsTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) { | |||
| 181 | + | panic("unexpected call") | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (c *tlsTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) { | |||
| 185 | + | panic("unexpected call") | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestTLSStatus_HumanOutput(t *testing.T) { | |||
| 189 | + | notAfter := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) | |||
| 190 | + | ||||
| 191 | + | cp := &tlsTestControlPlane{ | |||
| 192 | + | getTLSStatus: func(_ context.Context) (*dto.TLSStatusResponse, error) { | |||
| 193 | + | return &dto.TLSStatusResponse{ | |||
| 194 | + | ACMEEnabled: true, | |||
| 195 | + | ConfiguredMode: "acme", | |||
| 196 | + | EffectiveMode: "acme-staging", | |||
| 197 | + | SelectionReason: "configured", | |||
| 198 | + | TokenSource: "env", | |||
| 199 | + | Certificates: []dto.TLSCertificateEntry{ | |||
| 200 | + | { | |||
| 201 | + | ID: "cert-abc123", | |||
| 202 | + | Names: []string{"example.com", "www.example.com"}, | |||
| 203 | + | Status: "valid", | |||
| 204 | + | NotAfter: notAfter, | |||
| 205 | + | LastError: "", | |||
| 206 | + | }, | |||
| 207 | + | }, | |||
| 208 | + | Routes: []dto.TLSRouteCoverage{ | |||
| 209 | + | { | |||
| 210 | + | Domain: "example.com", | |||
| 211 | + | Covered: true, | |||
| 212 | + | CoveredBy: "cert-abc123", | |||
| 213 | + | RequiredACME: true, | |||
| 214 | + | }, | |||
| 215 | + | { | |||
| 216 | + | Domain: "internal.local", | |||
| 217 | + | Covered: false, | |||
| 218 | + | RequiredACME: false, | |||
| 219 | + | Error: "self-signed cert", | |||
| 220 | + | }, | |||
| 221 | + | }, | |||
| 222 | + | Errors: []string{"route internal.local has no ACME cert"}, | |||
| 223 | + | }, nil | |||
| 224 | + | }, | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | var buf bytes.Buffer | |||
| 228 | + | err := runTLSStatusCmd(context.Background(), cp, &buf, false) | |||
| 229 | + | require.NoError(t, err) | |||
| 230 | + | ||||
| 231 | + | output := buf.String() | |||
| 232 | + | t.Log("Human output:\n", output) | |||
| 233 | + | ||||
| 234 | + | // Check mode info is present | |||
| 235 | + | assert.Contains(t, output, "ACME:") | |||
| 236 | + | assert.Contains(t, output, "enabled") | |||
| 237 | + | assert.Contains(t, output, "Configured Mode:") | |||
| 238 | + | assert.Contains(t, output, "acme") | |||
| 239 | + | assert.Contains(t, output, "Effective Mode:") | |||
| 240 | + | assert.Contains(t, output, "acme-staging") | |||
| 241 | + | ||||
| 242 | + | // Check token source (not value) | |||
| 243 | + | assert.Contains(t, output, "Token Source:") | |||
| 244 | + | assert.Contains(t, output, "env") | |||
| 245 | + | assert.NotContains(t, output, "token_secret_value") | |||
| 246 | + | ||||
| 247 | + | // Check certificate ID | |||
| 248 | + | assert.Contains(t, output, "cert-abc123") | |||
| 249 | + | assert.Contains(t, output, "example.com, www.example.com") | |||
| 250 | + | assert.Contains(t, output, "valid") | |||
| 251 | + | assert.Contains(t, output, notAfter.Format(time.DateTime)) | |||
| 252 | + | ||||
| 253 | + | // Check route coverage | |||
| 254 | + | assert.Contains(t, output, "example.com") | |||
| 255 | + | assert.Contains(t, output, "covered=yes") | |||
| 256 | + | assert.Contains(t, output, "covered_by=cert-abc123") | |||
| 257 | + | assert.Contains(t, output, "internal.local") | |||
| 258 | + | assert.Contains(t, output, "covered=no") | |||
| 259 | + | ||||
| 260 | + | // Check errors section | |||
| 261 | + | assert.Contains(t, output, "Errors") | |||
| 262 | + | assert.Contains(t, output, "route internal.local has no ACME cert") | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestTLSStatus_JSONOutput(t *testing.T) { | |||
| 266 | + | notAfter := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) | |||
| 267 | + | ||||
| 268 | + | cp := &tlsTestControlPlane{ | |||
| 269 | + | getTLSStatus: func(_ context.Context) (*dto.TLSStatusResponse, error) { | |||
| 270 | + | return &dto.TLSStatusResponse{ | |||
| 271 | + | ACMEEnabled: true, | |||
| 272 | + | ConfiguredMode: "acme", | |||
| 273 | + | EffectiveMode: "acme-staging", | |||
| 274 | + | TokenSource: "env", | |||
| 275 | + | }, nil | |||
| 276 | + | }, | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | var buf bytes.Buffer | |||
| 280 | + | err := runTLSStatusCmd(context.Background(), cp, &buf, true) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | ||||
| 283 | + | var result dto.TLSStatusResponse | |||
| 284 | + | err = json.Unmarshal(buf.Bytes(), &result) | |||
| 285 | + | require.NoError(t, err) | |||
| 286 | + | ||||
| 287 | + | assert.True(t, result.ACMEEnabled) | |||
| 288 | + | assert.Equal(t, "acme", result.ConfiguredMode) | |||
| 289 | + | assert.Equal(t, "acme-staging", result.EffectiveMode) | |||
| 290 | + | assert.Equal(t, "env", result.TokenSource) | |||
| 291 | + | ||||
| 292 | + | _ = notAfter | |||
| 293 | + | } | |||
A internal/adapters/in/http/acme/handler.go +82 -0
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | // Package acme provides an HTTP handler for the ACME HTTP-01 challenge. | |||
| 2 | + | // It serves key authorizations at /.well-known/acme-challenge/{token}. | |||
| 3 | + | package acme | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Prefix is the path prefix for ACME HTTP-01 challenge requests. | |||
| 12 | + | const Prefix = "/.well-known/acme-challenge/" | |||
| 13 | + | ||||
| 14 | + | // ChallengeService defines the interface for retrieving HTTP-01 challenge tokens. | |||
| 15 | + | type ChallengeService interface { | |||
| 16 | + | GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Handler serves ACME HTTP-01 challenge responses. | |||
| 20 | + | type Handler struct { | |||
| 21 | + | svc ChallengeService | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewHandler creates a new Handler with the given ChallengeService. | |||
| 25 | + | func NewHandler(svc ChallengeService) *Handler { | |||
| 26 | + | return &Handler{svc: svc} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // ServeHTTP handles incoming HTTP requests for ACME HTTP-01 challenges. | |||
| 30 | + | // Only GET and HEAD methods are allowed; all others return 405 Method Not Allowed. | |||
| 31 | + | // The token must be a safe path component and must exist in the challenge store. | |||
| 32 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 33 | + | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |||
| 34 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | token := strings.TrimPrefix(r.URL.Path, Prefix) | |||
| 39 | + | if !strings.HasPrefix(r.URL.Path, Prefix) || !safeToken(token) { | |||
| 40 | + | http.NotFound(w, r) | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | if h.svc == nil { | |||
| 45 | + | http.NotFound(w, r) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | keyAuth, ok := h.svc.GetHTTP01Challenge(r.Context(), token) | |||
| 50 | + | if !ok { | |||
| 51 | + | http.NotFound(w, r) | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |||
| 56 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 57 | + | w.WriteHeader(http.StatusOK) | |||
| 58 | + | ||||
| 59 | + | if r.Method == http.MethodHead { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | _, _ = w.Write([]byte(keyAuth)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // safeToken validates that the token does not contain path traversal or special | |||
| 67 | + | // characters that could be used to access files outside the challenge directory. | |||
| 68 | + | func safeToken(token string) bool { | |||
| 69 | + | if token == "" { | |||
| 70 | + | return false | |||
| 71 | + | } | |||
| 72 | + | if strings.Contains(token, "/") { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | if strings.Contains(token, "\\") { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | if strings.Contains(token, "..") { | |||
| 79 | + | return false | |||
| 80 | + | } | |||
| 81 | + | return true | |||
| 82 | + | } | |||
A internal/adapters/in/http/acme/handler_test.go +171 -0
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package acme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // challengeSvc is a simple map-backed implementation of ChallengeService for testing. | |||
| 11 | + | type challengeSvc map[string]string | |||
| 12 | + | ||||
| 13 | + | func (s challengeSvc) GetHTTP01Challenge(_ context.Context, token string) (string, bool) { | |||
| 14 | + | keyAuth, ok := s[token] | |||
| 15 | + | return keyAuth, ok | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestHandlerServesGETChallenge(t *testing.T) { | |||
| 19 | + | svc := challengeSvc{"abc": "key-auth"} | |||
| 20 | + | h := NewHandler(svc) | |||
| 21 | + | ||||
| 22 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil) | |||
| 23 | + | rec := httptest.NewRecorder() | |||
| 24 | + | ||||
| 25 | + | h.ServeHTTP(rec, req) | |||
| 26 | + | ||||
| 27 | + | resp := rec.Result() | |||
| 28 | + | defer resp.Body.Close() | |||
| 29 | + | ||||
| 30 | + | if resp.StatusCode != http.StatusOK { | |||
| 31 | + | t.Errorf("expected status 200, got %d", resp.StatusCode) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" { | |||
| 35 | + | t.Errorf("expected Content-Type text/plain; charset=utf-8, got %q", ct) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | if cc := resp.Header.Get("Cache-Control"); cc != "no-store" { | |||
| 39 | + | t.Errorf("expected Cache-Control no-store, got %q", cc) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | body := rec.Body.String() | |||
| 43 | + | if body != "key-auth" { | |||
| 44 | + | t.Errorf("expected body %q, got %q", "key-auth", body) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestHandlerServesHEADChallenge(t *testing.T) { | |||
| 49 | + | svc := challengeSvc{"abc": "key-auth"} | |||
| 50 | + | h := NewHandler(svc) | |||
| 51 | + | ||||
| 52 | + | req := httptest.NewRequest(http.MethodHead, "/.well-known/acme-challenge/abc", nil) | |||
| 53 | + | rec := httptest.NewRecorder() | |||
| 54 | + | ||||
| 55 | + | h.ServeHTTP(rec, req) | |||
| 56 | + | ||||
| 57 | + | resp := rec.Result() | |||
| 58 | + | defer resp.Body.Close() | |||
| 59 | + | ||||
| 60 | + | if resp.StatusCode != http.StatusOK { | |||
| 61 | + | t.Errorf("expected status 200, got %d", resp.StatusCode) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" { | |||
| 65 | + | t.Errorf("expected Content-Type text/plain; charset=utf-8, got %q", ct) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if cc := resp.Header.Get("Cache-Control"); cc != "no-store" { | |||
| 69 | + | t.Errorf("expected Cache-Control no-store, got %q", cc) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | body := rec.Body.String() | |||
| 73 | + | if body != "" { | |||
| 74 | + | t.Errorf("expected empty body for HEAD, got %q", body) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestHandlerRejectsUnsafeToken(t *testing.T) { | |||
| 79 | + | tests := []struct { | |||
| 80 | + | name string | |||
| 81 | + | path string | |||
| 82 | + | }{ | |||
| 83 | + | {name: "path traversal", path: "/.well-known/acme-challenge/../secret"}, | |||
| 84 | + | {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"}, | |||
| 85 | + | {name: "empty token", path: "/.well-known/acme-challenge/"}, | |||
| 86 | + | {name: "backslash in token", path: "/.well-known/acme-challenge/foo\\bar"}, | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | for _, tt := range tests { | |||
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 91 | + | h := NewHandler(challengeSvc{"abc": "key-auth"}) | |||
| 92 | + | ||||
| 93 | + | req := httptest.NewRequest(http.MethodGet, tt.path, nil) | |||
| 94 | + | rec := httptest.NewRecorder() | |||
| 95 | + | ||||
| 96 | + | h.ServeHTTP(rec, req) | |||
| 97 | + | ||||
| 98 | + | resp := rec.Result() | |||
| 99 | + | defer resp.Body.Close() | |||
| 100 | + | ||||
| 101 | + | if resp.StatusCode != http.StatusNotFound { | |||
| 102 | + | t.Errorf("expected status 404, got %d for path %q", resp.StatusCode, tt.path) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestHandlerRejectsPOST(t *testing.T) { | |||
| 109 | + | svc := challengeSvc{"abc": "key-auth"} | |||
| 110 | + | h := NewHandler(svc) | |||
| 111 | + | ||||
| 112 | + | req := httptest.NewRequest(http.MethodPost, "/.well-known/acme-challenge/abc", nil) | |||
| 113 | + | rec := httptest.NewRecorder() | |||
| 114 | + | ||||
| 115 | + | h.ServeHTTP(rec, req) | |||
| 116 | + | ||||
| 117 | + | resp := rec.Result() | |||
| 118 | + | defer resp.Body.Close() | |||
| 119 | + | ||||
| 120 | + | if resp.StatusCode != http.StatusMethodNotAllowed { | |||
| 121 | + | t.Errorf("expected status 405, got %d", resp.StatusCode) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestHandlerRejectsMissingPrefix(t *testing.T) { | |||
| 126 | + | h := NewHandler(challengeSvc{"abc": "key-auth"}) | |||
| 127 | + | ||||
| 128 | + | req := httptest.NewRequest(http.MethodGet, "/some-other-path", nil) | |||
| 129 | + | rec := httptest.NewRecorder() | |||
| 130 | + | ||||
| 131 | + | h.ServeHTTP(rec, req) | |||
| 132 | + | ||||
| 133 | + | resp := rec.Result() | |||
| 134 | + | defer resp.Body.Close() | |||
| 135 | + | ||||
| 136 | + | if resp.StatusCode != http.StatusNotFound { | |||
| 137 | + | t.Errorf("expected status 404 for non-matching prefix, got %d", resp.StatusCode) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestHandlerRejectsUnknownToken(t *testing.T) { | |||
| 142 | + | h := NewHandler(challengeSvc{"abc": "key-auth"}) | |||
| 143 | + | ||||
| 144 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/unknown", nil) | |||
| 145 | + | rec := httptest.NewRecorder() | |||
| 146 | + | ||||
| 147 | + | h.ServeHTTP(rec, req) | |||
| 148 | + | ||||
| 149 | + | resp := rec.Result() | |||
| 150 | + | defer resp.Body.Close() | |||
| 151 | + | ||||
| 152 | + | if resp.StatusCode != http.StatusNotFound { | |||
| 153 | + | t.Errorf("expected status 404 for unknown token, got %d", resp.StatusCode) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestHandlerRejectsNilService(t *testing.T) { | |||
| 158 | + | h := NewHandler(nil) | |||
| 159 | + | ||||
| 160 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil) | |||
| 161 | + | rec := httptest.NewRecorder() | |||
| 162 | + | ||||
| 163 | + | h.ServeHTTP(rec, req) | |||
| 164 | + | ||||
| 165 | + | resp := rec.Result() | |||
| 166 | + | defer resp.Body.Close() | |||
| 167 | + | ||||
| 168 | + | if resp.StatusCode != http.StatusNotFound { | |||
| 169 | + | t.Errorf("expected status 404 for nil service, got %d", resp.StatusCode) | |||
| 170 | + | } | |||
| 171 | + | } | |||
M internal/adapters/in/http/admin/handler.go +65 -0
| @@ -52,6 +52,7 @@ type Handler struct { | |||||
| 52 | registrySvc registryDeployService | 52 | registrySvc registryDeployService | ||
| 53 | previewSvc previewService | 53 | previewSvc previewService | ||
| 54 | reloadTrigger reloadTrigger | 54 | reloadTrigger reloadTrigger | ||
| 55 | + | publicTLSSvc in.PublicTLSService | |||
| 55 | log zerowrap.Logger | 56 | log zerowrap.Logger | ||
| 56 | } | 57 | } | ||
| 57 | 58 | ||||
| @@ -149,6 +150,7 @@ type HandlerDeps struct { | |||||
| 149 | ImageSvc in.ImageService | 150 | ImageSvc in.ImageService | ||
| 150 | VolumeSvc in.VolumeService | 151 | VolumeSvc in.VolumeService | ||
| 151 | ReloadTrigger reloadTrigger | 152 | ReloadTrigger reloadTrigger | ||
| 153 | + | PublicTLSSvc in.PublicTLSService | |||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 154 | // NewHandler creates a new admin HTTP handler. | 156 | // NewHandler creates a new admin HTTP handler. | ||
| @@ -166,6 +168,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||||
| 166 | registrySvc: deps.RegistrySvc, | 168 | registrySvc: deps.RegistrySvc, | ||
| 167 | previewSvc: deps.PreviewSvc, | 169 | previewSvc: deps.PreviewSvc, | ||
| 168 | reloadTrigger: deps.ReloadTrigger, | 170 | reloadTrigger: deps.ReloadTrigger, | ||
| 171 | + | publicTLSSvc: deps.PublicTLSSvc, | |||
| 169 | log: deps.Log, | 172 | log: deps.Log, | ||
| 170 | } | 173 | } | ||
| 171 | } | 174 | } | ||
| @@ -216,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 216 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | 219 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | ||
| 217 | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | 220 | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | ||
| 218 | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | 221 | "/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) }, | |||
| 219 | } | 223 | } | ||
| 220 | if handler, ok := exactRoutes[path]; ok { | 224 | if handler, ok := exactRoutes[path]; ok { | ||
| 221 | return handler, true | 225 | return handler, true | ||
| @@ -1906,3 +1910,64 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) { | |||||
| 1906 | 1910 | ||||
| 1907 | h.sendJSON(w, http.StatusOK, response) | 1911 | h.sendJSON(w, http.StatusOK, response) | ||
| 1908 | } | 1912 | } | ||
| 1913 | + | ||||
| 1914 | + | // handleTLSStatus handles GET /admin/tls/status. | |||
| 1915 | + | func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) { | |||
| 1916 | + | if r.Method != http.MethodGet { | |||
| 1917 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 1918 | + | return | |||
| 1919 | + | } | |||
| 1920 | + | ||||
| 1921 | + | ctx := r.Context() | |||
| 1922 | + | ||||
| 1923 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 1924 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 1925 | + | return | |||
| 1926 | + | } | |||
| 1927 | + | ||||
| 1928 | + | if h.publicTLSSvc == nil { | |||
| 1929 | + | h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{}) | |||
| 1930 | + | return | |||
| 1931 | + | } | |||
| 1932 | + | ||||
| 1933 | + | status := h.publicTLSSvc.Status(ctx) | |||
| 1934 | + | h.sendJSON(w, http.StatusOK, toTLSStatusResponse(status)) | |||
| 1935 | + | } | |||
| 1936 | + | ||||
| 1937 | + | // toTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse. | |||
| 1938 | + | func toTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse { | |||
| 1939 | + | certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates)) | |||
| 1940 | + | for _, c := range s.Certificates { | |||
| 1941 | + | certs = append(certs, dto.TLSCertificateEntry{ | |||
| 1942 | + | ID: c.ID, | |||
| 1943 | + | Names: c.Names, | |||
| 1944 | + | Challenge: string(c.Challenge), | |||
| 1945 | + | Status: string(c.Status), | |||
| 1946 | + | NotAfter: c.NotAfter, | |||
| 1947 | + | LastError: c.LastError, | |||
| 1948 | + | RenewalPending: c.RenewalPending, | |||
| 1949 | + | }) | |||
| 1950 | + | } | |||
| 1951 | + | ||||
| 1952 | + | routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes)) | |||
| 1953 | + | for _, r := range s.Routes { | |||
| 1954 | + | routes = append(routes, dto.TLSRouteCoverage{ | |||
| 1955 | + | Domain: r.Domain, | |||
| 1956 | + | Covered: r.Covered, | |||
| 1957 | + | CoveredBy: r.CoveredBy, | |||
| 1958 | + | RequiredACME: r.RequiredACME, | |||
| 1959 | + | Error: r.Error, | |||
| 1960 | + | }) | |||
| 1961 | + | } | |||
| 1962 | + | ||||
| 1963 | + | return dto.TLSStatusResponse{ | |||
| 1964 | + | ACMEEnabled: s.ACMEEnabled, | |||
| 1965 | + | ConfiguredMode: string(s.ConfiguredMode), | |||
| 1966 | + | EffectiveMode: string(s.EffectiveMode), | |||
| 1967 | + | SelectionReason: s.SelectionReason, | |||
| 1968 | + | TokenSource: string(s.TokenSource), | |||
| 1969 | + | Certificates: certs, | |||
| 1970 | + | Routes: routes, | |||
| 1971 | + | Errors: s.Errors, | |||
| 1972 | + | } | |||
| 1973 | + | } | |||
M internal/adapters/in/http/admin/handler_test.go +117 -0
| @@ -3,6 +3,7 @@ package admin | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/tls" | |||
| 6 | "encoding/json" | 7 | "encoding/json" | ||
| 7 | "errors" | 8 | "errors" | ||
| 8 | "fmt" | 9 | "fmt" | ||
| @@ -2585,3 +2586,119 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||||
| 2585 | assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write") | 2586 | assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write") | ||
| 2586 | }) | 2587 | }) | ||
| 2587 | } | 2588 | } | ||
| 2589 | + | ||||
| 2590 | + | // fakeTLSService is a minimal in.PublicTLSService implementation for testing. | |||
| 2591 | + | type fakeTLSService struct { | |||
| 2592 | + | statusFunc func(ctx context.Context) domain.PublicTLSStatus | |||
| 2593 | + | } | |||
| 2594 | + | ||||
| 2595 | + | func (f *fakeTLSService) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 2596 | + | return nil, nil | |||
| 2597 | + | } | |||
| 2598 | + | ||||
| 2599 | + | func (f *fakeTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) { | |||
| 2600 | + | return "", false | |||
| 2601 | + | } | |||
| 2602 | + | ||||
| 2603 | + | func (f *fakeTLSService) Status(ctx context.Context) domain.PublicTLSStatus { | |||
| 2604 | + | if f.statusFunc != nil { | |||
| 2605 | + | return f.statusFunc(ctx) | |||
| 2606 | + | } | |||
| 2607 | + | return domain.PublicTLSStatus{} | |||
| 2608 | + | } | |||
| 2609 | + | ||||
| 2610 | + | func (f *fakeTLSService) Reconcile(ctx context.Context) error { | |||
| 2611 | + | return nil | |||
| 2612 | + | } | |||
| 2613 | + | ||||
| 2614 | + | func (f *fakeTLSService) Stop(ctx context.Context) error { | |||
| 2615 | + | return nil | |||
| 2616 | + | } | |||
| 2617 | + | ||||
| 2618 | + | func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) { | |||
| 2619 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2620 | + | d.PublicTLSSvc = &fakeTLSService{} | |||
| 2621 | + | }) | |||
| 2622 | + | ||||
| 2623 | + | req := httptest.NewRequest("GET", "/admin/tls/status", nil) | |||
| 2624 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 2625 | + | rec := httptest.NewRecorder() | |||
| 2626 | + | ||||
| 2627 | + | handler.ServeHTTP(rec, req) | |||
| 2628 | + | ||||
| 2629 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 2630 | + | assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read") | |||
| 2631 | + | } | |||
| 2632 | + | ||||
| 2633 | + | func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) { | |||
| 2634 | + | now := time.Now() | |||
| 2635 | + | fakeSvc := &fakeTLSService{ | |||
| 2636 | + | statusFunc: func(ctx context.Context) domain.PublicTLSStatus { | |||
| 2637 | + | return domain.PublicTLSStatus{ | |||
| 2638 | + | ACMEEnabled: true, | |||
| 2639 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |||
| 2640 | + | EffectiveMode: domain.ACMEChallengeHTTP01, | |||
| 2641 | + | SelectionReason: "auto-selected", | |||
| 2642 | + | TokenSource: domain.ACMETokenSourcePass, | |||
| 2643 | + | Certificates: []domain.ManagedCertificate{ | |||
| 2644 | + | { | |||
| 2645 | + | ID: "cert-1", | |||
| 2646 | + | Names: []string{"example.com"}, | |||
| 2647 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 2648 | + | Status: domain.TLSCertificateStatusValid, | |||
| 2649 | + | NotAfter: now.Add(60 * 24 * time.Hour), | |||
| 2650 | + | }, | |||
| 2651 | + | }, | |||
| 2652 | + | Routes: []domain.TLSRouteCoverage{ | |||
| 2653 | + | { | |||
| 2654 | + | Domain: "example.com", | |||
| 2655 | + | Covered: true, | |||
| 2656 | + | CoveredBy: "cert-1", | |||
| 2657 | + | RequiredACME: true, | |||
| 2658 | + | }, | |||
| 2659 | + | }, | |||
| 2660 | + | } | |||
| 2661 | + | }, | |||
| 2662 | + | } | |||
| 2663 | + | ||||
| 2664 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2665 | + | d.PublicTLSSvc = fakeSvc | |||
| 2666 | + | }) | |||
| 2667 | + | ||||
| 2668 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 2669 | + | resp, err := http.Get(server.URL + "/admin/tls/status") | |||
| 2670 | + | require.NoError(t, err) | |||
| 2671 | + | defer resp.Body.Close() | |||
| 2672 | + | ||||
| 2673 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 2674 | + | ||||
| 2675 | + | var body dto.TLSStatusResponse | |||
| 2676 | + | err = json.NewDecoder(resp.Body).Decode(&body) | |||
| 2677 | + | require.NoError(t, err) | |||
| 2678 | + | ||||
| 2679 | + | assert.True(t, body.ACMEEnabled) | |||
| 2680 | + | assert.Equal(t, "auto", body.ConfiguredMode) | |||
| 2681 | + | assert.Equal(t, "http-01", body.EffectiveMode) | |||
| 2682 | + | assert.Equal(t, "auto-selected", body.SelectionReason) | |||
| 2683 | + | assert.Equal(t, "pass", body.TokenSource) | |||
| 2684 | + | require.Len(t, body.Certificates, 1) | |||
| 2685 | + | assert.Equal(t, "cert-1", body.Certificates[0].ID) | |||
| 2686 | + | assert.Equal(t, "valid", body.Certificates[0].Status) | |||
| 2687 | + | assert.Equal(t, "http-01", body.Certificates[0].Challenge) | |||
| 2688 | + | require.Len(t, body.Routes, 1) | |||
| 2689 | + | assert.Equal(t, "example.com", body.Routes[0].Domain) | |||
| 2690 | + | assert.True(t, body.Routes[0].Covered) | |||
| 2691 | + | } | |||
| 2692 | + | ||||
| 2693 | + | func TestHandler_TLSStatus_POSTReturns405(t *testing.T) { | |||
| 2694 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 2695 | + | d.PublicTLSSvc = &fakeTLSService{} | |||
| 2696 | + | }) | |||
| 2697 | + | ||||
| 2698 | + | server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write") | |||
| 2699 | + | resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil) | |||
| 2700 | + | require.NoError(t, err) | |||
| 2701 | + | defer resp.Body.Close() | |||
| 2702 | + | ||||
| 2703 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |||
| 2704 | + | } | |||
A internal/adapters/out/acmelego/cloudflare.go +169 -0
| @@ -0,0 +1,169 @@ | |||||
| 1 | + | package acmelego | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/url" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // zoneAPIResponse is a minimal Cloudflare API response for zone listing. | |||
| 16 | + | type zoneAPIResponse struct { | |||
| 17 | + | Success bool `json:"success"` | |||
| 18 | + | Errors []zoneAPIError `json:"errors"` | |||
| 19 | + | Result []zoneAPIResult `json:"result"` | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type zoneAPIError struct { | |||
| 23 | + | Code int `json:"code"` | |||
| 24 | + | Message string `json:"message"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type zoneAPIResult struct { | |||
| 28 | + | ID string `json:"id"` | |||
| 29 | + | Name string `json:"name"` | |||
| 30 | + | Status string `json:"status"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | const defaultCloudflareBaseURL = "https://api.cloudflare.com/client/v4" | |||
| 34 | + | ||||
| 35 | + | // CloudflareZoneResolver resolves a hostname to its Cloudflare DNS zone by | |||
| 36 | + | // searching from the full hostname down to the TLD. | |||
| 37 | + | type CloudflareZoneResolver struct { | |||
| 38 | + | token string | |||
| 39 | + | baseURL string | |||
| 40 | + | client *http.Client | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // CloudflareZoneResolverOption configures a CloudflareZoneResolver. | |||
| 44 | + | type CloudflareZoneResolverOption func(*CloudflareZoneResolver) | |||
| 45 | + | ||||
| 46 | + | // WithCloudflareBaseURL sets the base URL for the Cloudflare API. | |||
| 47 | + | func WithCloudflareBaseURL(baseURL string) CloudflareZoneResolverOption { | |||
| 48 | + | return func(r *CloudflareZoneResolver) { | |||
| 49 | + | r.baseURL = strings.TrimRight(baseURL, "/") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // WithCloudflareHTTPClient sets the HTTP client for the resolver. | |||
| 54 | + | func WithCloudflareHTTPClient(client *http.Client) CloudflareZoneResolverOption { | |||
| 55 | + | return func(r *CloudflareZoneResolver) { | |||
| 56 | + | r.client = client | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // NewCloudflareZoneResolver creates a new CloudflareZoneResolver. | |||
| 61 | + | func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOption) *CloudflareZoneResolver { | |||
| 62 | + | r := &CloudflareZoneResolver{ | |||
| 63 | + | token: token, | |||
| 64 | + | baseURL: defaultCloudflareBaseURL, | |||
| 65 | + | client: http.DefaultClient, | |||
| 66 | + | } | |||
| 67 | + | for _, opt := range opts { | |||
| 68 | + | opt(r) | |||
| 69 | + | } | |||
| 70 | + | return r | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // compile-time interface check | |||
| 74 | + | var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil) | |||
| 75 | + | ||||
| 76 | + | // FindZone finds the most specific Cloudflare zone for the given domain. | |||
| 77 | + | // It searches from the full hostname down to the TLD (e.g. for | |||
| 78 | + | // api.prod.example.com it tries api.prod.example.com, prod.example.com, | |||
| 79 | + | // example.com, com) and returns the first active zone found. | |||
| 80 | + | func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string) (out.CloudflareZone, error) { | |||
| 81 | + | domainName = strings.TrimSuffix(strings.TrimSpace(domainName), ".") | |||
| 82 | + | if domainName == "" { | |||
| 83 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain") | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | parts := strings.Split(domainName, ".") | |||
| 87 | + | ||||
| 88 | + | // candidateCount is the total possible candidates (full domain down to TLD). | |||
| 89 | + | // For "api.prod.example.com" -> 4 candidates; for "example.com" -> 2. | |||
| 90 | + | candidateCount := len(parts) | |||
| 91 | + | candidates := make([]string, 0, candidateCount) | |||
| 92 | + | ||||
| 93 | + | // Build candidates from full hostname down to the TLD. | |||
| 94 | + | // For parts ["api","prod","example","com"]: | |||
| 95 | + | // "api.prod.example.com", "prod.example.com", "example.com", "com" | |||
| 96 | + | for i := range parts { | |||
| 97 | + | candidate := strings.Join(parts[i:], ".") | |||
| 98 | + | candidates = append(candidates, candidate) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | for _, candidate := range candidates { | |||
| 102 | + | zone, err := r.findZoneByName(ctx, candidate) | |||
| 103 | + | if err != nil { | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 106 | + | return zone, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: no active zone found for %q", domainName) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // findZoneByName queries the Cloudflare API for an active zone with the exact given name. | |||
| 113 | + | func (r *CloudflareZoneResolver) findZoneByName(ctx context.Context, name string) (out.CloudflareZone, error) { | |||
| 114 | + | u, err := url.Parse(r.baseURL + "/zones") | |||
| 115 | + | if err != nil { | |||
| 116 | + | return out.CloudflareZone{}, fmt.Errorf("parse url: %w", err) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | q := u.Query() | |||
| 120 | + | q.Set("name", name) | |||
| 121 | + | q.Set("status", "active") | |||
| 122 | + | u.RawQuery = q.Encode() | |||
| 123 | + | ||||
| 124 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return out.CloudflareZone{}, fmt.Errorf("create request: %w", err) | |||
| 127 | + | } | |||
| 128 | + | req.Header.Set("Authorization", "Bearer "+r.token) | |||
| 129 | + | req.Header.Set("Accept", "application/json") | |||
| 130 | + | ||||
| 131 | + | resp, err := r.client.Do(req) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return out.CloudflareZone{}, fmt.Errorf("request failed: %w", err) | |||
| 134 | + | } | |||
| 135 | + | defer resp.Body.Close() | |||
| 136 | + | ||||
| 137 | + | body, err := io.ReadAll(resp.Body) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return out.CloudflareZone{}, fmt.Errorf("read response: %w", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | if resp.StatusCode != http.StatusOK { | |||
| 143 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare api: status %d", resp.StatusCode) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | var apiResp zoneAPIResponse | |||
| 147 | + | if err := json.Unmarshal(body, &apiResp); err != nil { | |||
| 148 | + | return out.CloudflareZone{}, fmt.Errorf("parse response: %w", err) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | if !apiResp.Success { | |||
| 152 | + | errMsg := "cloudflare api returned unsuccessfully" | |||
| 153 | + | if len(apiResp.Errors) > 0 { | |||
| 154 | + | errMsg = apiResp.Errors[0].Message | |||
| 155 | + | } | |||
| 156 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare api: %s", errMsg) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | for _, z := range apiResp.Result { | |||
| 160 | + | if z.Name == name && z.Status == "active" { | |||
| 161 | + | return out.CloudflareZone{ | |||
| 162 | + | ID: z.ID, | |||
| 163 | + | Name: z.Name, | |||
| 164 | + | }, nil | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | return out.CloudflareZone{}, fmt.Errorf("no active zone found for %q", name) | |||
| 169 | + | } | |||
A internal/adapters/out/acmelego/cloudflare_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||||
| 1 | + | package acmelego | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestCloudflareZoneResolverFindsParentZone(t *testing.T) { | |||
| 17 | + | var requestCount int | |||
| 18 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 19 | + | requestCount++ | |||
| 20 | + | ||||
| 21 | + | // Verify auth header | |||
| 22 | + | assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) | |||
| 23 | + | ||||
| 24 | + | // Verify query parameters | |||
| 25 | + | name := r.URL.Query().Get("name") | |||
| 26 | + | status := r.URL.Query().Get("status") | |||
| 27 | + | assert.Equal(t, "active", status) | |||
| 28 | + | ||||
| 29 | + | switch name { | |||
| 30 | + | case "api.prod.example.com": | |||
| 31 | + | // Return empty - no zone found | |||
| 32 | + | w.Header().Set("Content-Type", "application/json") | |||
| 33 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |||
| 34 | + | Success: true, | |||
| 35 | + | Result: []zoneAPIResult{}, | |||
| 36 | + | }) | |||
| 37 | + | case "prod.example.com": | |||
| 38 | + | // Return empty - no zone found | |||
| 39 | + | w.Header().Set("Content-Type", "application/json") | |||
| 40 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |||
| 41 | + | Success: true, | |||
| 42 | + | Result: []zoneAPIResult{}, | |||
| 43 | + | }) | |||
| 44 | + | case "example.com": | |||
| 45 | + | // Return active zone | |||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |||
| 47 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |||
| 48 | + | Success: true, | |||
| 49 | + | Result: []zoneAPIResult{ | |||
| 50 | + | { | |||
| 51 | + | ID: "zone-id-123", | |||
| 52 | + | Name: "example.com", | |||
| 53 | + | Status: "active", | |||
| 54 | + | }, | |||
| 55 | + | }, | |||
| 56 | + | }) | |||
| 57 | + | default: | |||
| 58 | + | t.Errorf("unexpected zone name query: %s", name) | |||
| 59 | + | } | |||
| 60 | + | })) | |||
| 61 | + | defer server.Close() | |||
| 62 | + | ||||
| 63 | + | resolver := NewCloudflareZoneResolver("test-token", | |||
| 64 | + | WithCloudflareBaseURL(server.URL)) | |||
| 65 | + | ||||
| 66 | + | zone, err := resolver.FindZone(context.Background(), "api.prod.example.com") | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | assert.Equal(t, "zone-id-123", zone.ID) | |||
| 69 | + | assert.Equal(t, "example.com", zone.Name) | |||
| 70 | + | assert.Equal(t, 3, requestCount, "should have made 3 API calls") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestCloudflareZoneResolverDoesNotLeakTokenInError(t *testing.T) { | |||
| 74 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 75 | + | // Return a 500 error | |||
| 76 | + | w.WriteHeader(http.StatusInternalServerError) | |||
| 77 | + | w.Write([]byte(`{"success":false,"errors":[]}`)) | |||
| 78 | + | })) | |||
| 79 | + | defer server.Close() | |||
| 80 | + | ||||
| 81 | + | resolver := NewCloudflareZoneResolver("super-secret-token-abc123", | |||
| 82 | + | WithCloudflareBaseURL(server.URL)) | |||
| 83 | + | ||||
| 84 | + | _, err := resolver.FindZone(context.Background(), "example.com") | |||
| 85 | + | require.Error(t, err) | |||
| 86 | + | errStr := err.Error() | |||
| 87 | + | ||||
| 88 | + | // The error must NOT contain the token | |||
| 89 | + | assert.NotContains(t, errStr, "super-secret-token-abc123") | |||
| 90 | + | assert.NotContains(t, errStr, "secret-token") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestCloudflareZoneResolverSimpleDomain(t *testing.T) { | |||
| 94 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 95 | + | name := r.URL.Query().Get("name") | |||
| 96 | + | assert.Equal(t, "example.com", name) | |||
| 97 | + | ||||
| 98 | + | w.Header().Set("Content-Type", "application/json") | |||
| 99 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |||
| 100 | + | Success: true, | |||
| 101 | + | Result: []zoneAPIResult{ | |||
| 102 | + | { | |||
| 103 | + | ID: "zone-1", | |||
| 104 | + | Name: "example.com", | |||
| 105 | + | Status: "active", | |||
| 106 | + | }, | |||
| 107 | + | }, | |||
| 108 | + | }) | |||
| 109 | + | })) | |||
| 110 | + | defer server.Close() | |||
| 111 | + | ||||
| 112 | + | resolver := NewCloudflareZoneResolver("test-token", | |||
| 113 | + | WithCloudflareBaseURL(server.URL)) | |||
| 114 | + | ||||
| 115 | + | zone, err := resolver.FindZone(context.Background(), "example.com") | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | assert.Equal(t, "zone-1", zone.ID) | |||
| 118 | + | assert.Equal(t, "example.com", zone.Name) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestCloudflareZoneResolverNoZoneFound(t *testing.T) { | |||
| 122 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 123 | + | w.Header().Set("Content-Type", "application/json") | |||
| 124 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |||
| 125 | + | Success: true, | |||
| 126 | + | Result: []zoneAPIResult{}, | |||
| 127 | + | }) | |||
| 128 | + | })) | |||
| 129 | + | defer server.Close() | |||
| 130 | + | ||||
| 131 | + | resolver := NewCloudflareZoneResolver("test-token", | |||
| 132 | + | WithCloudflareBaseURL(server.URL)) | |||
| 133 | + | ||||
| 134 | + | _, err := resolver.FindZone(context.Background(), "missing.example.com") | |||
| 135 | + | require.Error(t, err) | |||
| 136 | + | assert.Contains(t, err.Error(), "no active zone found") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) { | |||
| 140 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 141 | + | w.Header().Set("Content-Type", "application/json") | |||
| 142 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |||
| 143 | + | Success: false, | |||
| 144 | + | Errors: []zoneAPIError{ | |||
| 145 | + | {Code: 1000, Message: "authentication error"}, | |||
| 146 | + | }, | |||
| 147 | + | Result: []zoneAPIResult{}, | |||
| 148 | + | }) | |||
| 149 | + | })) | |||
| 150 | + | defer server.Close() | |||
| 151 | + | ||||
| 152 | + | resolver := NewCloudflareZoneResolver("bad-token", | |||
| 153 | + | WithCloudflareBaseURL(server.URL)) | |||
| 154 | + | ||||
| 155 | + | _, err := resolver.FindZone(context.Background(), "example.com") | |||
| 156 | + | require.Error(t, err) | |||
| 157 | + | // The resolver iterates all candidates; the final error is the generic one. | |||
| 158 | + | assert.Contains(t, err.Error(), "no active zone found") | |||
| 159 | + | // Token must never leak in error messages. | |||
| 160 | + | assert.NotContains(t, err.Error(), "bad-token") | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // Ensure the interface compliance. | |||
| 164 | + | func TestCloudflareZoneResolverImplementsInterface(t *testing.T) { | |||
| 165 | + | var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil) | |||
| 166 | + | ||||
| 167 | + | // Test NewCloudflareZoneResolver returns non-nil | |||
| 168 | + | r := NewCloudflareZoneResolver("token") | |||
| 169 | + | require.NotNil(t, r) | |||
| 170 | + | ||||
| 171 | + | // Check defaults | |||
| 172 | + | assert.Equal(t, "https://api.cloudflare.com/client/v4", r.baseURL) | |||
| 173 | + | } | |||
A internal/adapters/out/acmelego/http_provider.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | // Package acmelego implements ACME certificate operations using the lego library. | |||
| 2 | + | package acmelego | |||
| 3 | + | ||||
| 4 | + | // HTTPChallengeSink defines the storage interface for HTTP-01 challenge | |||
| 5 | + | // tokens. The usecase layer implements this to serve challenge responses | |||
| 6 | + | // via the proxy. | |||
| 7 | + | type HTTPChallengeSink interface { | |||
| 8 | + | Present(token, keyAuth string) | |||
| 9 | + | CleanUp(token string) | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // HTTPProvider adapts an HTTPChallengeSink to the lego challenge.Provider | |||
| 13 | + | // interface for HTTP-01 challenge solving. | |||
| 14 | + | type HTTPProvider struct { | |||
| 15 | + | sink HTTPChallengeSink | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // NewHTTPProvider creates a new HTTPProvider. | |||
| 19 | + | func NewHTTPProvider(sink HTTPChallengeSink) *HTTPProvider { | |||
| 20 | + | return &HTTPProvider{sink: sink} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Present stores the key authorization for the given token via the sink. | |||
| 24 | + | // If the sink is nil, it returns nil (no-op) to allow graceful degradation | |||
| 25 | + | // in test or misconfigured scenarios. | |||
| 26 | + | func (p *HTTPProvider) Present(_domain, token, keyAuth string) error { | |||
| 27 | + | if p.sink == nil { | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | p.sink.Present(token, keyAuth) | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // CleanUp removes the key authorization for the given token via the sink. | |||
| 35 | + | func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error { | |||
| 36 | + | if p.sink == nil { | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | p.sink.CleanUp(token) | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
A internal/adapters/out/acmelego/http_provider_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package acmelego | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // challengeSink is a test sink that stores challenge tokens in a map. | |||
| 10 | + | type challengeSink struct { | |||
| 11 | + | values map[string]string | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func (s *challengeSink) Present(token, keyAuth string) { | |||
| 15 | + | if s.values == nil { | |||
| 16 | + | s.values = make(map[string]string) | |||
| 17 | + | } | |||
| 18 | + | s.values[token] = keyAuth | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (s *challengeSink) CleanUp(token string) { | |||
| 22 | + | delete(s.values, token) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) { | |||
| 26 | + | sink := &challengeSink{} | |||
| 27 | + | provider := NewHTTPProvider(sink) | |||
| 28 | + | ||||
| 29 | + | err := provider.Present("example.com", "token", "key-auth") | |||
| 30 | + | assert.NoError(t, err) | |||
| 31 | + | assert.Equal(t, "key-auth", sink.values["token"]) | |||
| 32 | + | ||||
| 33 | + | err = provider.CleanUp("example.com", "token", "key-auth") | |||
| 34 | + | assert.NoError(t, err) | |||
| 35 | + | assert.Empty(t, sink.values["token"]) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestHTTPProviderNilSink(t *testing.T) { | |||
| 39 | + | provider := NewHTTPProvider(nil) | |||
| 40 | + | ||||
| 41 | + | err := provider.Present("example.com", "token", "key-auth") | |||
| 42 | + | assert.NoError(t, err) | |||
| 43 | + | ||||
| 44 | + | err = provider.CleanUp("example.com", "token", "key-auth") | |||
| 45 | + | assert.NoError(t, err) | |||
| 46 | + | } | |||
A internal/adapters/out/acmelego/issuer.go +325 -0
| @@ -0,0 +1,325 @@ | |||||
| 1 | + | // Package acmelego implements ACME certificate operations using the lego library. | |||
| 2 | + | package acmelego | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "crypto/tls" | |||
| 7 | + | "crypto/x509" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "net/http" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/go-acme/lego/v4/acme" | |||
| 16 | + | "github.com/go-acme/lego/v4/certcrypto" | |||
| 17 | + | "github.com/go-acme/lego/v4/certificate" | |||
| 18 | + | "github.com/go-acme/lego/v4/lego" | |||
| 19 | + | "github.com/go-acme/lego/v4/providers/dns/cloudflare" | |||
| 20 | + | "github.com/go-acme/lego/v4/registration" | |||
| 21 | + | ||||
| 22 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 23 | + | "github.com/bnema/gordon/internal/domain" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // Config holds the configuration for the ACME certificate issuer. | |||
| 27 | + | type Config struct { | |||
| 28 | + | // Email is the ACME account contact email. | |||
| 29 | + | Email string | |||
| 30 | + | ||||
| 31 | + | // Challenge is the ACME challenge mode to use. | |||
| 32 | + | Challenge domain.ACMEChallengeMode | |||
| 33 | + | ||||
| 34 | + | // Token is the Cloudflare API token (required for DNS-01 challenge). | |||
| 35 | + | Token string | |||
| 36 | + | ||||
| 37 | + | // Store persists ACME accounts and certificates. | |||
| 38 | + | Store out.CertificateStore | |||
| 39 | + | ||||
| 40 | + | // HTTPChallengeSink is used for HTTP-01 challenge token storage. | |||
| 41 | + | HTTPChallengeSink HTTPChallengeSink | |||
| 42 | + | ||||
| 43 | + | // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used. | |||
| 44 | + | CADirectoryURL string | |||
| 45 | + | ||||
| 46 | + | // HTTPClient is an optional HTTP client for the ACME client. If nil, a default is used. | |||
| 47 | + | HTTPClient *http.Client | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Issuer implements out.PublicCertificateIssuer using the lego ACME client. | |||
| 51 | + | type Issuer struct { | |||
| 52 | + | cfg Config | |||
| 53 | + | mu sync.Mutex | |||
| 54 | + | client *lego.Client | |||
| 55 | + | user *AccountUser | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // NewIssuer creates a new Issuer. Config is validated; the ACME account is | |||
| 59 | + | // loaded or created lazily on first Obtain or Renew call. | |||
| 60 | + | func NewIssuer(cfg Config) (*Issuer, error) { | |||
| 61 | + | if cfg.Email == "" { | |||
| 62 | + | return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired) | |||
| 63 | + | } | |||
| 64 | + | if cfg.Store == nil { | |||
| 65 | + | return nil, errors.New("acmelego: certificate store is required") | |||
| 66 | + | } | |||
| 67 | + | if cfg.Challenge == "" { | |||
| 68 | + | cfg.Challenge = domain.ACMEChallengeHTTP01 | |||
| 69 | + | } | |||
| 70 | + | if cfg.Challenge == domain.ACMEChallengeCloudflareDNS01 && cfg.Token == "" { | |||
| 71 | + | return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | return &Issuer{cfg: cfg}, nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // compile-time interface check | |||
| 78 | + | var _ out.PublicCertificateIssuer = (*Issuer)(nil) | |||
| 79 | + | ||||
| 80 | + | // Obtain obtains a new certificate for the given order. The ACME client is | |||
| 81 | + | // initialized lazily on the first call. | |||
| 82 | + | func (i *Issuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 83 | + | if err := i.ensureClient(ctx); err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | resource, err := i.client.Certificate.Obtain(certificate.ObtainRequest{ | |||
| 88 | + | Domains: order.Names, | |||
| 89 | + | Bundle: true, | |||
| 90 | + | }) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return nil, fmt.Errorf("obtain certificate: %w", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | return resourceToStored(order, resource), nil | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Renew renews an existing certificate. The ACME client is initialized lazily | |||
| 99 | + | // on the first call. Bundle and MustStaple are set to true/false respectively. | |||
| 100 | + | func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |||
| 101 | + | if err := i.ensureClient(ctx); err != nil { | |||
| 102 | + | return nil, err | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | domain := "" | |||
| 106 | + | if len(cert.Names) > 0 { | |||
| 107 | + | domain = cert.Names[0] | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | legoCert := certificate.Resource{ | |||
| 111 | + | Domain: domain, | |||
| 112 | + | PrivateKey: cert.PrivateKeyPEM, | |||
| 113 | + | Certificate: cert.CertPEM, | |||
| 114 | + | IssuerCertificate: cert.ChainPEM, | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // If FullchainPEM is available but CertPEM is empty, use FullchainPEM. | |||
| 118 | + | if len(legoCert.Certificate) == 0 && len(cert.FullchainPEM) > 0 { | |||
| 119 | + | legoCert.Certificate = cert.FullchainPEM | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | renewed, err := i.client.Certificate.RenewWithOptions(legoCert, &certificate.RenewOptions{ | |||
| 123 | + | Bundle: true, | |||
| 124 | + | }) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return nil, fmt.Errorf("renew certificate: %w", err) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // Preserve the original order info | |||
| 130 | + | order := out.CertificateOrder{ | |||
| 131 | + | ID: cert.ID, | |||
| 132 | + | Names: cert.Names, | |||
| 133 | + | Challenge: cert.Challenge, | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | return resourceToStored(order, renewed), nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // ensureClient initializes the lego client and ACME account if not yet done. | |||
| 140 | + | func (i *Issuer) ensureClient(ctx context.Context) error { | |||
| 141 | + | i.mu.Lock() | |||
| 142 | + | defer i.mu.Unlock() | |||
| 143 | + | ||||
| 144 | + | if i.client != nil { | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Load or create ACME account | |||
| 149 | + | user, err := i.loadOrCreateAccount(ctx) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return fmt.Errorf("acme account: %w", err) | |||
| 152 | + | } | |||
| 153 | + | i.user = user | |||
| 154 | + | ||||
| 155 | + | // Build lego config | |||
| 156 | + | legoCfg := lego.NewConfig(user) | |||
| 157 | + | legoCfg.Certificate.KeyType = certcrypto.EC256 // ECDSA P-256 | |||
| 158 | + | ||||
| 159 | + | if i.cfg.CADirectoryURL != "" { | |||
| 160 | + | legoCfg.CADirURL = i.cfg.CADirectoryURL | |||
| 161 | + | } | |||
| 162 | + | if i.cfg.HTTPClient != nil { | |||
| 163 | + | legoCfg.HTTPClient = i.cfg.HTTPClient | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | client, err := lego.NewClient(legoCfg) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return fmt.Errorf("create lego client: %w", err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // Set up challenge providers | |||
| 172 | + | switch i.cfg.Challenge { | |||
| 173 | + | case domain.ACMEChallengeHTTP01: | |||
| 174 | + | if i.cfg.HTTPChallengeSink != nil { | |||
| 175 | + | if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil { | |||
| 176 | + | return fmt.Errorf("set http-01 provider: %w", err) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | case domain.ACMEChallengeCloudflareDNS01: | |||
| 180 | + | cfCfg := cloudflare.NewDefaultConfig() | |||
| 181 | + | cfCfg.AuthToken = i.cfg.Token | |||
| 182 | + | cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg) | |||
| 183 | + | if err != nil { | |||
| 184 | + | return fmt.Errorf("create cloudflare dns provider: %w", err) | |||
| 185 | + | } | |||
| 186 | + | if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil { | |||
| 187 | + | return fmt.Errorf("set dns-01 provider: %w", err) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | i.client = client | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // loadOrCreateAccount loads an existing ACME account from the store or creates | |||
| 196 | + | // a new one. If creating, it registers the account with the ACME server. | |||
| 197 | + | func (i *Issuer) loadOrCreateAccount(ctx context.Context) (*AccountUser, error) { | |||
| 198 | + | stored, err := i.cfg.Store.LoadAccount(ctx) | |||
| 199 | + | if err != nil { | |||
| 200 | + | return nil, fmt.Errorf("load account: %w", err) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | if stored != nil && len(stored.PrivateKeyPEM) > 0 { | |||
| 204 | + | return restoreAccount(stored) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Generate new ECDSA P-256 private key for the ACME account | |||
| 208 | + | privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return nil, fmt.Errorf("generate account key: %w", err) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | user := NewAccountUser(i.cfg.Email, privateKey, nil) | |||
| 214 | + | ||||
| 215 | + | // We need a temporary client to register the account | |||
| 216 | + | legoCfg := lego.NewConfig(user) | |||
| 217 | + | legoCfg.Certificate.KeyType = certcrypto.EC256 | |||
| 218 | + | if i.cfg.CADirectoryURL != "" { | |||
| 219 | + | legoCfg.CADirURL = i.cfg.CADirectoryURL | |||
| 220 | + | } | |||
| 221 | + | if i.cfg.HTTPClient != nil { | |||
| 222 | + | legoCfg.HTTPClient = i.cfg.HTTPClient | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | tmpClient, err := lego.NewClient(legoCfg) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return nil, fmt.Errorf("create temporary lego client: %w", err) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | reg, err := tmpClient.Registration.Register(registration.RegisterOptions{ | |||
| 231 | + | TermsOfServiceAgreed: true, | |||
| 232 | + | }) | |||
| 233 | + | if err != nil { | |||
| 234 | + | return nil, fmt.Errorf("register acme account: %w", err) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | user = NewAccountUser(i.cfg.Email, privateKey, reg) | |||
| 238 | + | ||||
| 239 | + | // Persist the account | |||
| 240 | + | bodyJSON, err := json.Marshal(reg.Body) | |||
| 241 | + | if err != nil { | |||
| 242 | + | return nil, fmt.Errorf("marshal account body: %w", err) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | privateKeyPEM := certcrypto.PEMEncode(privateKey) | |||
| 246 | + | ||||
| 247 | + | if err := i.cfg.Store.SaveAccount(ctx, out.ACMEAccount{ | |||
| 248 | + | Email: i.cfg.Email, | |||
| 249 | + | PrivateKeyPEM: privateKeyPEM, | |||
| 250 | + | RegistrationURI: reg.URI, | |||
| 251 | + | BodyJSON: bodyJSON, | |||
| 252 | + | }); err != nil { | |||
| 253 | + | return nil, fmt.Errorf("save account: %w", err) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | return user, nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // restoreAccount reconstructs an AccountUser from a stored ACMEAccount. | |||
| 260 | + | func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) { | |||
| 261 | + | privateKey, err := certcrypto.ParsePEMPrivateKey(stored.PrivateKeyPEM) | |||
| 262 | + | if err != nil { | |||
| 263 | + | return nil, fmt.Errorf("parse account private key: %w", err) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | reg := ®istration.Resource{ | |||
| 267 | + | URI: stored.RegistrationURI, | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | if len(stored.BodyJSON) > 0 { | |||
| 271 | + | var body acme.Account | |||
| 272 | + | if err := json.Unmarshal(stored.BodyJSON, &body); err != nil { | |||
| 273 | + | return nil, fmt.Errorf("unmarshal account body: %w", err) | |||
| 274 | + | } | |||
| 275 | + | reg.Body = body | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | return NewAccountUser(stored.Email, privateKey, reg), nil | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // acmeAccount mirrors acme.Account for JSON round-trip without importing acme | |||
| 282 | + | // in the test file. | |||
| 283 | + | type acmeAccount struct { | |||
| 284 | + | Status string `json:"status,omitempty"` | |||
| 285 | + | Contact []string `json:"contact,omitempty"` | |||
| 286 | + | TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"` | |||
| 287 | + | Orders string `json:"orders,omitempty"` | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // resourceToStored converts a lego certificate.Resource to out.StoredCertificate. | |||
| 291 | + | func resourceToStored(order out.CertificateOrder, res *certificate.Resource) *out.StoredCertificate { | |||
| 292 | + | var fullchainPEM []byte | |||
| 293 | + | if len(res.IssuerCertificate) > 0 { | |||
| 294 | + | fullchainPEM = append(append([]byte{}, res.Certificate...), res.IssuerCertificate...) | |||
| 295 | + | } else { | |||
| 296 | + | fullchainPEM = res.Certificate | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // Parse the certificate to extract NotAfter and populate tls.Certificate. | |||
| 300 | + | var tlsCert tls.Certificate | |||
| 301 | + | notAfter := time.Time{} | |||
| 302 | + | if parsedTLSCert, err := tls.X509KeyPair(fullchainPEM, res.PrivateKey); err == nil && len(parsedTLSCert.Certificate) > 0 { | |||
| 303 | + | tlsCert = parsedTLSCert | |||
| 304 | + | if parsed, parseErr := x509.ParseCertificate(parsedTLSCert.Certificate[0]); parseErr == nil { | |||
| 305 | + | notAfter = parsed.NotAfter | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | names := order.Names | |||
| 310 | + | if names == nil { | |||
| 311 | + | names = []string{} | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | return &out.StoredCertificate{ | |||
| 315 | + | ID: order.ID, | |||
| 316 | + | Names: names, | |||
| 317 | + | Challenge: order.Challenge, | |||
| 318 | + | Certificate: tlsCert, | |||
| 319 | + | CertPEM: res.Certificate, | |||
| 320 | + | ChainPEM: res.IssuerCertificate, | |||
| 321 | + | FullchainPEM: fullchainPEM, | |||
| 322 | + | PrivateKeyPEM: res.PrivateKey, | |||
| 323 | + | NotAfter: notAfter, | |||
| 324 | + | } | |||
| 325 | + | } | |||
A internal/adapters/out/acmelego/issuer_test.go +290 -0
| @@ -0,0 +1,290 @@ | |||||
| 1 | + | package acmelego | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/ecdsa" | |||
| 6 | + | "crypto/elliptic" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "crypto/rsa" | |||
| 9 | + | "crypto/tls" | |||
| 10 | + | "crypto/x509" | |||
| 11 | + | "crypto/x509/pkix" | |||
| 12 | + | "encoding/json" | |||
| 13 | + | "encoding/pem" | |||
| 14 | + | "math/big" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/go-acme/lego/v4/certcrypto" | |||
| 19 | + | "github.com/go-acme/lego/v4/certificate" | |||
| 20 | + | "github.com/stretchr/testify/assert" | |||
| 21 | + | "github.com/stretchr/testify/require" | |||
| 22 | + | ||||
| 23 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 24 | + | "github.com/bnema/gordon/internal/domain" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | func TestNewIssuerValidatesEmail(t *testing.T) { | |||
| 28 | + | _, err := NewIssuer(Config{}) | |||
| 29 | + | assert.Error(t, err) | |||
| 30 | + | assert.ErrorIs(t, err, domain.ErrACMEEmailRequired) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestNewIssuerValidatesStore(t *testing.T) { | |||
| 34 | + | _, err := NewIssuer(Config{Email: "test@example.com"}) | |||
| 35 | + | assert.Error(t, err) | |||
| 36 | + | assert.Contains(t, err.Error(), "certificate store is required") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestNewIssuerValidatesCloudflareToken(t *testing.T) { | |||
| 40 | + | _, err := NewIssuer(Config{ | |||
| 41 | + | Email: "test@example.com", | |||
| 42 | + | Challenge: domain.ACMEChallengeCloudflareDNS01, | |||
| 43 | + | Store: &mockStore{}, | |||
| 44 | + | }) | |||
| 45 | + | assert.Error(t, err) | |||
| 46 | + | assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestNewIssuerValidWithDefaults(t *testing.T) { | |||
| 50 | + | issuer, err := NewIssuer(Config{ | |||
| 51 | + | Email: "test@example.com", | |||
| 52 | + | Store: &mockStore{}, | |||
| 53 | + | }) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | require.NotNil(t, issuer) | |||
| 56 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // mockStore implements out.CertificateStore for testing. | |||
| 60 | + | type mockStore struct { | |||
| 61 | + | account *out.ACMEAccount | |||
| 62 | + | certs []out.StoredCertificate | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (m *mockStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) { | |||
| 66 | + | return m.account, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (m *mockStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error { | |||
| 70 | + | m.account = &account | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (m *mockStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) { | |||
| 75 | + | return m.certs, nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (m *mockStore) Save(ctx context.Context, cert out.StoredCertificate) error { | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (m *mockStore) Lock(ctx context.Context) (func() error, error) { | |||
| 83 | + | return func() error { return nil }, nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestPrivateKeyRoundTrip(t *testing.T) { | |||
| 87 | + | // Generate an ECDSA P-256 private key | |||
| 88 | + | privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | require.NotNil(t, privateKey) | |||
| 91 | + | ||||
| 92 | + | // PEM encode | |||
| 93 | + | pemBytes := certcrypto.PEMEncode(privateKey) | |||
| 94 | + | require.NotEmpty(t, pemBytes) | |||
| 95 | + | ||||
| 96 | + | // Parse back | |||
| 97 | + | parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.NotNil(t, parsedKey) | |||
| 100 | + | ||||
| 101 | + | // Verify it's the same curve (ECDSA P-256) | |||
| 102 | + | ecKey, ok := parsedKey.(*ecdsa.PrivateKey) | |||
| 103 | + | require.True(t, ok, "expected *ecdsa.PrivateKey") | |||
| 104 | + | assert.Equal(t, elliptic.P256(), ecKey.Curve) | |||
| 105 | + | ||||
| 106 | + | // Verify we can sign with the original and verify with the parsed | |||
| 107 | + | digest := []byte("test message") | |||
| 108 | + | sig, err := ecdsa.SignASN1(rand.Reader, privateKey.(*ecdsa.PrivateKey), digest[:]) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | assert.True(t, ecdsa.VerifyASN1(&ecKey.PublicKey, digest[:], sig)) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestPrivateKeyRSARoundTrip(t *testing.T) { | |||
| 114 | + | // Test RSA private key round-trip via certcrypto | |||
| 115 | + | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | ||||
| 118 | + | pemBytes := certcrypto.PEMEncode(privateKey) | |||
| 119 | + | require.NotEmpty(t, pemBytes) | |||
| 120 | + | ||||
| 121 | + | parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes) | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | require.NotNil(t, parsedKey) | |||
| 124 | + | ||||
| 125 | + | _, ok := parsedKey.(*rsa.PrivateKey) | |||
| 126 | + | require.True(t, ok) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestResourceConversionToStoredCertificate(t *testing.T) { | |||
| 130 | + | // Generate a self-signed certificate for testing | |||
| 131 | + | certPEM, keyPEM, err := generateSelfSignedCert("example.com") | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | ||||
| 134 | + | // Generate a fake issuer certificate | |||
| 135 | + | issuerPEM, _, err := generateSelfSignedCert("fake-ca.example.com") | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | ||||
| 138 | + | resource := &certificate.Resource{ | |||
| 139 | + | Domain: "example.com", | |||
| 140 | + | CertURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123", | |||
| 141 | + | CertStableURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123", | |||
| 142 | + | PrivateKey: keyPEM, | |||
| 143 | + | Certificate: certPEM, | |||
| 144 | + | IssuerCertificate: issuerPEM, | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | order := out.CertificateOrder{ | |||
| 148 | + | ID: "test-cert", | |||
| 149 | + | Names: []string{"example.com"}, | |||
| 150 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | stored := resourceToStored(order, resource) | |||
| 154 | + | require.NotNil(t, stored) | |||
| 155 | + | assert.Equal(t, "test-cert", stored.ID) | |||
| 156 | + | assert.Equal(t, []string{"example.com"}, stored.Names) | |||
| 157 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, stored.Challenge) | |||
| 158 | + | assert.Equal(t, certPEM, stored.CertPEM) | |||
| 159 | + | assert.Equal(t, issuerPEM, stored.ChainPEM) | |||
| 160 | + | ||||
| 161 | + | // FullchainPEM should be cert + issuer | |||
| 162 | + | expectedFullchain := append(append([]byte{}, certPEM...), issuerPEM...) | |||
| 163 | + | assert.Equal(t, expectedFullchain, stored.FullchainPEM) | |||
| 164 | + | assert.Equal(t, keyPEM, stored.PrivateKeyPEM) | |||
| 165 | + | ||||
| 166 | + | // NotAfter should be set | |||
| 167 | + | assert.False(t, stored.NotAfter.IsZero(), "NotAfter should be set") | |||
| 168 | + | ||||
| 169 | + | // Verify tls.Certificate is populated from the PEM data | |||
| 170 | + | assert.NotEmpty(t, stored.Certificate.Certificate, "tls.Certificate should be populated from PEM") | |||
| 171 | + | assert.NotNil(t, stored.Certificate.PrivateKey, "private key should be populated") | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestResourceConversionNoIssuer(t *testing.T) { | |||
| 175 | + | certPEM, keyPEM, err := generateSelfSignedCert("example.com") | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | ||||
| 178 | + | resource := &certificate.Resource{ | |||
| 179 | + | Domain: "example.com", | |||
| 180 | + | PrivateKey: keyPEM, | |||
| 181 | + | Certificate: certPEM, | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | order := out.CertificateOrder{ | |||
| 185 | + | ID: "test-cert-no-issuer", | |||
| 186 | + | Names: []string{"example.com"}, | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | stored := resourceToStored(order, resource) | |||
| 190 | + | require.NotNil(t, stored) | |||
| 191 | + | assert.Equal(t, certPEM, stored.FullchainPEM, "fullchain should equal cert when no issuer") | |||
| 192 | + | assert.Empty(t, stored.ChainPEM) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestAccountJSONRoundTrip(t *testing.T) { | |||
| 196 | + | // Simulate an ACME account with BodyJSON containing the serialized acme.Account | |||
| 197 | + | body := acmeAccount{ | |||
| 198 | + | Status: "valid", | |||
| 199 | + | Contact: []string{"mailto:test@example.com"}, | |||
| 200 | + | TermsOfServiceAgreed: true, | |||
| 201 | + | Orders: "https://acme-v02.api.letsencrypt.org/acme/orders/abc123", | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | bodyJSON, err := json.Marshal(body) | |||
| 205 | + | require.NoError(t, err) | |||
| 206 | + | ||||
| 207 | + | var restored acmeAccount | |||
| 208 | + | err = json.Unmarshal(bodyJSON, &restored) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | assert.Equal(t, body.Status, restored.Status) | |||
| 211 | + | assert.Equal(t, body.Contact, restored.Contact) | |||
| 212 | + | assert.Equal(t, body.TermsOfServiceAgreed, restored.TermsOfServiceAgreed) | |||
| 213 | + | assert.Equal(t, body.Orders, restored.Orders) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // generateSelfSignedCert creates a self-signed certificate and its private key | |||
| 217 | + | // for testing purposes. | |||
| 218 | + | func generateSelfSignedCert(domain string) (certPEM, keyPEM []byte, err error) { | |||
| 219 | + | privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 220 | + | if err != nil { | |||
| 221 | + | return nil, nil, err | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return nil, nil, err | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | template := &x509.Certificate{ | |||
| 230 | + | SerialNumber: serialNumber, | |||
| 231 | + | Subject: pkix.Name{ | |||
| 232 | + | CommonName: domain, | |||
| 233 | + | Organization: []string{"Test"}, | |||
| 234 | + | }, | |||
| 235 | + | DNSNames: []string{domain}, | |||
| 236 | + | NotBefore: time.Now().Add(-1 * time.Hour), | |||
| 237 | + | NotAfter: time.Now().Add(24 * time.Hour), | |||
| 238 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |||
| 239 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 240 | + | BasicConstraintsValid: true, | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) | |||
| 244 | + | if err != nil { | |||
| 245 | + | return nil, nil, err | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 249 | + | if certPEM == nil { | |||
| 250 | + | return nil, nil, err | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | keyDER, err := x509.MarshalECPrivateKey(privateKey) | |||
| 254 | + | if err != nil { | |||
| 255 | + | return nil, nil, err | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 259 | + | if keyPEM == nil { | |||
| 260 | + | return nil, nil, err | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | return certPEM, keyPEM, nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // Ensure TestResourceConversion also validates the tls.X509KeyPair parse works. | |||
| 267 | + | func TestParsedCertificateNotAfter(t *testing.T) { | |||
| 268 | + | certPEM, keyPEM, err := generateSelfSignedCert("test.example.com") | |||
| 269 | + | require.NoError(t, err) | |||
| 270 | + | ||||
| 271 | + | resource := &certificate.Resource{ | |||
| 272 | + | Domain: "test.example.com", | |||
| 273 | + | PrivateKey: keyPEM, | |||
| 274 | + | Certificate: certPEM, | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | order := out.CertificateOrder{ | |||
| 278 | + | ID: "parse-test", | |||
| 279 | + | Names: []string{"test.example.com"}, | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | stored := resourceToStored(order, resource) | |||
| 283 | + | require.NotNil(t, stored) | |||
| 284 | + | assert.False(t, stored.NotAfter.IsZero()) | |||
| 285 | + | assert.True(t, stored.NotAfter.After(time.Now())) | |||
| 286 | + | ||||
| 287 | + | // Confirm tls.X509KeyPair succeeds on the stored PEMs | |||
| 288 | + | _, parseErr := tls.X509KeyPair(stored.FullchainPEM, stored.PrivateKeyPEM) | |||
| 289 | + | assert.NoError(t, parseErr, "tls.X509KeyPair should succeed") | |||
| 290 | + | } | |||
A internal/adapters/out/acmelego/user.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package acmelego | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto" | |||
| 5 | + | ||||
| 6 | + | "github.com/go-acme/lego/v4/registration" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // AccountUser implements the lego registration.User interface for ACME | |||
| 10 | + | // account registration and certificate operations. | |||
| 11 | + | type AccountUser struct { | |||
| 12 | + | email string | |||
| 13 | + | privateKey crypto.PrivateKey | |||
| 14 | + | registration *registration.Resource | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewAccountUser creates a new AccountUser. | |||
| 18 | + | func NewAccountUser(email string, key crypto.PrivateKey, reg *registration.Resource) *AccountUser { | |||
| 19 | + | return &AccountUser{ | |||
| 20 | + | email: email, | |||
| 21 | + | privateKey: key, | |||
| 22 | + | registration: reg, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // GetEmail returns the account email. | |||
| 27 | + | func (u *AccountUser) GetEmail() string { | |||
| 28 | + | return u.email | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // GetRegistration returns the ACME registration resource. | |||
| 32 | + | func (u *AccountUser) GetRegistration() *registration.Resource { | |||
| 33 | + | return u.registration | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // GetPrivateKey returns the account private key. | |||
| 37 | + | func (u *AccountUser) GetPrivateKey() crypto.PrivateKey { | |||
| 38 | + | return u.privateKey | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // compile-time interface check | |||
| 42 | + | var _ registration.User = (*AccountUser)(nil) | |||
A internal/adapters/out/acmestore/store.go +341 -0
| @@ -0,0 +1,341 @@ | |||||
| 1 | + | package acmestore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // certMetadata is the JSON structure persisted in each certificate directory. | |||
| 19 | + | type certMetadata struct { | |||
| 20 | + | ID string `json:"id"` | |||
| 21 | + | Names []string `json:"names"` | |||
| 22 | + | Challenge string `json:"challenge"` | |||
| 23 | + | NotAfter time.Time `json:"not_after,omitempty"` | |||
| 24 | + | LastError string `json:"last_error,omitempty"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | lockFile = ".lock" | |||
| 29 | + | accountFile = "account.json" | |||
| 30 | + | certDir = "certs" | |||
| 31 | + | certFile = "cert.pem" | |||
| 32 | + | chainFile = "chain.pem" | |||
| 33 | + | fullchainFile = "fullchain.pem" | |||
| 34 | + | privkeyFile = "privkey.pem" | |||
| 35 | + | metaFile = "metadata.json" | |||
| 36 | + | ||||
| 37 | + | dirMode os.FileMode = 0700 | |||
| 38 | + | privKeyMode os.FileMode = 0600 | |||
| 39 | + | metaMode os.FileMode = 0600 | |||
| 40 | + | accountMode os.FileMode = 0600 | |||
| 41 | + | lockMode os.FileMode = 0600 | |||
| 42 | + | pemMode os.FileMode = 0644 | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | // Store implements out.CertificateStore using the local filesystem. | |||
| 46 | + | type Store struct { | |||
| 47 | + | root string | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // New creates a Store rooted at root. root must be non-empty and is created | |||
| 51 | + | // along with the certs subdirectory if they do not exist. | |||
| 52 | + | func New(root string) (*Store, error) { | |||
| 53 | + | if root == "" { | |||
| 54 | + | return nil, errors.New("acmestore: root is required") | |||
| 55 | + | } | |||
| 56 | + | if err := os.MkdirAll(root, dirMode); err != nil { | |||
| 57 | + | return nil, fmt.Errorf("acmestore: mkdir root: %w", err) | |||
| 58 | + | } | |||
| 59 | + | if err := os.MkdirAll(filepath.Join(root, certDir), dirMode); err != nil { | |||
| 60 | + | return nil, fmt.Errorf("acmestore: mkdir certs: %w", err) | |||
| 61 | + | } | |||
| 62 | + | return &Store{root: root}, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // LoadAccount reads the account from <root>/account.json. | |||
| 66 | + | // It returns nil, nil when the file does not exist. | |||
| 67 | + | func (s *Store) LoadAccount(_ context.Context) (*out.ACMEAccount, error) { | |||
| 68 | + | path := filepath.Join(s.root, accountFile) | |||
| 69 | + | data, err := os.ReadFile(path) | |||
| 70 | + | if err != nil { | |||
| 71 | + | if os.IsNotExist(err) { | |||
| 72 | + | return nil, nil | |||
| 73 | + | } | |||
| 74 | + | return nil, fmt.Errorf("acmestore: read account: %w", err) | |||
| 75 | + | } | |||
| 76 | + | var acct out.ACMEAccount | |||
| 77 | + | if err := json.Unmarshal(data, &acct); err != nil { | |||
| 78 | + | return nil, fmt.Errorf("acmestore: unmarshal account: %w", err) | |||
| 79 | + | } | |||
| 80 | + | return &acct, nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // SaveAccount writes the account to <root>/account.json atomically with mode 0600. | |||
| 84 | + | func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error { | |||
| 85 | + | data, err := json.MarshalIndent(account, "", " ") | |||
| 86 | + | if err != nil { | |||
| 87 | + | return fmt.Errorf("acmestore: marshal account: %w", err) | |||
| 88 | + | } | |||
| 89 | + | if err := writeAtomic(filepath.Join(s.root, accountFile), data, accountMode); err != nil { | |||
| 90 | + | return fmt.Errorf("acmestore: save account: %w", err) | |||
| 91 | + | } | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // LoadAll reads all certificate directories under <root>/certs/ and returns | |||
| 96 | + | // the stored certificates. Directories with unsafe IDs are skipped. | |||
| 97 | + | func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) { | |||
| 98 | + | certsPath := filepath.Join(s.root, certDir) | |||
| 99 | + | entries, err := os.ReadDir(certsPath) | |||
| 100 | + | if err != nil { | |||
| 101 | + | if os.IsNotExist(err) { | |||
| 102 | + | return nil, nil | |||
| 103 | + | } | |||
| 104 | + | return nil, fmt.Errorf("acmestore: list certs: %w", err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | var result []out.StoredCertificate | |||
| 108 | + | for _, entry := range entries { | |||
| 109 | + | if !entry.IsDir() { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | id := entry.Name() | |||
| 113 | + | if !safeID(id) { | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | cert, err := s.loadOne(id) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return nil, fmt.Errorf("acmestore: load %s: %w", id, err) | |||
| 120 | + | } | |||
| 121 | + | result = append(result, *cert) | |||
| 122 | + | } | |||
| 123 | + | return result, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // loadOne reads a single certificate directory and returns the stored | |||
| 127 | + | // certificate. | |||
| 128 | + | func (s *Store) loadOne(id string) (*out.StoredCertificate, error) { | |||
| 129 | + | dir := filepath.Join(s.root, certDir, id) | |||
| 130 | + | ||||
| 131 | + | meta, err := readMetadata(filepath.Join(dir, metaFile)) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return nil, fmt.Errorf("acmestore: metadata %s: %w", id, err) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | certPEM, err := readPEM(filepath.Join(dir, certFile)) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return nil, fmt.Errorf("acmestore: read %s cert.pem: %w", id, err) | |||
| 139 | + | } | |||
| 140 | + | chainPEM, err := readPEM(filepath.Join(dir, chainFile)) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return nil, fmt.Errorf("acmestore: read %s chain.pem: %w", id, err) | |||
| 143 | + | } | |||
| 144 | + | fullchainPEM, err := readPEM(filepath.Join(dir, fullchainFile)) | |||
| 145 | + | if err != nil { | |||
| 146 | + | return nil, fmt.Errorf("acmestore: read %s fullchain.pem: %w", id, err) | |||
| 147 | + | } | |||
| 148 | + | privKeyPEM, err := readPEM(filepath.Join(dir, privkeyFile)) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return nil, fmt.Errorf("acmestore: read %s privkey.pem: %w", id, err) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | var tlsCert tls.Certificate | |||
| 154 | + | if len(fullchainPEM) > 0 && len(privKeyPEM) > 0 { | |||
| 155 | + | parsed, err := tls.X509KeyPair(fullchainPEM, privKeyPEM) | |||
| 156 | + | if err != nil { | |||
| 157 | + | return nil, fmt.Errorf("acmestore: parse key pair %s: %w", id, err) | |||
| 158 | + | } | |||
| 159 | + | tlsCert = parsed | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | challenge, err := domain.ParseACMEChallengeMode(meta.Challenge) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return nil, fmt.Errorf("acmestore: challenge %s: %w", id, err) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | return &out.StoredCertificate{ | |||
| 168 | + | ID: id, | |||
| 169 | + | Names: meta.Names, | |||
| 170 | + | Challenge: challenge, | |||
| 171 | + | Certificate: tlsCert, | |||
| 172 | + | CertPEM: certPEM, | |||
| 173 | + | ChainPEM: chainPEM, | |||
| 174 | + | FullchainPEM: fullchainPEM, | |||
| 175 | + | PrivateKeyPEM: privKeyPEM, | |||
| 176 | + | NotAfter: meta.NotAfter, | |||
| 177 | + | LastError: meta.LastError, | |||
| 178 | + | }, nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Save persists a certificate to <root>/certs/<safe-id>/. | |||
| 182 | + | // All PEM files and metadata are written atomically. | |||
| 183 | + | func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error { | |||
| 184 | + | if !safeID(cert.ID) { | |||
| 185 | + | return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | dir := filepath.Join(s.root, certDir, cert.ID) | |||
| 189 | + | if err := os.MkdirAll(dir, dirMode); err != nil { | |||
| 190 | + | return fmt.Errorf("acmestore: mkdir %s: %w", cert.ID, err) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | meta := certMetadata{ | |||
| 194 | + | ID: cert.ID, | |||
| 195 | + | Names: cert.Names, | |||
| 196 | + | Challenge: string(cert.Challenge), | |||
| 197 | + | NotAfter: cert.NotAfter, | |||
| 198 | + | LastError: cert.LastError, | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | if err := writeFileIfNonNil(filepath.Join(dir, certFile), cert.CertPEM, pemMode); err != nil { | |||
| 202 | + | return fmt.Errorf("acmestore: write cert.pem: %w", err) | |||
| 203 | + | } | |||
| 204 | + | if err := writeFileIfNonNil(filepath.Join(dir, chainFile), cert.ChainPEM, pemMode); err != nil { | |||
| 205 | + | return fmt.Errorf("acmestore: write chain.pem: %w", err) | |||
| 206 | + | } | |||
| 207 | + | if err := writeFileIfNonNil(filepath.Join(dir, fullchainFile), cert.FullchainPEM, pemMode); err != nil { | |||
| 208 | + | return fmt.Errorf("acmestore: write fullchain.pem: %w", err) | |||
| 209 | + | } | |||
| 210 | + | if err := writeFileIfNonNil(filepath.Join(dir, privkeyFile), cert.PrivateKeyPEM, privKeyMode); err != nil { | |||
| 211 | + | return fmt.Errorf("acmestore: write privkey.pem: %w", err) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | metaData, err := json.MarshalIndent(meta, "", " ") | |||
| 215 | + | if err != nil { | |||
| 216 | + | return fmt.Errorf("acmestore: marshal metadata: %w", err) | |||
| 217 | + | } | |||
| 218 | + | if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil { | |||
| 219 | + | return fmt.Errorf("acmestore: write metadata: %w", err) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | return nil | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Lock acquires an exclusive lock using a lock file at <root>/.lock. | |||
| 226 | + | // The lock is released by calling the returned unlock function. | |||
| 227 | + | // If the lock file already exists, Lock returns an error. | |||
| 228 | + | // Context cancellation is respected before attempting the lock. | |||
| 229 | + | func (s *Store) Lock(ctx context.Context) (func() error, error) { | |||
| 230 | + | select { | |||
| 231 | + | case <-ctx.Done(): | |||
| 232 | + | return nil, ctx.Err() | |||
| 233 | + | default: | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | path := filepath.Join(s.root, lockFile) | |||
| 237 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, lockMode) | |||
| 238 | + | if err != nil { | |||
| 239 | + | if os.IsExist(err) { | |||
| 240 | + | return nil, fmt.Errorf("acmestore: lock already held") | |||
| 241 | + | } | |||
| 242 | + | return nil, fmt.Errorf("acmestore: acquire lock: %w", err) | |||
| 243 | + | } | |||
| 244 | + | if err := f.Close(); err != nil { | |||
| 245 | + | os.Remove(path) | |||
| 246 | + | return nil, fmt.Errorf("acmestore: close lock file: %w", err) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | return func() error { | |||
| 250 | + | if err := os.Remove(path); err != nil && !os.IsNotExist(err) { | |||
| 251 | + | return fmt.Errorf("acmestore: release lock: %w", err) | |||
| 252 | + | } | |||
| 253 | + | return nil | |||
| 254 | + | }, nil | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // safeID rejects IDs that could cause path traversal. | |||
| 258 | + | func safeID(id string) bool { | |||
| 259 | + | if id == "" { | |||
| 260 | + | return false | |||
| 261 | + | } | |||
| 262 | + | if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") { | |||
| 263 | + | return false | |||
| 264 | + | } | |||
| 265 | + | return true | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // writeAtomic writes data to path atomically by writing to a temporary file | |||
| 269 | + | // in the same directory and then renaming. | |||
| 270 | + | func writeAtomic(path string, data []byte, mode os.FileMode) error { | |||
| 271 | + | dir := filepath.Dir(path) | |||
| 272 | + | tmp, err := os.CreateTemp(dir, ".tmp-*") | |||
| 273 | + | if err != nil { | |||
| 274 | + | return fmt.Errorf("create temp: %w", err) | |||
| 275 | + | } | |||
| 276 | + | tmpName := tmp.Name() | |||
| 277 | + | ||||
| 278 | + | // Clean up temp file on any failure | |||
| 279 | + | removeOnErr := true | |||
| 280 | + | defer func() { | |||
| 281 | + | if removeOnErr { | |||
| 282 | + | os.Remove(tmpName) | |||
| 283 | + | } | |||
| 284 | + | }() | |||
| 285 | + | ||||
| 286 | + | if err := tmp.Chmod(mode); err != nil { | |||
| 287 | + | tmp.Close() | |||
| 288 | + | return fmt.Errorf("chmod temp: %w", err) | |||
| 289 | + | } | |||
| 290 | + | if _, err := tmp.Write(data); err != nil { | |||
| 291 | + | tmp.Close() | |||
| 292 | + | return fmt.Errorf("write temp: %w", err) | |||
| 293 | + | } | |||
| 294 | + | // Sync before close to ensure data is on disk. | |||
| 295 | + | if err := tmp.Sync(); err != nil { | |||
| 296 | + | tmp.Close() | |||
| 297 | + | return fmt.Errorf("sync temp: %w", err) | |||
| 298 | + | } | |||
| 299 | + | if err := tmp.Close(); err != nil { | |||
| 300 | + | return fmt.Errorf("close temp: %w", err) | |||
| 301 | + | } | |||
| 302 | + | if err := os.Rename(tmpName, path); err != nil { | |||
| 303 | + | return fmt.Errorf("rename temp: %w", err) | |||
| 304 | + | } | |||
| 305 | + | removeOnErr = false | |||
| 306 | + | return nil | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // writeFileIfNonNil writes data to path atomically only when data is non-nil. | |||
| 310 | + | func writeFileIfNonNil(path string, data []byte, mode os.FileMode) error { | |||
| 311 | + | if data == nil { | |||
| 312 | + | return nil | |||
| 313 | + | } | |||
| 314 | + | return writeAtomic(path, data, mode) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // readPEM reads a PEM file from path. If the file does not exist it returns | |||
| 318 | + | // nil, nil without error. Other read errors are returned. | |||
| 319 | + | func readPEM(path string) ([]byte, error) { | |||
| 320 | + | data, err := os.ReadFile(path) | |||
| 321 | + | if err != nil { | |||
| 322 | + | if os.IsNotExist(err) { | |||
| 323 | + | return nil, nil | |||
| 324 | + | } | |||
| 325 | + | return nil, err | |||
| 326 | + | } | |||
| 327 | + | return data, nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // readMetadata reads and unmarshals a metadata JSON file. | |||
| 331 | + | func readMetadata(path string) (*certMetadata, error) { | |||
| 332 | + | data, err := os.ReadFile(path) | |||
| 333 | + | if err != nil { | |||
| 334 | + | return nil, fmt.Errorf("read: %w", err) | |||
| 335 | + | } | |||
| 336 | + | var meta certMetadata | |||
| 337 | + | if err := json.Unmarshal(data, &meta); err != nil { | |||
| 338 | + | return nil, fmt.Errorf("unmarshal: %w", err) | |||
| 339 | + | } | |||
| 340 | + | return &meta, nil | |||
| 341 | + | } | |||
A internal/adapters/out/acmestore/store_test.go +305 -0
| @@ -0,0 +1,305 @@ | |||||
| 1 | + | package acmestore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/ecdsa" | |||
| 6 | + | "crypto/elliptic" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "crypto/x509" | |||
| 9 | + | "crypto/x509/pkix" | |||
| 10 | + | "encoding/json" | |||
| 11 | + | "encoding/pem" | |||
| 12 | + | "math/big" | |||
| 13 | + | "os" | |||
| 14 | + | "path/filepath" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/stretchr/testify/assert" | |||
| 19 | + | "github.com/stretchr/testify/require" | |||
| 20 | + | ||||
| 21 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 22 | + | "github.com/bnema/gordon/internal/domain" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // generateTestCertPEM creates a self-signed ECDSA P256 certificate and its | |||
| 26 | + | // private key, both PEM-encoded, for use in tests that require a valid | |||
| 27 | + | // tls.Certificate. | |||
| 28 | + | func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) { | |||
| 29 | + | t.Helper() | |||
| 30 | + | ||||
| 31 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | ||||
| 34 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | ||||
| 37 | + | keyPEM = pem.EncodeToMemory(&pem.Block{ | |||
| 38 | + | Type: "EC PRIVATE KEY", | |||
| 39 | + | Bytes: keyDER, | |||
| 40 | + | }) | |||
| 41 | + | ||||
| 42 | + | template := &x509.Certificate{ | |||
| 43 | + | SerialNumber: big.NewInt(1), | |||
| 44 | + | Subject: pkix.Name{ | |||
| 45 | + | CommonName: "test.example.com", | |||
| 46 | + | }, | |||
| 47 | + | NotBefore: time.Now().Add(-1 * time.Hour), | |||
| 48 | + | NotAfter: time.Now().Add(24 * time.Hour), | |||
| 49 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | ||||
| 55 | + | certPEM = pem.EncodeToMemory(&pem.Block{ | |||
| 56 | + | Type: "CERTIFICATE", | |||
| 57 | + | Bytes: certDER, | |||
| 58 | + | }) | |||
| 59 | + | ||||
| 60 | + | return certPEM, keyPEM | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestStoreSaveLoadAll(t *testing.T) { | |||
| 64 | + | root := t.TempDir() | |||
| 65 | + | ctx := context.Background() | |||
| 66 | + | ||||
| 67 | + | store, err := New(root) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | ||||
| 70 | + | now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) | |||
| 71 | + | ||||
| 72 | + | fullchainPEM, privKeyPEM := generateTestCertPEM(t) | |||
| 73 | + | ||||
| 74 | + | cert := out.StoredCertificate{ | |||
| 75 | + | ID: "dns01-example.com", | |||
| 76 | + | Names: []string{"example.com", "*.example.com"}, | |||
| 77 | + | Challenge: domain.ACMEChallengeCloudflareDNS01, | |||
| 78 | + | CertPEM: []byte("dummy-cert"), | |||
| 79 | + | ChainPEM: []byte("dummy-chain"), | |||
| 80 | + | FullchainPEM: fullchainPEM, | |||
| 81 | + | PrivateKeyPEM: privKeyPEM, | |||
| 82 | + | NotAfter: now, | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | err = store.Save(ctx, cert) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | ||||
| 88 | + | certs, err := store.LoadAll(ctx) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | require.Len(t, certs, 1) | |||
| 91 | + | ||||
| 92 | + | assert.Equal(t, "dns01-example.com", certs[0].ID) | |||
| 93 | + | assert.Equal(t, []string{"example.com", "*.example.com"}, certs[0].Names) | |||
| 94 | + | assert.Equal(t, privKeyPEM, certs[0].PrivateKeyPEM) | |||
| 95 | + | // Verify tls.Certificate is populated from valid PEM | |||
| 96 | + | assert.NotEmpty(t, certs[0].Certificate.Certificate, "tls.Certificate.Certificate should be populated") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestStorePrivateKeyMode(t *testing.T) { | |||
| 100 | + | root := t.TempDir() | |||
| 101 | + | ctx := context.Background() | |||
| 102 | + | ||||
| 103 | + | store, err := New(root) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | ||||
| 106 | + | cert := out.StoredCertificate{ | |||
| 107 | + | ID: "http01-app.example.com", | |||
| 108 | + | Names: []string{"app.example.com"}, | |||
| 109 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 110 | + | PrivateKeyPEM: []byte("key"), | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | err = store.Save(ctx, cert) | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | ||||
| 116 | + | privkeyPath := filepath.Join(root, certDir, "http01-app.example.com", privkeyFile) | |||
| 117 | + | info, err := os.Stat(privkeyPath) | |||
| 118 | + | require.NoError(t, err) | |||
| 119 | + | ||||
| 120 | + | // Mode 0600 (owner read+write only) | |||
| 121 | + | assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestStoreRejectsUnsafeID(t *testing.T) { | |||
| 125 | + | root := t.TempDir() | |||
| 126 | + | ctx := context.Background() | |||
| 127 | + | ||||
| 128 | + | store, err := New(root) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | ||||
| 131 | + | cert := out.StoredCertificate{ | |||
| 132 | + | ID: "../escape", | |||
| 133 | + | PrivateKeyPEM: []byte("key"), | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | err = store.Save(ctx, cert) | |||
| 137 | + | require.Error(t, err) | |||
| 138 | + | assert.Contains(t, err.Error(), "unsafe certificate id") | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestStoreSaveLoadAccount(t *testing.T) { | |||
| 142 | + | root := t.TempDir() | |||
| 143 | + | ctx := context.Background() | |||
| 144 | + | ||||
| 145 | + | store, err := New(root) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | ||||
| 148 | + | acct := out.ACMEAccount{ | |||
| 149 | + | Email: "admin@example.com", | |||
| 150 | + | PrivateKeyPEM: []byte("key"), | |||
| 151 | + | RegistrationURI: "https://acme.test/acct/1", | |||
| 152 | + | BodyJSON: []byte(`{"status":"valid"}`), | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | err = store.SaveAccount(ctx, acct) | |||
| 156 | + | require.NoError(t, err) | |||
| 157 | + | ||||
| 158 | + | loaded, err := store.LoadAccount(ctx) | |||
| 159 | + | require.NoError(t, err) | |||
| 160 | + | require.NotNil(t, loaded) | |||
| 161 | + | ||||
| 162 | + | assert.Equal(t, "admin@example.com", loaded.Email) | |||
| 163 | + | assert.Equal(t, []byte("key"), loaded.PrivateKeyPEM) | |||
| 164 | + | assert.Equal(t, "https://acme.test/acct/1", loaded.RegistrationURI) | |||
| 165 | + | assert.Equal(t, []byte(`{"status":"valid"}`), loaded.BodyJSON) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestStoreAccountFileMode(t *testing.T) { | |||
| 169 | + | root := t.TempDir() | |||
| 170 | + | ctx := context.Background() | |||
| 171 | + | ||||
| 172 | + | store, err := New(root) | |||
| 173 | + | require.NoError(t, err) | |||
| 174 | + | ||||
| 175 | + | acct := out.ACMEAccount{ | |||
| 176 | + | Email: "test@example.com", | |||
| 177 | + | PrivateKeyPEM: []byte("test-key"), | |||
| 178 | + | RegistrationURI: "https://acme.test/acct/99", | |||
| 179 | + | BodyJSON: []byte(`{"status":"valid"}`), | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | err = store.SaveAccount(ctx, acct) | |||
| 183 | + | require.NoError(t, err) | |||
| 184 | + | ||||
| 185 | + | accountPath := filepath.Join(root, accountFile) | |||
| 186 | + | info, err := os.Stat(accountPath) | |||
| 187 | + | require.NoError(t, err) | |||
| 188 | + | ||||
| 189 | + | assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestStoreLoadAllInvalidChallenge(t *testing.T) { | |||
| 193 | + | root := t.TempDir() | |||
| 194 | + | ctx := context.Background() | |||
| 195 | + | ||||
| 196 | + | store, err := New(root) | |||
| 197 | + | require.NoError(t, err) | |||
| 198 | + | ||||
| 199 | + | // Create a cert dir manually with invalid challenge in metadata | |||
| 200 | + | certDirPath := filepath.Join(root, certDir, "bad-challenge.example.com") | |||
| 201 | + | err = os.MkdirAll(certDirPath, dirMode) | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | ||||
| 204 | + | meta := certMetadata{ | |||
| 205 | + | ID: "bad-challenge.example.com", | |||
| 206 | + | Names: []string{"bad-challenge.example.com"}, | |||
| 207 | + | Challenge: "invalid-challenge-mode", | |||
| 208 | + | } | |||
| 209 | + | metaData, err := json.MarshalIndent(meta, "", " ") | |||
| 210 | + | require.NoError(t, err) | |||
| 211 | + | err = os.WriteFile(filepath.Join(certDirPath, metaFile), metaData, metaMode) | |||
| 212 | + | require.NoError(t, err) | |||
| 213 | + | ||||
| 214 | + | // LoadAll should return an error | |||
| 215 | + | _, err = store.LoadAll(ctx) | |||
| 216 | + | require.Error(t, err) | |||
| 217 | + | assert.Contains(t, err.Error(), "acme challenge invalid") | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestStoreLoadAllCorruptPrivkey(t *testing.T) { | |||
| 221 | + | root := t.TempDir() | |||
| 222 | + | ctx := context.Background() | |||
| 223 | + | ||||
| 224 | + | store, err := New(root) | |||
| 225 | + | require.NoError(t, err) | |||
| 226 | + | ||||
| 227 | + | // Save a valid cert first | |||
| 228 | + | cert := out.StoredCertificate{ | |||
| 229 | + | ID: "corrupt-privkey.example.com", | |||
| 230 | + | Names: []string{"corrupt-privkey.example.com"}, | |||
| 231 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 232 | + | FullchainPEM: []byte("fullchain-data"), | |||
| 233 | + | PrivateKeyPEM: []byte("privkey-data"), | |||
| 234 | + | } | |||
| 235 | + | err = store.Save(ctx, cert) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | ||||
| 238 | + | // Replace privkey.pem with an unreadable directory (simulate corruption) | |||
| 239 | + | privkeyPath := filepath.Join(root, certDir, "corrupt-privkey.example.com", privkeyFile) | |||
| 240 | + | err = os.Remove(privkeyPath) | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | // Create a directory in its place to cause a read error | |||
| 243 | + | err = os.MkdirAll(privkeyPath, 0700) | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | ||||
| 246 | + | // LoadAll should return an error | |||
| 247 | + | _, err = store.LoadAll(ctx) | |||
| 248 | + | require.Error(t, err) | |||
| 249 | + | assert.Contains(t, err.Error(), "privkey.pem") | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestStoreLoadAllCorruptFullchain(t *testing.T) { | |||
| 253 | + | root := t.TempDir() | |||
| 254 | + | ctx := context.Background() | |||
| 255 | + | ||||
| 256 | + | store, err := New(root) | |||
| 257 | + | require.NoError(t, err) | |||
| 258 | + | ||||
| 259 | + | // Save a valid cert first | |||
| 260 | + | cert := out.StoredCertificate{ | |||
| 261 | + | ID: "corrupt-fullchain.example.com", | |||
| 262 | + | Names: []string{"corrupt-fullchain.example.com"}, | |||
| 263 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 264 | + | FullchainPEM: []byte("fullchain-data"), | |||
| 265 | + | PrivateKeyPEM: []byte("privkey-data"), | |||
| 266 | + | } | |||
| 267 | + | err = store.Save(ctx, cert) | |||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | ||||
| 270 | + | // Replace fullchain.pem with an unreadable directory | |||
| 271 | + | fullchainPath := filepath.Join(root, certDir, "corrupt-fullchain.example.com", fullchainFile) | |||
| 272 | + | err = os.Remove(fullchainPath) | |||
| 273 | + | require.NoError(t, err) | |||
| 274 | + | err = os.MkdirAll(fullchainPath, 0700) | |||
| 275 | + | require.NoError(t, err) | |||
| 276 | + | ||||
| 277 | + | // LoadAll should return an error | |||
| 278 | + | _, err = store.LoadAll(ctx) | |||
| 279 | + | require.Error(t, err) | |||
| 280 | + | assert.Contains(t, err.Error(), "fullchain.pem") | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestStoreLoadAllInvalidPEM(t *testing.T) { | |||
| 284 | + | root := t.TempDir() | |||
| 285 | + | ctx := context.Background() | |||
| 286 | + | ||||
| 287 | + | store, err := New(root) | |||
| 288 | + | require.NoError(t, err) | |||
| 289 | + | ||||
| 290 | + | // Save a cert with non-empty but invalid PEM bytes (mismatched key) | |||
| 291 | + | cert := out.StoredCertificate{ | |||
| 292 | + | ID: "invalid-pem.example.com", | |||
| 293 | + | Names: []string{"invalid-pem.example.com"}, | |||
| 294 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 295 | + | FullchainPEM: []byte("invalid-fullchain"), | |||
| 296 | + | PrivateKeyPEM: []byte("invalid-privkey"), | |||
| 297 | + | } | |||
| 298 | + | err = store.Save(ctx, cert) | |||
| 299 | + | require.NoError(t, err) | |||
| 300 | + | ||||
| 301 | + | // LoadAll should return an error because tls.X509KeyPair fails | |||
| 302 | + | _, err = store.LoadAll(ctx) | |||
| 303 | + | require.Error(t, err) | |||
| 304 | + | assert.Contains(t, err.Error(), "parse key pair") | |||
| 305 | + | } | |||
A internal/adapters/out/secrets/public_tls_resolver.go +113 -0
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package secrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const defaultCloudflareTokenPassName = "gordon/cloudflare/api-token" | |||
| 17 | + | ||||
| 18 | + | // PublicTLSResolverConfig holds optional overrides for PublicTLSResolver. | |||
| 19 | + | type PublicTLSResolverConfig struct { | |||
| 20 | + | PassLookup func(ctx context.Context, name string) (string, error) | |||
| 21 | + | EnvLookup func(key string) string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // PublicTLSResolver resolves Cloudflare API tokens using pass, file, or env. | |||
| 25 | + | type PublicTLSResolver struct { | |||
| 26 | + | passLookup func(ctx context.Context, name string) (string, error) | |||
| 27 | + | envLookup func(key string) string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | var _ out.SecretResolver = (*PublicTLSResolver)(nil) | |||
| 31 | + | ||||
| 32 | + | // NewPublicTLSResolver creates a new PublicTLSResolver with the given config. | |||
| 33 | + | // Defaults PassLookup to defaultPassLookup and EnvLookup to os.Getenv. | |||
| 34 | + | func NewPublicTLSResolver(cfg PublicTLSResolverConfig) *PublicTLSResolver { | |||
| 35 | + | r := &PublicTLSResolver{ | |||
| 36 | + | passLookup: defaultPassLookup, | |||
| 37 | + | envLookup: os.Getenv, | |||
| 38 | + | } | |||
| 39 | + | if cfg.PassLookup != nil { | |||
| 40 | + | r.passLookup = cfg.PassLookup | |||
| 41 | + | } | |||
| 42 | + | if cfg.EnvLookup != nil { | |||
| 43 | + | r.envLookup = cfg.EnvLookup | |||
| 44 | + | } | |||
| 45 | + | return r | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // ResolveCloudflareToken resolves the Cloudflare API token. | |||
| 49 | + | // Lookup order: pass, token file env, token env. | |||
| 50 | + | func (r *PublicTLSResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) { | |||
| 51 | + | var passErr error | |||
| 52 | + | var fileErr error | |||
| 53 | + | ||||
| 54 | + | // 1. Try pass | |||
| 55 | + | if token, err := r.passLookup(ctx, defaultCloudflareTokenPassName); err == nil { | |||
| 56 | + | if trimmed := strings.TrimSpace(token); trimmed != "" { | |||
| 57 | + | return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourcePass}, nil | |||
| 58 | + | } | |||
| 59 | + | } else if !errors.Is(err, os.ErrNotExist) { | |||
| 60 | + | passErr = err | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // 2. Try token file env | |||
| 64 | + | if filePath := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN_FILE"); filePath != "" { | |||
| 65 | + | if data, err := os.ReadFile(filePath); err == nil { | |||
| 66 | + | if trimmed := strings.TrimSpace(string(data)); trimmed != "" { | |||
| 67 | + | return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceFile}, nil | |||
| 68 | + | } | |||
| 69 | + | } else { | |||
| 70 | + | fileErr = fmt.Errorf("read token file: %w", err) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // 3. Try token env | |||
| 75 | + | if token := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN"); token != "" { | |||
| 76 | + | if trimmed := strings.TrimSpace(token); trimmed != "" { | |||
| 77 | + | return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceEnv}, nil | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // 4. No token found — return remembered errors if any | |||
| 82 | + | if passErr != nil { | |||
| 83 | + | return out.SecretValue{Source: domain.ACMETokenSourceNone}, fmt.Errorf("resolve pass token: %w", passErr) | |||
| 84 | + | } | |||
| 85 | + | if fileErr != nil { | |||
| 86 | + | return out.SecretValue{Source: domain.ACMETokenSourceNone}, fileErr | |||
| 87 | + | } | |||
| 88 | + | return out.SecretValue{Source: domain.ACMETokenSourceNone}, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // defaultPassLookup executes "pass show <name>" with a 2-second timeout. | |||
| 92 | + | // Returns the raw output with trailing newline. Never logs token values. | |||
| 93 | + | func defaultPassLookup(ctx context.Context, name string) (string, error) { | |||
| 94 | + | // Only allow the known Cloudflare token pass name. | |||
| 95 | + | if name != defaultCloudflareTokenPassName { | |||
| 96 | + | return "", fmt.Errorf("pass lookup: disallowed name %q", name) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | passCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 100 | + | defer cancel() | |||
| 101 | + | ||||
| 102 | + | // #nosec G204 — name is validated above as the allowlisted constant | |||
| 103 | + | // defaultCloudflareTokenPassName; no shell is used. | |||
| 104 | + | cmd := exec.CommandContext(passCtx, "pass", "show", name) | |||
| 105 | + | output, err := cmd.Output() | |||
| 106 | + | if err != nil { | |||
| 107 | + | if passCtx.Err() == context.DeadlineExceeded { | |||
| 108 | + | return "", context.DeadlineExceeded | |||
| 109 | + | } | |||
| 110 | + | return "", err | |||
| 111 | + | } | |||
| 112 | + | return string(output), nil | |||
| 113 | + | } | |||
A internal/adapters/out/secrets/public_tls_resolver_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | package secrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestPublicTLSResolverPrefersPass(t *testing.T) { | |||
| 18 | + | env := map[string]string{ | |||
| 19 | + | "GORDON_CLOUDFLARE_API_TOKEN": "env-token", | |||
| 20 | + | } | |||
| 21 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 22 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 23 | + | return " pass-token\n", nil | |||
| 24 | + | }, | |||
| 25 | + | EnvLookup: func(key string) string { | |||
| 26 | + | return env[key] | |||
| 27 | + | }, | |||
| 28 | + | }) | |||
| 29 | + | ||||
| 30 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | assert.Equal(t, "pass-token", sv.Value) | |||
| 33 | + | assert.Equal(t, domain.ACMETokenSourcePass, sv.Source) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestPublicTLSResolverFallsBackToTokenFile(t *testing.T) { | |||
| 37 | + | tmpDir := t.TempDir() | |||
| 38 | + | tmpFile := filepath.Join(tmpDir, "cf-token") | |||
| 39 | + | err := os.WriteFile(tmpFile, []byte("file-token\n"), 0644) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | ||||
| 42 | + | env := map[string]string{ | |||
| 43 | + | "GORDON_CLOUDFLARE_API_TOKEN_FILE": tmpFile, | |||
| 44 | + | } | |||
| 45 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 46 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 47 | + | return "", os.ErrNotExist | |||
| 48 | + | }, | |||
| 49 | + | EnvLookup: func(key string) string { | |||
| 50 | + | return env[key] | |||
| 51 | + | }, | |||
| 52 | + | }) | |||
| 53 | + | ||||
| 54 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | assert.Equal(t, "file-token", sv.Value) | |||
| 57 | + | assert.Equal(t, domain.ACMETokenSourceFile, sv.Source) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestPublicTLSResolverFallsBackToEnv(t *testing.T) { | |||
| 61 | + | env := map[string]string{ | |||
| 62 | + | "GORDON_CLOUDFLARE_API_TOKEN": "env-token", | |||
| 63 | + | } | |||
| 64 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 65 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 66 | + | return "", os.ErrNotExist | |||
| 67 | + | }, | |||
| 68 | + | EnvLookup: func(key string) string { | |||
| 69 | + | return env[key] | |||
| 70 | + | }, | |||
| 71 | + | }) | |||
| 72 | + | ||||
| 73 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | assert.Equal(t, "env-token", sv.Value) | |||
| 76 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestPublicTLSResolverNoToken(t *testing.T) { | |||
| 80 | + | env := map[string]string{} | |||
| 81 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 82 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 83 | + | return "", os.ErrNotExist | |||
| 84 | + | }, | |||
| 85 | + | EnvLookup: func(key string) string { | |||
| 86 | + | return env[key] | |||
| 87 | + | }, | |||
| 88 | + | }) | |||
| 89 | + | ||||
| 90 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | assert.Empty(t, sv.Value) | |||
| 93 | + | assert.Equal(t, domain.ACMETokenSourceNone, sv.Source) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestPublicTLSResolverPassErrorWithoutFallback(t *testing.T) { | |||
| 97 | + | expectedErr := errors.New("pass show failed") | |||
| 98 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 99 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 100 | + | return "", expectedErr | |||
| 101 | + | }, | |||
| 102 | + | EnvLookup: func(key string) string { | |||
| 103 | + | return "" | |||
| 104 | + | }, | |||
| 105 | + | }) | |||
| 106 | + | ||||
| 107 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 108 | + | require.Error(t, err) | |||
| 109 | + | assert.Contains(t, err.Error(), "resolve pass token") | |||
| 110 | + | assert.Contains(t, err.Error(), expectedErr.Error()) | |||
| 111 | + | assert.Empty(t, sv.Value) | |||
| 112 | + | assert.Equal(t, domain.ACMETokenSourceNone, sv.Source) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestPublicTLSResolverPassErrorWithEnvFallback(t *testing.T) { | |||
| 116 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 117 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 118 | + | return "", errors.New("pass not available") | |||
| 119 | + | }, | |||
| 120 | + | EnvLookup: func(key string) string { | |||
| 121 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN" { | |||
| 122 | + | return "env-token" | |||
| 123 | + | } | |||
| 124 | + | return "" | |||
| 125 | + | }, | |||
| 126 | + | }) | |||
| 127 | + | ||||
| 128 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | assert.Equal(t, "env-token", sv.Value) | |||
| 131 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestPublicTLSResolverTokenFileErrorWithoutFallback(t *testing.T) { | |||
| 135 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 136 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 137 | + | return "", os.ErrNotExist | |||
| 138 | + | }, | |||
| 139 | + | EnvLookup: func(key string) string { | |||
| 140 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN_FILE" { | |||
| 141 | + | return "/nonexistent/token-file" | |||
| 142 | + | } | |||
| 143 | + | return "" | |||
| 144 | + | }, | |||
| 145 | + | }) | |||
| 146 | + | ||||
| 147 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 148 | + | require.Error(t, err) | |||
| 149 | + | assert.Contains(t, err.Error(), "read token file") | |||
| 150 | + | assert.Empty(t, sv.Value) | |||
| 151 | + | assert.Equal(t, domain.ACMETokenSourceNone, sv.Source) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestPublicTLSResolverTokenFileErrorWithEnvFallback(t *testing.T) { | |||
| 155 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 156 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 157 | + | return "", os.ErrNotExist | |||
| 158 | + | }, | |||
| 159 | + | EnvLookup: func(key string) string { | |||
| 160 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN_FILE" { | |||
| 161 | + | return "/nonexistent/token-file" | |||
| 162 | + | } | |||
| 163 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN" { | |||
| 164 | + | return "env-token" | |||
| 165 | + | } | |||
| 166 | + | return "" | |||
| 167 | + | }, | |||
| 168 | + | }) | |||
| 169 | + | ||||
| 170 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 171 | + | require.NoError(t, err) | |||
| 172 | + | assert.Equal(t, "env-token", sv.Value) | |||
| 173 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestPublicTLSResolverEmptyPassValueFallback(t *testing.T) { | |||
| 177 | + | // Empty string from pass (no error) should fall through to other sources | |||
| 178 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |||
| 179 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |||
| 180 | + | return "", nil // no error but empty value | |||
| 181 | + | }, | |||
| 182 | + | EnvLookup: func(key string) string { | |||
| 183 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN" { | |||
| 184 | + | return "env-token" | |||
| 185 | + | } | |||
| 186 | + | return "" | |||
| 187 | + | }, | |||
| 188 | + | }) | |||
| 189 | + | ||||
| 190 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | assert.Equal(t, "env-token", sv.Value) | |||
| 193 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Ensure PublicTLSResolver implements out.SecretResolver at compile time. | |||
| 197 | + | var _ out.SecretResolver = (*PublicTLSResolver)(nil) | |||
A internal/app/acme_config_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/viper" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestLoadConfig_ACMEDefaults(t *testing.T) { | |||
| 12 | + | v := viper.New() | |||
| 13 | + | require.NoError(t, loadConfig(v, "")) | |||
| 14 | + | assert.False(t, v.GetBool("tls.acme.enabled")) | |||
| 15 | + | assert.Equal(t, "", v.GetString("tls.acme.email")) | |||
| 16 | + | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | |||
| 17 | + | } | |||
M internal/app/kernel.go +22 -2
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 9 | 10 | ||||
| @@ -25,6 +26,7 @@ type Kernel struct { | |||||
| 25 | healthSvc in.HealthService | 26 | healthSvc in.HealthService | ||
| 26 | logSvc in.LogService | 27 | logSvc in.LogService | ||
| 27 | volumeSvc in.VolumeService | 28 | volumeSvc in.VolumeService | ||
| 29 | + | publicTLSSvc in.PublicTLSService | |||
| 28 | cleanup func() | 30 | cleanup func() | ||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| @@ -59,7 +61,22 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 59 | 61 | ||||
| 60 | // Prefer full service wiring so local CLI can execute the same operations | 62 | // Prefer full service wiring so local CLI can execute the same operations | ||
| 61 | // as remote mode without going through HTTP admin endpoints. | 63 | // 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 | + | ||||
| 63 | return &Kernel{ | 80 | return &Kernel{ | ||
| 64 | authEnabled: cfg.Auth.Enabled, | 81 | authEnabled: cfg.Auth.Enabled, | ||
| 65 | configSvc: svc.configSvc, | 82 | configSvc: svc.configSvc, | ||
| @@ -70,7 +87,8 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 70 | healthSvc: svc.healthSvc, | 87 | healthSvc: svc.healthSvc, | ||
| 71 | logSvc: svc.logSvc, | 88 | logSvc: svc.logSvc, | ||
| 72 | volumeSvc: svc.volumeSvc, | 89 | volumeSvc: svc.volumeSvc, | ||
| 73 | - | cleanup: cleanup, | 90 | + | publicTLSSvc: svc.publicTLSSvc, |
| 91 | + | cleanup: wrappedCleanup, | |||
| 74 | }, nil | 92 | }, nil | ||
| 75 | } else { | 93 | } else { | ||
| 76 | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | 94 | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | ||
| @@ -126,4 +144,6 @@ func (k *Kernel) Logs() in.LogService { return k.logSvc } | |||||
| 126 | 144 | ||||
| 127 | func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc } | 145 | func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc } | ||
| 128 | 146 | ||||
| 147 | + | func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc } | |||
| 148 | + | ||||
| 129 | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | 149 | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | ||
M internal/app/migrate_env_test.go +2 -2
| @@ -32,7 +32,7 @@ func requirePass(t *testing.T) { | |||||
| 32 | } | 32 | } | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | - | func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | 35 | + | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { |
| 36 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | 36 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | ||
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | return | 38 | return | ||
| @@ -87,7 +87,7 @@ func TestMigrateEnvFile(t *testing.T) { | |||||
| 87 | assert.Equal(t, "pass456", values["DB_PASSWORD"]) | 87 | assert.Equal(t, "pass456", values["DB_PASSWORD"]) | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | - | func cleanupPassAttachment(t *testing.T, containerName string, keys []string) { | 90 | + | func cleanupPassAttachment(_ *testing.T, containerName string, keys []string) { |
| 91 | for _, key := range keys { | 91 | for _, key := range keys { | ||
| 92 | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | 92 | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | ||
| 93 | _ = passCmd("rm", "-f", path) | 93 | _ = passCmd("rm", "-f", path) | ||
A internal/app/public_tls_wiring_test.go +171 -0
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | "crypto/rsa" | |||
| 7 | + | "crypto/tls" | |||
| 8 | + | "crypto/x509" | |||
| 9 | + | "math/big" | |||
| 10 | + | "net" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // stubPublicTLS implements in.PublicTLSService for testing the certificate selector. | |||
| 21 | + | type stubPublicTLS struct { | |||
| 22 | + | getCertFunc func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (s *stubPublicTLS) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 26 | + | if s.getCertFunc != nil { | |||
| 27 | + | return s.getCertFunc(hello) | |||
| 28 | + | } | |||
| 29 | + | return nil, nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (s *stubPublicTLS) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) { | |||
| 33 | + | return "", false | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (s *stubPublicTLS) Status(ctx context.Context) domain.PublicTLSStatus { | |||
| 37 | + | return domain.PublicTLSStatus{} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (s *stubPublicTLS) Reconcile(ctx context.Context) error { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (s *stubPublicTLS) Stop(ctx context.Context) error { | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // generateTestCert creates a self-signed certificate for the given hostnames. | |||
| 49 | + | func generateTestCert(t *testing.T, hosts ...string) tls.Certificate { | |||
| 50 | + | t.Helper() | |||
| 51 | + | ||||
| 52 | + | privKey, err := rsa.GenerateKey(rand.Reader, 2048) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | ||||
| 55 | + | template := &x509.Certificate{ | |||
| 56 | + | SerialNumber: big.NewInt(1), | |||
| 57 | + | NotBefore: time.Now().Add(-1 * time.Hour), | |||
| 58 | + | NotAfter: time.Now().Add(24 * time.Hour), | |||
| 59 | + | IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, | |||
| 60 | + | } | |||
| 61 | + | for _, host := range hosts { | |||
| 62 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 63 | + | template.IPAddresses = append(template.IPAddresses, ip) | |||
| 64 | + | } else { | |||
| 65 | + | template.DNSNames = append(template.DNSNames, host) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | ||||
| 72 | + | return tls.Certificate{ | |||
| 73 | + | Certificate: [][]byte{certDER}, | |||
| 74 | + | PrivateKey: privKey, | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // TestCertificateSelector_StaticCertWins tests that a static certificate matching | |||
| 79 | + | // the server name is returned before public TLS. | |||
| 80 | + | func TestCertificateSelector_StaticCertWins(t *testing.T) { | |||
| 81 | + | staticCert := generateTestCert(t, "static.example.com") | |||
| 82 | + | publicCert := generateTestCert(t, "static.example.com") | |||
| 83 | + | ||||
| 84 | + | selector := &certificateSelector{ | |||
| 85 | + | staticCerts: []tls.Certificate{staticCert}, | |||
| 86 | + | publicTLS: &stubPublicTLS{ | |||
| 87 | + | getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 88 | + | return &publicCert, nil | |||
| 89 | + | }, | |||
| 90 | + | }, | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"}) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | require.NotNil(t, got) | |||
| 96 | + | assert.Equal(t, staticCert.Certificate[0], got.Certificate[0], | |||
| 97 | + | "static cert should be returned, not public TLS") | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // TestCertificateSelector_PublicTLSErrorPropagates tests that if public TLS | |||
| 101 | + | // returns an error (e.g. ErrTLSRouteNotCovered), the error propagates. | |||
| 102 | + | func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) { | |||
| 103 | + | selector := &certificateSelector{ | |||
| 104 | + | publicTLS: &stubPublicTLS{ | |||
| 105 | + | getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 106 | + | return nil, domain.ErrTLSRouteNotCovered | |||
| 107 | + | }, | |||
| 108 | + | }, | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"}) | |||
| 112 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered, | |||
| 113 | + | "public TLS error should propagate") | |||
| 114 | + | assert.Nil(t, got, "no certificate should be returned on error") | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns | |||
| 118 | + | // nil,nil and no local PKI, the selector returns nil,nil. | |||
| 119 | + | func TestCertificateSelector_PublicTLSNilNil(t *testing.T) { | |||
| 120 | + | selector := &certificateSelector{ | |||
| 121 | + | publicTLS: &stubPublicTLS{ | |||
| 122 | + | getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 123 | + | return nil, nil | |||
| 124 | + | }, | |||
| 125 | + | }, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"}) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // TestCertificateSelector_NoSource tests that when no source has a matching cert, | |||
| 134 | + | // nil, nil is returned. | |||
| 135 | + | func TestCertificateSelector_NoSource(t *testing.T) { | |||
| 136 | + | selector := &certificateSelector{} | |||
| 137 | + | ||||
| 138 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"}) | |||
| 139 | + | assert.NoError(t, err) | |||
| 140 | + | assert.Nil(t, got) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // TestMatchingStaticCert tests the matchingStaticCert helper. | |||
| 144 | + | func TestMatchingStaticCert(t *testing.T) { | |||
| 145 | + | certA := generateTestCert(t, "app.example.com") | |||
| 146 | + | certB := generateTestCert(t, "api.example.com") | |||
| 147 | + | ||||
| 148 | + | certs := []tls.Certificate{certA, certB} | |||
| 149 | + | ||||
| 150 | + | // Exact match | |||
| 151 | + | got := matchingStaticCert(certs, "app.example.com") | |||
| 152 | + | require.NotNil(t, got) | |||
| 153 | + | assert.Equal(t, certA.Certificate[0], got.Certificate[0]) | |||
| 154 | + | ||||
| 155 | + | // Second cert match | |||
| 156 | + | got = matchingStaticCert(certs, "api.example.com") | |||
| 157 | + | require.NotNil(t, got) | |||
| 158 | + | assert.Equal(t, certB.Certificate[0], got.Certificate[0]) | |||
| 159 | + | ||||
| 160 | + | // No match | |||
| 161 | + | got = matchingStaticCert(certs, "other.example.com") | |||
| 162 | + | assert.Nil(t, got) | |||
| 163 | + | ||||
| 164 | + | // Empty server name | |||
| 165 | + | got = matchingStaticCert(certs, "") | |||
| 166 | + | assert.Nil(t, got) | |||
| 167 | + | ||||
| 168 | + | // Empty cert list | |||
| 169 | + | got = matchingStaticCert(nil, "app.example.com") | |||
| 170 | + | assert.Nil(t, got) | |||
| 171 | + | } | |||
M internal/app/run.go +278 -42
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "crypto/rand" | 6 | "crypto/rand" | ||
| 7 | "crypto/tls" | 7 | "crypto/tls" | ||
| 8 | + | "crypto/x509" | |||
| 8 | "encoding/hex" | 9 | "encoding/hex" | ||
| 9 | "encoding/json" | 10 | "encoding/json" | ||
| 10 | "errors" | 11 | "errors" | ||
| @@ -27,6 +28,8 @@ import ( | |||||
| 27 | 28 | ||||
| 28 | // Adapters - Output | 29 | // Adapters - Output | ||
| 29 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | 30 | "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" | |||
| 30 | "github.com/bnema/gordon/internal/adapters/out/docker" | 33 | "github.com/bnema/gordon/internal/adapters/out/docker" | ||
| 31 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | 34 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | ||
| 32 | "github.com/bnema/gordon/internal/adapters/out/envloader" | 35 | "github.com/bnema/gordon/internal/adapters/out/envloader" | ||
| @@ -40,11 +43,15 @@ import ( | |||||
| 40 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | 43 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | ||
| 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | 44 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | ||
| 42 | 45 | ||||
| 46 | + | // Boundaries | |||
| 47 | + | "github.com/bnema/gordon/internal/boundaries/in" | |||
| 48 | + | ||||
| 43 | // OTel | 49 | // OTel | ||
| 44 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" | 50 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" | ||
| 45 | 51 | ||||
| 46 | // Adapters - Input | 52 | // Adapters - Input | ||
| 47 | "github.com/bnema/gordon/internal/adapters/dto" | 53 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 54 | + | acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme" | |||
| 48 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | 55 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 49 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | 56 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | ||
| 50 | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | 57 | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | ||
| @@ -75,6 +82,7 @@ import ( | |||||
| 75 | "github.com/bnema/gordon/internal/usecase/logs" | 82 | "github.com/bnema/gordon/internal/usecase/logs" | ||
| 76 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | 83 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | ||
| 77 | "github.com/bnema/gordon/internal/usecase/proxy" | 84 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| 85 | + | "github.com/bnema/gordon/internal/usecase/publictls" | |||
| 78 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| 79 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| 80 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | 88 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | ||
| @@ -189,6 +197,14 @@ type Config struct { | |||||
| 189 | } `mapstructure:"containers"` | 197 | } `mapstructure:"containers"` | ||
| 190 | 198 | ||||
| 191 | Telemetry telemetry.Config `mapstructure:"telemetry"` | 199 | Telemetry telemetry.Config `mapstructure:"telemetry"` | ||
| 200 | + | ||||
| 201 | + | TLS struct { | |||
| 202 | + | ACME struct { | |||
| 203 | + | Enabled bool `mapstructure:"enabled"` | |||
| 204 | + | Email string `mapstructure:"email"` | |||
| 205 | + | Challenge string `mapstructure:"challenge"` | |||
| 206 | + | } `mapstructure:"acme"` | |||
| 207 | + | } `mapstructure:"tls"` | |||
| 192 | } | 208 | } | ||
| 193 | 209 | ||||
| 194 | // services holds all the services used by the application. | 210 | // services holds all the services used by the application. | ||
| @@ -224,6 +240,7 @@ type services struct { | |||||
| 224 | caAdapter *pkiadapter.CA | 240 | caAdapter *pkiadapter.CA | ||
| 225 | pkiSvc *pkiusecase.Service | 241 | pkiSvc *pkiusecase.Service | ||
| 226 | reloadCoordinator *reloadCoordinator | 242 | reloadCoordinator *reloadCoordinator | ||
| 243 | + | publicTLSSvc in.PublicTLSService | |||
| 227 | registryHandler interface { | 244 | registryHandler interface { | ||
| 228 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 245 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 229 | } | 246 | } | ||
| @@ -401,27 +418,53 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||||
| 401 | return writer, nil | 418 | return writer, nil | ||
| 402 | } | 419 | } | ||
| 403 | 420 | ||||
| 421 | + | // serviceOptions controls optional behavior for createServicesWithOptions. | |||
| 422 | + | type serviceOptions struct { | |||
| 423 | + | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |||
| 424 | + | // Server runtime sets true; Kernel/local CLI sets false (read-only status). | |||
| 425 | + | StartPublicTLS bool | |||
| 426 | + | } | |||
| 427 | + | ||||
| 404 | // serviceInit holds the shared context for service initialization helpers. | 428 | // serviceInit holds the shared context for service initialization helpers. | ||
| 405 | type serviceInit struct { | 429 | type serviceInit struct { | ||
| 406 | - | ctx context.Context | 430 | + | ctx context.Context |
| 407 | - | v *viper.Viper | 431 | + | v *viper.Viper |
| 408 | - | cfg Config | 432 | + | cfg Config |
| 409 | - | log zerowrap.Logger | 433 | + | log zerowrap.Logger |
| 410 | - | svc *services | 434 | + | svc *services |
| 435 | + | startPublicTLS bool | |||
| 411 | } | 436 | } | ||
| 412 | 437 | ||||
| 413 | - | // createServices creates all the application services. | 438 | + | // createServices creates all the application services for server runtime. |
| 439 | + | // It calls createServicesWithOptions with StartPublicTLS: true. | |||
| 414 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | 440 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | ||
| 441 | + | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: true}) | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | // createServicesWithOptions creates all the application services with the given options. | |||
| 445 | + | // When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that | |||
| 446 | + | // read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects. | |||
| 447 | + | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) { | |||
| 415 | si := &serviceInit{ | 448 | si := &serviceInit{ | ||
| 416 | - | ctx: ctx, | 449 | + | ctx: ctx, |
| 417 | - | v: v, | 450 | + | v: v, |
| 418 | - | cfg: cfg, | 451 | + | cfg: cfg, |
| 419 | - | log: log, | 452 | + | log: log, |
| 420 | - | svc: &services{}, | 453 | + | svc: &services{}, |
| 454 | + | startPublicTLS: opts.StartPublicTLS, | |||
| 421 | } | 455 | } | ||
| 422 | defer func() { | 456 | defer func() { | ||
| 423 | - | if retErr != nil && si.svc.pkiSvc != nil { | 457 | + | if retErr != nil { |
| 424 | - | si.svc.pkiSvc.Stop() | 458 | + | if si.svc.pkiSvc != nil { |
| 459 | + | si.svc.pkiSvc.Stop() | |||
| 460 | + | } | |||
| 461 | + | if si.svc.publicTLSSvc != nil { | |||
| 462 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 463 | + | defer cancel() | |||
| 464 | + | if err := si.svc.publicTLSSvc.Stop(ctx); err != nil { | |||
| 465 | + | si.log.Warn().Err(err).Msg("failed to stop public TLS service during createServices cleanup") | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 425 | } | 468 | } | ||
| 426 | }() | 469 | }() | ||
| 427 | var err error | 470 | var err error | ||
| @@ -465,11 +508,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 465 | return nil, err | 508 | return nil, err | ||
| 466 | } | 509 | } | ||
| 467 | 510 | ||||
| 511 | + | if err := si.initPublicTLS(); err != nil { | |||
| 512 | + | return nil, err | |||
| 513 | + | } | |||
| 514 | + | ||||
| 468 | if err := si.initRuntimeAndProxy(); err != nil { | 515 | if err := si.initRuntimeAndProxy(); err != nil { | ||
| 469 | return nil, err | 516 | return nil, err | ||
| 470 | } | 517 | } | ||
| 471 | 518 | ||||
| 472 | - | si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log) | 519 | + | si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log) |
| 473 | 520 | ||||
| 474 | si.initHandlers() | 521 | si.initHandlers() | ||
| 475 | 522 | ||||
| @@ -496,6 +543,92 @@ func (si *serviceInit) initPKI() error { | |||||
| 496 | return nil | 543 | return nil | ||
| 497 | } | 544 | } | ||
| 498 | 545 | ||||
| 546 | + | // initPublicTLS initializes the public ACME TLS service if enabled. | |||
| 547 | + | func (si *serviceInit) initPublicTLS() error { | |||
| 548 | + | if !si.cfg.TLS.ACME.Enabled { | |||
| 549 | + | return nil | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | if si.cfg.Server.TLSPort == 0 { | |||
| 553 | + | return nil | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | ctx := si.ctx | |||
| 557 | + | log := si.log | |||
| 558 | + | ||||
| 559 | + | publicTLSCfg := publictls.Config{ | |||
| 560 | + | Enabled: si.cfg.TLS.ACME.Enabled, | |||
| 561 | + | Email: si.cfg.TLS.ACME.Email, | |||
| 562 | + | Challenge: si.cfg.TLS.ACME.Challenge, | |||
| 563 | + | HTTPPort: si.cfg.Server.Port, | |||
| 564 | + | TLSPort: si.cfg.Server.TLSPort, | |||
| 565 | + | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | |||
| 569 | + | ||||
| 570 | + | effective, err := publictls.ResolveEffectiveChallenge(ctx, publicTLSCfg, tokenResolver) | |||
| 571 | + | if err != nil { | |||
| 572 | + | return log.WrapErr(err, "resolve ACME challenge") | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme")) | |||
| 576 | + | if err != nil { | |||
| 577 | + | return log.WrapErr(err, "create ACME store") | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | challenges := publictls.NewHTTP01Challenges() | |||
| 581 | + | ||||
| 582 | + | var zoneResolver *acmelego.CloudflareZoneResolver | |||
| 583 | + | if effective.Mode == domain.ACMEChallengeCloudflareDNS01 { | |||
| 584 | + | zoneResolver = acmelego.NewCloudflareZoneResolver(effective.Token) | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | issuer, err := acmelego.NewIssuer(acmelego.Config{ | |||
| 588 | + | Email: si.cfg.TLS.ACME.Email, | |||
| 589 | + | Challenge: effective.Mode, | |||
| 590 | + | Token: effective.Token, | |||
| 591 | + | Store: store, | |||
| 592 | + | HTTPChallengeSink: challenges, | |||
| 593 | + | }) | |||
| 594 | + | if err != nil { | |||
| 595 | + | return log.WrapErr(err, "create ACME issuer") | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{ | |||
| 599 | + | Routes: si.svc.configSvc, | |||
| 600 | + | Issuer: issuer, | |||
| 601 | + | Store: store, | |||
| 602 | + | ZoneResolver: zoneResolver, | |||
| 603 | + | Challenges: challenges, | |||
| 604 | + | Effective: effective, | |||
| 605 | + | }) | |||
| 606 | + | ||||
| 607 | + | if err := svc.Load(ctx); err != nil { | |||
| 608 | + | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | if si.startPublicTLS { | |||
| 612 | + | if err := svc.Reconcile(ctx); err != nil { | |||
| 613 | + | log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing") | |||
| 614 | + | } | |||
| 615 | + | svc.StartRenewalLoop(ctx, time.Hour) | |||
| 616 | + | log.Info(). | |||
| 617 | + | Str("email", si.cfg.TLS.ACME.Email). | |||
| 618 | + | Str("challenge", string(effective.Mode)). | |||
| 619 | + | Str("reason", effective.Reason). | |||
| 620 | + | Msg("public ACME TLS initialized") | |||
| 621 | + | } else { | |||
| 622 | + | log.Info(). | |||
| 623 | + | Str("email", si.cfg.TLS.ACME.Email). | |||
| 624 | + | Str("challenge", string(effective.Mode)). | |||
| 625 | + | Msg("public ACME TLS initialized (read-only, no renewal loop)") | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | si.svc.publicTLSSvc = svc | |||
| 629 | + | return nil | |||
| 630 | + | } | |||
| 631 | + | ||||
| 499 | // initSecrets creates the domain secret store, env loader, and secret service. | 632 | // initSecrets creates the domain secret store, env loader, and secret service. | ||
| 500 | func (si *serviceInit) initSecrets() error { | 633 | func (si *serviceInit) initSecrets() error { | ||
| 501 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | 634 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | ||
| @@ -576,6 +709,7 @@ func (si *serviceInit) initHandlers() { | |||||
| 576 | PreviewSvc: si.svc.previewService, | 709 | PreviewSvc: si.svc.previewService, | ||
| 577 | ImageSvc: si.svc.imageSvc, | 710 | ImageSvc: si.svc.imageSvc, | ||
| 578 | VolumeSvc: si.svc.volumeSvc, | 711 | VolumeSvc: si.svc.volumeSvc, | ||
| 712 | + | PublicTLSSvc: si.svc.publicTLSSvc, | |||
| 579 | }) | 713 | }) | ||
| 580 | } | 714 | } | ||
| 581 | 715 | ||||
| @@ -595,13 +729,7 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero | |||||
| 595 | } | 729 | } | ||
| 596 | 730 | ||||
| 597 | // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute. | 731 | // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute. | ||
| 598 | - | sweepInterval := previewConfig.TTL / 2 | 732 | + | sweepInterval := max(min(previewConfig.TTL/2, time.Hour), time.Minute) |
| 599 | - | if sweepInterval > 1*time.Hour { | |||
| 600 | - | sweepInterval = 1 * time.Hour | |||
| 601 | - | } | |||
| 602 | - | if sweepInterval < 1*time.Minute { | |||
| 603 | - | sweepInterval = 1 * time.Minute | |||
| 604 | - | } | |||
| 605 | 733 | ||||
| 606 | svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) { | 734 | svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) { | ||
| 607 | teardownTrackedPreview(ctx, svc, p) | 735 | teardownTrackedPreview(ctx, svc, p) | ||
| @@ -840,8 +968,8 @@ func resolveRuntimeConfig(value string) string { | |||||
| 840 | return "/var/run/docker.sock" | 968 | return "/var/run/docker.sock" | ||
| 841 | } | 969 | } | ||
| 842 | // Explicit socket path — strip URI scheme if present. | 970 | // Explicit socket path — strip URI scheme if present. | ||
| 843 | - | if strings.HasPrefix(value, "unix://") { | 971 | + | if socketPath, ok := strings.CutPrefix(value, "unix://"); ok { |
| 844 | - | return strings.TrimPrefix(value, "unix://") | 972 | + | return socketPath |
| 845 | } | 973 | } | ||
| 846 | return value | 974 | return value | ||
| 847 | } | 975 | } | ||
| @@ -1430,6 +1558,11 @@ type configWatcher interface { | |||||
| 1430 | Watch(ctx context.Context, onChange func()) error | 1558 | Watch(ctx context.Context, onChange func()) error | ||
| 1431 | } | 1559 | } | ||
| 1432 | 1560 | ||||
| 1561 | + | // publicTLSReconciler is the interface for reconciling public TLS certificates. | |||
| 1562 | + | type publicTLSReconciler interface { | |||
| 1563 | + | Reconcile(context.Context) error | |||
| 1564 | + | } | |||
| 1565 | + | ||||
| 1433 | type configReloader interface { | 1566 | type configReloader interface { | ||
| 1434 | Reload(ctx context.Context) error | 1567 | Reload(ctx context.Context) error | ||
| 1435 | } | 1568 | } | ||
| @@ -1457,13 +1590,14 @@ type reloadCoordinator struct { | |||||
| 1457 | registryLimits interface { | 1590 | registryLimits interface { | ||
| 1458 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 1591 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 1459 | } | 1592 | } | ||
| 1460 | - | eventBus out.EventPublisher | 1593 | + | eventBus out.EventPublisher |
| 1461 | - | log zerowrap.Logger | 1594 | + | publicTLS publicTLSReconciler |
| 1595 | + | log zerowrap.Logger | |||
| 1462 | } | 1596 | } | ||
| 1463 | 1597 | ||||
| 1464 | func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface { | 1598 | func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface { | ||
| 1465 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 1599 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 1466 | - | }, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator { | 1600 | + | }, eventBus out.EventPublisher, publicTLS publicTLSReconciler, log zerowrap.Logger) *reloadCoordinator { |
| 1467 | return &reloadCoordinator{ | 1601 | return &reloadCoordinator{ | ||
| 1468 | debounce: 500 * time.Millisecond, | 1602 | debounce: 500 * time.Millisecond, | ||
| 1469 | configSvc: configSvc, | 1603 | configSvc: configSvc, | ||
| @@ -1471,6 +1605,7 @@ func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc pro | |||||
| 1471 | proxySvc: proxySvc, | 1605 | proxySvc: proxySvc, | ||
| 1472 | registryLimits: registryLimits, | 1606 | registryLimits: registryLimits, | ||
| 1473 | eventBus: eventBus, | 1607 | eventBus: eventBus, | ||
| 1608 | + | publicTLS: publicTLS, | |||
| 1474 | log: log, | 1609 | log: log, | ||
| 1475 | } | 1610 | } | ||
| 1476 | } | 1611 | } | ||
| @@ -1511,14 +1646,14 @@ func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) e | |||||
| 1511 | } | 1646 | } | ||
| 1512 | } | 1647 | } | ||
| 1513 | 1648 | ||||
| 1514 | - | if err := c.applyLoadedConfig(now); err != nil { | 1649 | + | if err := c.applyLoadedConfig(ctx, now); err != nil { |
| 1515 | return err | 1650 | return err | ||
| 1516 | } | 1651 | } | ||
| 1517 | 1652 | ||||
| 1518 | return nil | 1653 | return nil | ||
| 1519 | } | 1654 | } | ||
| 1520 | 1655 | ||||
| 1521 | - | func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error { | 1656 | + | func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time) error { |
| 1522 | var reloadCfg Config | 1657 | var reloadCfg Config | ||
| 1523 | if err := c.v.Unmarshal(&reloadCfg); err != nil { | 1658 | if err := c.v.Unmarshal(&reloadCfg); err != nil { | ||
| 1524 | c.log.Error().Err(err).Msg("failed to unmarshal config on reload") | 1659 | c.log.Error().Err(err).Msg("failed to unmarshal config on reload") | ||
| @@ -1545,6 +1680,14 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error { | |||||
| 1545 | 1680 | ||||
| 1546 | c.lastRun = now | 1681 | c.lastRun = now | ||
| 1547 | 1682 | ||||
| 1683 | + | // Reconcile public TLS certificates after config reload. | |||
| 1684 | + | if c.publicTLS != nil { | |||
| 1685 | + | if err := c.publicTLS.Reconcile(ctx); err != nil { | |||
| 1686 | + | c.log.Error().Err(err).Msg("failed to reconcile public TLS certificates after reload") | |||
| 1687 | + | return fmt.Errorf("reconcile public TLS: %w", err) | |||
| 1688 | + | } | |||
| 1689 | + | } | |||
| 1690 | + | ||||
| 1548 | c.log.Debug().Msg("config hot reload complete") | 1691 | c.log.Debug().Msg("config hot reload complete") | ||
| 1549 | return nil | 1692 | return nil | ||
| 1550 | } | 1693 | } | ||
| @@ -1925,7 +2068,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 1925 | // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain. | 2068 | // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain. | ||
| 1926 | // Direct clients get an onboarding gate (when CA is available) placed BEFORE | 2069 | // Direct clients get an onboarding gate (when CA is available) placed BEFORE | ||
| 1927 | // HTTPSRedirect so force_https_redirect cannot bypass onboarding. | 2070 | // HTTPSRedirect so force_https_redirect cannot bypass onboarding. | ||
| 2071 | + | // ACME HTTP-01 challenge handler is registered before the catch-all "/" so | |||
| 2072 | + | // it gets first chance regardless of source IP. | |||
| 1928 | proxyMux := http.NewServeMux() | 2073 | proxyMux := http.NewServeMux() | ||
| 2074 | + | ||||
| 2075 | + | // Register ACME HTTP-01 challenge handler before all other routes so | |||
| 2076 | + | // Let's Encrypt validation always succeeds, even for onboarding clients. | |||
| 2077 | + | if svc.publicTLSSvc != nil { | |||
| 2078 | + | proxyMux.Handle(acmehttp.Prefix, acmehttp.NewHandler(svc.publicTLSSvc)) | |||
| 2079 | + | } | |||
| 2080 | + | ||||
| 1929 | if proxyCIDRMiddleware != nil && proxyAllowedNets == nil { | 2081 | if proxyCIDRMiddleware != nil && proxyAllowedNets == nil { | ||
| 1930 | // Invalid proxy_allowed_ips: deny all traffic (fail-closed). | 2082 | // Invalid proxy_allowed_ips: deny all traffic (fail-closed). | ||
| 1931 | proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware)) | 2083 | proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware)) | ||
| @@ -2300,7 +2452,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2300 | } | 2452 | } | ||
| 2301 | } | 2453 | } | ||
| 2302 | 2454 | ||||
| 2303 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log) | 2455 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log) |
| 2304 | if err != nil { | 2456 | if err != nil { | ||
| 2305 | closeStarted(registrySrv) | 2457 | closeStarted(registrySrv) | ||
| 2306 | return err | 2458 | return err | ||
| @@ -2344,7 +2496,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2344 | 2496 | ||||
| 2345 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | 2497 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | ||
| 2346 | cleanupHandlers() // Stop debounce timers before draining containers | 2498 | cleanupHandlers() // Stop debounce timers before draining containers | ||
| 2347 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log) | 2499 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log) |
| 2348 | return nil | 2500 | return nil | ||
| 2349 | } | 2501 | } | ||
| 2350 | 2502 | ||||
| @@ -2544,7 +2696,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||||
| 2544 | 2696 | ||||
| 2545 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | 2697 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | ||
| 2546 | // the container service and cleans up runtime files. | 2698 | // the container service and cleans up runtime files. | ||
| 2547 | - | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, log zerowrap.Logger) { | 2699 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) { |
| 2548 | log.Info().Msg("shutting down Gordon...") | 2700 | log.Info().Msg("shutting down Gordon...") | ||
| 2549 | 2701 | ||||
| 2550 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | 2702 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| @@ -2565,6 +2717,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 2565 | pkiSvc.Stop() | 2717 | pkiSvc.Stop() | ||
| 2566 | } | 2718 | } | ||
| 2567 | 2719 | ||||
| 2720 | + | // Stop public ACME TLS renewal loop | |||
| 2721 | + | if publicTLS != nil { | |||
| 2722 | + | if err := publicTLS.Stop(shutdownCtx); err != nil { | |||
| 2723 | + | log.Warn().Err(err).Msg("public TLS stop error") | |||
| 2724 | + | } | |||
| 2725 | + | } | |||
| 2726 | + | ||||
| 2568 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | 2727 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | ||
| 2569 | if proxySvc != nil { | 2728 | if proxySvc != nil { | ||
| 2570 | log.Info().Msg("draining in-flight registry requests...") | 2729 | log.Info().Msg("draining in-flight registry requests...") | ||
| @@ -2592,7 +2751,71 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 2592 | 2751 | ||||
| 2593 | // startProxyServers sets up the HTTP proxy server and, when tls_port != 0, | 2752 | // startProxyServers sets up the HTTP proxy server and, when tls_port != 0, | ||
| 2594 | // an HTTPS proxy server with on-demand TLS certificates from the internal CA. | 2753 | // an HTTPS proxy server with on-demand TLS certificates from the internal CA. | ||
| 2595 | - | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | 2754 | + | // certificateSelector implements a multi-source TLS certificate lookup. |
| 2755 | + | // Priority: static certs → public ACME TLS → local PKI (internal CA). | |||
| 2756 | + | type certificateSelector struct { | |||
| 2757 | + | staticCerts []tls.Certificate | |||
| 2758 | + | publicTLS in.PublicTLSService | |||
| 2759 | + | localPKI *pkiusecase.Service | |||
| 2760 | + | } | |||
| 2761 | + | ||||
| 2762 | + | // GetCertificate selects a TLS certificate based on the ClientHello SNI. | |||
| 2763 | + | // | |||
| 2764 | + | // Priority: | |||
| 2765 | + | // 1. Static certs — exact SNI match (leaf VerifyHostname) | |||
| 2766 | + | // 2. Public ACME TLS — if the host requires ACME coverage | |||
| 2767 | + | // 3. Local PKI (internal CA) — fallback for all other hosts | |||
| 2768 | + | // 4. nil, nil — if no source can serve the host | |||
| 2769 | + | func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 2770 | + | // 1. Static certs — exact match via leaf VerifyHostname. | |||
| 2771 | + | if cert := matchingStaticCert(s.staticCerts, hello.ServerName); cert != nil { | |||
| 2772 | + | return cert, nil | |||
| 2773 | + | } | |||
| 2774 | + | ||||
| 2775 | + | // 2. Public ACME TLS. | |||
| 2776 | + | if s.publicTLS != nil { | |||
| 2777 | + | cert, err := s.publicTLS.GetCertificate(hello) | |||
| 2778 | + | if err != nil { | |||
| 2779 | + | return nil, err | |||
| 2780 | + | } | |||
| 2781 | + | if cert != nil { | |||
| 2782 | + | return cert, nil | |||
| 2783 | + | } | |||
| 2784 | + | // nil, nil means this host is not an ACME-required route. | |||
| 2785 | + | // Fall through to local PKI. | |||
| 2786 | + | } | |||
| 2787 | + | ||||
| 2788 | + | // 3. Local PKI (internal CA). | |||
| 2789 | + | if s.localPKI != nil { | |||
| 2790 | + | return s.localPKI.GetCertificate(hello) | |||
| 2791 | + | } | |||
| 2792 | + | ||||
| 2793 | + | return nil, nil | |||
| 2794 | + | } | |||
| 2795 | + | ||||
| 2796 | + | // matchingStaticCert returns a pointer to the first static certificate whose | |||
| 2797 | + | // leaf verifies the given serverName. Returns nil if no match is found. | |||
| 2798 | + | func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate { | |||
| 2799 | + | if serverName == "" { | |||
| 2800 | + | return nil | |||
| 2801 | + | } | |||
| 2802 | + | for i := range certs { | |||
| 2803 | + | cert := &certs[i] | |||
| 2804 | + | if len(cert.Certificate) == 0 { | |||
| 2805 | + | continue | |||
| 2806 | + | } | |||
| 2807 | + | leaf, err := x509.ParseCertificate(cert.Certificate[0]) | |||
| 2808 | + | if err != nil { | |||
| 2809 | + | continue | |||
| 2810 | + | } | |||
| 2811 | + | if err := leaf.VerifyHostname(serverName); err == nil { | |||
| 2812 | + | return cert | |||
| 2813 | + | } | |||
| 2814 | + | } | |||
| 2815 | + | return nil | |||
| 2816 | + | } | |||
| 2817 | + | ||||
| 2818 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |||
| 2596 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | 2819 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | ||
| 2597 | var httpProtos http.Protocols | 2820 | var httpProtos http.Protocols | ||
| 2598 | httpProtos.SetHTTP1(true) | 2821 | httpProtos.SetHTTP1(true) | ||
| @@ -2610,23 +2833,33 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||||
| 2610 | return httpSrv, httpReady, nil, nil, nil | 2833 | return httpSrv, httpReady, nil, nil, nil | ||
| 2611 | } | 2834 | } | ||
| 2612 | 2835 | ||||
| 2613 | - | // HTTPS listener — static cert (if provided) takes priority by SNI match, | 2836 | + | // Load static cert into a local slice (not tls.Config.Certificates) so the |
| 2614 | - | // unmatched domains fall through to on-demand internal CA certs. | 2837 | + | // certificate selector has full control over priority ordering. |
| 2615 | - | tlsConfig := &tls.Config{ | 2838 | + | var staticCerts []tls.Certificate |
| 2616 | - | MinVersion: tls.VersionTLS12, | |||
| 2617 | - | GetCertificate: pkiSvc.GetCertificate, | |||
| 2618 | - | NextProtos: []string{"h2", "http/1.1"}, | |||
| 2619 | - | } | |||
| 2620 | if cfg.Server.TLSCertFile != "" { | 2839 | if cfg.Server.TLSCertFile != "" { | ||
| 2621 | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | 2840 | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | ||
| 2622 | if err != nil { | 2841 | if err != nil { | ||
| 2623 | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) | 2842 | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) | ||
| 2624 | } | 2843 | } | ||
| 2625 | - | tlsConfig.Certificates = []tls.Certificate{staticCert} | 2844 | + | staticCerts = []tls.Certificate{staticCert} |
| 2626 | log.Info(). | 2845 | log.Info(). | ||
| 2627 | Str("cert", cfg.Server.TLSCertFile). | 2846 | Str("cert", cfg.Server.TLSCertFile). | ||
| 2628 | Str("key", cfg.Server.TLSKeyFile). | 2847 | Str("key", cfg.Server.TLSKeyFile). | ||
| 2629 | - | Msg("loaded static TLS certificate (internal CA handles remaining domains)") | 2848 | + | Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)") |
| 2849 | + | } | |||
| 2850 | + | ||||
| 2851 | + | selector := &certificateSelector{ | |||
| 2852 | + | staticCerts: staticCerts, | |||
| 2853 | + | publicTLS: publicTLS, | |||
| 2854 | + | localPKI: pkiSvc, | |||
| 2855 | + | } | |||
| 2856 | + | ||||
| 2857 | + | // HTTPS listener — static cert (if provided) takes priority by SNI match via | |||
| 2858 | + | // certificateSelector, then public ACME TLS, then on-demand internal CA certs. | |||
| 2859 | + | tlsConfig := &tls.Config{ | |||
| 2860 | + | MinVersion: tls.VersionTLS12, | |||
| 2861 | + | GetCertificate: selector.GetCertificate, | |||
| 2862 | + | NextProtos: []string{"h2", "http/1.1"}, | |||
| 2630 | } | 2863 | } | ||
| 2631 | tlsSrv, tlsReady := startTLSServerWithConfig( | 2864 | tlsSrv, tlsReady := startTLSServerWithConfig( | ||
| 2632 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | 2865 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | ||
| @@ -2831,7 +3064,7 @@ func createPidFile(log zerowrap.Logger) string { | |||||
| 2831 | if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil { | 3064 | if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil { | ||
| 2832 | continue | 3065 | continue | ||
| 2833 | } | 3066 | } | ||
| 2834 | - | if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil { | 3067 | + | if err := os.WriteFile(location, fmt.Appendf(nil, "%d", pid), 0600); err == nil { |
| 2835 | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file") | 3068 | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file") | ||
| 2836 | return location | 3069 | return location | ||
| 2837 | } | 3070 | } | ||
| @@ -2966,6 +3199,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2966 | v.SetDefault("server.tls_port", 8443) | 3199 | v.SetDefault("server.tls_port", 8443) | ||
| 2967 | v.SetDefault("server.tls_cert_file", "") | 3200 | v.SetDefault("server.tls_cert_file", "") | ||
| 2968 | v.SetDefault("server.tls_key_file", "") | 3201 | v.SetDefault("server.tls_key_file", "") | ||
| 3202 | + | v.SetDefault("tls.acme.enabled", false) | |||
| 3203 | + | v.SetDefault("tls.acme.email", "") | |||
| 3204 | + | v.SetDefault("tls.acme.challenge", "auto") | |||
| 2969 | v.SetDefault("server.force_https_redirect", false) | 3205 | v.SetDefault("server.force_https_redirect", false) | ||
| 2970 | v.SetDefault("server.data_dir", DefaultDataDir()) | 3206 | v.SetDefault("server.data_dir", DefaultDataDir()) | ||
| 2971 | v.SetDefault("server.runtime", "auto") | 3207 | v.SetDefault("server.runtime", "auto") | ||
M internal/app/run_reload_test.go +5 -5
| @@ -185,7 +185,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||||
| 185 | events := &eventBusRecorder{} | 185 | events := &eventBusRecorder{} | ||
| 186 | 186 | ||||
| 187 | registryLimits := ®istryLimitsRecorder{} | 187 | registryLimits := ®istryLimitsRecorder{} | ||
| 188 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default()) | 188 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) |
| 189 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | 189 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | ||
| 190 | 190 | ||||
| 191 | require.Equal(t, 0, reloadSvc.Calls()) | 191 | require.Equal(t, 0, reloadSvc.Calls()) | ||
| @@ -220,7 +220,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |||||
| 220 | events := &eventBusRecorder{} | 220 | events := &eventBusRecorder{} | ||
| 221 | 221 | ||||
| 222 | registryLimits := ®istryLimitsRecorder{} | 222 | registryLimits := ®istryLimitsRecorder{} | ||
| 223 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default()) | 223 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) |
| 224 | require.NoError(t, coord.Trigger(ctx)) | 224 | require.NoError(t, coord.Trigger(ctx)) | ||
| 225 | require.NoError(t, coord.Trigger(ctx)) | 225 | require.NoError(t, coord.Trigger(ctx)) | ||
| 226 | 226 | ||||
| @@ -253,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) { | |||||
| 253 | reloadErr := errors.New("reload failed") | 253 | reloadErr := errors.New("reload failed") | ||
| 254 | reloadSvc := &reloadRecorder{err: reloadErr} | 254 | reloadSvc := &reloadRecorder{err: reloadErr} | ||
| 255 | proxySvc := &proxyRecorder{} | 255 | proxySvc := &proxyRecorder{} | ||
| 256 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default()) | 256 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default()) |
| 257 | 257 | ||||
| 258 | err := coord.Trigger(ctx) | 258 | err := coord.Trigger(ctx) | ||
| 259 | require.ErrorIs(t, err, reloadErr) | 259 | require.ErrorIs(t, err, reloadErr) | ||
| @@ -282,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) | |||||
| 282 | proxySvc := &proxyRecorder{} | 282 | proxySvc := &proxyRecorder{} | ||
| 283 | events := &eventBusRecorder{err: publishErr} | 283 | events := &eventBusRecorder{err: publishErr} | ||
| 284 | 284 | ||||
| 285 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, zerowrap.Default()) | 285 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, nil, zerowrap.Default()) |
| 286 | 286 | ||||
| 287 | err := coord.Trigger(ctx) | 287 | err := coord.Trigger(ctx) | ||
| 288 | require.ErrorIs(t, err, publishErr) | 288 | require.ErrorIs(t, err, publishErr) | ||
| @@ -314,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) { | |||||
| 314 | <-release | 314 | <-release | ||
| 315 | }} | 315 | }} | ||
| 316 | proxySvc := &proxyRecorder{} | 316 | proxySvc := &proxyRecorder{} | ||
| 317 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default()) | 317 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default()) |
| 318 | 318 | ||||
| 319 | firstDone := make(chan struct{}) | 319 | firstDone := make(chan struct{}) | ||
| 320 | go func() { | 320 | go func() { | ||
A internal/boundaries/in/public_tls.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | // Package in defines input ports (interfaces) for use cases. | |||
| 2 | + | // These interfaces define the contract between driving adapters (HTTP, CLI) | |||
| 3 | + | // and the business logic (use cases). | |||
| 4 | + | package in | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "context" | |||
| 8 | + | "crypto/tls" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // PublicTLSService defines the contract for public TLS / ACME certificate management. | |||
| 14 | + | type PublicTLSService interface { | |||
| 15 | + | // GetCertificate returns a TLS certificate for the given ClientHello. | |||
| 16 | + | GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) | |||
| 17 | + | ||||
| 18 | + | // GetHTTP01Challenge returns the key authorization for an HTTP-01 challenge token. | |||
| 19 | + | GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool) | |||
| 20 | + | ||||
| 21 | + | // Status returns the current public TLS status. | |||
| 22 | + | Status(ctx context.Context) domain.PublicTLSStatus | |||
| 23 | + | ||||
| 24 | + | // Reconcile ensures the desired certificate state matches the actual state. | |||
| 25 | + | Reconcile(ctx context.Context) error | |||
| 26 | + | ||||
| 27 | + | // Stop gracefully shuts down the public TLS service. | |||
| 28 | + | Stop(ctx context.Context) error | |||
| 29 | + | } | |||
A internal/boundaries/in/public_tls_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package in | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // publicTLSStub is a compile-time stub for PublicTLSService. | |||
| 12 | + | type publicTLSStub struct{} | |||
| 13 | + | ||||
| 14 | + | func (s publicTLSStub) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 15 | + | return nil, nil | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (s publicTLSStub) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) { | |||
| 19 | + | return "", false | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (s publicTLSStub) Status(ctx context.Context) domain.PublicTLSStatus { | |||
| 23 | + | return domain.PublicTLSStatus{} | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (s publicTLSStub) Reconcile(ctx context.Context) error { | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (s publicTLSStub) Stop(ctx context.Context) error { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // TestPublicTLSServiceInterface asserts that publicTLSStub satisfies PublicTLSService. | |||
| 35 | + | func TestPublicTLSServiceInterface(t *testing.T) { | |||
| 36 | + | var _ PublicTLSService = publicTLSStub{} | |||
| 37 | + | } | |||
A internal/boundaries/out/public_tls.go +92 -0
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | // Package out defines output ports (interfaces) for infrastructure. | |||
| 2 | + | // These interfaces define the contract between use cases and driven adapters. | |||
| 3 | + | package out | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/tls" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // CertificateOrder represents a request to obtain or renew a certificate. | |||
| 14 | + | type CertificateOrder struct { | |||
| 15 | + | ID string | |||
| 16 | + | Names []string | |||
| 17 | + | Challenge domain.ACMEChallengeMode | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // StoredCertificate represents a persisted certificate with its metadata. | |||
| 21 | + | type StoredCertificate struct { | |||
| 22 | + | ID string | |||
| 23 | + | Names []string | |||
| 24 | + | Challenge domain.ACMEChallengeMode | |||
| 25 | + | Certificate tls.Certificate | |||
| 26 | + | CertPEM []byte | |||
| 27 | + | ChainPEM []byte | |||
| 28 | + | FullchainPEM []byte | |||
| 29 | + | PrivateKeyPEM []byte | |||
| 30 | + | NotAfter time.Time | |||
| 31 | + | LastError string | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ACMEAccount represents a stored ACME account registration. | |||
| 35 | + | type ACMEAccount struct { | |||
| 36 | + | Email string | |||
| 37 | + | PrivateKeyPEM []byte | |||
| 38 | + | RegistrationURI string | |||
| 39 | + | BodyJSON []byte | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // SecretValue holds a resolved secret value and its source. | |||
| 43 | + | type SecretValue struct { | |||
| 44 | + | Value string | |||
| 45 | + | Source domain.ACMETokenSource | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // CloudflareZone represents a Cloudflare DNS zone. | |||
| 49 | + | type CloudflareZone struct { | |||
| 50 | + | ID string | |||
| 51 | + | Name string | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // PublicCertificateIssuer defines the contract for obtaining and renewing | |||
| 55 | + | // TLS certificates via ACME. | |||
| 56 | + | type PublicCertificateIssuer interface { | |||
| 57 | + | // Obtain requests a new certificate for the given order. | |||
| 58 | + | Obtain(ctx context.Context, order CertificateOrder) (*StoredCertificate, error) | |||
| 59 | + | ||||
| 60 | + | // Renew renews an existing certificate. | |||
| 61 | + | Renew(ctx context.Context, cert StoredCertificate) (*StoredCertificate, error) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // CertificateStore defines the contract for persisting ACME accounts and certificates. | |||
| 65 | + | type CertificateStore interface { | |||
| 66 | + | // LoadAccount retrieves an ACME account. | |||
| 67 | + | LoadAccount(ctx context.Context) (*ACMEAccount, error) | |||
| 68 | + | ||||
| 69 | + | // SaveAccount persists an ACME account. | |||
| 70 | + | SaveAccount(ctx context.Context, account ACMEAccount) error | |||
| 71 | + | ||||
| 72 | + | // LoadAll returns all stored certificates. | |||
| 73 | + | LoadAll(ctx context.Context) ([]StoredCertificate, error) | |||
| 74 | + | ||||
| 75 | + | // Save persists a certificate. | |||
| 76 | + | Save(ctx context.Context, cert StoredCertificate) error | |||
| 77 | + | ||||
| 78 | + | // Lock acquires a lock to prevent concurrent certificate operations. | |||
| 79 | + | Lock(ctx context.Context) (unlock func() error, err error) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // SecretResolver defines the contract for resolving Cloudflare API tokens. | |||
| 83 | + | type SecretResolver interface { | |||
| 84 | + | // ResolveCloudflareToken retrieves the Cloudflare API token from the configured source. | |||
| 85 | + | ResolveCloudflareToken(ctx context.Context) (SecretValue, error) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // CloudflareZoneResolver defines the contract for resolving DNS zones. | |||
| 89 | + | type CloudflareZoneResolver interface { | |||
| 90 | + | // FindZone finds the Cloudflare zone for the given domain. | |||
| 91 | + | FindZone(ctx context.Context, domain string) (CloudflareZone, error) | |||
| 92 | + | } | |||
A internal/boundaries/out/public_tls_test.go +72 -0
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // issuerStub implements PublicCertificateIssuer. | |||
| 13 | + | type issuerStub struct{} | |||
| 14 | + | ||||
| 15 | + | func (s issuerStub) Obtain(ctx context.Context, order CertificateOrder) (*StoredCertificate, error) { | |||
| 16 | + | return nil, nil | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (s issuerStub) Renew(ctx context.Context, cert StoredCertificate) (*StoredCertificate, error) { | |||
| 20 | + | return nil, nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // storeStub implements CertificateStore. | |||
| 24 | + | type storeStub struct{} | |||
| 25 | + | ||||
| 26 | + | func (s storeStub) LoadAccount(ctx context.Context) (*ACMEAccount, error) { | |||
| 27 | + | return nil, nil | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (s storeStub) SaveAccount(ctx context.Context, account ACMEAccount) error { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (s storeStub) LoadAll(ctx context.Context) ([]StoredCertificate, error) { | |||
| 35 | + | return nil, nil | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (s storeStub) Save(ctx context.Context, cert StoredCertificate) error { | |||
| 39 | + | return nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (s storeStub) Lock(ctx context.Context) (func() error, error) { | |||
| 43 | + | return func() error { return nil }, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // secretStub implements SecretResolver. | |||
| 47 | + | type secretStub struct{} | |||
| 48 | + | ||||
| 49 | + | func (s secretStub) ResolveCloudflareToken(ctx context.Context) (SecretValue, error) { | |||
| 50 | + | return SecretValue{}, nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // zoneStub implements CloudflareZoneResolver. | |||
| 54 | + | type zoneStub struct{} | |||
| 55 | + | ||||
| 56 | + | func (s zoneStub) FindZone(ctx context.Context, domainName string) (CloudflareZone, error) { | |||
| 57 | + | return CloudflareZone{}, nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // TestPublicTLSOutputInterfaces asserts that all stubs satisfy their respective interfaces | |||
| 61 | + | // and that the required types are importable. | |||
| 62 | + | func TestPublicTLSOutputInterfaces(t *testing.T) { | |||
| 63 | + | var _ PublicCertificateIssuer = issuerStub{} | |||
| 64 | + | var _ CertificateStore = storeStub{} | |||
| 65 | + | var _ SecretResolver = secretStub{} | |||
| 66 | + | var _ CloudflareZoneResolver = zoneStub{} | |||
| 67 | + | ||||
| 68 | + | // Verify that referenced types are importable | |||
| 69 | + | var _ tls.Certificate | |||
| 70 | + | var _ time.Time | |||
| 71 | + | _ = domain.ACMEChallengeHTTP01 | |||
| 72 | + | } | |||
A internal/domain/public_tls.go +124 -0
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type ACMEChallengeMode string | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | ACMEChallengeAuto ACMEChallengeMode = "auto" | |||
| 14 | + | ACMEChallengeHTTP01 ACMEChallengeMode = "http-01" | |||
| 15 | + | ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | var ( | |||
| 19 | + | ErrACMEDisabled = errors.New("acme disabled") | |||
| 20 | + | ErrACMEEmailRequired = errors.New("acme email required") | |||
| 21 | + | ErrACMEChallengeInvalid = errors.New("acme challenge invalid") | |||
| 22 | + | ErrCloudflareTokenMissing = errors.New("cloudflare api token missing") | |||
| 23 | + | ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate") | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) { | |||
| 27 | + | switch strings.TrimSpace(value) { | |||
| 28 | + | case "", string(ACMEChallengeAuto): | |||
| 29 | + | return ACMEChallengeAuto, nil | |||
| 30 | + | case string(ACMEChallengeHTTP01): | |||
| 31 | + | return ACMEChallengeHTTP01, nil | |||
| 32 | + | case string(ACMEChallengeCloudflareDNS01): | |||
| 33 | + | return ACMEChallengeCloudflareDNS01, nil | |||
| 34 | + | default: | |||
| 35 | + | return "", fmt.Errorf("%w: %q", ErrACMEChallengeInvalid, value) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type ACMETokenSource string | |||
| 40 | + | ||||
| 41 | + | const ( | |||
| 42 | + | ACMETokenSourceNone ACMETokenSource = "none" | |||
| 43 | + | ACMETokenSourcePass ACMETokenSource = "pass" | |||
| 44 | + | ACMETokenSourceFile ACMETokenSource = "file" | |||
| 45 | + | ACMETokenSourceEnv ACMETokenSource = "env" | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 48 | + | type TLSCertificateStatus string | |||
| 49 | + | ||||
| 50 | + | const ( | |||
| 51 | + | TLSCertificateStatusMissing TLSCertificateStatus = "missing" | |||
| 52 | + | TLSCertificateStatusValid TLSCertificateStatus = "valid" | |||
| 53 | + | TLSCertificateStatusWarning TLSCertificateStatus = "warning" | |||
| 54 | + | TLSCertificateStatusExpired TLSCertificateStatus = "expired" | |||
| 55 | + | TLSCertificateStatusError TLSCertificateStatus = "error" | |||
| 56 | + | ) | |||
| 57 | + | ||||
| 58 | + | type ManagedCertificate struct { | |||
| 59 | + | ID string `json:"id"` | |||
| 60 | + | Names []string `json:"names"` | |||
| 61 | + | Challenge ACMEChallengeMode `json:"challenge"` | |||
| 62 | + | Status TLSCertificateStatus `json:"status"` | |||
| 63 | + | NotAfter time.Time `json:"not_after"` | |||
| 64 | + | LastError string `json:"last_error,omitempty"` | |||
| 65 | + | RenewalPending bool `json:"renewal_pending"` | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (c ManagedCertificate) Covers(host string) bool { | |||
| 69 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |||
| 70 | + | if host == "" { | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | for _, name := range c.Names { | |||
| 74 | + | name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") | |||
| 75 | + | if name == host { | |||
| 76 | + | return true | |||
| 77 | + | } | |||
| 78 | + | if strings.HasPrefix(name, "*.") { | |||
| 79 | + | suffix := strings.TrimPrefix(name, "*.") | |||
| 80 | + | if strings.HasSuffix(host, "."+suffix) { | |||
| 81 | + | left := strings.TrimSuffix(host, "."+suffix) | |||
| 82 | + | if left != "" && !strings.Contains(left, ".") { | |||
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | return false | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (c ManagedCertificate) Health(now time.Time) TLSCertificateStatus { | |||
| 92 | + | if c.LastError != "" { | |||
| 93 | + | return TLSCertificateStatusError | |||
| 94 | + | } | |||
| 95 | + | if c.NotAfter.IsZero() { | |||
| 96 | + | return TLSCertificateStatusMissing | |||
| 97 | + | } | |||
| 98 | + | if !now.Before(c.NotAfter) { | |||
| 99 | + | return TLSCertificateStatusExpired | |||
| 100 | + | } | |||
| 101 | + | if now.Add(30 * 24 * time.Hour).After(c.NotAfter) { | |||
| 102 | + | return TLSCertificateStatusWarning | |||
| 103 | + | } | |||
| 104 | + | return TLSCertificateStatusValid | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | type TLSRouteCoverage struct { | |||
| 108 | + | Domain string `json:"domain"` | |||
| 109 | + | Covered bool `json:"covered"` | |||
| 110 | + | CoveredBy string `json:"covered_by,omitempty"` | |||
| 111 | + | RequiredACME bool `json:"required_acme"` | |||
| 112 | + | Error string `json:"error,omitempty"` | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | type PublicTLSStatus struct { | |||
| 116 | + | ACMEEnabled bool `json:"acme_enabled"` | |||
| 117 | + | ConfiguredMode ACMEChallengeMode `json:"configured_mode"` | |||
| 118 | + | EffectiveMode ACMEChallengeMode `json:"effective_mode"` | |||
| 119 | + | SelectionReason string `json:"selection_reason"` | |||
| 120 | + | TokenSource ACMETokenSource `json:"token_source"` | |||
| 121 | + | Certificates []ManagedCertificate `json:"certificates"` | |||
| 122 | + | Routes []TLSRouteCoverage `json:"routes"` | |||
| 123 | + | Errors []string `json:"errors,omitempty"` | |||
| 124 | + | } | |||
A internal/domain/public_tls_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestACMEChallengeModeParse(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | input string | |||
| 14 | + | want ACMEChallengeMode | |||
| 15 | + | ok bool | |||
| 16 | + | }{ | |||
| 17 | + | {"", ACMEChallengeAuto, true}, | |||
| 18 | + | {"auto", ACMEChallengeAuto, true}, | |||
| 19 | + | {"http-01", ACMEChallengeHTTP01, true}, | |||
| 20 | + | {"cloudflare-dns-01", ACMEChallengeCloudflareDNS01, true}, | |||
| 21 | + | {"dns", "", false}, | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | got, err := ParseACMEChallengeMode(tt.input) | |||
| 26 | + | if tt.ok { | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | assert.Equal(t, tt.want, got) | |||
| 29 | + | } else { | |||
| 30 | + | require.Error(t, err) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestManagedCertificateCovers(t *testing.T) { | |||
| 36 | + | apexCert := ManagedCertificate{Names: []string{"example.com", "*.example.com"}} | |||
| 37 | + | prodCert := ManagedCertificate{Names: []string{"prod.example.com", "*.prod.example.com"}} | |||
| 38 | + | ||||
| 39 | + | assert.True(t, apexCert.Covers("example.com")) | |||
| 40 | + | assert.True(t, apexCert.Covers("app.example.com")) | |||
| 41 | + | assert.False(t, apexCert.Covers("api.prod.example.com"), "*.example.com must not cover two labels") | |||
| 42 | + | assert.True(t, prodCert.Covers("api.prod.example.com")) | |||
| 43 | + | assert.False(t, apexCert.Covers("other.test")) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestManagedCertificateHealth(t *testing.T) { | |||
| 47 | + | valid := ManagedCertificate{NotAfter: time.Now().Add(60 * 24 * time.Hour)} | |||
| 48 | + | expired := ManagedCertificate{NotAfter: time.Now().Add(-time.Hour)} | |||
| 49 | + | ||||
| 50 | + | assert.Equal(t, TLSCertificateStatusValid, valid.Health(time.Now())) | |||
| 51 | + | assert.Equal(t, TLSCertificateStatusExpired, expired.Health(time.Now())) | |||
| 52 | + | } | |||
A internal/usecase/publictls/config.go +147 -0
| @@ -0,0 +1,147 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Config holds the resolved configuration for public ACME TLS. | |||
| 13 | + | type Config struct { | |||
| 14 | + | Enabled bool | |||
| 15 | + | Email string | |||
| 16 | + | Challenge string | |||
| 17 | + | HTTPPort int | |||
| 18 | + | TLSPort int | |||
| 19 | + | DataDir string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // tokenResolver resolves Cloudflare API tokens. | |||
| 23 | + | type tokenResolver interface { | |||
| 24 | + | ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // EffectiveChallenge represents the resolved ACME challenge configuration. | |||
| 28 | + | type EffectiveChallenge struct { | |||
| 29 | + | ConfiguredMode domain.ACMEChallengeMode | |||
| 30 | + | Mode domain.ACMEChallengeMode | |||
| 31 | + | Token string | |||
| 32 | + | TokenSource domain.ACMETokenSource | |||
| 33 | + | Reason string | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ResolveEffectiveChallenge resolves the effective ACME challenge configuration | |||
| 37 | + | // from the given Config and optional token resolver. | |||
| 38 | + | func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver tokenResolver) (EffectiveChallenge, error) { | |||
| 39 | + | if !cfg.Enabled { | |||
| 40 | + | return EffectiveChallenge{ | |||
| 41 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |||
| 42 | + | Mode: domain.ACMEChallengeAuto, | |||
| 43 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 44 | + | Reason: "ACME disabled", | |||
| 45 | + | }, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | if cfg.Email == "" { | |||
| 49 | + | return EffectiveChallenge{}, domain.ErrACMEEmailRequired | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | if cfg.TLSPort == 0 { | |||
| 53 | + | return EffectiveChallenge{}, fmt.Errorf("%w: tls_port must be set", domain.ErrACMEChallengeInvalid) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | parsedMode, err := domain.ParseACMEChallengeMode(cfg.Challenge) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return EffectiveChallenge{}, err | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | switch parsedMode { | |||
| 62 | + | case domain.ACMEChallengeHTTP01: | |||
| 63 | + | if cfg.HTTPPort == 0 { | |||
| 64 | + | return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be set for http-01 challenge", domain.ErrACMEChallengeInvalid) | |||
| 65 | + | } | |||
| 66 | + | return EffectiveChallenge{ | |||
| 67 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 68 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 69 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 70 | + | Reason: "http-01 challenge selected", | |||
| 71 | + | }, nil | |||
| 72 | + | ||||
| 73 | + | case domain.ACMEChallengeCloudflareDNS01: | |||
| 74 | + | token, source, err := resolveToken(ctx, resolver) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return EffectiveChallenge{}, fmt.Errorf("resolve Cloudflare token: %w", err) | |||
| 77 | + | } | |||
| 78 | + | if token == "" { | |||
| 79 | + | return EffectiveChallenge{}, domain.ErrCloudflareTokenMissing | |||
| 80 | + | } | |||
| 81 | + | return EffectiveChallenge{ | |||
| 82 | + | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |||
| 83 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |||
| 84 | + | Token: token, | |||
| 85 | + | TokenSource: source, | |||
| 86 | + | Reason: "cloudflare dns-01 challenge selected", | |||
| 87 | + | }, nil | |||
| 88 | + | ||||
| 89 | + | case domain.ACMEChallengeAuto: | |||
| 90 | + | token, source := resolveOptionalToken(ctx, resolver) | |||
| 91 | + | if token != "" { | |||
| 92 | + | return EffectiveChallenge{ | |||
| 93 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |||
| 94 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |||
| 95 | + | Token: token, | |||
| 96 | + | TokenSource: source, | |||
| 97 | + | Reason: "auto selected cloudflare dns-01 (token available)", | |||
| 98 | + | }, nil | |||
| 99 | + | } | |||
| 100 | + | if cfg.HTTPPort == 0 { | |||
| 101 | + | return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be set for http-01 fallback in auto mode", domain.ErrACMEChallengeInvalid) | |||
| 102 | + | } | |||
| 103 | + | return EffectiveChallenge{ | |||
| 104 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |||
| 105 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 106 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 107 | + | Reason: "auto selected http-01 (no cloudflare token)", | |||
| 108 | + | }, nil | |||
| 109 | + | ||||
| 110 | + | default: | |||
| 111 | + | return EffectiveChallenge{}, fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, parsedMode) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // resolveOptionalToken resolves the Cloudflare token, ignoring resolver errors. | |||
| 116 | + | // If the resolver is nil or returns an empty token, the result is empty with source none. | |||
| 117 | + | func resolveOptionalToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource) { | |||
| 118 | + | if resolver == nil { | |||
| 119 | + | return "", domain.ACMETokenSourceNone | |||
| 120 | + | } | |||
| 121 | + | sv, err := resolver.ResolveCloudflareToken(ctx) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return "", domain.ACMETokenSourceNone | |||
| 124 | + | } | |||
| 125 | + | token := strings.TrimSpace(sv.Value) | |||
| 126 | + | if token == "" { | |||
| 127 | + | return "", domain.ACMETokenSourceNone | |||
| 128 | + | } | |||
| 129 | + | return token, sv.Source | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // resolveToken resolves the Cloudflare token via the resolver, propagating errors. | |||
| 133 | + | // Returns empty token and none source if resolver is nil. | |||
| 134 | + | func resolveToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource, error) { | |||
| 135 | + | if resolver == nil { | |||
| 136 | + | return "", domain.ACMETokenSourceNone, nil | |||
| 137 | + | } | |||
| 138 | + | sv, err := resolver.ResolveCloudflareToken(ctx) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return "", domain.ACMETokenSourceNone, err | |||
| 141 | + | } | |||
| 142 | + | token := strings.TrimSpace(sv.Value) | |||
| 143 | + | if token == "" { | |||
| 144 | + | return "", domain.ACMETokenSourceNone, nil | |||
| 145 | + | } | |||
| 146 | + | return token, sv.Source, nil | |||
| 147 | + | } | |||
A internal/usecase/publictls/config_test.go +158 -0
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // stubResolver is a simple implementation of tokenResolver for testing. | |||
| 16 | + | type stubResolver struct { | |||
| 17 | + | value out.SecretValue | |||
| 18 | + | err error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (s stubResolver) ResolveCloudflareToken(_ context.Context) (out.SecretValue, error) { | |||
| 22 | + | return s.value, s.err | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestResolveEffectiveChallenge(t *testing.T) { | |||
| 26 | + | t.Run("disabled returns auto mode with none source", func(t *testing.T) { | |||
| 27 | + | cfg := Config{Enabled: false} | |||
| 28 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode) | |||
| 31 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode) | |||
| 32 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |||
| 33 | + | assert.Equal(t, "ACME disabled", ec.Reason) | |||
| 34 | + | }) | |||
| 35 | + | ||||
| 36 | + | t.Run("enabled requires non-empty email", func(t *testing.T) { | |||
| 37 | + | cfg := Config{Enabled: true, Email: "", TLSPort: 8443} | |||
| 38 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |||
| 39 | + | require.Error(t, err) | |||
| 40 | + | assert.ErrorIs(t, err, domain.ErrACMEEmailRequired) | |||
| 41 | + | }) | |||
| 42 | + | ||||
| 43 | + | t.Run("enabled requires non-zero tls port", func(t *testing.T) { | |||
| 44 | + | cfg := Config{Enabled: true, Email: "admin@example.com", TLSPort: 0} | |||
| 45 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |||
| 46 | + | require.Error(t, err) | |||
| 47 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |||
| 48 | + | assert.Contains(t, err.Error(), "tls_port") | |||
| 49 | + | }) | |||
| 50 | + | ||||
| 51 | + | t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) { | |||
| 52 | + | cfg := Config{ | |||
| 53 | + | Enabled: true, | |||
| 54 | + | Email: "admin@example.com", | |||
| 55 | + | Challenge: "auto", | |||
| 56 | + | TLSPort: 8443, | |||
| 57 | + | HTTPPort: 8088, | |||
| 58 | + | } | |||
| 59 | + | resolver := stubResolver{ | |||
| 60 | + | value: out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass}, | |||
| 61 | + | } | |||
| 62 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode) | |||
| 65 | + | assert.Equal(t, domain.ACMETokenSourcePass, ec.TokenSource) | |||
| 66 | + | assert.Equal(t, "token", ec.Token) | |||
| 67 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode) | |||
| 68 | + | }) | |||
| 69 | + | ||||
| 70 | + | t.Run("auto without token returns http-01", func(t *testing.T) { | |||
| 71 | + | cfg := Config{ | |||
| 72 | + | Enabled: true, | |||
| 73 | + | Email: "admin@example.com", | |||
| 74 | + | Challenge: "auto", | |||
| 75 | + | TLSPort: 8443, | |||
| 76 | + | HTTPPort: 8088, | |||
| 77 | + | } | |||
| 78 | + | resolver := stubResolver{ | |||
| 79 | + | value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, | |||
| 80 | + | } | |||
| 81 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode) | |||
| 84 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |||
| 85 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode) | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 88 | + | t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) { | |||
| 89 | + | cfg := Config{ | |||
| 90 | + | Enabled: true, | |||
| 91 | + | Email: "admin@example.com", | |||
| 92 | + | Challenge: "cloudflare-dns-01", | |||
| 93 | + | TLSPort: 8443, | |||
| 94 | + | HTTPPort: 8088, | |||
| 95 | + | } | |||
| 96 | + | resolver := stubResolver{ | |||
| 97 | + | value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, | |||
| 98 | + | } | |||
| 99 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |||
| 100 | + | require.Error(t, err) | |||
| 101 | + | assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing) | |||
| 102 | + | }) | |||
| 103 | + | ||||
| 104 | + | t.Run("http-01 requires http port", func(t *testing.T) { | |||
| 105 | + | cfg := Config{ | |||
| 106 | + | Enabled: true, | |||
| 107 | + | Email: "admin@example.com", | |||
| 108 | + | Challenge: "http-01", | |||
| 109 | + | TLSPort: 8443, | |||
| 110 | + | HTTPPort: 0, | |||
| 111 | + | } | |||
| 112 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |||
| 113 | + | require.Error(t, err) | |||
| 114 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |||
| 115 | + | }) | |||
| 116 | + | ||||
| 117 | + | t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) { | |||
| 118 | + | cfg := Config{ | |||
| 119 | + | Enabled: true, | |||
| 120 | + | Email: "admin@example.com", | |||
| 121 | + | Challenge: "cloudflare-dns-01", | |||
| 122 | + | TLSPort: 8443, | |||
| 123 | + | } | |||
| 124 | + | resolver := stubResolver{ | |||
| 125 | + | err: errors.New("pass failed"), | |||
| 126 | + | } | |||
| 127 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |||
| 128 | + | require.Error(t, err) | |||
| 129 | + | assert.Contains(t, err.Error(), "resolve Cloudflare token") | |||
| 130 | + | assert.False(t, errors.Is(err, domain.ErrCloudflareTokenMissing), "must not collapse to missing token") | |||
| 131 | + | }) | |||
| 132 | + | ||||
| 133 | + | t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) { | |||
| 134 | + | cfg := Config{ | |||
| 135 | + | Enabled: true, | |||
| 136 | + | Email: "admin@example.com", | |||
| 137 | + | Challenge: "auto", | |||
| 138 | + | TLSPort: 8443, | |||
| 139 | + | HTTPPort: 8088, | |||
| 140 | + | } | |||
| 141 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |||
| 142 | + | require.NoError(t, err) | |||
| 143 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode) | |||
| 144 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |||
| 145 | + | }) | |||
| 146 | + | ||||
| 147 | + | t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) { | |||
| 148 | + | cfg := Config{ | |||
| 149 | + | Enabled: true, | |||
| 150 | + | Email: "admin@example.com", | |||
| 151 | + | Challenge: "cloudflare-dns-01", | |||
| 152 | + | TLSPort: 8443, | |||
| 153 | + | } | |||
| 154 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |||
| 155 | + | require.Error(t, err) | |||
| 156 | + | assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing) | |||
| 157 | + | }) | |||
| 158 | + | } | |||
A internal/usecase/publictls/http01.go +71 -0
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key | |||
| 10 | + | // authorizations. It is safe for concurrent use. | |||
| 11 | + | type HTTP01Challenges struct { | |||
| 12 | + | mu sync.Mutex | |||
| 13 | + | data map[string]string | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewHTTP01Challenges returns an initialized HTTP01Challenges. | |||
| 17 | + | func NewHTTP01Challenges() *HTTP01Challenges { | |||
| 18 | + | return &HTTP01Challenges{ | |||
| 19 | + | data: make(map[string]string), | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Present stores the key authorization for the given token. It silently | |||
| 24 | + | // ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values. | |||
| 25 | + | func (c *HTTP01Challenges) Present(token, keyAuth string) { | |||
| 26 | + | if !safeHTTP01Token(token) || keyAuth == "" { | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | c.mu.Lock() | |||
| 30 | + | defer c.mu.Unlock() | |||
| 31 | + | c.data[token] = keyAuth | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // CleanUp removes the stored key authorization for the given token. | |||
| 35 | + | func (c *HTTP01Challenges) CleanUp(token string) { | |||
| 36 | + | c.mu.Lock() | |||
| 37 | + | defer c.mu.Unlock() | |||
| 38 | + | delete(c.data, token) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Get returns the key authorization for the given token, and a boolean | |||
| 42 | + | // indicating whether it was found. It returns false for unsafe tokens | |||
| 43 | + | // (see safeHTTP01Token). | |||
| 44 | + | func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | |||
| 45 | + | if !safeHTTP01Token(token) { | |||
| 46 | + | return "", false | |||
| 47 | + | } | |||
| 48 | + | c.mu.Lock() | |||
| 49 | + | defer c.mu.Unlock() | |||
| 50 | + | keyAuth, ok := c.data[token] | |||
| 51 | + | return keyAuth, ok | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // safeHTTP01Token validates an ACME HTTP-01 challenge token. | |||
| 55 | + | // It rejects empty tokens, tokens containing "/" or "\", and tokens | |||
| 56 | + | // equal to "..", which could allow path traversal. | |||
| 57 | + | func safeHTTP01Token(token string) bool { | |||
| 58 | + | if token == "" { | |||
| 59 | + | return false | |||
| 60 | + | } | |||
| 61 | + | if strings.Contains(token, "/") { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | if strings.Contains(token, "\\") { | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | if token == ".." { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | return true | |||
| 71 | + | } | |||
A internal/usecase/publictls/http01_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHTTP01Challenges(t *testing.T) { | |||
| 12 | + | ctx := context.Background() | |||
| 13 | + | ||||
| 14 | + | t.Run("full lifecycle", func(t *testing.T) { | |||
| 15 | + | ch := NewHTTP01Challenges() | |||
| 16 | + | require.NotNil(t, ch) | |||
| 17 | + | ||||
| 18 | + | // Present a valid token | |||
| 19 | + | ch.Present("token", "key-auth") | |||
| 20 | + | got, ok := ch.Get(ctx, "token") | |||
| 21 | + | assert.True(t, ok) | |||
| 22 | + | assert.Equal(t, "key-auth", got) | |||
| 23 | + | ||||
| 24 | + | // Clean up | |||
| 25 | + | ch.CleanUp("token") | |||
| 26 | + | _, ok = ch.Get(ctx, "token") | |||
| 27 | + | assert.False(t, ok) | |||
| 28 | + | }) | |||
| 29 | + | ||||
| 30 | + | t.Run("concurrent access does not panic", func(t *testing.T) { | |||
| 31 | + | ch := NewHTTP01Challenges() | |||
| 32 | + | done := make(chan struct{}) | |||
| 33 | + | go func() { | |||
| 34 | + | ch.Present("t1", "ka1") | |||
| 35 | + | done <- struct{}{} | |||
| 36 | + | }() | |||
| 37 | + | go func() { | |||
| 38 | + | ch.Get(ctx, "t1") | |||
| 39 | + | done <- struct{}{} | |||
| 40 | + | }() | |||
| 41 | + | <-done | |||
| 42 | + | <-done | |||
| 43 | + | // If we reach here without a race, the test passes. | |||
| 44 | + | }) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestHTTP01ChallengesRejectUnsafeToken(t *testing.T) { | |||
| 48 | + | ctx := context.Background() | |||
| 49 | + | ||||
| 50 | + | t.Run("path traversal token ../secret is rejected", func(t *testing.T) { | |||
| 51 | + | ch := NewHTTP01Challenges() | |||
| 52 | + | ||||
| 53 | + | ch.Present("../secret", "key-auth") | |||
| 54 | + | _, ok := ch.Get(ctx, "../secret") | |||
| 55 | + | assert.False(t, ok, "Present should silently ignore ../secret") | |||
| 56 | + | }) | |||
| 57 | + | ||||
| 58 | + | t.Run("token with slash is rejected", func(t *testing.T) { | |||
| 59 | + | ch := NewHTTP01Challenges() | |||
| 60 | + | ||||
| 61 | + | ch.Present("a/b", "key-auth") | |||
| 62 | + | _, ok := ch.Get(ctx, "a/b") | |||
| 63 | + | assert.False(t, ok, "token containing / should be rejected") | |||
| 64 | + | ||||
| 65 | + | // Get also rejects the unsafe token | |||
| 66 | + | _, ok = ch.Get(ctx, "safe/token") | |||
| 67 | + | assert.False(t, ok) | |||
| 68 | + | }) | |||
| 69 | + | ||||
| 70 | + | t.Run("token with backslash is rejected", func(t *testing.T) { | |||
| 71 | + | ch := NewHTTP01Challenges() | |||
| 72 | + | ||||
| 73 | + | ch.Present("a\\b", "key-auth") | |||
| 74 | + | _, ok := ch.Get(ctx, "a\\b") | |||
| 75 | + | assert.False(t, ok, "token containing backslash should be rejected") | |||
| 76 | + | ||||
| 77 | + | _, ok = ch.Get(ctx, "safe\\token") | |||
| 78 | + | assert.False(t, ok) | |||
| 79 | + | }) | |||
| 80 | + | ||||
| 81 | + | t.Run("empty token is rejected", func(t *testing.T) { | |||
| 82 | + | ch := NewHTTP01Challenges() | |||
| 83 | + | ||||
| 84 | + | ch.Present("", "key-auth") | |||
| 85 | + | _, ok := ch.Get(ctx, "") | |||
| 86 | + | assert.False(t, ok, "empty token should be rejected") | |||
| 87 | + | }) | |||
| 88 | + | ||||
| 89 | + | t.Run("'..' token is rejected", func(t *testing.T) { | |||
| 90 | + | ch := NewHTTP01Challenges() | |||
| 91 | + | ||||
| 92 | + | ch.Present("..", "key-auth") | |||
| 93 | + | _, ok := ch.Get(ctx, "..") | |||
| 94 | + | assert.False(t, ok, "token '..' should be rejected") | |||
| 95 | + | }) | |||
| 96 | + | ||||
| 97 | + | t.Run("empty keyAuth is silently ignored", func(t *testing.T) { | |||
| 98 | + | ch := NewHTTP01Challenges() | |||
| 99 | + | ch.Present("valid-token", "") | |||
| 100 | + | _, ok := ch.Get(ctx, "valid-token") | |||
| 101 | + | assert.False(t, ok, "Present with empty keyAuth should not store anything") | |||
| 102 | + | }) | |||
| 103 | + | ||||
| 104 | + | t.Run("valid tokens are accepted and retrievable", func(t *testing.T) { | |||
| 105 | + | ch := NewHTTP01Challenges() | |||
| 106 | + | ch.Present("valid-token", "key-auth-value") | |||
| 107 | + | got, ok := ch.Get(ctx, "valid-token") | |||
| 108 | + | assert.True(t, ok) | |||
| 109 | + | assert.Equal(t, "key-auth-value", got) | |||
| 110 | + | }) | |||
| 111 | + | } | |||
A internal/usecase/publictls/integration_test.go +306 -0
| @@ -0,0 +1,306 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // --------------------------------------------------------------------------- | |||
| 16 | + | // Integration-style tests using fakes (no real ACME / Cloudflare) | |||
| 17 | + | // --------------------------------------------------------------------------- | |||
| 18 | + | ||||
| 19 | + | func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing.T) { | |||
| 20 | + | ctx := context.Background() | |||
| 21 | + | ||||
| 22 | + | // Routes: app.example.com, api.prod.example.com, example.com | |||
| 23 | + | routes := &fakeRoutes{ | |||
| 24 | + | routes: []domain.Route{ | |||
| 25 | + | {Domain: "app.example.com"}, | |||
| 26 | + | {Domain: "api.prod.example.com"}, | |||
| 27 | + | {Domain: "example.com"}, | |||
| 28 | + | }, | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Fake zone resolver returns "example.com" zone. | |||
| 32 | + | zoneResolver := fixedZoneResolver{zone: "example.com"} | |||
| 33 | + | ||||
| 34 | + | // Fake issuer: returns valid stored cert for any order. | |||
| 35 | + | issuer := &fakeIssuer{ | |||
| 36 | + | obtain: func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 37 | + | return defaultStoredCert(order) | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | store := &fakeStore{} | |||
| 41 | + | ||||
| 42 | + | cfg := Config{ | |||
| 43 | + | Enabled: true, | |||
| 44 | + | Email: "admin@example.com", | |||
| 45 | + | Challenge: "cloudflare-dns-01", | |||
| 46 | + | TLSPort: 8443, | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 50 | + | Config: cfg, | |||
| 51 | + | Routes: routes, | |||
| 52 | + | Issuer: issuer, | |||
| 53 | + | Store: store, | |||
| 54 | + | ZoneResolver: zoneResolver, | |||
| 55 | + | Challenges: NewHTTP01Challenges(), | |||
| 56 | + | Effective: EffectiveChallenge{ | |||
| 57 | + | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |||
| 58 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |||
| 59 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 60 | + | Reason: "cloudflare dns-01 challenge selected", | |||
| 61 | + | }, | |||
| 62 | + | }) | |||
| 63 | + | ||||
| 64 | + | // Load (nothing stored). | |||
| 65 | + | err := svc.Load(ctx) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | ||||
| 68 | + | // Reconcile — should obtain DNS wildcard base targets: | |||
| 69 | + | // example.com (for app.example.com + example.com) and prod.example.com (for api.prod.example.com). | |||
| 70 | + | err = svc.Reconcile(ctx) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | ||||
| 73 | + | // Verify issuer received exactly 2 orders. | |||
| 74 | + | issuer.mu.Lock() | |||
| 75 | + | orders := make([]out.CertificateOrder, len(issuer.orders)) | |||
| 76 | + | copy(orders, issuer.orders) | |||
| 77 | + | issuer.mu.Unlock() | |||
| 78 | + | ||||
| 79 | + | require.Len(t, orders, 2, "expected 2 DNS-01 orders") | |||
| 80 | + | ||||
| 81 | + | // Check order details (orders are sorted alphabetically by target base). | |||
| 82 | + | order0 := orders[0] | |||
| 83 | + | assert.Equal(t, "dns01-example.com", order0.ID) | |||
| 84 | + | assert.ElementsMatch(t, []string{"example.com", "*.example.com"}, order0.Names) | |||
| 85 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order0.Challenge) | |||
| 86 | + | ||||
| 87 | + | order1 := orders[1] | |||
| 88 | + | assert.Equal(t, "dns01-prod.example.com", order1.ID) | |||
| 89 | + | assert.ElementsMatch(t, []string{"prod.example.com", "*.prod.example.com"}, order1.Names) | |||
| 90 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order1.Challenge) | |||
| 91 | + | ||||
| 92 | + | // --- Status reports routes covered --- | |||
| 93 | + | status := svc.Status(ctx) | |||
| 94 | + | require.True(t, status.ACMEEnabled) | |||
| 95 | + | require.Len(t, status.Routes, 3, "expected 3 routes in status") | |||
| 96 | + | ||||
| 97 | + | // Build a map for easier assertions. | |||
| 98 | + | routeMap := make(map[string]domain.TLSRouteCoverage) | |||
| 99 | + | for _, r := range status.Routes { | |||
| 100 | + | routeMap[r.Domain] = r | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | for _, domain := range []string{"app.example.com", "api.prod.example.com", "example.com"} { | |||
| 104 | + | rc, ok := routeMap[domain] | |||
| 105 | + | require.True(t, ok, "route %s should be present in status", domain) | |||
| 106 | + | assert.True(t, rc.Covered, "route %s should be covered", domain) | |||
| 107 | + | assert.True(t, rc.RequiredACME) | |||
| 108 | + | assert.NotEmpty(t, rc.CoveredBy, "route %s should have CoveredBy set", domain) | |||
| 109 | + | assert.Empty(t, rc.Error, "route %s should have no error", domain) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // app.example.com should be covered by the wildcard cert (dns01-example.com). | |||
| 113 | + | assert.Equal(t, "dns01-example.com", routeMap["app.example.com"].CoveredBy, | |||
| 114 | + | "app.example.com should be covered by dns01-example.com (wildcard *.example.com)") | |||
| 115 | + | ||||
| 116 | + | // example.com should be covered by the base cert (dns01-example.com). | |||
| 117 | + | assert.Equal(t, "dns01-example.com", routeMap["example.com"].CoveredBy, | |||
| 118 | + | "example.com should be covered by dns01-example.com") | |||
| 119 | + | ||||
| 120 | + | // api.prod.example.com should be covered by the wildcard cert (dns01-prod.example.com). | |||
| 121 | + | assert.Equal(t, "dns01-prod.example.com", routeMap["api.prod.example.com"].CoveredBy, | |||
| 122 | + | "api.prod.example.com should be covered by dns01-prod.example.com (wildcard *.prod.example.com)") | |||
| 123 | + | ||||
| 124 | + | // --- GetCertificate returns non-nil certs --- | |||
| 125 | + | t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) { | |||
| 126 | + | cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"}) | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | require.NotNil(t, cert) | |||
| 129 | + | require.NotEmpty(t, cert.Certificate) | |||
| 130 | + | }) | |||
| 131 | + | ||||
| 132 | + | t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) { | |||
| 133 | + | cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "api.prod.example.com"}) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | require.NotNil(t, cert) | |||
| 136 | + | require.NotEmpty(t, cert.Certificate) | |||
| 137 | + | }) | |||
| 138 | + | ||||
| 139 | + | t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) { | |||
| 140 | + | cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "example.com"}) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | require.NotNil(t, cert) | |||
| 143 | + | require.NotEmpty(t, cert.Certificate) | |||
| 144 | + | }) | |||
| 145 | + | ||||
| 146 | + | // Also verify that a host not in the routes returns nil (no required ACME). | |||
| 147 | + | // "unrelated.com" is not covered by any wildcard base in requiredHosts. | |||
| 148 | + | t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) { | |||
| 149 | + | cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "unrelated.com"}) | |||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | assert.Nil(t, cert, "unrelated host should not require ACME coverage") | |||
| 152 | + | }) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) { | |||
| 156 | + | ctx := context.Background() | |||
| 157 | + | ||||
| 158 | + | routes := &fakeRoutes{ | |||
| 159 | + | routes: []domain.Route{ | |||
| 160 | + | {Domain: "app.example.com"}, | |||
| 161 | + | }, | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | issuer := &fakeIssuer{} | |||
| 165 | + | store := &fakeStore{} | |||
| 166 | + | challenges := NewHTTP01Challenges() | |||
| 167 | + | ||||
| 168 | + | cfg := Config{ | |||
| 169 | + | Enabled: true, | |||
| 170 | + | Email: "admin@example.com", | |||
| 171 | + | Challenge: "http-01", | |||
| 172 | + | HTTPPort: 8088, | |||
| 173 | + | TLSPort: 8443, | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 177 | + | Config: cfg, | |||
| 178 | + | Routes: routes, | |||
| 179 | + | Issuer: issuer, | |||
| 180 | + | Store: store, | |||
| 181 | + | Challenges: challenges, | |||
| 182 | + | Effective: EffectiveChallenge{ | |||
| 183 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 184 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 185 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 186 | + | Reason: "http-01 challenge selected", | |||
| 187 | + | }, | |||
| 188 | + | }) | |||
| 189 | + | ||||
| 190 | + | // Present an HTTP-01 challenge token/keyAuth. | |||
| 191 | + | token := "test-token-abc123" | |||
| 192 | + | keyAuth := "test-key-auth-xyz789" | |||
| 193 | + | challenges.Present(token, keyAuth) | |||
| 194 | + | ||||
| 195 | + | // GetHTTP01Challenge should return the keyAuth. | |||
| 196 | + | gotKeyAuth, ok := svc.GetHTTP01Challenge(ctx, token) | |||
| 197 | + | require.True(t, ok, "GetHTTP01Challenge should find the token") | |||
| 198 | + | assert.Equal(t, keyAuth, gotKeyAuth) | |||
| 199 | + | ||||
| 200 | + | // Load the service (nothing stored). | |||
| 201 | + | err := svc.Load(ctx) | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | ||||
| 204 | + | // Reconcile — should create per-route target app.example.com. | |||
| 205 | + | err = svc.Reconcile(ctx) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | ||||
| 208 | + | // Verify the issuer received exactly 1 order. | |||
| 209 | + | issuer.mu.Lock() | |||
| 210 | + | orders := make([]out.CertificateOrder, len(issuer.orders)) | |||
| 211 | + | copy(orders, issuer.orders) | |||
| 212 | + | issuer.mu.Unlock() | |||
| 213 | + | ||||
| 214 | + | require.Len(t, orders, 1, "expected 1 HTTP-01 order") | |||
| 215 | + | assert.Equal(t, "http01-app.example.com", orders[0].ID) | |||
| 216 | + | assert.Equal(t, []string{"app.example.com"}, orders[0].Names) | |||
| 217 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge) | |||
| 218 | + | ||||
| 219 | + | // Status reports covered route. | |||
| 220 | + | status := svc.Status(ctx) | |||
| 221 | + | require.True(t, status.ACMEEnabled) | |||
| 222 | + | require.Len(t, status.Routes, 1, "expected 1 route in status") | |||
| 223 | + | assert.Equal(t, "app.example.com", status.Routes[0].Domain) | |||
| 224 | + | assert.True(t, status.Routes[0].Covered, "route should be covered") | |||
| 225 | + | assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy) | |||
| 226 | + | assert.Empty(t, status.Routes[0].Error) | |||
| 227 | + | ||||
| 228 | + | // GetCertificate returns non-nil cert for app.example.com. | |||
| 229 | + | cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"}) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | require.NotNil(t, cert) | |||
| 232 | + | require.NotEmpty(t, cert.Certificate) | |||
| 233 | + | ||||
| 234 | + | // GetCertificate returns nil for unrelated host. | |||
| 235 | + | cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "other.example.com"}) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | assert.Nil(t, cert) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing.T) { | |||
| 241 | + | ctx := context.Background() | |||
| 242 | + | ||||
| 243 | + | routes := &fakeRoutes{ | |||
| 244 | + | routes: []domain.Route{ | |||
| 245 | + | {Domain: "missing.example.com"}, | |||
| 246 | + | }, | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Issuer returns error so no cert is obtained. | |||
| 250 | + | issuer := &fakeIssuer{ | |||
| 251 | + | obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 252 | + | return nil, assert.AnError | |||
| 253 | + | }, | |||
| 254 | + | } | |||
| 255 | + | store := &fakeStore{} | |||
| 256 | + | ||||
| 257 | + | cfg := Config{ | |||
| 258 | + | Enabled: true, | |||
| 259 | + | Email: "admin@example.com", | |||
| 260 | + | Challenge: "http-01", | |||
| 261 | + | HTTPPort: 8088, | |||
| 262 | + | TLSPort: 8443, | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 266 | + | Config: cfg, | |||
| 267 | + | Routes: routes, | |||
| 268 | + | Issuer: issuer, | |||
| 269 | + | Store: store, | |||
| 270 | + | Challenges: NewHTTP01Challenges(), | |||
| 271 | + | Effective: EffectiveChallenge{ | |||
| 272 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 273 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 274 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 275 | + | Reason: "http-01 challenge selected", | |||
| 276 | + | }, | |||
| 277 | + | }) | |||
| 278 | + | ||||
| 279 | + | // Load (nothing stored). | |||
| 280 | + | err := svc.Load(ctx) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | ||||
| 283 | + | // Reconcile — issuer fails, so no cert is cached but requiredHosts is set. | |||
| 284 | + | err = svc.Reconcile(ctx) | |||
| 285 | + | require.NoError(t, err) // Reconcile swallows per-target obtain errors | |||
| 286 | + | ||||
| 287 | + | // GetCertificate for missing.example.com should return ErrTLSRouteNotCovered. | |||
| 288 | + | _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "missing.example.com"}) | |||
| 289 | + | require.Error(t, err) | |||
| 290 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered) | |||
| 291 | + | // The error message should contain the hostname. | |||
| 292 | + | assert.Contains(t, err.Error(), "missing.example.com") | |||
| 293 | + | ||||
| 294 | + | // Status should show the route is NOT covered and has an error. | |||
| 295 | + | status := svc.Status(ctx) | |||
| 296 | + | require.True(t, status.ACMEEnabled) | |||
| 297 | + | require.Len(t, status.Routes, 1, "expected 1 route in status") | |||
| 298 | + | rc := status.Routes[0] | |||
| 299 | + | assert.Equal(t, "missing.example.com", rc.Domain) | |||
| 300 | + | assert.False(t, rc.Covered, "route should NOT be covered") | |||
| 301 | + | assert.True(t, rc.RequiredACME) | |||
| 302 | + | ||||
| 303 | + | // The route error may be empty since not all code paths populate routeErr; | |||
| 304 | + | // at minimum the covered flag must be false. | |||
| 305 | + | t.Logf("Route error (may be empty): %q", rc.Error) | |||
| 306 | + | } | |||
A internal/usecase/publictls/renewal.go +131 -0
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // renewalWindow is the window before certificate expiry when renewal should begin. | |||
| 12 | + | const renewalWindow = 30 * 24 * time.Hour | |||
| 13 | + | ||||
| 14 | + | // ShouldRenew reports whether a certificate should be renewed at the given time. | |||
| 15 | + | // A certificate with a zero NotAfter is always considered due for renewal. | |||
| 16 | + | func ShouldRenew(cert out.StoredCertificate, now time.Time) bool { | |||
| 17 | + | if cert.NotAfter.IsZero() { | |||
| 18 | + | return true | |||
| 19 | + | } | |||
| 20 | + | return !now.Before(cert.NotAfter.Add(-renewalWindow)) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // StartRenewalLoop starts a background goroutine that periodically reconciles | |||
| 24 | + | // certificates and renews due certificates. Returns a channel that is closed | |||
| 25 | + | // when the loop exits (ctx is cancelled). | |||
| 26 | + | // | |||
| 27 | + | // If interval <= 0, a default of 1 hour is used. | |||
| 28 | + | func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) <-chan struct{} { | |||
| 29 | + | if interval <= 0 { | |||
| 30 | + | interval = time.Hour | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | done := make(chan struct{}) | |||
| 34 | + | ||||
| 35 | + | // Derive a cancellable context so Stop can terminate the loop. | |||
| 36 | + | loopCtx, cancel := context.WithCancel(ctx) | |||
| 37 | + | ||||
| 38 | + | // Cancel any previous loop and wait for it to fully exit before | |||
| 39 | + | // overwriting s.done, preventing overlapping loop goroutines. | |||
| 40 | + | s.mu.Lock() | |||
| 41 | + | oldCancel := s.cancel | |||
| 42 | + | oldDone := s.done | |||
| 43 | + | s.mu.Unlock() | |||
| 44 | + | ||||
| 45 | + | if oldCancel != nil { | |||
| 46 | + | oldCancel() | |||
| 47 | + | } | |||
| 48 | + | if oldDone != nil { | |||
| 49 | + | <-oldDone | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | s.mu.Lock() | |||
| 53 | + | s.cancel = cancel | |||
| 54 | + | s.done = done | |||
| 55 | + | s.mu.Unlock() | |||
| 56 | + | ||||
| 57 | + | go func() { | |||
| 58 | + | defer close(done) | |||
| 59 | + | defer cancel() | |||
| 60 | + | ||||
| 61 | + | ticker := time.NewTicker(interval) | |||
| 62 | + | defer ticker.Stop() | |||
| 63 | + | ||||
| 64 | + | // Run once immediately on start. | |||
| 65 | + | _ = s.Reconcile(loopCtx) | |||
| 66 | + | _ = s.renewDueCertificates(loopCtx, time.Now()) | |||
| 67 | + | ||||
| 68 | + | for { | |||
| 69 | + | select { | |||
| 70 | + | case <-loopCtx.Done(): | |||
| 71 | + | return | |||
| 72 | + | case <-ticker.C: | |||
| 73 | + | _ = s.Reconcile(loopCtx) | |||
| 74 | + | _ = s.renewDueCertificates(loopCtx, time.Now()) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | }() | |||
| 78 | + | ||||
| 79 | + | return done | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // renewDueCertificates renews all certificates in the cache that are due | |||
| 83 | + | // for renewal according to ShouldRenew. | |||
| 84 | + | // | |||
| 85 | + | // Per-certificate errors are recorded in lastErr and do not halt the loop. | |||
| 86 | + | // Returns nil unless the store or issuer is nil (systemic error). | |||
| 87 | + | func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error { | |||
| 88 | + | if !s.cfg.Enabled { | |||
| 89 | + | return nil | |||
| 90 | + | } | |||
| 91 | + | if s.deps.Store == nil { | |||
| 92 | + | return fmt.Errorf("certificate store is nil") | |||
| 93 | + | } | |||
| 94 | + | if s.deps.Issuer == nil { | |||
| 95 | + | return fmt.Errorf("certificate issuer is nil") | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Collect due certs under lock, then release before making external calls. | |||
| 99 | + | s.mu.Lock() | |||
| 100 | + | due := make([]*out.StoredCertificate, 0) | |||
| 101 | + | for _, cert := range s.certs { | |||
| 102 | + | if ShouldRenew(*cert, now) { | |||
| 103 | + | due = append(due, cert) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | s.mu.Unlock() | |||
| 107 | + | ||||
| 108 | + | for _, cert := range due { | |||
| 109 | + | renewed, err := s.deps.Issuer.Renew(ctx, *cert) | |||
| 110 | + | if err != nil { | |||
| 111 | + | s.mu.Lock() | |||
| 112 | + | s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err) | |||
| 113 | + | s.mu.Unlock() | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | if err := s.deps.Store.Save(ctx, *renewed); err != nil { | |||
| 118 | + | s.mu.Lock() | |||
| 119 | + | s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", err) | |||
| 120 | + | s.mu.Unlock() | |||
| 121 | + | continue | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | s.mu.Lock() | |||
| 125 | + | s.certs[renewed.ID] = renewed | |||
| 126 | + | delete(s.lastErr, renewed.ID) | |||
| 127 | + | s.mu.Unlock() | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | return nil | |||
| 131 | + | } | |||
A internal/usecase/publictls/renewal_test.go +196 -0
| @@ -0,0 +1,196 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // --------------------------------------------------------------------------- | |||
| 17 | + | // ShouldRenew | |||
| 18 | + | // --------------------------------------------------------------------------- | |||
| 19 | + | ||||
| 20 | + | func TestShouldRenew(t *testing.T) { | |||
| 21 | + | // Fixed "now" for deterministic tests. | |||
| 22 | + | now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) | |||
| 23 | + | ||||
| 24 | + | tests := []struct { | |||
| 25 | + | name string | |||
| 26 | + | cert out.StoredCertificate | |||
| 27 | + | want bool | |||
| 28 | + | }{ | |||
| 29 | + | { | |||
| 30 | + | name: "cert with NotAfter 45 days from now should not renew", | |||
| 31 | + | cert: out.StoredCertificate{NotAfter: now.Add(45 * 24 * time.Hour)}, | |||
| 32 | + | want: false, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "cert with NotAfter 10 days from now should renew", | |||
| 36 | + | cert: out.StoredCertificate{NotAfter: now.Add(10 * 24 * time.Hour)}, | |||
| 37 | + | want: true, | |||
| 38 | + | }, | |||
| 39 | + | { | |||
| 40 | + | name: "expired cert should renew", | |||
| 41 | + | cert: out.StoredCertificate{NotAfter: now.Add(-1 * time.Hour)}, | |||
| 42 | + | want: true, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "cert with zero NotAfter should renew", | |||
| 46 | + | cert: out.StoredCertificate{}, | |||
| 47 | + | want: true, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, tt := range tests { | |||
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | + | got := ShouldRenew(tt.cert, now) | |||
| 54 | + | assert.Equal(t, tt.want, got) | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // --------------------------------------------------------------------------- | |||
| 60 | + | // RenewalLoopStops | |||
| 61 | + | // --------------------------------------------------------------------------- | |||
| 62 | + | ||||
| 63 | + | func TestRenewalLoopStops(t *testing.T) { | |||
| 64 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 65 | + | ||||
| 66 | + | cfg := Config{ | |||
| 67 | + | Enabled: true, | |||
| 68 | + | Email: "admin@example.com", | |||
| 69 | + | Challenge: "http-01", | |||
| 70 | + | HTTPPort: 8088, | |||
| 71 | + | TLSPort: 8443, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 75 | + | Config: cfg, | |||
| 76 | + | Routes: &fakeRoutes{}, | |||
| 77 | + | Issuer: &fakeIssuer{}, | |||
| 78 | + | Store: &fakeStore{}, | |||
| 79 | + | Challenges: NewHTTP01Challenges(), | |||
| 80 | + | Effective: EffectiveChallenge{ | |||
| 81 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 82 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 83 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 84 | + | Reason: "http-01 challenge selected", | |||
| 85 | + | }, | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 88 | + | done := svc.StartRenewalLoop(ctx, 10*time.Millisecond) | |||
| 89 | + | cancel() | |||
| 90 | + | ||||
| 91 | + | require.Eventually(t, func() bool { | |||
| 92 | + | select { | |||
| 93 | + | case <-done: | |||
| 94 | + | return true | |||
| 95 | + | default: | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // --------------------------------------------------------------------------- | |||
| 102 | + | // RenewDueCertificates | |||
| 103 | + | // --------------------------------------------------------------------------- | |||
| 104 | + | ||||
| 105 | + | func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) { | |||
| 106 | + | ctx := context.Background() | |||
| 107 | + | ||||
| 108 | + | certID := "http01-app.example.com" | |||
| 109 | + | now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) | |||
| 110 | + | notAfterSoon := now.Add(10 * 24 * time.Hour) // 10 days from now — due for renewal | |||
| 111 | + | notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert | |||
| 112 | + | ||||
| 113 | + | // Generate a test certificate. | |||
| 114 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |||
| 115 | + | require.NoError(t, err) | |||
| 116 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | ||||
| 119 | + | store := &fakeStore{ | |||
| 120 | + | certs: []out.StoredCertificate{ | |||
| 121 | + | { | |||
| 122 | + | ID: certID, | |||
| 123 | + | Names: []string{"app.example.com"}, | |||
| 124 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 125 | + | Certificate: tlsCert, | |||
| 126 | + | FullchainPEM: certPEM, | |||
| 127 | + | PrivateKeyPEM: keyPEM, | |||
| 128 | + | NotAfter: notAfterSoon, | |||
| 129 | + | }, | |||
| 130 | + | }, | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | issuer := &fakeIssuer{ | |||
| 134 | + | renewFn: func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |||
| 135 | + | renewed := cert | |||
| 136 | + | renewed.NotAfter = notAfterRenewed | |||
| 137 | + | return &renewed, nil | |||
| 138 | + | }, | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | cfg := Config{ | |||
| 142 | + | Enabled: true, | |||
| 143 | + | Email: "admin@example.com", | |||
| 144 | + | Challenge: "http-01", | |||
| 145 | + | HTTPPort: 8088, | |||
| 146 | + | TLSPort: 8443, | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 150 | + | Config: cfg, | |||
| 151 | + | Routes: &fakeRoutes{}, | |||
| 152 | + | Issuer: issuer, | |||
| 153 | + | Store: store, | |||
| 154 | + | Challenges: NewHTTP01Challenges(), | |||
| 155 | + | Effective: EffectiveChallenge{ | |||
| 156 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 157 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 158 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 159 | + | Reason: "http-01 challenge selected", | |||
| 160 | + | }, | |||
| 161 | + | }) | |||
| 162 | + | ||||
| 163 | + | // Load certs into the cache from the store. | |||
| 164 | + | err = svc.Load(ctx) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | ||||
| 167 | + | // Verify cert is in cache with original NotAfter. | |||
| 168 | + | svc.mu.Lock() | |||
| 169 | + | cached, ok := svc.certs[certID] | |||
| 170 | + | svc.mu.Unlock() | |||
| 171 | + | require.True(t, ok, "cert should be in cache after Load") | |||
| 172 | + | assert.True(t, cached.NotAfter.Equal(notAfterSoon), "cache should have original NotAfter") | |||
| 173 | + | ||||
| 174 | + | // Run renewDueCertificates. | |||
| 175 | + | err = svc.renewDueCertificates(ctx, now) | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | ||||
| 178 | + | // Verify the store has the updated certificate. | |||
| 179 | + | all, err := store.LoadAll(ctx) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | require.Len(t, all, 1) | |||
| 182 | + | assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter") | |||
| 183 | + | ||||
| 184 | + | // Verify the cache has the updated certificate. | |||
| 185 | + | svc.mu.Lock() | |||
| 186 | + | cached, ok = svc.certs[certID] | |||
| 187 | + | svc.mu.Unlock() | |||
| 188 | + | require.True(t, ok, "cert should still be in cache after renewal") | |||
| 189 | + | assert.True(t, cached.NotAfter.Equal(notAfterRenewed), "cache should have renewed NotAfter") | |||
| 190 | + | ||||
| 191 | + | // Verify lastErr is cleared for the renewed certificate. | |||
| 192 | + | svc.mu.Lock() | |||
| 193 | + | _, hasErr := svc.lastErr[certID] | |||
| 194 | + | svc.mu.Unlock() | |||
| 195 | + | assert.False(t, hasErr, "lastErr should be cleared for renewed cert") | |||
| 196 | + | } | |||
A internal/usecase/publictls/service.go +356 -0
| @@ -0,0 +1,356 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // RouteSource provides routes from which certificate targets are derived. | |||
| 16 | + | type RouteSource interface { | |||
| 17 | + | GetRoutes(ctx context.Context) []domain.Route | |||
| 18 | + | GetExternalRoutes() map[string]string | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // ServiceDeps contains the dependencies for the Service. | |||
| 22 | + | type ServiceDeps struct { | |||
| 23 | + | Config Config | |||
| 24 | + | Routes RouteSource | |||
| 25 | + | Issuer out.PublicCertificateIssuer | |||
| 26 | + | Store out.CertificateStore | |||
| 27 | + | ZoneResolver zoneResolver | |||
| 28 | + | Challenges *HTTP01Challenges | |||
| 29 | + | Effective EffectiveChallenge | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Service manages public TLS certificates via ACME. | |||
| 33 | + | type Service struct { | |||
| 34 | + | mu sync.Mutex | |||
| 35 | + | cfg Config | |||
| 36 | + | deps ServiceDeps | |||
| 37 | + | certs map[string]*out.StoredCertificate // indexed by cert ID | |||
| 38 | + | lastErr map[string]string // indexed by cert ID | |||
| 39 | + | routeErr map[string]string // indexed by host | |||
| 40 | + | ||||
| 41 | + | // requiredHosts is the set of hosts that must be covered by ACME certs. | |||
| 42 | + | requiredHosts map[string]struct{} | |||
| 43 | + | ||||
| 44 | + | // cancel cancels the renewal loop context. | |||
| 45 | + | cancel context.CancelFunc | |||
| 46 | + | // done is closed when the renewal loop exits. | |||
| 47 | + | done chan struct{} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // NewService creates a new public TLS Service. | |||
| 51 | + | func NewService(cfg Config, deps ServiceDeps) *Service { | |||
| 52 | + | if deps.Challenges == nil { | |||
| 53 | + | deps.Challenges = NewHTTP01Challenges() | |||
| 54 | + | } | |||
| 55 | + | return &Service{ | |||
| 56 | + | cfg: cfg, | |||
| 57 | + | deps: deps, | |||
| 58 | + | certs: make(map[string]*out.StoredCertificate), | |||
| 59 | + | lastErr: make(map[string]string), | |||
| 60 | + | routeErr: make(map[string]string), | |||
| 61 | + | requiredHosts: make(map[string]struct{}), | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Load loads all stored certificates from the store into the internal cache. | |||
| 66 | + | // If the store is nil, this is a no-op. | |||
| 67 | + | func (s *Service) Load(ctx context.Context) error { | |||
| 68 | + | if s.deps.Store == nil { | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | stored, err := s.deps.Store.LoadAll(ctx) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return fmt.Errorf("load stored certificates: %w", err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | s.mu.Lock() | |||
| 78 | + | defer s.mu.Unlock() | |||
| 79 | + | ||||
| 80 | + | for i := range stored { | |||
| 81 | + | cert := &stored[i] | |||
| 82 | + | // If the tls.Certificate is empty but PEM data is available, parse it. | |||
| 83 | + | if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 { | |||
| 84 | + | parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM) | |||
| 85 | + | if parseErr == nil { | |||
| 86 | + | cert.Certificate = parsed | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | s.certs[cert.ID] = cert | |||
| 90 | + | if cert.LastError != "" { | |||
| 91 | + | s.lastErr[cert.ID] = cert.LastError | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | return nil | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Reconcile ensures all desired certificates are obtained and cached. | |||
| 99 | + | // If ACME is disabled, it is a no-op. | |||
| 100 | + | func (s *Service) Reconcile(ctx context.Context) error { | |||
| 101 | + | if !s.cfg.Enabled { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | if s.deps.Store == nil { | |||
| 106 | + | return fmt.Errorf("certificate store is nil") | |||
| 107 | + | } | |||
| 108 | + | if s.deps.Issuer == nil { | |||
| 109 | + | return fmt.Errorf("certificate issuer is nil") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Determine effective challenge mode. | |||
| 113 | + | effective := s.deps.Effective | |||
| 114 | + | if effective.Mode == "" { | |||
| 115 | + | // Derive conservatively from config. | |||
| 116 | + | mode, err := domain.ParseACMEChallengeMode(s.cfg.Challenge) | |||
| 117 | + | if err != nil { | |||
| 118 | + | mode = domain.ACMEChallengeHTTP01 | |||
| 119 | + | } | |||
| 120 | + | effective.Mode = mode | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // Get route hosts early to build required hosts set before target derivation. | |||
| 124 | + | // This ensures GetCertificate returns ErrTLSRouteNotCovered even if | |||
| 125 | + | // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver). | |||
| 126 | + | routes := s.deps.Routes.GetRoutes(ctx) | |||
| 127 | + | external := s.deps.Routes.GetExternalRoutes() | |||
| 128 | + | hosts := routeHosts(routes, external) | |||
| 129 | + | ||||
| 130 | + | // Build required hosts set from route hosts (before target derivation). | |||
| 131 | + | required := make(map[string]struct{}) | |||
| 132 | + | for _, host := range hosts { | |||
| 133 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |||
| 134 | + | if host != "" { | |||
| 135 | + | required[host] = struct{}{} | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Set requiredHosts before DeriveCertificateTargets so GetCertificate | |||
| 140 | + | // returns ErrTLSRouteNotCovered if target derivation/issuer fails. | |||
| 141 | + | s.mu.Lock() | |||
| 142 | + | s.requiredHosts = required | |||
| 143 | + | s.mu.Unlock() | |||
| 144 | + | ||||
| 145 | + | // Derive desired targets. | |||
| 146 | + | targets, err := DeriveCertificateTargets(ctx, effective.Mode, | |||
| 147 | + | routes, external, | |||
| 148 | + | s.deps.ZoneResolver, | |||
| 149 | + | ) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return fmt.Errorf("derive certificate targets: %w", err) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Merge target names into required hosts set. | |||
| 155 | + | // For DNS-01 wildcard targets, this adds wildcard entries as well; | |||
| 156 | + | // the exact route hosts already present suffice for isRequiredHostLocked. | |||
| 157 | + | for _, t := range targets { | |||
| 158 | + | for _, name := range t.Names { | |||
| 159 | + | name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") | |||
| 160 | + | if name != "" { | |||
| 161 | + | required[name] = struct{}{} | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Acquire store lock to serialise certificate operations. | |||
| 167 | + | unlock, err := s.deps.Store.Lock(ctx) | |||
| 168 | + | if err != nil { | |||
| 169 | + | return fmt.Errorf("acquire store lock: %w", err) | |||
| 170 | + | } | |||
| 171 | + | defer func() { _ = unlock() }() | |||
| 172 | + | ||||
| 173 | + | // Under mu: update requiredHosts (with target names) and compute missing targets. | |||
| 174 | + | s.mu.Lock() | |||
| 175 | + | s.requiredHosts = required | |||
| 176 | + | ||||
| 177 | + | missing := make([]CertificateTarget, 0) | |||
| 178 | + | for _, target := range targets { | |||
| 179 | + | if s.certificateExistsLocked(target) { | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | missing = append(missing, target) | |||
| 183 | + | } | |||
| 184 | + | s.mu.Unlock() | |||
| 185 | + | ||||
| 186 | + | // Process missing targets without holding s.mu so GetCertificate/Status | |||
| 187 | + | // are not blocked during network I/O (Obtain) or storage writes (Save). | |||
| 188 | + | s.reconcileMissingTargets(ctx, missing) | |||
| 189 | + | ||||
| 190 | + | return nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // reconcileMissingTargets obtains and saves certificates for each missing | |||
| 194 | + | // target. Called without s.mu held so network I/O does not block readers. | |||
| 195 | + | func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget) { | |||
| 196 | + | for _, target := range missing { | |||
| 197 | + | order := out.CertificateOrder{ | |||
| 198 | + | ID: target.ID, | |||
| 199 | + | Names: target.Names, | |||
| 200 | + | Challenge: target.Challenge, | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | stored, err := s.deps.Issuer.Obtain(ctx, order) | |||
| 204 | + | if err != nil { | |||
| 205 | + | s.mu.Lock() | |||
| 206 | + | s.lastErr[target.ID] = err.Error() | |||
| 207 | + | s.mu.Unlock() | |||
| 208 | + | continue | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | if err := s.deps.Store.Save(ctx, *stored); err != nil { | |||
| 212 | + | s.mu.Lock() | |||
| 213 | + | s.lastErr[target.ID] = fmt.Sprintf("save certificate: %v", err) | |||
| 214 | + | s.mu.Unlock() | |||
| 215 | + | continue | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | s.mu.Lock() | |||
| 219 | + | s.certs[stored.ID] = stored | |||
| 220 | + | delete(s.lastErr, stored.ID) | |||
| 221 | + | s.mu.Unlock() | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // certificateExistsLocked checks if a valid non-expired cached certificate | |||
| 226 | + | // covers the target. Must be called with s.mu held. | |||
| 227 | + | func (s *Service) certificateExistsLocked(target CertificateTarget) bool { | |||
| 228 | + | now := time.Now() | |||
| 229 | + | for _, cert := range s.certs { | |||
| 230 | + | if cert.NotAfter.IsZero() || now.After(cert.NotAfter) { | |||
| 231 | + | continue // expired or no expiry | |||
| 232 | + | } | |||
| 233 | + | if namesCover(cert.Names, target.Names) { | |||
| 234 | + | return true | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | return false | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // namesCover reports whether names covers all required names (exact match, case-insensitive). | |||
| 241 | + | func namesCover(names, required []string) bool { | |||
| 242 | + | covered := make(map[string]bool) | |||
| 243 | + | for _, n := range names { | |||
| 244 | + | covered[strings.ToLower(strings.TrimSuffix(n, "."))] = true | |||
| 245 | + | } | |||
| 246 | + | for _, r := range required { | |||
| 247 | + | r = strings.ToLower(strings.TrimSuffix(r, ".")) | |||
| 248 | + | if !covered[r] { | |||
| 249 | + | return false | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | return true | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // hostMatchesCert checks if any name in the given list matches host, | |||
| 256 | + | // including wildcard matching. | |||
| 257 | + | func hostMatchesCert(names []string, host string) bool { | |||
| 258 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |||
| 259 | + | if host == "" { | |||
| 260 | + | return false | |||
| 261 | + | } | |||
| 262 | + | for _, name := range names { | |||
| 263 | + | name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") | |||
| 264 | + | if name == host { | |||
| 265 | + | return true | |||
| 266 | + | } | |||
| 267 | + | if strings.HasPrefix(name, "*.") { | |||
| 268 | + | suffix := strings.TrimPrefix(name, "*.") | |||
| 269 | + | if strings.HasSuffix(host, "."+suffix) { | |||
| 270 | + | left := strings.TrimSuffix(host, "."+suffix) | |||
| 271 | + | if left != "" && !strings.Contains(left, ".") { | |||
| 272 | + | return true | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | return false | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // GetCertificate returns a TLS certificate for the given ClientHello. | |||
| 281 | + | // | |||
| 282 | + | // If the SNI host does not require ACME coverage, returns nil, nil. | |||
| 283 | + | // If ACME is required but no cert covers the host, returns | |||
| 284 | + | // ErrTLSRouteNotCovered. | |||
| 285 | + | // If a valid cert is found, returns a pointer to it. | |||
| 286 | + | func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 287 | + | host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hello.ServerName)), ".") | |||
| 288 | + | if host == "" { | |||
| 289 | + | return nil, nil | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | s.mu.Lock() | |||
| 293 | + | defer s.mu.Unlock() | |||
| 294 | + | ||||
| 295 | + | // Check if this host requires ACME coverage. | |||
| 296 | + | if !s.isRequiredHostLocked(host) { | |||
| 297 | + | return nil, nil | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | // Look for a valid non-expired cached certificate covering this host. | |||
| 301 | + | now := time.Now() | |||
| 302 | + | for _, cert := range s.certs { | |||
| 303 | + | if cert.NotAfter.IsZero() || now.After(cert.NotAfter) { | |||
| 304 | + | continue | |||
| 305 | + | } | |||
| 306 | + | if hostMatchesCert(cert.Names, host) { | |||
| 307 | + | return &cert.Certificate, nil | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // isRequiredHostLocked checks if host or any wildcard covering it is in | |||
| 315 | + | // the required hosts set. Must be called with s.mu held. | |||
| 316 | + | func (s *Service) isRequiredHostLocked(host string) bool { | |||
| 317 | + | if _, ok := s.requiredHosts[host]; ok { | |||
| 318 | + | return true | |||
| 319 | + | } | |||
| 320 | + | // Check wildcard: split at the first dot. | |||
| 321 | + | parts := strings.SplitN(host, ".", 2) | |||
| 322 | + | if len(parts) == 2 { | |||
| 323 | + | if _, ok := s.requiredHosts["*."+parts[1]]; ok { | |||
| 324 | + | return true | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // GetHTTP01Challenge delegates to the HTTP-01 challenge store. | |||
| 331 | + | func (s *Service) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) { | |||
| 332 | + | return s.deps.Challenges.Get(ctx, token) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | // Stop gracefully stops the service. If a renewal loop is running, it is | |||
| 336 | + | // cancelled and Stop waits for it to finish (subject to ctx deadline). | |||
| 337 | + | func (s *Service) Stop(ctx context.Context) error { | |||
| 338 | + | s.mu.Lock() | |||
| 339 | + | cancel := s.cancel | |||
| 340 | + | done := s.done | |||
| 341 | + | s.cancel = nil | |||
| 342 | + | s.done = nil | |||
| 343 | + | s.mu.Unlock() | |||
| 344 | + | ||||
| 345 | + | if cancel != nil { | |||
| 346 | + | cancel() | |||
| 347 | + | } | |||
| 348 | + | if done != nil { | |||
| 349 | + | select { | |||
| 350 | + | case <-done: | |||
| 351 | + | case <-ctx.Done(): | |||
| 352 | + | return ctx.Err() | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | return nil | |||
| 356 | + | } | |||
A internal/usecase/publictls/service_test.go +580 -0
| @@ -0,0 +1,580 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | "crypto/rsa" | |||
| 7 | + | "crypto/tls" | |||
| 8 | + | "crypto/x509" | |||
| 9 | + | "crypto/x509/pkix" | |||
| 10 | + | "encoding/pem" | |||
| 11 | + | "fmt" | |||
| 12 | + | "math/big" | |||
| 13 | + | "sync" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/stretchr/testify/assert" | |||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ||||
| 20 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 21 | + | "github.com/bnema/gordon/internal/domain" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // --------------------------------------------------------------------------- | |||
| 25 | + | // Fakes | |||
| 26 | + | // --------------------------------------------------------------------------- | |||
| 27 | + | ||||
| 28 | + | // fakeRoutes implements RouteSource for testing. | |||
| 29 | + | type fakeRoutes struct { | |||
| 30 | + | mu sync.Mutex | |||
| 31 | + | routes []domain.Route | |||
| 32 | + | external map[string]string | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route { | |||
| 36 | + | f.mu.Lock() | |||
| 37 | + | defer f.mu.Unlock() | |||
| 38 | + | out := make([]domain.Route, len(f.routes)) | |||
| 39 | + | copy(out, f.routes) | |||
| 40 | + | return out | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (f *fakeRoutes) GetExternalRoutes() map[string]string { | |||
| 44 | + | f.mu.Lock() | |||
| 45 | + | defer f.mu.Unlock() | |||
| 46 | + | out := make(map[string]string, len(f.external)) | |||
| 47 | + | for k, v := range f.external { | |||
| 48 | + | out[k] = v | |||
| 49 | + | } | |||
| 50 | + | return out | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // fakeIssuer implements out.PublicCertificateIssuer for testing. | |||
| 54 | + | type fakeIssuer struct { | |||
| 55 | + | mu sync.Mutex | |||
| 56 | + | orders []out.CertificateOrder // recorded orders | |||
| 57 | + | obtain func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) | |||
| 58 | + | renewFn func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (f *fakeIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 62 | + | f.mu.Lock() | |||
| 63 | + | f.orders = append(f.orders, order) | |||
| 64 | + | f.mu.Unlock() | |||
| 65 | + | if f.obtain != nil { | |||
| 66 | + | return f.obtain(ctx, order) | |||
| 67 | + | } | |||
| 68 | + | // Default: return a valid certificate. | |||
| 69 | + | return defaultStoredCert(order) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (f *fakeIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |||
| 73 | + | if f.renewFn != nil { | |||
| 74 | + | return f.renewFn(ctx, cert) | |||
| 75 | + | } | |||
| 76 | + | return &cert, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // defaultStoredCert creates a StoredCertificate with a self-signed TLS cert | |||
| 80 | + | // for the given order. | |||
| 81 | + | func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 82 | + | certPEM, keyPEM, err := generateTestCertPEM(order.Names) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return nil, err | |||
| 89 | + | } | |||
| 90 | + | return &out.StoredCertificate{ | |||
| 91 | + | ID: order.ID, | |||
| 92 | + | Names: order.Names, | |||
| 93 | + | Challenge: order.Challenge, | |||
| 94 | + | Certificate: tlsCert, | |||
| 95 | + | FullchainPEM: certPEM, | |||
| 96 | + | PrivateKeyPEM: keyPEM, | |||
| 97 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |||
| 98 | + | }, nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // fakeStore implements out.CertificateStore for testing. | |||
| 102 | + | type fakeStore struct { | |||
| 103 | + | mu sync.Mutex | |||
| 104 | + | lockMu sync.Mutex // separate lock for Lock()/unlock to avoid deadlock with mu | |||
| 105 | + | account *out.ACMEAccount | |||
| 106 | + | certs []out.StoredCertificate | |||
| 107 | + | lockHold bool | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (f *fakeStore) LoadAccount(_ context.Context) (*out.ACMEAccount, error) { | |||
| 111 | + | f.mu.Lock() | |||
| 112 | + | defer f.mu.Unlock() | |||
| 113 | + | if f.account == nil { | |||
| 114 | + | return nil, nil | |||
| 115 | + | } | |||
| 116 | + | acct := *f.account | |||
| 117 | + | return &acct, nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (f *fakeStore) SaveAccount(_ context.Context, account out.ACMEAccount) error { | |||
| 121 | + | f.mu.Lock() | |||
| 122 | + | defer f.mu.Unlock() | |||
| 123 | + | acct := account | |||
| 124 | + | f.account = &acct | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (f *fakeStore) LoadAll(_ context.Context) ([]out.StoredCertificate, error) { | |||
| 129 | + | f.mu.Lock() | |||
| 130 | + | defer f.mu.Unlock() | |||
| 131 | + | out := make([]out.StoredCertificate, len(f.certs)) | |||
| 132 | + | copy(out, f.certs) | |||
| 133 | + | return out, nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (f *fakeStore) Save(_ context.Context, cert out.StoredCertificate) error { | |||
| 137 | + | f.mu.Lock() | |||
| 138 | + | defer f.mu.Unlock() | |||
| 139 | + | for i, c := range f.certs { | |||
| 140 | + | if c.ID == cert.ID { | |||
| 141 | + | f.certs[i] = cert | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | f.certs = append(f.certs, cert) | |||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (f *fakeStore) Lock(_ context.Context) (func() error, error) { | |||
| 150 | + | f.lockMu.Lock() | |||
| 151 | + | f.lockHold = true | |||
| 152 | + | return func() error { | |||
| 153 | + | f.lockHold = false | |||
| 154 | + | f.lockMu.Unlock() | |||
| 155 | + | return nil | |||
| 156 | + | }, nil | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // --------------------------------------------------------------------------- | |||
| 160 | + | // Test certificate helpers | |||
| 161 | + | // --------------------------------------------------------------------------- | |||
| 162 | + | ||||
| 163 | + | // generateTestCertPEM generates a self-signed certificate for the given names. | |||
| 164 | + | // Returns PEM-encoded fullchain and private key. | |||
| 165 | + | func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err error) { | |||
| 166 | + | if len(names) == 0 { | |||
| 167 | + | names = []string{"localhost"} | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | key, err := rsa.GenerateKey(rand.Reader, 2048) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return nil, nil, fmt.Errorf("generate key: %w", err) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return nil, nil, fmt.Errorf("generate serial: %w", err) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | now := time.Now() | |||
| 181 | + | tmpl := &x509.Certificate{ | |||
| 182 | + | SerialNumber: serial, | |||
| 183 | + | Subject: pkix.Name{ | |||
| 184 | + | CommonName: names[0], | |||
| 185 | + | }, | |||
| 186 | + | NotBefore: now.Add(-1 * time.Hour), | |||
| 187 | + | NotAfter: now.Add(90 * 24 * time.Hour), | |||
| 188 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |||
| 189 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 190 | + | BasicConstraintsValid: true, | |||
| 191 | + | DNSNames: names, | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return nil, nil, fmt.Errorf("create certificate: %w", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 200 | + | if certPEM == nil { | |||
| 201 | + | return nil, nil, fmt.Errorf("encode cert PEM") | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | keyDER := x509.MarshalPKCS1PrivateKey(key) | |||
| 205 | + | keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}) | |||
| 206 | + | if keyPEM == nil { | |||
| 207 | + | return nil, nil, fmt.Errorf("encode key PEM") | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | return certPEM, keyPEM, nil | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // --------------------------------------------------------------------------- | |||
| 214 | + | // Tests | |||
| 215 | + | // --------------------------------------------------------------------------- | |||
| 216 | + | ||||
| 217 | + | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | |||
| 218 | + | ctx := context.Background() | |||
| 219 | + | ||||
| 220 | + | routes := &fakeRoutes{ | |||
| 221 | + | routes: []domain.Route{ | |||
| 222 | + | {Domain: "app.example.com"}, | |||
| 223 | + | }, | |||
| 224 | + | } | |||
| 225 | + | issuer := &fakeIssuer{} | |||
| 226 | + | store := &fakeStore{} | |||
| 227 | + | ||||
| 228 | + | cfg := Config{ | |||
| 229 | + | Enabled: true, | |||
| 230 | + | Email: "admin@example.com", | |||
| 231 | + | Challenge: "http-01", | |||
| 232 | + | HTTPPort: 8088, | |||
| 233 | + | TLSPort: 8443, | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 237 | + | Config: cfg, | |||
| 238 | + | Routes: routes, | |||
| 239 | + | Issuer: issuer, | |||
| 240 | + | Store: store, | |||
| 241 | + | Challenges: NewHTTP01Challenges(), | |||
| 242 | + | Effective: EffectiveChallenge{ | |||
| 243 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 244 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 245 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 246 | + | Reason: "http-01 challenge selected", | |||
| 247 | + | }, | |||
| 248 | + | }) | |||
| 249 | + | ||||
| 250 | + | // Load (nothing stored yet). | |||
| 251 | + | err := svc.Load(ctx) | |||
| 252 | + | require.NoError(t, err) | |||
| 253 | + | ||||
| 254 | + | // Reconcile — should obtain one certificate for app.example.com. | |||
| 255 | + | err = svc.Reconcile(ctx) | |||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | ||||
| 258 | + | // Verify the issuer received exactly one order. | |||
| 259 | + | issuer.mu.Lock() | |||
| 260 | + | orders := make([]out.CertificateOrder, len(issuer.orders)) | |||
| 261 | + | copy(orders, issuer.orders) | |||
| 262 | + | issuer.mu.Unlock() | |||
| 263 | + | ||||
| 264 | + | require.Len(t, orders, 1, "expected one order to be placed") | |||
| 265 | + | assert.Equal(t, "http01-app.example.com", orders[0].ID) | |||
| 266 | + | assert.Equal(t, []string{"app.example.com"}, orders[0].Names) | |||
| 267 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge) | |||
| 268 | + | ||||
| 269 | + | // Verify the store has the saved certificate. | |||
| 270 | + | stored, err := store.LoadAll(ctx) | |||
| 271 | + | require.NoError(t, err) | |||
| 272 | + | require.Len(t, stored, 1) | |||
| 273 | + | assert.Equal(t, "http01-app.example.com", stored[0].ID) | |||
| 274 | + | assert.Equal(t, []string{"app.example.com"}, stored[0].Names) | |||
| 275 | + | assert.False(t, stored[0].NotAfter.IsZero()) | |||
| 276 | + | assert.NotEmpty(t, stored[0].Certificate.Certificate) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func TestServiceStatusReportsCoverage(t *testing.T) { | |||
| 280 | + | ctx := context.Background() | |||
| 281 | + | ||||
| 282 | + | // Pre-populate the store with a valid certificate. | |||
| 283 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | ||||
| 288 | + | store := &fakeStore{ | |||
| 289 | + | certs: []out.StoredCertificate{ | |||
| 290 | + | { | |||
| 291 | + | ID: "http01-app.example.com", | |||
| 292 | + | Names: []string{"app.example.com"}, | |||
| 293 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 294 | + | Certificate: tlsCert, | |||
| 295 | + | FullchainPEM: certPEM, | |||
| 296 | + | PrivateKeyPEM: keyPEM, | |||
| 297 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |||
| 298 | + | }, | |||
| 299 | + | }, | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | routes := &fakeRoutes{ | |||
| 303 | + | routes: []domain.Route{ | |||
| 304 | + | {Domain: "app.example.com"}, | |||
| 305 | + | }, | |||
| 306 | + | } | |||
| 307 | + | cfg := Config{ | |||
| 308 | + | Enabled: true, | |||
| 309 | + | Email: "admin@example.com", | |||
| 310 | + | Challenge: "http-01", | |||
| 311 | + | HTTPPort: 8088, | |||
| 312 | + | TLSPort: 8443, | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 316 | + | Config: cfg, | |||
| 317 | + | Routes: routes, | |||
| 318 | + | Store: store, | |||
| 319 | + | Effective: EffectiveChallenge{ | |||
| 320 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 321 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 322 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 323 | + | Reason: "http-01 challenge selected", | |||
| 324 | + | }, | |||
| 325 | + | }) | |||
| 326 | + | ||||
| 327 | + | // Load the cert into the cache. | |||
| 328 | + | err = svc.Load(ctx) | |||
| 329 | + | require.NoError(t, err) | |||
| 330 | + | ||||
| 331 | + | // Get status. | |||
| 332 | + | status := svc.Status(ctx) | |||
| 333 | + | ||||
| 334 | + | assert.True(t, status.ACMEEnabled) | |||
| 335 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode) | |||
| 336 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode) | |||
| 337 | + | ||||
| 338 | + | require.Len(t, status.Certificates, 1, "expected one managed certificate") | |||
| 339 | + | assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID) | |||
| 340 | + | assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status) | |||
| 341 | + | ||||
| 342 | + | require.Len(t, status.Routes, 1, "expected one route coverage") | |||
| 343 | + | assert.Equal(t, "app.example.com", status.Routes[0].Domain) | |||
| 344 | + | assert.True(t, status.Routes[0].Covered, "route should be covered") | |||
| 345 | + | assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy) | |||
| 346 | + | assert.True(t, status.Routes[0].RequiredACME) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) { | |||
| 350 | + | ctx := context.Background() | |||
| 351 | + | ||||
| 352 | + | routes := &fakeRoutes{ | |||
| 353 | + | routes: []domain.Route{ | |||
| 354 | + | {Domain: "app.example.com"}, | |||
| 355 | + | }, | |||
| 356 | + | } | |||
| 357 | + | issuer := &fakeIssuer{ | |||
| 358 | + | // Make Obtain fail so no cert is cached. | |||
| 359 | + | obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 360 | + | return nil, fmt.Errorf("acme provider unavailable") | |||
| 361 | + | }, | |||
| 362 | + | } | |||
| 363 | + | store := &fakeStore{} | |||
| 364 | + | ||||
| 365 | + | cfg := Config{ | |||
| 366 | + | Enabled: true, | |||
| 367 | + | Email: "admin@example.com", | |||
| 368 | + | Challenge: "http-01", | |||
| 369 | + | HTTPPort: 8088, | |||
| 370 | + | TLSPort: 8443, | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 374 | + | Config: cfg, | |||
| 375 | + | Routes: routes, | |||
| 376 | + | Issuer: issuer, | |||
| 377 | + | Store: store, | |||
| 378 | + | Challenges: NewHTTP01Challenges(), | |||
| 379 | + | Effective: EffectiveChallenge{ | |||
| 380 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 381 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 382 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 383 | + | Reason: "http-01 challenge selected", | |||
| 384 | + | }, | |||
| 385 | + | }) | |||
| 386 | + | ||||
| 387 | + | // Load (no certs stored). | |||
| 388 | + | err := svc.Load(ctx) | |||
| 389 | + | require.NoError(t, err) | |||
| 390 | + | ||||
| 391 | + | // Reconcile — issuer fails, no cert cached but requiredHosts is set. | |||
| 392 | + | err = svc.Reconcile(ctx) | |||
| 393 | + | require.NoError(t, err) // Reconcile swallows per-target obtain errors | |||
| 394 | + | ||||
| 395 | + | // GetCertificate for app.example.com should return ErrTLSRouteNotCovered. | |||
| 396 | + | _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"}) | |||
| 397 | + | require.Error(t, err) | |||
| 398 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered) | |||
| 399 | + | assert.Contains(t, err.Error(), "app.example.com") | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // brokenZoneResolver always returns an error. | |||
| 403 | + | type brokenZoneResolver struct{} | |||
| 404 | + | ||||
| 405 | + | func (brokenZoneResolver) FindZone(_ context.Context, host string) (out.CloudflareZone, error) { | |||
| 406 | + | return out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for %s", host) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) { | |||
| 410 | + | ctx := context.Background() | |||
| 411 | + | ||||
| 412 | + | routes := &fakeRoutes{ | |||
| 413 | + | routes: []domain.Route{ | |||
| 414 | + | {Domain: "app.example.com"}, | |||
| 415 | + | }, | |||
| 416 | + | } | |||
| 417 | + | issuer := &fakeIssuer{} | |||
| 418 | + | store := &fakeStore{} | |||
| 419 | + | ||||
| 420 | + | cfg := Config{ | |||
| 421 | + | Enabled: true, | |||
| 422 | + | Email: "admin@example.com", | |||
| 423 | + | Challenge: "dns-01", | |||
| 424 | + | HTTPPort: 8088, | |||
| 425 | + | TLSPort: 8443, | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 429 | + | Config: cfg, | |||
| 430 | + | Routes: routes, | |||
| 431 | + | Issuer: issuer, | |||
| 432 | + | Store: store, | |||
| 433 | + | ZoneResolver: brokenZoneResolver{}, // broken: always fails | |||
| 434 | + | Challenges: NewHTTP01Challenges(), | |||
| 435 | + | Effective: EffectiveChallenge{ | |||
| 436 | + | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |||
| 437 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |||
| 438 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 439 | + | Reason: "dns-01 challenge selected", | |||
| 440 | + | }, | |||
| 441 | + | }) | |||
| 442 | + | ||||
| 443 | + | // Load (nothing stored). | |||
| 444 | + | err := svc.Load(ctx) | |||
| 445 | + | require.NoError(t, err) | |||
| 446 | + | ||||
| 447 | + | // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails. | |||
| 448 | + | err = svc.Reconcile(ctx) | |||
| 449 | + | require.Error(t, err) | |||
| 450 | + | assert.Contains(t, err.Error(), "derive certificate targets") | |||
| 451 | + | ||||
| 452 | + | // GetCertificate for the configured route should return ErrTLSRouteNotCovered, | |||
| 453 | + | // not nil,nil, because requiredHosts was set from route hosts before | |||
| 454 | + | // the failed DeriveCertificateTargets call. | |||
| 455 | + | _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"}) | |||
| 456 | + | require.Error(t, err) | |||
| 457 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered) | |||
| 458 | + | assert.Contains(t, err.Error(), "app.example.com") | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) { | |||
| 462 | + | ctx := context.Background() | |||
| 463 | + | ||||
| 464 | + | // Generate valid PEM cert data. | |||
| 465 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"}) | |||
| 466 | + | require.NoError(t, err) | |||
| 467 | + | ||||
| 468 | + | // Store a cert with PEM data but empty tls.Certificate. | |||
| 469 | + | store := &fakeStore{ | |||
| 470 | + | certs: []out.StoredCertificate{ | |||
| 471 | + | { | |||
| 472 | + | ID: "http01-test.example.com", | |||
| 473 | + | Names: []string{"test.example.com"}, | |||
| 474 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 475 | + | FullchainPEM: certPEM, | |||
| 476 | + | PrivateKeyPEM: keyPEM, | |||
| 477 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |||
| 478 | + | // Certificate field is zero — will be populated by Load. | |||
| 479 | + | }, | |||
| 480 | + | }, | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | routes := &fakeRoutes{} | |||
| 484 | + | cfg := Config{Enabled: true} | |||
| 485 | + | ||||
| 486 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 487 | + | Config: cfg, | |||
| 488 | + | Routes: routes, | |||
| 489 | + | Store: store, | |||
| 490 | + | }) | |||
| 491 | + | ||||
| 492 | + | // Load — should parse PEM into tls.Certificate. | |||
| 493 | + | err = svc.Load(ctx) | |||
| 494 | + | require.NoError(t, err) | |||
| 495 | + | ||||
| 496 | + | // Check that the cached cert has a valid tls.Certificate. | |||
| 497 | + | //nolint:unused // accessing the cert through the map is sufficient | |||
| 498 | + | svc.mu.Lock() | |||
| 499 | + | cached, ok := svc.certs["http01-test.example.com"] | |||
| 500 | + | svc.mu.Unlock() | |||
| 501 | + | ||||
| 502 | + | require.True(t, ok, "cert should be cached") | |||
| 503 | + | require.NotNil(t, cached) | |||
| 504 | + | assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated") | |||
| 505 | + | assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated") | |||
| 506 | + | ||||
| 507 | + | // Verify it's a valid TLS cert by performing a parse round-trip. | |||
| 508 | + | leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0]) | |||
| 509 | + | require.NoError(t, err) | |||
| 510 | + | assert.Contains(t, leaf.DNSNames, "test.example.com") | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | func TestServiceStatusRedactsSensitiveStrings(t *testing.T) { | |||
| 514 | + | ctx := context.Background() | |||
| 515 | + | ||||
| 516 | + | // Pre-populate the store with a certificate that has a LastError | |||
| 517 | + | // containing sensitive data. | |||
| 518 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |||
| 519 | + | require.NoError(t, err) | |||
| 520 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |||
| 521 | + | require.NoError(t, err) | |||
| 522 | + | ||||
| 523 | + | store := &fakeStore{ | |||
| 524 | + | certs: []out.StoredCertificate{ | |||
| 525 | + | { | |||
| 526 | + | ID: "http01-app.example.com", | |||
| 527 | + | Names: []string{"app.example.com"}, | |||
| 528 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 529 | + | Certificate: tlsCert, | |||
| 530 | + | FullchainPEM: certPEM, | |||
| 531 | + | PrivateKeyPEM: keyPEM, | |||
| 532 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |||
| 533 | + | LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid", | |||
| 534 | + | }, | |||
| 535 | + | }, | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | routes := &fakeRoutes{ | |||
| 539 | + | routes: []domain.Route{ | |||
| 540 | + | {Domain: "app.example.com"}, | |||
| 541 | + | }, | |||
| 542 | + | } | |||
| 543 | + | cfg := Config{ | |||
| 544 | + | Enabled: true, | |||
| 545 | + | Email: "admin@example.com", | |||
| 546 | + | Challenge: "http-01", | |||
| 547 | + | HTTPPort: 8088, | |||
| 548 | + | TLSPort: 8443, | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 552 | + | Config: cfg, | |||
| 553 | + | Routes: routes, | |||
| 554 | + | Store: store, | |||
| 555 | + | Effective: EffectiveChallenge{ | |||
| 556 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 557 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 558 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 559 | + | Reason: "http-01 challenge selected", | |||
| 560 | + | }, | |||
| 561 | + | }) | |||
| 562 | + | ||||
| 563 | + | // Load the cert into the cache. | |||
| 564 | + | err = svc.Load(ctx) | |||
| 565 | + | require.NoError(t, err) | |||
| 566 | + | ||||
| 567 | + | // Get status. | |||
| 568 | + | status := svc.Status(ctx) | |||
| 569 | + | ||||
| 570 | + | require.Len(t, status.Certificates, 1) | |||
| 571 | + | lastErr := status.Certificates[0].LastError | |||
| 572 | + | t.Logf("sanitized LastError: %q", lastErr) | |||
| 573 | + | ||||
| 574 | + | // The original error contained "token=sk-secret-goes-here". | |||
| 575 | + | // After sanitization, the secret value should be redacted. | |||
| 576 | + | assert.NotContains(t, lastErr, "sk-secret-goes-here", | |||
| 577 | + | "LastError must not contain the raw secret value") | |||
| 578 | + | assert.Contains(t, lastErr, "token=redacted", | |||
| 579 | + | "LastError should contain token=redacted") | |||
| 580 | + | } | |||
A internal/usecase/publictls/status.go +203 -0
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Status returns the current public TLS status. | |||
| 13 | + | func (s *Service) Status(ctx context.Context) domain.PublicTLSStatus { | |||
| 14 | + | // Collect route domains outside the lock so route source methods | |||
| 15 | + | // (GetRoutes/GetExternalRoutes) are not called while holding s.mu. | |||
| 16 | + | routeDomains := collectRouteDomains(ctx, s.deps.Routes) | |||
| 17 | + | ||||
| 18 | + | s.mu.Lock() | |||
| 19 | + | defer s.mu.Unlock() | |||
| 20 | + | ||||
| 21 | + | status := domain.PublicTLSStatus{ | |||
| 22 | + | ACMEEnabled: s.cfg.Enabled, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | if s.deps.Effective.Mode != "" { | |||
| 26 | + | status.ConfiguredMode = s.deps.Effective.ConfiguredMode | |||
| 27 | + | status.EffectiveMode = s.deps.Effective.Mode | |||
| 28 | + | status.SelectionReason = s.deps.Effective.Reason | |||
| 29 | + | status.TokenSource = s.deps.Effective.TokenSource | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | status.Certificates = s.buildManagedCertificatesLocked() | |||
| 33 | + | status.Routes = s.buildRouteCoverageLocked(routeDomains, status.Certificates) | |||
| 34 | + | return status | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // buildManagedCertificatesLocked returns sorted managed certificates. | |||
| 38 | + | // Must be called with s.mu held. | |||
| 39 | + | func (s *Service) buildManagedCertificatesLocked() []domain.ManagedCertificate { | |||
| 40 | + | certs := make([]domain.ManagedCertificate, 0, len(s.certs)) | |||
| 41 | + | now := time.Now() | |||
| 42 | + | for _, cert := range s.certs { | |||
| 43 | + | lastErr := cert.LastError | |||
| 44 | + | if e, ok := s.lastErr[cert.ID]; ok && e != "" { | |||
| 45 | + | lastErr = e | |||
| 46 | + | } | |||
| 47 | + | mc := domain.ManagedCertificate{ | |||
| 48 | + | ID: cert.ID, | |||
| 49 | + | Names: cert.Names, | |||
| 50 | + | Challenge: cert.Challenge, | |||
| 51 | + | NotAfter: cert.NotAfter, | |||
| 52 | + | LastError: sanitizeError(lastErr), | |||
| 53 | + | } | |||
| 54 | + | mc.Status = mc.Health(now) | |||
| 55 | + | certs = append(certs, mc) | |||
| 56 | + | } | |||
| 57 | + | sort.Slice(certs, func(i, j int) bool { | |||
| 58 | + | return certs[i].ID < certs[j].ID | |||
| 59 | + | }) | |||
| 60 | + | return certs | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // buildRouteCoverageLocked returns route coverage for the given domains. | |||
| 64 | + | // Must be called with s.mu held. | |||
| 65 | + | func (s *Service) buildRouteCoverageLocked(routeDomains []string, managedCerts []domain.ManagedCertificate) []domain.TLSRouteCoverage { | |||
| 66 | + | sort.Strings(routeDomains) | |||
| 67 | + | ||||
| 68 | + | now := time.Now() | |||
| 69 | + | routes := make([]domain.TLSRouteCoverage, 0, len(routeDomains)) | |||
| 70 | + | for _, d := range routeDomains { | |||
| 71 | + | coverage := domain.TLSRouteCoverage{ | |||
| 72 | + | Domain: d, | |||
| 73 | + | RequiredACME: s.cfg.Enabled, | |||
| 74 | + | } | |||
| 75 | + | coverage.Covered, coverage.CoveredBy = s.findCoverageLocked(d, managedCerts, now) | |||
| 76 | + | if err, hasErr := s.routeErr[d]; hasErr { | |||
| 77 | + | coverage.Error = sanitizeError(err) | |||
| 78 | + | } | |||
| 79 | + | routes = append(routes, coverage) | |||
| 80 | + | } | |||
| 81 | + | return routes | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // collectRouteDomains returns all unique canonical route domains. | |||
| 85 | + | // Does not hold s.mu so route source methods may be called safely. | |||
| 86 | + | func collectRouteDomains(ctx context.Context, routes RouteSource) []string { | |||
| 87 | + | routeSet := make(map[string]struct{}) | |||
| 88 | + | var domains []string | |||
| 89 | + | ||||
| 90 | + | for _, r := range routes.GetRoutes(ctx) { | |||
| 91 | + | canonical, ok := domain.CanonicalRouteDomain(r.Domain) | |||
| 92 | + | if ok { | |||
| 93 | + | if _, exists := routeSet[canonical]; !exists { | |||
| 94 | + | routeSet[canonical] = struct{}{} | |||
| 95 | + | domains = append(domains, canonical) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | for h := range routes.GetExternalRoutes() { | |||
| 100 | + | canonical, ok := domain.CanonicalRouteDomain(h) | |||
| 101 | + | if ok { | |||
| 102 | + | if _, exists := routeSet[canonical]; !exists { | |||
| 103 | + | routeSet[canonical] = struct{}{} | |||
| 104 | + | domains = append(domains, canonical) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return domains | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // findCoverageLocked checks if any managed certificate covers the given domain. | |||
| 112 | + | // Must be called with s.mu held. | |||
| 113 | + | func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.ManagedCertificate, now time.Time) (bool, string) { | |||
| 114 | + | for _, mc := range managedCerts { | |||
| 115 | + | if !mc.Covers(routeDomain) { | |||
| 116 | + | continue | |||
| 117 | + | } | |||
| 118 | + | switch mc.Status { | |||
| 119 | + | case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing, domain.TLSCertificateStatusError: | |||
| 120 | + | continue | |||
| 121 | + | } | |||
| 122 | + | // Double-check expiry from the raw stored cert. | |||
| 123 | + | if sc, ok := s.certs[mc.ID]; ok { | |||
| 124 | + | if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) { | |||
| 125 | + | continue | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return true, mc.ID | |||
| 129 | + | } | |||
| 130 | + | return false, "" | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // sanitizeError removes obvious sensitive strings from error messages. | |||
| 134 | + | // At minimum it ensures that the literal "secret" and patterns like | |||
| 135 | + | // "token=secret" are not present in the output. | |||
| 136 | + | func sanitizeError(err string) string { | |||
| 137 | + | if err == "" { | |||
| 138 | + | return "" | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | result := err | |||
| 142 | + | ||||
| 143 | + | // Remove key=value patterns for well-known sensitive keys. | |||
| 144 | + | result = sanitizeKeyEqValue(result, "token") | |||
| 145 | + | result = sanitizeKeyEqValue(result, "secret") | |||
| 146 | + | result = sanitizeKeyEqValue(result, "key") | |||
| 147 | + | result = sanitizeKeyEqValue(result, "password") | |||
| 148 | + | result = sanitizeKeyEqValue(result, "pass") | |||
| 149 | + | result = sanitizeKeyEqValue(result, "api_key") | |||
| 150 | + | result = sanitizeKeyEqValue(result, "apikey") | |||
| 151 | + | result = sanitizeKeyEqValue(result, "auth") | |||
| 152 | + | ||||
| 153 | + | // Replace any remaining occurrences of "secret" (case-insensitive). | |||
| 154 | + | lower := strings.ToLower(result) | |||
| 155 | + | if strings.Contains(lower, "secret") { | |||
| 156 | + | result = "redacted" | |||
| 157 | + | return result | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | if result == "" { | |||
| 161 | + | return "certificate error" | |||
| 162 | + | } | |||
| 163 | + | return result | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // sanitizeKeyEqValue removes patterns like "key=value" from the string. | |||
| 167 | + | // It replaces the value portion with "redacted". | |||
| 168 | + | func sanitizeKeyEqValue(s, key string) string { | |||
| 169 | + | prefix := key + "=" | |||
| 170 | + | lowerPrefix := strings.ToLower(prefix) | |||
| 171 | + | result := s | |||
| 172 | + | pos := 0 | |||
| 173 | + | ||||
| 174 | + | for { | |||
| 175 | + | lowerResult := strings.ToLower(result) | |||
| 176 | + | if pos >= len(lowerResult) { | |||
| 177 | + | break | |||
| 178 | + | } | |||
| 179 | + | idx := strings.Index(lowerResult[pos:], lowerPrefix) | |||
| 180 | + | if idx == -1 { | |||
| 181 | + | break | |||
| 182 | + | } | |||
| 183 | + | idx += pos // make absolute | |||
| 184 | + | ||||
| 185 | + | // Find end of value (space, newline, or end of string). | |||
| 186 | + | valStart := idx + len(prefix) | |||
| 187 | + | rest := result[valStart:] | |||
| 188 | + | end := strings.IndexAny(rest, " \n") | |||
| 189 | + | ||||
| 190 | + | replacement := key + "=redacted" | |||
| 191 | + | var after string | |||
| 192 | + | if end == -1 { | |||
| 193 | + | after = "" | |||
| 194 | + | } else { | |||
| 195 | + | after = rest[end:] | |||
| 196 | + | } | |||
| 197 | + | result = result[:idx] + replacement + after | |||
| 198 | + | ||||
| 199 | + | // Advance search position past the replacement to avoid re-matching. | |||
| 200 | + | pos = idx + len(replacement) | |||
| 201 | + | } | |||
| 202 | + | return result | |||
| 203 | + | } | |||
A internal/usecase/publictls/targets.go +144 -0
| @@ -0,0 +1,144 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // CertificateTarget represents a single certificate to obtain or renew. | |||
| 14 | + | type CertificateTarget struct { | |||
| 15 | + | ID string | |||
| 16 | + | Names []string | |||
| 17 | + | Challenge domain.ACMEChallengeMode | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // zoneResolver resolves a hostname to its Cloudflare DNS zone. | |||
| 21 | + | type zoneResolver interface { | |||
| 22 | + | FindZone(ctx context.Context, host string) (out.CloudflareZone, error) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // DeriveCertificateTargets derives certificate targets from routes and external | |||
| 26 | + | // routes based on the given ACME challenge mode. | |||
| 27 | + | func DeriveCertificateTargets( | |||
| 28 | + | ctx context.Context, | |||
| 29 | + | mode domain.ACMEChallengeMode, | |||
| 30 | + | routes []domain.Route, | |||
| 31 | + | external map[string]string, | |||
| 32 | + | resolver zoneResolver, | |||
| 33 | + | ) ([]CertificateTarget, error) { | |||
| 34 | + | hosts := routeHosts(routes, external) | |||
| 35 | + | ||||
| 36 | + | switch mode { | |||
| 37 | + | case domain.ACMEChallengeHTTP01: | |||
| 38 | + | return deriveHTTP01Targets(hosts), nil | |||
| 39 | + | case domain.ACMEChallengeCloudflareDNS01: | |||
| 40 | + | return deriveDNS01Targets(ctx, hosts, resolver) | |||
| 41 | + | default: | |||
| 42 | + | return nil, fmt.Errorf("unsupported challenge mode: %s", mode) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // routeHosts collects all unique canonical hosts from routes and external keys, | |||
| 47 | + | // sorted alphabetically. Trailing dots are stripped before canonicalization. | |||
| 48 | + | func routeHosts(routes []domain.Route, external map[string]string) []string { | |||
| 49 | + | seen := make(map[string]struct{}) | |||
| 50 | + | var hosts []string | |||
| 51 | + | ||||
| 52 | + | addHost := func(raw string) { | |||
| 53 | + | raw = strings.TrimSuffix(raw, ".") | |||
| 54 | + | canonical, ok := domain.CanonicalRouteDomain(raw) | |||
| 55 | + | if !ok { | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | if _, exists := seen[canonical]; !exists { | |||
| 59 | + | seen[canonical] = struct{}{} | |||
| 60 | + | hosts = append(hosts, canonical) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | for _, r := range routes { | |||
| 65 | + | addHost(r.Domain) | |||
| 66 | + | } | |||
| 67 | + | for h := range external { | |||
| 68 | + | addHost(h) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | sort.Strings(hosts) | |||
| 72 | + | return hosts | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // deriveHTTP01Targets creates one target per host for HTTP-01 challenge. | |||
| 76 | + | func deriveHTTP01Targets(hosts []string) []CertificateTarget { | |||
| 77 | + | targets := make([]CertificateTarget, len(hosts)) | |||
| 78 | + | for i, host := range hosts { | |||
| 79 | + | targets[i] = CertificateTarget{ | |||
| 80 | + | ID: "http01-" + host, | |||
| 81 | + | Names: []string{host}, | |||
| 82 | + | Challenge: domain.ACMEChallengeHTTP01, | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | return targets | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge. | |||
| 89 | + | func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolver) ([]CertificateTarget, error) { | |||
| 90 | + | if resolver == nil { | |||
| 91 | + | return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones") | |||
| 92 | + | } | |||
| 93 | + | baseSeen := make(map[string]struct{}) | |||
| 94 | + | var bases []string | |||
| 95 | + | ||||
| 96 | + | for _, host := range hosts { | |||
| 97 | + | zone, err := resolver.FindZone(ctx, host) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return nil, fmt.Errorf("find zone for %s: %w", host, err) | |||
| 100 | + | } | |||
| 101 | + | base := wildcardBase(host, zone.Name) | |||
| 102 | + | if _, exists := baseSeen[base]; !exists { | |||
| 103 | + | baseSeen[base] = struct{}{} | |||
| 104 | + | bases = append(bases, base) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | sort.Strings(bases) | |||
| 109 | + | ||||
| 110 | + | targets := make([]CertificateTarget, len(bases)) | |||
| 111 | + | for i, base := range bases { | |||
| 112 | + | targets[i] = CertificateTarget{ | |||
| 113 | + | ID: "dns01-" + base, | |||
| 114 | + | Names: []string{base, "*." + base}, | |||
| 115 | + | Challenge: domain.ACMEChallengeCloudflareDNS01, | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | return targets, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // wildcardBase determines the wildcard base for a host within a zone. | |||
| 122 | + | // | |||
| 123 | + | // If host == zone, returns zone. | |||
| 124 | + | // If host is a direct child of zone, returns zone. | |||
| 125 | + | // If host is deeper (e.g. api.prod.example.com within example.com), | |||
| 126 | + | // returns the parent label under the zone (prod.example.com). | |||
| 127 | + | func wildcardBase(host, zone string) string { | |||
| 128 | + | if host == zone { | |||
| 129 | + | return zone | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts. | |||
| 133 | + | trimmed := strings.TrimSuffix(host, "."+zone) | |||
| 134 | + | ||||
| 135 | + | if !strings.Contains(trimmed, ".") { | |||
| 136 | + | // Direct child of the zone (e.g. app.example.com). | |||
| 137 | + | return zone | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Deeper than direct child: extract the parent under the zone. | |||
| 141 | + | // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod" | |||
| 142 | + | _, parent, _ := strings.Cut(trimmed, ".") | |||
| 143 | + | return parent + "." + zone | |||
| 144 | + | } | |||
A internal/usecase/publictls/targets_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package publictls | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // fixedZoneResolver returns a fixed zone name for all lookups. | |||
| 15 | + | type fixedZoneResolver struct { | |||
| 16 | + | zone string | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (f fixedZoneResolver) FindZone(_ context.Context, _ string) (out.CloudflareZone, error) { | |||
| 20 | + | return out.CloudflareZone{Name: f.zone}, nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestDeriveTargets_HTTP01PerRoute(t *testing.T) { | |||
| 24 | + | routes := []domain.Route{ | |||
| 25 | + | {Domain: "app.example.com"}, | |||
| 26 | + | {Domain: "registry.example.com"}, | |||
| 27 | + | } | |||
| 28 | + | mode := domain.ACMEChallengeHTTP01 | |||
| 29 | + | ||||
| 30 | + | targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Len(t, targets, 2) | |||
| 33 | + | ||||
| 34 | + | assert.Equal(t, "http01-app.example.com", targets[0].ID) | |||
| 35 | + | assert.Equal(t, []string{"app.example.com"}, targets[0].Names) | |||
| 36 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge) | |||
| 37 | + | ||||
| 38 | + | assert.Equal(t, "http01-registry.example.com", targets[1].ID) | |||
| 39 | + | assert.Equal(t, []string{"registry.example.com"}, targets[1].Names) | |||
| 40 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestDeriveTargets_DNS01WildcardBases(t *testing.T) { | |||
| 44 | + | routes := []domain.Route{ | |||
| 45 | + | {Domain: "app.example.com"}, | |||
| 46 | + | {Domain: "api.prod.example.com"}, | |||
| 47 | + | {Domain: "example.com"}, | |||
| 48 | + | } | |||
| 49 | + | mode := domain.ACMEChallengeCloudflareDNS01 | |||
| 50 | + | resolver := fixedZoneResolver{zone: "example.com"} | |||
| 51 | + | ||||
| 52 | + | targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, resolver) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | require.Len(t, targets, 2) | |||
| 55 | + | ||||
| 56 | + | // First target: example.com (includes base and wildcard) | |||
| 57 | + | assert.Equal(t, "dns01-example.com", targets[0].ID) | |||
| 58 | + | assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names) | |||
| 59 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge) | |||
| 60 | + | ||||
| 61 | + | // Second target: prod.example.com (includes base and wildcard) | |||
| 62 | + | assert.Equal(t, "dns01-prod.example.com", targets[1].ID) | |||
| 63 | + | assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names) | |||
| 64 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) { | |||
| 68 | + | routes := []domain.Route{ | |||
| 69 | + | {Domain: "app.example.com"}, | |||
| 70 | + | } | |||
| 71 | + | external := map[string]string{ | |||
| 72 | + | "external.example.com": "127.0.0.1:8080", | |||
| 73 | + | } | |||
| 74 | + | mode := domain.ACMEChallengeHTTP01 | |||
| 75 | + | ||||
| 76 | + | targets, err := DeriveCertificateTargets(context.Background(), mode, routes, external, nil) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | require.Len(t, targets, 2) | |||
| 79 | + | ||||
| 80 | + | assert.Equal(t, "http01-app.example.com", targets[0].ID) | |||
| 81 | + | assert.Equal(t, "http01-external.example.com", targets[1].ID) | |||
| 82 | + | assert.Equal(t, []string{"external.example.com"}, targets[1].Names) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) { | |||
| 86 | + | routes := []domain.Route{ | |||
| 87 | + | {Domain: "app.example.com"}, | |||
| 88 | + | } | |||
| 89 | + | mode := domain.ACMEChallengeCloudflareDNS01 | |||
| 90 | + | ||||
| 91 | + | _, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil) | |||
| 92 | + | require.Error(t, err) | |||
| 93 | + | assert.Contains(t, err.Error(), "resolver is nil") | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) { | |||
| 97 | + | routes := []domain.Route{ | |||
| 98 | + | {Domain: "App.Example.Com"}, | |||
| 99 | + | {Domain: "app.example.com"}, | |||
| 100 | + | } | |||
| 101 | + | mode := domain.ACMEChallengeHTTP01 | |||
| 102 | + | ||||
| 103 | + | targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target") | |||
| 106 | + | ||||
| 107 | + | assert.Equal(t, "http01-app.example.com", targets[0].ID) | |||
| 108 | + | assert.Equal(t, []string{"app.example.com"}, targets[0].Names) | |||
| 109 | + | } | |||