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 | 10 | github.com/coreos/go-systemd/v22 v22.7.0 | |
| 11 | 11 | github.com/docker/docker v28.5.2+incompatible | |
| 12 | 12 | github.com/docker/go-connections v0.6.0 | |
| 13 | + | github.com/go-acme/lego/v4 v4.35.2 | |
| 13 | 14 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 14 | 15 | github.com/google/go-containerregistry v0.21.5 | |
| 15 | 16 | github.com/google/uuid v1.6.0 | |
| @@ -46,10 +47,12 @@ require ( | |||
| 46 | 47 | github.com/docker/cli v29.4.0+incompatible // indirect | |
| 47 | 48 | github.com/docker/docker-credential-helpers v0.9.5 // indirect | |
| 48 | 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 50 | + | github.com/go-jose/go-jose/v4 v4.1.4 // indirect | |
| 49 | 51 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | |
| 50 | 52 | github.com/klauspost/compress v1.18.5 // indirect | |
| 51 | 53 | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect | |
| 52 | 54 | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 55 | + | github.com/miekg/dns v1.1.72 // indirect | |
| 53 | 56 | github.com/mitchellh/go-homedir v1.1.0 // indirect | |
| 54 | 57 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 55 | 58 | github.com/morikuni/aec v1.1.0 // indirect | |
| @@ -63,7 +66,9 @@ require ( | |||
| 63 | 66 | go.opentelemetry.io/otel/log v0.19.0 // indirect | |
| 64 | 67 | go.opentelemetry.io/proto/otlp v1.10.0 // indirect | |
| 65 | 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 | 72 | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect | |
| 68 | 73 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect | |
| 69 | 74 | google.golang.org/grpc v1.80.0 // indirect | |
| @@ -72,8 +77,8 @@ require ( | |||
| 72 | 77 | ) | |
| 73 | 78 | ||
| 74 | 79 | require ( | |
| 75 | - | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 76 | - | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 80 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | |
| 81 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | |
| 77 | 82 | github.com/stretchr/objx v0.5.3 // indirect | |
| 78 | 83 | go.uber.org/mock v0.6.0 // direct | |
| 79 | 84 | golang.org/x/time v0.15.0 | |
| @@ -111,7 +116,7 @@ require ( | |||
| 111 | 116 | go.opentelemetry.io/otel/metric v1.43.0 | |
| 112 | 117 | go.opentelemetry.io/otel/trace v1.43.0 | |
| 113 | 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 | 120 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 116 | 121 | gotest.tools/v3 v3.5.2 // indirect | |
| 117 | 122 | ) | |
M go.sum +20 -10
| @@ -45,8 +45,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpk | |||
| 45 | 45 | github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= | |
| 46 | 46 | github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= | |
| 47 | 47 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 48 | - | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 49 | - | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 48 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | |
| 49 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 50 | 50 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | |
| 51 | 51 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | |
| 52 | 52 | github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= | |
| @@ -67,6 +67,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk | |||
| 67 | 67 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | |
| 68 | 68 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | |
| 69 | 69 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | |
| 70 | + | github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= | |
| 71 | + | github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= | |
| 72 | + | github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= | |
| 73 | + | github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= | |
| 70 | 74 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | |
| 71 | 75 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | |
| 72 | 76 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | |
| @@ -105,6 +109,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J | |||
| 105 | 109 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= | |
| 106 | 110 | github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= | |
| 107 | 111 | github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= | |
| 112 | + | github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= | |
| 113 | + | github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= | |
| 108 | 114 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= | |
| 109 | 115 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= | |
| 110 | 116 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | |
| @@ -131,8 +137,8 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4 | |||
| 131 | 137 | github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= | |
| 132 | 138 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | |
| 133 | 139 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |
| 134 | - | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 135 | - | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 140 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= | |
| 141 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 136 | 142 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= | |
| 137 | 143 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= | |
| 138 | 144 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= | |
| @@ -203,21 +209,25 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= | |||
| 203 | 209 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= | |
| 204 | 210 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| 205 | 211 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 206 | - | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= | |
| 207 | - | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | |
| 212 | + | golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= | |
| 213 | + | golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= | |
| 214 | + | golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= | |
| 215 | + | golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= | |
| 208 | 216 | golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= | |
| 209 | 217 | golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= | |
| 210 | - | golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= | |
| 211 | - | golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= | |
| 218 | + | golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= | |
| 219 | + | golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= | |
| 212 | 220 | golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | |
| 213 | 221 | golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 214 | 222 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 215 | 223 | golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= | |
| 216 | 224 | golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 217 | - | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= | |
| 218 | - | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= | |
| 225 | + | golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= | |
| 226 | + | golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= | |
| 219 | 227 | golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | |
| 220 | 228 | golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= | |
| 229 | + | golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= | |
| 230 | + | golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= | |
| 221 | 231 | gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= | |
| 222 | 232 | gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= | |
| 223 | 233 | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= | |
A internal/adapters/dto/tls.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // TLSStatusResponse represents the public TLS/ACME status for the admin API. | |
| 6 | + | type TLSStatusResponse struct { | |
| 7 | + | ACMEEnabled bool `json:"acme_enabled"` | |
| 8 | + | ConfiguredMode string `json:"configured_mode"` | |
| 9 | + | EffectiveMode string `json:"effective_mode"` | |
| 10 | + | SelectionReason string `json:"selection_reason"` | |
| 11 | + | TokenSource string `json:"token_source"` | |
| 12 | + | Certificates []TLSCertificateEntry `json:"certificates"` | |
| 13 | + | Routes []TLSRouteCoverage `json:"routes"` | |
| 14 | + | Errors []string `json:"errors,omitempty"` | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // TLSCertificateEntry represents a managed certificate entry in the TLS status. | |
| 18 | + | type TLSCertificateEntry struct { | |
| 19 | + | ID string `json:"id"` | |
| 20 | + | Names []string `json:"names"` | |
| 21 | + | Challenge string `json:"challenge"` | |
| 22 | + | Status string `json:"status"` | |
| 23 | + | NotAfter time.Time `json:"not_after"` | |
| 24 | + | LastError string `json:"last_error,omitempty"` | |
| 25 | + | RenewalPending bool `json:"renewal_pending"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // TLSRouteCoverage represents a route's TLS coverage status. | |
| 29 | + | type TLSRouteCoverage struct { | |
| 30 | + | Domain string `json:"domain"` | |
| 31 | + | Covered bool `json:"covered"` | |
| 32 | + | CoveredBy string `json:"covered_by,omitempty"` | |
| 33 | + | RequiredACME bool `json:"required_acme"` | |
| 34 | + | Error string `json:"error,omitempty"` | |
| 35 | + | } | |
M internal/adapters/in/cli/attachments_push_test.go +4 -0
| @@ -108,6 +108,10 @@ func (c *attachmentPushTestControlPlane) RemoveAutoRouteAllowedDomain(context.Co | |||
| 108 | 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 | 115 | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 112 | 116 | if c.getStatus != nil { | |
| 113 | 117 | return c.getStatus(ctx) | |
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -38,6 +38,7 @@ type ControlPlane interface { | |||
| 38 | 38 | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 39 | 39 | ||
| 40 | 40 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 41 | + | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 41 | 42 | Reload(ctx context.Context) error | |
| 42 | 43 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 43 | 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 | 25 | healthSvc in.HealthService | |
| 26 | 26 | logSvc in.LogService | |
| 27 | 27 | volumeSvc in.VolumeService | |
| 28 | + | publicTLSSvc in.PublicTLSService | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |
| @@ -50,6 +51,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 50 | 51 | healthSvc: kernel.Health(), | |
| 51 | 52 | logSvc: kernel.Logs(), | |
| 52 | 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 | 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 | 346 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 335 | 347 | if l.configSvc == nil { | |
| 336 | 348 | return nil, fmt.Errorf("local config service unavailable") | |
| @@ -625,6 +637,44 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |||
| 625 | 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 | 678 | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | |
| 629 | 679 | var startedAt *time.Time | |
| 630 | 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 | 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 | 111 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | |
| 108 | 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 | 25 | {"routes show", newRoutesShowCmd}, | |
| 26 | 26 | {"networks list", newNetworksListCmd}, | |
| 27 | 27 | {"images tags", newImagesTagsCmd}, | |
| 28 | + | {"tls status", newTLSStatusCmd}, | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 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 | 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 | 121 | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |
| 118 | 122 | panic("unexpected call") | |
| 119 | 123 | } | |
M internal/adapters/in/cli/remote/client.go +15 -0
| @@ -657,6 +657,21 @@ type Status struct { | |||
| 657 | 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 | 675 | // GetStatus returns the Gordon server status. | |
| 661 | 676 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | |
| 662 | 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 | 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 | 136 | func TestClientListImages(t *testing.T) { | |
| 120 | 137 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 121 | 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 | 149 | previewCmd.GroupID = groupManage | |
| 150 | 150 | rootCmd.AddCommand(previewCmd) | |
| 151 | 151 | ||
| 152 | + | tlsCmd := newTLSCmd() | |
| 153 | + | tlsCmd.GroupID = groupManage | |
| 154 | + | rootCmd.AddCommand(tlsCmd) | |
| 155 | + | ||
| 152 | 156 | // Client-only commands (no server needed) | |
| 153 | 157 | remotesCmd := newRemotesCmd() | |
| 154 | 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 | 52 | registrySvc registryDeployService | |
| 53 | 53 | previewSvc previewService | |
| 54 | 54 | reloadTrigger reloadTrigger | |
| 55 | + | publicTLSSvc in.PublicTLSService | |
| 55 | 56 | log zerowrap.Logger | |
| 56 | 57 | } | |
| 57 | 58 | ||
| @@ -149,6 +150,7 @@ type HandlerDeps struct { | |||
| 149 | 150 | ImageSvc in.ImageService | |
| 150 | 151 | VolumeSvc in.VolumeService | |
| 151 | 152 | ReloadTrigger reloadTrigger | |
| 153 | + | PublicTLSSvc in.PublicTLSService | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 154 | 156 | // NewHandler creates a new admin HTTP handler. | |
| @@ -166,6 +168,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||
| 166 | 168 | registrySvc: deps.RegistrySvc, | |
| 167 | 169 | previewSvc: deps.PreviewSvc, | |
| 168 | 170 | reloadTrigger: deps.ReloadTrigger, | |
| 171 | + | publicTLSSvc: deps.PublicTLSSvc, | |
| 169 | 172 | log: deps.Log, | |
| 170 | 173 | } | |
| 171 | 174 | } | |
| @@ -216,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 216 | 219 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | |
| 217 | 220 | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | |
| 218 | 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 | 224 | if handler, ok := exactRoutes[path]; ok { | |
| 221 | 225 | return handler, true | |
| @@ -1906,3 +1910,64 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) { | |||
| 1906 | 1910 | ||
| 1907 | 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 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/tls" | |
| 6 | 7 | "encoding/json" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "fmt" | |
| @@ -2585,3 +2586,119 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||
| 2585 | 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 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/zerowrap" | |
| 9 | 10 | ||
| @@ -25,6 +26,7 @@ type Kernel struct { | |||
| 25 | 26 | healthSvc in.HealthService | |
| 26 | 27 | logSvc in.LogService | |
| 27 | 28 | volumeSvc in.VolumeService | |
| 29 | + | publicTLSSvc in.PublicTLSService | |
| 28 | 30 | cleanup func() | |
| 29 | 31 | } | |
| 30 | 32 | ||
| @@ -59,7 +61,22 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 59 | 61 | ||
| 60 | 62 | // Prefer full service wiring so local CLI can execute the same operations | |
| 61 | 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 | 80 | return &Kernel{ | |
| 64 | 81 | authEnabled: cfg.Auth.Enabled, | |
| 65 | 82 | configSvc: svc.configSvc, | |
| @@ -70,7 +87,8 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 70 | 87 | healthSvc: svc.healthSvc, | |
| 71 | 88 | logSvc: svc.logSvc, | |
| 72 | 89 | volumeSvc: svc.volumeSvc, | |
| 73 | - | cleanup: cleanup, | |
| 90 | + | publicTLSSvc: svc.publicTLSSvc, | |
| 91 | + | cleanup: wrappedCleanup, | |
| 74 | 92 | }, nil | |
| 75 | 93 | } else { | |
| 76 | 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 | 145 | func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc } | |
| 128 | 146 | ||
| 147 | + | func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc } | |
| 148 | + | ||
| 129 | 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 | 36 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 37 | 37 | if err != nil { | |
| 38 | 38 | return | |
| @@ -87,7 +87,7 @@ func TestMigrateEnvFile(t *testing.T) { | |||
| 87 | 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 | 91 | for _, key := range keys { | |
| 92 | 92 | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | |
| 93 | 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 | 5 | "context" | |
| 6 | 6 | "crypto/rand" | |
| 7 | 7 | "crypto/tls" | |
| 8 | + | "crypto/x509" | |
| 8 | 9 | "encoding/hex" | |
| 9 | 10 | "encoding/json" | |
| 10 | 11 | "errors" | |
| @@ -27,6 +28,8 @@ import ( | |||
| 27 | 28 | ||
| 28 | 29 | // Adapters - Output | |
| 29 | 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 | 33 | "github.com/bnema/gordon/internal/adapters/out/docker" | |
| 31 | 34 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 32 | 35 | "github.com/bnema/gordon/internal/adapters/out/envloader" | |
| @@ -40,11 +43,15 @@ import ( | |||
| 40 | 43 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 41 | 44 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | |
| 42 | 45 | ||
| 46 | + | // Boundaries | |
| 47 | + | "github.com/bnema/gordon/internal/boundaries/in" | |
| 48 | + | ||
| 43 | 49 | // OTel | |
| 44 | 50 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" | |
| 45 | 51 | ||
| 46 | 52 | // Adapters - Input | |
| 47 | 53 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 54 | + | acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme" | |
| 48 | 55 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 49 | 56 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | |
| 50 | 57 | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| @@ -75,6 +82,7 @@ import ( | |||
| 75 | 82 | "github.com/bnema/gordon/internal/usecase/logs" | |
| 76 | 83 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 77 | 84 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 85 | + | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 78 | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 79 | 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 80 | 88 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | |
| @@ -189,6 +197,14 @@ type Config struct { | |||
| 189 | 197 | } `mapstructure:"containers"` | |
| 190 | 198 | ||
| 191 | 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 | 210 | // services holds all the services used by the application. | |
| @@ -224,6 +240,7 @@ type services struct { | |||
| 224 | 240 | caAdapter *pkiadapter.CA | |
| 225 | 241 | pkiSvc *pkiusecase.Service | |
| 226 | 242 | reloadCoordinator *reloadCoordinator | |
| 243 | + | publicTLSSvc in.PublicTLSService | |
| 227 | 244 | registryHandler interface { | |
| 228 | 245 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 229 | 246 | } | |
| @@ -401,27 +418,53 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 401 | 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 | 428 | // serviceInit holds the shared context for service initialization helpers. | |
| 405 | 429 | type serviceInit struct { | |
| 406 | - | ctx context.Context | |
| 407 | - | v *viper.Viper | |
| 408 | - | cfg Config | |
| 409 | - | log zerowrap.Logger | |
| 410 | - | svc *services | |
| 430 | + | ctx context.Context | |
| 431 | + | v *viper.Viper | |
| 432 | + | cfg Config | |
| 433 | + | log zerowrap.Logger | |
| 434 | + | svc *services | |
| 435 | + | startPublicTLS bool | |
| 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 | 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 | 448 | si := &serviceInit{ | |
| 416 | - | ctx: ctx, | |
| 417 | - | v: v, | |
| 418 | - | cfg: cfg, | |
| 419 | - | log: log, | |
| 420 | - | svc: &services{}, | |
| 449 | + | ctx: ctx, | |
| 450 | + | v: v, | |
| 451 | + | cfg: cfg, | |
| 452 | + | log: log, | |
| 453 | + | svc: &services{}, | |
| 454 | + | startPublicTLS: opts.StartPublicTLS, | |
| 421 | 455 | } | |
| 422 | 456 | defer func() { | |
| 423 | - | if retErr != nil && si.svc.pkiSvc != nil { | |
| 424 | - | si.svc.pkiSvc.Stop() | |
| 457 | + | if retErr != nil { | |
| 458 | + | if si.svc.pkiSvc != nil { | |
| 459 | + | si.svc.pkiSvc.Stop() | |
| 460 | + | } | |
| 461 | + | if si.svc.publicTLSSvc != nil { | |
| 462 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 463 | + | defer cancel() | |
| 464 | + | if err := si.svc.publicTLSSvc.Stop(ctx); err != nil { | |
| 465 | + | si.log.Warn().Err(err).Msg("failed to stop public TLS service during createServices cleanup") | |
| 466 | + | } | |
| 467 | + | } | |
| 425 | 468 | } | |
| 426 | 469 | }() | |
| 427 | 470 | var err error | |
| @@ -465,11 +508,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 465 | 508 | return nil, err | |
| 466 | 509 | } | |
| 467 | 510 | ||
| 511 | + | if err := si.initPublicTLS(); err != nil { | |
| 512 | + | return nil, err | |
| 513 | + | } | |
| 514 | + | ||
| 468 | 515 | if err := si.initRuntimeAndProxy(); err != nil { | |
| 469 | 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 | 521 | si.initHandlers() | |
| 475 | 522 | ||
| @@ -496,6 +543,92 @@ func (si *serviceInit) initPKI() error { | |||
| 496 | 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 | 632 | // initSecrets creates the domain secret store, env loader, and secret service. | |
| 500 | 633 | func (si *serviceInit) initSecrets() error { | |
| 501 | 634 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | |
| @@ -576,6 +709,7 @@ func (si *serviceInit) initHandlers() { | |||
| 576 | 709 | PreviewSvc: si.svc.previewService, | |
| 577 | 710 | ImageSvc: si.svc.imageSvc, | |
| 578 | 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 | 731 | // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute. | |
| 598 | - | sweepInterval := previewConfig.TTL / 2 | |
| 599 | - | if sweepInterval > 1*time.Hour { | |
| 600 | - | sweepInterval = 1 * time.Hour | |
| 601 | - | } | |
| 602 | - | if sweepInterval < 1*time.Minute { | |
| 603 | - | sweepInterval = 1 * time.Minute | |
| 604 | - | } | |
| 732 | + | sweepInterval := max(min(previewConfig.TTL/2, time.Hour), time.Minute) | |
| 605 | 733 | ||
| 606 | 734 | svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) { | |
| 607 | 735 | teardownTrackedPreview(ctx, svc, p) | |
| @@ -840,8 +968,8 @@ func resolveRuntimeConfig(value string) string { | |||
| 840 | 968 | return "/var/run/docker.sock" | |
| 841 | 969 | } | |
| 842 | 970 | // Explicit socket path — strip URI scheme if present. | |
| 843 | - | if strings.HasPrefix(value, "unix://") { | |
| 844 | - | return strings.TrimPrefix(value, "unix://") | |
| 971 | + | if socketPath, ok := strings.CutPrefix(value, "unix://"); ok { | |
| 972 | + | return socketPath | |
| 845 | 973 | } | |
| 846 | 974 | return value | |
| 847 | 975 | } | |
| @@ -1430,6 +1558,11 @@ type configWatcher interface { | |||
| 1430 | 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 | 1566 | type configReloader interface { | |
| 1434 | 1567 | Reload(ctx context.Context) error | |
| 1435 | 1568 | } | |
| @@ -1457,13 +1590,14 @@ type reloadCoordinator struct { | |||
| 1457 | 1590 | registryLimits interface { | |
| 1458 | 1591 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 1459 | 1592 | } | |
| 1460 | - | eventBus out.EventPublisher | |
| 1461 | - | log zerowrap.Logger | |
| 1593 | + | eventBus out.EventPublisher | |
| 1594 | + | publicTLS publicTLSReconciler | |
| 1595 | + | log zerowrap.Logger | |
| 1462 | 1596 | } | |
| 1463 | 1597 | ||
| 1464 | 1598 | func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface { | |
| 1465 | 1599 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 1466 | - | }, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator { | |
| 1600 | + | }, eventBus out.EventPublisher, publicTLS publicTLSReconciler, log zerowrap.Logger) *reloadCoordinator { | |
| 1467 | 1601 | return &reloadCoordinator{ | |
| 1468 | 1602 | debounce: 500 * time.Millisecond, | |
| 1469 | 1603 | configSvc: configSvc, | |
| @@ -1471,6 +1605,7 @@ func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc pro | |||
| 1471 | 1605 | proxySvc: proxySvc, | |
| 1472 | 1606 | registryLimits: registryLimits, | |
| 1473 | 1607 | eventBus: eventBus, | |
| 1608 | + | publicTLS: publicTLS, | |
| 1474 | 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 | 1650 | return err | |
| 1516 | 1651 | } | |
| 1517 | 1652 | ||
| 1518 | 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 | 1657 | var reloadCfg Config | |
| 1523 | 1658 | if err := c.v.Unmarshal(&reloadCfg); err != nil { | |
| 1524 | 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 | 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 | 1691 | c.log.Debug().Msg("config hot reload complete") | |
| 1549 | 1692 | return nil | |
| 1550 | 1693 | } | |
| @@ -1925,7 +2068,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 1925 | 2068 | // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain. | |
| 1926 | 2069 | // Direct clients get an onboarding gate (when CA is available) placed BEFORE | |
| 1927 | 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 | 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 | 2081 | if proxyCIDRMiddleware != nil && proxyAllowedNets == nil { | |
| 1930 | 2082 | // Invalid proxy_allowed_ips: deny all traffic (fail-closed). | |
| 1931 | 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 | 2456 | if err != nil { | |
| 2305 | 2457 | closeStarted(registrySrv) | |
| 2306 | 2458 | return err | |
| @@ -2344,7 +2496,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2344 | 2496 | ||
| 2345 | 2497 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | |
| 2346 | 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 | 2500 | return nil | |
| 2349 | 2501 | } | |
| 2350 | 2502 | ||
| @@ -2544,7 +2696,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||
| 2544 | 2696 | ||
| 2545 | 2697 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 2546 | 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 | 2700 | log.Info().Msg("shutting down Gordon...") | |
| 2549 | 2701 | ||
| 2550 | 2702 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| @@ -2565,6 +2717,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2565 | 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 | 2727 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | |
| 2569 | 2728 | if proxySvc != nil { | |
| 2570 | 2729 | log.Info().Msg("draining in-flight registry requests...") | |
| @@ -2592,7 +2751,71 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2592 | 2751 | ||
| 2593 | 2752 | // startProxyServers sets up the HTTP proxy server and, when tls_port != 0, | |
| 2594 | 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 | 2819 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | |
| 2597 | 2820 | var httpProtos http.Protocols | |
| 2598 | 2821 | httpProtos.SetHTTP1(true) | |
| @@ -2610,23 +2833,33 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||
| 2610 | 2833 | return httpSrv, httpReady, nil, nil, nil | |
| 2611 | 2834 | } | |
| 2612 | 2835 | ||
| 2613 | - | // HTTPS listener — static cert (if provided) takes priority by SNI match, | |
| 2614 | - | // unmatched domains fall through to on-demand internal CA certs. | |
| 2615 | - | tlsConfig := &tls.Config{ | |
| 2616 | - | MinVersion: tls.VersionTLS12, | |
| 2617 | - | GetCertificate: pkiSvc.GetCertificate, | |
| 2618 | - | NextProtos: []string{"h2", "http/1.1"}, | |
| 2619 | - | } | |
| 2836 | + | // Load static cert into a local slice (not tls.Config.Certificates) so the | |
| 2837 | + | // certificate selector has full control over priority ordering. | |
| 2838 | + | var staticCerts []tls.Certificate | |
| 2620 | 2839 | if cfg.Server.TLSCertFile != "" { | |
| 2621 | 2840 | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | |
| 2622 | 2841 | if err != nil { | |
| 2623 | 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 | 2845 | log.Info(). | |
| 2627 | 2846 | Str("cert", cfg.Server.TLSCertFile). | |
| 2628 | 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 | 2864 | tlsSrv, tlsReady := startTLSServerWithConfig( | |
| 2632 | 2865 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| @@ -2831,7 +3064,7 @@ func createPidFile(log zerowrap.Logger) string { | |||
| 2831 | 3064 | if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil { | |
| 2832 | 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 | 3068 | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file") | |
| 2836 | 3069 | return location | |
| 2837 | 3070 | } | |
| @@ -2966,6 +3199,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2966 | 3199 | v.SetDefault("server.tls_port", 8443) | |
| 2967 | 3200 | v.SetDefault("server.tls_cert_file", "") | |
| 2968 | 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 | 3205 | v.SetDefault("server.force_https_redirect", false) | |
| 2970 | 3206 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 2971 | 3207 | v.SetDefault("server.runtime", "auto") | |
M internal/app/run_reload_test.go +5 -5
| @@ -185,7 +185,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||
| 185 | 185 | events := &eventBusRecorder{} | |
| 186 | 186 | ||
| 187 | 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 | 189 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | |
| 190 | 190 | ||
| 191 | 191 | require.Equal(t, 0, reloadSvc.Calls()) | |
| @@ -220,7 +220,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |||
| 220 | 220 | events := &eventBusRecorder{} | |
| 221 | 221 | ||
| 222 | 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 | 224 | require.NoError(t, coord.Trigger(ctx)) | |
| 225 | 225 | require.NoError(t, coord.Trigger(ctx)) | |
| 226 | 226 | ||
| @@ -253,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) { | |||
| 253 | 253 | reloadErr := errors.New("reload failed") | |
| 254 | 254 | reloadSvc := &reloadRecorder{err: reloadErr} | |
| 255 | 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 | 258 | err := coord.Trigger(ctx) | |
| 259 | 259 | require.ErrorIs(t, err, reloadErr) | |
| @@ -282,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) | |||
| 282 | 282 | proxySvc := &proxyRecorder{} | |
| 283 | 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 | 287 | err := coord.Trigger(ctx) | |
| 288 | 288 | require.ErrorIs(t, err, publishErr) | |
| @@ -314,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) { | |||
| 314 | 314 | <-release | |
| 315 | 315 | }} | |
| 316 | 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 | 319 | firstDone := make(chan struct{}) | |
| 320 | 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 | + | } | |