From eeef07241bfd16b68fe997dc38632ed33c5baa57 Mon Sep 17 00:00:00 2001 From: Mlanawo MBECHEZI Date: Fri, 22 May 2026 18:55:51 +0300 Subject: [PATCH] Add DNS-01 challenge with named resolvers and wildcard certificates via cheti --- CHANGELOG.md | 7 + Cargo.lock | 232 +++++++++++++- Cargo.toml | 1 + README.md | 2 +- documentation/tls/acme.md | 59 +++- src/acme/mod.rs | 499 ++++++++++++----------------- src/acme/resolver.rs | 197 ++++++++++++ src/api/server.rs | 4 + src/config.rs | 51 ++- src/labels/lint.rs | 1 + src/labels/parser.rs | 2 + src/main.rs | 9 + src/model/entrypoint.rs | 11 + src/provider/docker.rs | 1 + src/provider/http.rs | 1 + src/provider/kubernetes/gateway.rs | 1 + src/provider/kubernetes/mod.rs | 1 + 17 files changed, 770 insertions(+), 309 deletions(-) create mode 100644 src/acme/resolver.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1349bda..a4f579d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### TLS / ACME + +- DNS-01 challenge support via named resolvers — declare `acme.resolvers` with `challenge: dns-01` and a provider (Cloudflare, OVH, Gandi, Scaleway), then point an entrypoint at it with `acme.resolver: `. Provider credentials are read from environment variables, never inlined in YAML. DNS-01 solving is delegated to [cheti](https://github.com/kemeter/cheti). +- Wildcard certificates — `*.example.com` can now be issued through a DNS-01 resolver. Attempting a wildcard on an HTTP-01 resolver fails loudly. Wildcard certs are stored under `_wildcard_.example.com/` on disk. +- Entrypoints with `tls: true` but no `acme.resolver` keep the existing HTTP-01 behaviour on `challenge_port` — no migration needed. +- ACME account persistence and certificate renewal checks now use cheti (`FileAccountStore`, `needs_renewal`), replacing the in-tree X.509 parser. Storage layout is unchanged (`certs_dir/account_credentials.json`). + ### Routing - `addPrefix` middleware — prepend a fixed path prefix to incoming requests before forwarding to the backend. Counterpart of `stripPrefix`, useful for serving a sub-path of an existing app under a dedicated subdomain (e.g. `expats.example.com` → backend receives `/foo`). Available via Docker/Swarm/Podman/Nomad labels (`sozune.http..addPrefix=/foo`), the HTTP provider, the YAML config file, and the REST API. diff --git a/Cargo.lock b/Cargo.lock index 4a38e17..0e26010 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,13 +107,29 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", @@ -123,6 +139,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -464,6 +492,27 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cheti" +version = "0.1.0" +source = "git+https://github.com/kemeter/cheti?rev=d996136f09f7a60af7649535c908289a8ed8b57e#d996136f09f7a60af7649535c908289a8ed8b57e" +dependencies = [ + "async-trait", + "hex", + "hickory-resolver", + "instant-acme", + "percent-encoding", + "reqwest", + "secrecy", + "serde", + "serde_json", + "sha1", + "thiserror 1.0.69", + "tokio", + "url", + "x509-parser 0.16.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -702,13 +751,27 @@ dependencies = [ "syn", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "displaydoc", "nom", "num-bigint", @@ -814,6 +877,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1205,6 +1280,51 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.6", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.6", + "resolv-conf", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "home" version = "0.5.12" @@ -1579,6 +1699,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1914,6 +2047,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1951,6 +2090,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2130,13 +2278,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", ] [[package]] @@ -2588,7 +2745,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", + "x509-parser 0.18.1", "yasna", ] @@ -2685,6 +2842,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -3260,7 +3423,7 @@ dependencies = [ "thiserror 2.0.18", "time", "toml", - "x509-parser", + "x509-parser 0.18.1", ] [[package]] @@ -3306,6 +3469,7 @@ dependencies = [ "base64 0.22.1", "bollard", "brotli", + "cheti", "clap", "flate2", "futures-util", @@ -3999,6 +4163,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -4036,6 +4206,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4298,19 +4497,36 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "aws-lc-rs", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "ring", "rusticata-macros", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index e60dd63..f651aa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ signal-hook-tokio = { version = "0.4", features = ["futures-v0_3"] } futures-util = "0.3" notify = "8.2.0" instant-acme = "0.8" +cheti = { git = "https://github.com/kemeter/cheti", rev = "d996136f09f7a60af7649535c908289a8ed8b57e" } rcgen = { version = "0.14", features = ["pem"] } hyper = { version = "1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "tokio", "http1"] } diff --git a/README.md b/README.md index 229f8c7..3921d17 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Sōzune (pronounce *Sozuné*) is a modern reverse proxy built on [Sōzu](https:/ ## Features - **Multi-platform service discovery** — Docker, Podman, Swarm, Kubernetes (Ingress + Gateway API), Nomad, an HTTP endpoint, or a YAML file. -- **Automatic HTTPS** — ACME (Let's Encrypt) provisioning and renewal, no intervention. +- **Automatic HTTPS** — ACME (Let's Encrypt) provisioning and renewal, no intervention. HTTP-01 and DNS-01 (Cloudflare, OVH, Gandi, Scaleway), including wildcard certificates. - **HTTP/2** — negotiated through ALPN on every TLS listener. - **Hot reload** — REST API applies changes on the fly, no downtime. - **Wildcard & regex hostnames** — `*.example.com`, `/cdn[0-9]+/.example.com`. diff --git a/documentation/tls/acme.md b/documentation/tls/acme.md index ab7f08d..cdef7f1 100644 --- a/documentation/tls/acme.md +++ b/documentation/tls/acme.md @@ -11,6 +11,14 @@ acme: certs_dir: "/etc/sozune/certs" staging: true challenge_port: 3036 + resolvers: + legacy: + challenge: http-01 + cloudflare-main: + challenge: dns-01 + provider: + type: cloudflare + api_token_env: CF_API_TOKEN ``` | Field | Default | Description | @@ -20,8 +28,53 @@ acme: | `certs_dir` | `/etc/sozune/certs` | Where certificates and the ACME account credentials are stored. | | `staging` | `true` | Use Let's Encrypt's staging environment (no rate limit, untrusted certs). **Switch to `false` for production.** | | `challenge_port` | `3036` | Port where Sōzune answers HTTP-01 challenges (loopback only). | +| `resolvers` | `{}` | Named challenge resolvers (HTTP-01 or DNS-01 with a provider). Entrypoints reference one by name. | -Every field is overridable through `SOZUNE_ACME_*` environment variables. +Top-level fields are overridable through `SOZUNE_ACME_*` environment variables. Provider credentials are always read from environment variables — never inlined in YAML. + +## DNS-01 and wildcards + +Wildcard certificates (`*.example.com`) cannot be issued through HTTP-01 — they require DNS-01. Declare a DNS-01 resolver and point your entrypoint at it: + +```yaml +# config.yaml +acme: + enabled: true + email: ops@example.com + resolvers: + cloudflare-main: + challenge: dns-01 + provider: + type: cloudflare + api_token_env: CF_API_TOKEN +``` + +```yaml +# entrypoint (from any provider — Docker label, k8s, file, etc.) +- id: app + config: + hostnames: ["example.com", "*.example.com"] + tls: true + acme: + resolver: cloudflare-main +``` + +Set `CF_API_TOKEN=...` in the environment before starting Sōzune. The token must have `Zone:DNS:Edit` scope on the matching zone. + +**Supported providers:** + +| Provider | `type` value | Required env vars | Optional fields | +|---|---|---|---| +| Cloudflare | `cloudflare` | `api_token_env` | — | +| OVH | `ovh` | `application_key_env`, `application_secret_env`, `consumer_key_env` | `endpoint` (default `ovh-eu`) | +| Gandi | `gandi` | `personal_access_token_env` | — | +| Scaleway | `scaleway` | `secret_key_env` | — | + +**Entrypoint without a resolver:** if `tls: true` is set but no `acme.resolver` is defined, Sōzune falls back to the legacy HTTP-01 flow on `challenge_port` (the behaviour before resolvers existed). This keeps existing deployments working unchanged. + +**Wildcard on an HTTP-01 resolver:** the order will fail loudly with `wildcard hostname requires a DNS-01 resolver`. Wildcards always need DNS-01. + +**Multiple entrypoints sharing a hostname with different resolvers:** Sōzune issues one certificate per hostname; the first resolver seen wins. A warning is logged for the others. ## How it works @@ -76,7 +129,7 @@ A certificate that is already valid for more than 30 days is left untouched. └── key.pem ``` -- One subdirectory per hostname. +- One subdirectory per hostname. Wildcard hostnames are stored under `_wildcard_.example.com/` (the `*` is not filesystem-portable). - Filenames are fixed: `cert.pem` (full chain) and `key.pem`. - Persisting `certs_dir` across restarts is what avoids re-issuing certs at every boot. **Always mount it on a volume in production** — Let's Encrypt enforces rate limits on new orders. @@ -100,7 +153,7 @@ Every TLS hostname is validated before it's used as a directory name. Names cont ## Limitations -- **HTTP-01 only.** No DNS-01. Wildcards (`*.example.com`) cannot be issued by Let's Encrypt with HTTP-01 — they require DNS-01. A wildcard hostname declared with `tls=true` will fail to provision. +- **HTTP-01 and DNS-01.** DNS-01 is available through named resolvers (Cloudflare, OVH, Gandi, Scaleway), which also unlocks wildcard certificates. DNS-01 challenge solving is delegated to [cheti](https://github.com/kemeter/cheti). - **Let's Encrypt only.** The ACME directory URL is hardcoded. No support for custom ACME providers (ZeroSSL, Buypass, internal CA, Pebble for testing). - **No manual certificate path.** You cannot inject a cert managed externally (purchased, self-signed, internal PKI). ACME is the only source. - **No EAB.** No External Account Binding — incompatible with ACME providers that require it. diff --git a/src/acme/mod.rs b/src/acme/mod.rs index 431a749..f40b197 100644 --- a/src/acme/mod.rs +++ b/src/acme/mod.rs @@ -1,13 +1,13 @@ pub mod challenge_server; +pub mod resolver; use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use std::time::Duration; -use instant_acme::{ - Account, AccountCredentials, ChallengeType, Identifier, NewAccount, NewOrder, OrderStatus, -}; +use cheti::{AccountStore, Dns01Solver, FileAccountStore}; +use instant_acme::{Account, ChallengeType, Identifier, NewAccount, NewOrder, Order, OrderStatus}; use rcgen::{CertificateParams, KeyPair}; use tokio::sync::{Notify, mpsc}; use tracing::{debug, error, info, warn}; @@ -16,6 +16,7 @@ use crate::config::AcmeConfig; use crate::model::Entrypoint; use self::challenge_server::ChallengeState; +use self::resolver::{Resolver, build_resolver}; /// Command sent from ACME manager to the proxy reload handler pub struct CertCommand { @@ -84,19 +85,22 @@ impl AcmeManager { /// Scan storage for entrypoints with tls: true and provision certificates async fn provision_all(&self) -> anyhow::Result<()> { - let hostnames = self.collect_tls_hostnames(); - if hostnames.is_empty() { + let certs = self.collect_tls_certs(); + if certs.is_empty() { debug!("No TLS-enabled hostnames found, skipping ACME provisioning"); return Ok(()); } - info!("Found {} TLS-enabled hostname(s) to check", hostnames.len()); + info!("Found {} TLS-enabled hostname(s) to check", certs.len()); - for hostname in &hostnames { + for (hostname, resolver_name) in &certs { match self.needs_certificate(hostname).await { true => { info!("Requesting certificate for {}", hostname); - if let Err(e) = self.provision_certificate(hostname).await { + if let Err(e) = self + .provision_certificate(hostname, resolver_name.as_deref()) + .await + { error!("Failed to provision certificate for {}: {}", hostname, e); } } @@ -109,8 +113,11 @@ impl AcmeManager { Ok(()) } - /// Collect all unique hostnames with tls: true from storage - fn collect_tls_hostnames(&self) -> Vec { + /// Collect all unique hostnames with tls: true, paired with the first + /// resolver name we see for each. If two entrypoints share a hostname + /// with different resolvers, the second one is dropped with a warning — + /// we want one cert per hostname, not duplicate ACME orders. + fn collect_tls_certs(&self) -> Vec<(String, Option)> { let storage = match self.storage.read() { Ok(guard) => guard, Err(e) => { @@ -122,28 +129,40 @@ impl AcmeManager { } }; - let mut hostnames = Vec::new(); + let mut certs: Vec<(String, Option)> = Vec::new(); for entrypoint in storage.values() { - if entrypoint.config.tls { - for hostname in &entrypoint.config.hostnames { - if !hostnames.contains(hostname) { - hostnames.push(hostname.clone()); + if !entrypoint.config.tls { + continue; + } + let resolver_name = entrypoint.config.acme.as_ref().map(|a| a.resolver.clone()); + for hostname in &entrypoint.config.hostnames { + if let Some((_, existing)) = certs.iter().find(|(h, _)| h == hostname) { + if existing != &resolver_name { + warn!( + "Hostname {} is claimed by multiple entrypoints with different resolvers ({:?} vs {:?}); keeping the first", + hostname, existing, resolver_name + ); } + continue; } + certs.push((hostname.clone(), resolver_name.clone())); } } - hostnames + certs } - /// Validate that a hostname is safe to use as a directory name (no path traversal) + /// Validate that a hostname is safe to use as a directory name (no path traversal). + /// Wildcards (`*.foo.com`) are accepted; the wildcard label must be the leftmost one. fn validate_hostname(hostname: &str) -> anyhow::Result<()> { - if hostname.is_empty() - || hostname.contains('/') - || hostname.contains('\\') - || hostname.contains('\0') - || hostname == "." - || hostname == ".." - || hostname.contains("..") + let trailing = hostname.strip_prefix("*.").unwrap_or(hostname); + if trailing.is_empty() + || trailing.contains('*') + || trailing.contains('/') + || trailing.contains('\\') + || trailing.contains('\0') + || trailing == "." + || trailing == ".." + || trailing.contains("..") { anyhow::bail!("Invalid hostname for certificate storage: {}", hostname); } @@ -156,22 +175,42 @@ impl AcmeManager { warn!("Skipping invalid hostname: {}", hostname); return false; } - let cert_path = self.certs_dir.join(hostname).join("cert.pem"); + let cert_path = self.certs_dir.join(path_safe(hostname)).join("cert.pem"); if !cert_path.exists() { return true; } // Read and parse existing cert to check expiration match tokio::fs::read_to_string(&cert_path).await { - Ok(pem_data) => is_cert_expiring_soon(&pem_data, 30), + Ok(pem_data) => cheti::needs_renewal_checked(&pem_data, 30).unwrap_or_else(|e| { + warn!( + "Could not parse certificate expiry, assuming renewal needed: {}", + e + ); + true + }), Err(_) => true, } } - /// Full ACME HTTP-01 flow for a single hostname - async fn provision_certificate(&self, hostname: &str) -> anyhow::Result<()> { + /// Provision a certificate for `hostname` using the resolver named by + /// the entrypoint (or the legacy HTTP-01 fallback if none). + async fn provision_certificate( + &self, + hostname: &str, + resolver_name: Option<&str>, + ) -> anyhow::Result<()> { Self::validate_hostname(hostname)?; + let resolver = build_resolver(resolver_name, &self.config)?; + + if is_wildcard(hostname) && !resolver.as_ref().is_some_and(Resolver::supports_wildcard) { + anyhow::bail!( + "wildcard hostname `{}` requires a DNS-01 resolver; assign one via `acme.resolver` on the entrypoint", + hostname + ); + } + // Ensure rustls has a crypto provider installed (needed by instant-acme/reqwest) let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -181,14 +220,39 @@ impl AcmeManager { "https://acme-v02.api.letsencrypt.org/directory" }; - // Create or load ACME account - let (account, _credentials) = self.get_or_create_account(server_url).await?; - - // Create order + let account = self.get_or_create_account(server_url).await?; let identifiers = vec![Identifier::Dns(hostname.to_string())]; - let mut order = account.new_order(&NewOrder::new(&identifiers)).await?; + let order = account.new_order(&NewOrder::new(&identifiers)).await?; + + let (cert_chain_pem, key_pem) = match resolver { + Some(Resolver::Dns01(provider)) => self.solve_dns01(order, provider).await?, + Some(Resolver::Http01) | None => self.solve_http01(order, hostname).await?, + }; + + self.save_certificate(hostname, &cert_chain_pem, &key_pem) + .await?; - // Process authorizations and collect challenge tokens for cleanup + let (cert_pem, chain) = split_pem_chain(&cert_chain_pem); + self.cert_tx + .send(CertCommand { + hostname: hostname.to_string(), + cert_pem, + key_pem, + chain, + }) + .await?; + + info!("Certificate for {} provisioned successfully", hostname); + Ok(()) + } + + /// HTTP-01 challenge flow: stash key auth in shared state, let + /// `challenge_server` answer the ACME validation request, then finalize. + async fn solve_http01( + &self, + mut order: Order, + hostname: &str, + ) -> anyhow::Result<(String, String)> { let mut challenge_tokens: Vec = Vec::new(); let mut authorizations = order.authorizations(); while let Some(result) = authorizations.next().await { @@ -200,7 +264,6 @@ impl AcmeManager { let token = challenge.token.clone(); let key_auth = challenge.key_authorization(); - // Store token → key_authorization in shared challenge state { let mut challenges = self.challenges.write().map_err(|e| { anyhow::anyhow!( @@ -213,97 +276,60 @@ impl AcmeManager { debug!("Challenge token stored: {} (type: HTTP-01)", token); challenge_tokens.push(token); - - // Tell ACME server we're ready challenge.set_ready().await?; } - // Authorizations holds a borrow on order; let NLL release it here. let _ = authorizations; - // Wait for order to become ready let ready_result = Self::poll_order_ready(&mut order).await; - - // Clean up challenge tokens regardless of outcome self.cleanup_challenge_tokens(&challenge_tokens); - ready_result?; info!("Order for {} is ready", hostname); - // Generate key pair and CSR let key_pair = KeyPair::generate()?; let mut params = CertificateParams::new(vec![hostname.to_string()])?; params.distinguished_name = rcgen::DistinguishedName::new(); let csr = params.serialize_request(&key_pair)?; - // Finalize order with CSR order.finalize_csr(csr.der()).await?; - - // Poll for certificate let cert_chain_pem = Self::poll_certificate(&mut order).await?; - - // Save to disk let key_pem = key_pair.serialize_pem(); - self.save_certificate(hostname, &cert_chain_pem, &key_pem) - .await?; - - // Parse chain and send to Sozu - let (cert_pem, chain) = split_pem_chain(&cert_chain_pem); - self.cert_tx - .send(CertCommand { - hostname: hostname.to_string(), - cert_pem, - key_pem, - chain, - }) - .await?; - - info!("Certificate for {} provisioned successfully", hostname); - Ok(()) + Ok((cert_chain_pem, key_pem)) } - /// Get or create an ACME account - async fn get_or_create_account( + /// DNS-01 challenge flow: hand the order off to cheti, which drives + /// provider TXT records + propagation polling + finalize. + async fn solve_dns01( &self, - server_url: &str, - ) -> anyhow::Result<(Account, AccountCredentials)> { - let creds_path = self.certs_dir.join("account_credentials.json"); - - // Try to load existing credentials - if creds_path.exists() { - match tokio::fs::read_to_string(&creds_path).await { - Ok(data) => { - match serde_json::from_str::(&data) { - Ok(credentials) => { - match Account::builder()?.from_credentials(credentials).await { - Ok(account) => { - // Re-parse from already loaded data (from_credentials consumed the first parse) - let creds: AccountCredentials = serde_json::from_str(&data)?; - info!("Loaded existing ACME account"); - return Ok((account, creds)); - } - Err(e) => { - warn!( - "Failed to restore ACME account, creating new one: {}", - e - ); - } - } - } - Err(e) => { - warn!( - "Failed to parse account credentials, creating new one: {}", - e - ); - } + order: Order, + provider: Box, + ) -> anyhow::Result<(String, String)> { + Dns01Solver::new(provider) + .solve_and_finalize(order) + .await + .map_err(|e| anyhow::anyhow!("DNS-01 challenge failed: {e}")) + } + + /// Get or create an ACME account, persisted via cheti's FileAccountStore. + async fn get_or_create_account(&self, server_url: &str) -> anyhow::Result { + let store = FileAccountStore::new(self.certs_dir.join("account_credentials.json")); + + match store.load() { + Ok(Some(credentials)) => { + match Account::builder()?.from_credentials(credentials).await { + Ok(account) => { + info!("Loaded existing ACME account"); + return Ok(account); } - } - Err(e) => { - warn!("Failed to read account credentials: {}", e); + Err(e) => warn!("Failed to restore ACME account, creating new one: {}", e), } } + Ok(None) => {} + Err(e) => warn!( + "Failed to load account credentials, creating new one: {}", + e + ), } - // Create new account let contact = if self.config.email.is_empty() { vec![] } else { @@ -322,13 +348,12 @@ impl AcmeManager { ) .await?; - // Save credentials with restrictive permissions - tokio::fs::create_dir_all(&self.certs_dir).await?; - let creds_json = serde_json::to_string_pretty(&credentials)?; - write_with_restricted_permissions(&creds_path, creds_json.as_bytes()).await?; + store + .save(&credentials) + .map_err(|e| anyhow::anyhow!("persist ACME account credentials: {e}"))?; info!("Created new ACME account"); - Ok((account, credentials)) + Ok(account) } /// Remove challenge tokens from shared state after order completion @@ -409,7 +434,7 @@ impl AcmeManager { cert_chain_pem: &str, key_pem: &str, ) -> anyhow::Result<()> { - let cert_dir = self.certs_dir.join(hostname); + let cert_dir = self.certs_dir.join(path_safe(hostname)); tokio::fs::create_dir_all(&cert_dir).await?; tokio::fs::write(cert_dir.join("cert.pem"), cert_chain_pem).await?; @@ -441,16 +466,18 @@ impl AcmeManager { continue; } - let hostname = match path.file_name().and_then(|n| n.to_str()) { + let dir_name = match path.file_name().and_then(|n| n.to_str()) { Some(name) => name.to_string(), None => continue, }; - // Skip account credentials directory - if hostname == "account_credentials.json" { + // Skip account credentials file + if dir_name == "account_credentials.json" { continue; } + let hostname = hostname_from_path(&dir_name); + let cert_path = path.join("cert.pem"); let key_path = path.join("key.pem"); @@ -475,7 +502,7 @@ impl AcmeManager { }; // Check if cert is expired (not just expiring soon) - if is_cert_expiring_soon(&cert_pem, 0) { + if cheti::needs_renewal(&cert_pem, 0) { warn!("Certificate for {} is expired, skipping load", hostname); continue; } @@ -514,6 +541,31 @@ async fn write_with_restricted_permissions( Ok(()) } +fn is_wildcard(hostname: &str) -> bool { + hostname.starts_with("*.") +} + +/// Translate a hostname into a filesystem-safe directory name. `*` is not +/// portable across tools, so wildcard hostnames are stored under +/// `_wildcard_.{rest}`. Inverse of `hostname_from_path`. +fn path_safe(hostname: &str) -> String { + if let Some(rest) = hostname.strip_prefix("*.") { + format!("_wildcard_.{rest}") + } else { + hostname.to_string() + } +} + +/// Inverse of `path_safe`: recover the wildcard hostname stored under a +/// `_wildcard_.{rest}` directory. +fn hostname_from_path(dir_name: &str) -> String { + if let Some(rest) = dir_name.strip_prefix("_wildcard_.") { + format!("*.{rest}") + } else { + dir_name.to_string() + } +} + /// Split a PEM chain into the leaf certificate and the rest of the chain fn split_pem_chain(pem_chain: &str) -> (String, Vec) { let pem_blocks: Vec<&str> = pem_chain @@ -535,195 +587,58 @@ fn split_pem_chain(pem_chain: &str) -> (String, Vec) { (leaf, chain) } -/// Check if a PEM certificate is expiring within `days` days. -/// Returns true if cert is invalid or expiring soon. -fn is_cert_expiring_soon(pem_data: &str, days: i64) -> bool { - // Parse the PEM to extract the leaf cert and check its notAfter field. - // Falls back to assuming renewal is needed if parsing fails. - match parse_cert_expiry(pem_data) { - Some(expiry) => { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let threshold = now + (days * 86400); - expiry < threshold - } - None => { - warn!("Could not parse certificate expiry, assuming renewal needed"); - true - } - } -} - -/// Parse the notAfter timestamp from a PEM certificate. -/// Returns Unix timestamp or None if parsing fails. -fn parse_cert_expiry(pem_data: &str) -> Option { - // Extract the first PEM block - let begin = pem_data.find("-----BEGIN CERTIFICATE-----")?; - let end = pem_data.find("-----END CERTIFICATE-----")?; - let b64_start = begin + "-----BEGIN CERTIFICATE-----".len(); - let b64 = &pem_data[b64_start..end]; - - // Decode base64 - let der = base64_decode(b64)?; - - // Parse ASN.1 DER to find validity.notAfter - // TBSCertificate is the first element of the SEQUENCE - // validity is at a known position in TBSCertificate - parse_x509_not_after(&der) -} +#[cfg(test)] +mod tests { + use super::*; -/// Simple base64 decoder (no external dependency needed) -fn base64_decode(input: &str) -> Option> { - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let input: Vec = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); - let mut output = Vec::with_capacity(input.len() * 3 / 4); - - for chunk in input.chunks(4) { - let mut buf = [0u8; 4]; - let mut valid = 0; - for (i, &byte) in chunk.iter().enumerate() { - if byte == b'=' { - break; - } - buf[i] = TABLE.iter().position(|&c| c == byte)? as u8; - valid = i + 1; - } - if valid >= 2 { - output.push((buf[0] << 2) | (buf[1] >> 4)); - } - if valid >= 3 { - output.push((buf[1] << 4) | (buf[2] >> 2)); - } - if valid >= 4 { - output.push((buf[2] << 6) | buf[3]); - } + #[test] + fn is_wildcard_detects_leading_star_dot() { + assert!(is_wildcard("*.example.com")); + assert!(!is_wildcard("example.com")); + assert!(!is_wildcard("api.*.example.com")); + assert!(!is_wildcard("*example.com")); } - Some(output) -} - -/// Parse X.509 DER to extract notAfter as a Unix timestamp -fn parse_x509_not_after(der: &[u8]) -> Option { - // X.509 structure: - // SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue } - // tbsCertificate = SEQUENCE { version, serialNumber, signature, issuer, validity, ... } - // validity = SEQUENCE { notBefore, notAfter } - - let (_, content) = parse_asn1_sequence(der)?; - let (_, tbs) = parse_asn1_sequence(content)?; - - let mut pos = 0; - // version [0] EXPLICIT (optional, skip if present) - if tbs.get(pos)? & 0xe0 == 0xa0 { - let (len, next) = parse_asn1_element(&tbs[pos..])?; - pos += len + next; + #[test] + fn path_safe_round_trips_wildcard() { + assert_eq!(path_safe("*.example.com"), "_wildcard_.example.com"); + assert_eq!( + hostname_from_path("_wildcard_.example.com"), + "*.example.com" + ); + assert_eq!( + hostname_from_path(&path_safe("*.deep.sub.example.com")), + "*.deep.sub.example.com" + ); } - // serialNumber (INTEGER, skip) - let (len, next) = parse_asn1_element(&tbs[pos..])?; - pos += len + next; - - // signature (SEQUENCE, skip) - let (len, next) = parse_asn1_element(&tbs[pos..])?; - pos += len + next; - - // issuer (SEQUENCE, skip) - let (len, next) = parse_asn1_element(&tbs[pos..])?; - pos += len + next; - - // validity (SEQUENCE) - let (_, validity_content) = parse_asn1_sequence(&tbs[pos..])?; - - // notBefore (skip) - let (len, next) = parse_asn1_element(validity_content)?; - let not_after_data = &validity_content[len + next..]; - - // notAfter - parse_asn1_time(not_after_data) -} - -/// Parse an ASN.1 SEQUENCE and return (header_len, content) -fn parse_asn1_sequence(data: &[u8]) -> Option<(usize, &[u8])> { - if data.first()? != &0x30 { - return None; + #[test] + fn path_safe_passes_plain_hostnames_through() { + assert_eq!(path_safe("example.com"), "example.com"); + assert_eq!(hostname_from_path("example.com"), "example.com"); } - let (header_len, content_len) = parse_asn1_length(&data[1..])?; - let total_header = 1 + header_len; - Some(( - total_header, - &data[total_header..total_header + content_len], - )) -} - -/// Parse an ASN.1 element and return (header_size, content_size) — total = header + content -fn parse_asn1_element(data: &[u8]) -> Option<(usize, usize)> { - let tag_len = 1; - let (len_bytes, content_len) = parse_asn1_length(&data[tag_len..])?; - Some((tag_len + len_bytes, content_len)) -} -/// Parse ASN.1 length bytes. Returns (number_of_length_bytes, actual_length) -fn parse_asn1_length(data: &[u8]) -> Option<(usize, usize)> { - let first = *data.first()?; - if first < 0x80 { - Some((1, first as usize)) - } else { - let num_bytes = (first & 0x7f) as usize; - let mut length = 0usize; - for i in 0..num_bytes { - length = (length << 8) | (*data.get(1 + i)? as usize); - } - Some((1 + num_bytes, length)) + #[test] + fn validate_hostname_accepts_wildcards_and_plain() { + AcmeManager::validate_hostname("example.com").unwrap(); + AcmeManager::validate_hostname("*.example.com").unwrap(); + AcmeManager::validate_hostname("*.deep.sub.example.com").unwrap(); } -} - -/// Parse an ASN.1 UTCTime or GeneralizedTime to Unix timestamp -fn parse_asn1_time(data: &[u8]) -> Option { - let tag = *data.first()?; - let (header, content_len) = parse_asn1_element(data)?; - let time_str = std::str::from_utf8(&data[header..header + content_len]).ok()?; - - let (year, month, day, hour, min, sec) = if tag == 0x17 { - // UTCTime: YYMMDDHHMMSSZ - let y: i32 = time_str.get(0..2)?.parse().ok()?; - let year = if y >= 50 { 1900 + y } else { 2000 + y }; - ( - year, - time_str.get(2..4)?.parse::().ok()?, - time_str.get(4..6)?.parse::().ok()?, - time_str.get(6..8)?.parse::().ok()?, - time_str.get(8..10)?.parse::().ok()?, - time_str.get(10..12)?.parse::().ok()?, - ) - } else if tag == 0x18 { - // GeneralizedTime: YYYYMMDDHHMMSSZ - ( - time_str.get(0..4)?.parse::().ok()?, - time_str.get(4..6)?.parse::().ok()?, - time_str.get(6..8)?.parse::().ok()?, - time_str.get(8..10)?.parse::().ok()?, - time_str.get(10..12)?.parse::().ok()?, - time_str.get(12..14)?.parse::().ok()?, - ) - } else { - return None; - }; - // Convert to Unix timestamp (simplified, no leap seconds) - let days = days_from_civil(year, month, day)?; - Some(days * 86400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64) -} + #[test] + fn validate_hostname_rejects_path_traversal() { + assert!(AcmeManager::validate_hostname("../etc/passwd").is_err()); + assert!(AcmeManager::validate_hostname("a/b").is_err()); + assert!(AcmeManager::validate_hostname("").is_err()); + assert!(AcmeManager::validate_hostname(".").is_err()); + } -/// Convert a civil date to days since Unix epoch (algorithm from Howard Hinnant) -fn days_from_civil(y: i32, m: u32, d: u32) -> Option { - let y = if m <= 2 { y - 1 } else { y } as i64; - let m = m as i64; - let d = d as i64; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = (y - era * 400) as u64; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; - let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy; - Some(era * 146097 + doe - 719468) + #[test] + fn validate_hostname_rejects_misplaced_wildcards() { + // Only the leftmost label may be a wildcard. + assert!(AcmeManager::validate_hostname("api.*.example.com").is_err()); + assert!(AcmeManager::validate_hostname("*.*.example.com").is_err()); + // Bare `*.` is also invalid (no apex). + assert!(AcmeManager::validate_hostname("*.").is_err()); + } } diff --git a/src/acme/resolver.rs b/src/acme/resolver.rs new file mode 100644 index 0000000..4e2f6ee --- /dev/null +++ b/src/acme/resolver.rs @@ -0,0 +1,197 @@ +//! Build cheti DNS providers from `AcmeConfig` resolver entries. + +use cheti::{ + CloudflareConfig, CloudflareProvider, DnsProvider, GandiConfig, GandiProvider, OvhConfig, + OvhProvider, ScalewayConfig, ScalewayProvider, +}; + +use crate::config::{AcmeConfig, ProviderConfig, ResolverConfig}; + +/// What kind of ACME challenge to run for a given hostname. +pub enum Resolver { + Http01, + Dns01(Box), +} + +/// Resolve a resolver name from `AcmeConfig.resolvers` and build it. +/// Returns `Ok(None)` if `name` is `None` (caller will fall back to the +/// legacy HTTP-01 challenge port). +pub fn build_resolver(name: Option<&str>, acme: &AcmeConfig) -> anyhow::Result> { + let Some(name) = name else { + return Ok(None); + }; + + let Some(cfg) = acme.resolvers.get(name) else { + anyhow::bail!("unknown ACME resolver `{}`", name); + }; + + match cfg { + ResolverConfig::Http01 => Ok(Some(Resolver::Http01)), + ResolverConfig::Dns01 { provider } => Ok(Some(Resolver::Dns01(build_provider(provider)?))), + } +} + +fn build_provider(cfg: &ProviderConfig) -> anyhow::Result> { + match cfg { + ProviderConfig::Cloudflare { api_token_env } => { + let token = read_env(api_token_env)?; + let provider = CloudflareProvider::new(CloudflareConfig::new(token)) + .map_err(|e| anyhow::anyhow!("build Cloudflare provider: {e}"))?; + Ok(Box::new(provider)) + } + ProviderConfig::Ovh { + endpoint: _, + application_key_env, + application_secret_env, + consumer_key_env, + } => { + let app_key = read_env(application_key_env)?; + let app_secret = read_env(application_secret_env)?; + let consumer_key = read_env(consumer_key_env)?; + let provider = OvhProvider::new(OvhConfig::new(app_key, app_secret, consumer_key)) + .map_err(|e| anyhow::anyhow!("build OVH provider: {e}"))?; + Ok(Box::new(provider)) + } + ProviderConfig::Gandi { + personal_access_token_env, + } => { + let pat = read_env(personal_access_token_env)?; + let provider = GandiProvider::new(GandiConfig::new(pat)) + .map_err(|e| anyhow::anyhow!("build Gandi provider: {e}"))?; + Ok(Box::new(provider)) + } + ProviderConfig::Scaleway { secret_key_env } => { + let key = read_env(secret_key_env)?; + let provider = ScalewayProvider::new(ScalewayConfig::new(key)) + .map_err(|e| anyhow::anyhow!("build Scaleway provider: {e}"))?; + Ok(Box::new(provider)) + } + } +} + +fn read_env(name: &str) -> anyhow::Result { + std::env::var(name) + .map_err(|_| anyhow::anyhow!("required environment variable `{}` is not set", name)) +} + +impl Resolver { + /// True if this resolver can validate wildcard hostnames (`*.example.com`). + pub fn supports_wildcard(&self) -> bool { + matches!(self, Resolver::Dns01(_)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AcmeConfig, ProviderConfig, ResolverConfig}; + use crate::test_env::ENV_LOCK; + use std::collections::HashMap; + + struct EnvGuard { + keys: Vec<&'static str>, + } + + impl EnvGuard { + fn new(vars: &[(&'static str, &str)]) -> Self { + let keys = vars.iter().map(|(k, _)| *k).collect(); + unsafe { + for (k, v) in vars { + std::env::set_var(k, v); + } + } + Self { keys } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + for k in &self.keys { + std::env::remove_var(k); + } + } + } + } + + fn empty_acme() -> AcmeConfig { + AcmeConfig { + enabled: true, + email: String::new(), + certs_dir: String::from("/tmp"), + staging: true, + challenge_port: 80, + resolvers: HashMap::new(), + } + } + + #[test] + fn returns_none_when_name_is_none() { + let acme = empty_acme(); + assert!(build_resolver(None, &acme).unwrap().is_none()); + } + + #[test] + fn fails_when_resolver_name_unknown() { + let acme = empty_acme(); + let err = match build_resolver(Some("nope"), &acme) { + Ok(_) => panic!("expected error for unknown resolver"), + Err(e) => e, + }; + assert!(err.to_string().contains("unknown ACME resolver")); + } + + #[test] + fn http01_resolver_does_not_support_wildcard() { + let mut acme = empty_acme(); + acme.resolvers + .insert("legacy".to_string(), ResolverConfig::Http01); + let resolver = build_resolver(Some("legacy"), &acme).unwrap().unwrap(); + assert!(!resolver.supports_wildcard()); + assert!(matches!(resolver, Resolver::Http01)); + } + + #[test] + fn dns01_resolver_fails_when_env_missing() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Ensure the var is absent for this test. + unsafe { std::env::remove_var("TEST_CF_TOKEN_MISSING") }; + + let mut acme = empty_acme(); + acme.resolvers.insert( + "cf".to_string(), + ResolverConfig::Dns01 { + provider: ProviderConfig::Cloudflare { + api_token_env: "TEST_CF_TOKEN_MISSING".to_string(), + }, + }, + ); + let err = match build_resolver(Some("cf"), &acme) { + Ok(_) => panic!("expected error when env var missing"), + Err(e) => e, + }; + assert!( + err.to_string().contains("TEST_CF_TOKEN_MISSING"), + "error should name the missing env var, got: {err}" + ); + } + + #[test] + fn dns01_cloudflare_builds_when_env_present() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _env = EnvGuard::new(&[("TEST_CF_TOKEN_PRESENT", "dummy-token")]); + + let mut acme = empty_acme(); + acme.resolvers.insert( + "cf".to_string(), + ResolverConfig::Dns01 { + provider: ProviderConfig::Cloudflare { + api_token_env: "TEST_CF_TOKEN_PRESENT".to_string(), + }, + }, + ); + let resolver = build_resolver(Some("cf"), &acme).unwrap().unwrap(); + assert!(resolver.supports_wildcard()); + assert!(matches!(resolver, Resolver::Dns01(_))); + } +} diff --git a/src/api/server.rs b/src/api/server.rs index 349066a..5949e86 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -1035,6 +1035,7 @@ mod tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: Some("docker".to_string()), }, @@ -1487,6 +1488,7 @@ mod tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: Some("docker".to_string()), }, @@ -1580,6 +1582,7 @@ mod tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: Some("docker".to_string()), }, @@ -1636,6 +1639,7 @@ mod tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: source.map(|s| s.to_string()), } diff --git a/src/config.rs b/src/config.rs index 140db9a..a8e6bb8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::Deserialize; #[derive(Deserialize, Debug, Clone, Default)] @@ -34,6 +36,45 @@ pub struct AcmeConfig { deserialize_with = "deserialize_acme_challenge_port_with_env" )] pub challenge_port: u16, + #[serde(default)] + pub resolvers: HashMap, +} + +/// One named ACME challenge resolver. Entrypoints reference these by name. +#[derive(Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "challenge", rename_all = "kebab-case")] +pub enum ResolverConfig { + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "dns-01")] + Dns01 { provider: ProviderConfig }, +} + +/// DNS-01 provider configuration. Credentials are referenced by env var name, +/// never inlined in YAML. +#[derive(Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ProviderConfig { + Cloudflare { + api_token_env: String, + }, + Ovh { + #[serde(default = "default_ovh_endpoint")] + endpoint: String, + application_key_env: String, + application_secret_env: String, + consumer_key_env: String, + }, + Gandi { + personal_access_token_env: String, + }, + Scaleway { + secret_key_env: String, + }, +} + +fn default_ovh_endpoint() -> String { + "ovh-eu".to_string() } #[derive(Deserialize, Debug, Default, Clone)] @@ -899,6 +940,7 @@ impl AppConfig { certs_dir: default_acme_certs_dir(), staging: default_acme_staging(), challenge_port: default_acme_challenge_port(), + resolvers: HashMap::new(), }); acme.apply_env_overrides(); } @@ -1195,11 +1237,7 @@ impl AcmeConfig { #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; - - /// Serialise tests that mutate the process environment, otherwise - /// `cargo test` parallelism races on shared env vars. - static ENV_LOCK: Mutex<()> = Mutex::new(()); + use crate::test_env::ENV_LOCK; /// RAII helper: sets `SOZUNE_*` vars on construction, removes them on drop. /// Use together with `ENV_LOCK` to keep tests isolated. @@ -1541,6 +1579,9 @@ enabled: true #[test] fn test_proxy_config_with_defaults() { + // ProxyConfig deserializers read SOZUNE_* env vars; hold the shared + // lock so a sibling test setting those vars can't bleed in here. + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let yaml = r#" http: listen_address: 9080 diff --git a/src/labels/lint.rs b/src/labels/lint.rs index e3d88fc..cfa966e 100644 --- a/src/labels/lint.rs +++ b/src/labels/lint.rs @@ -157,6 +157,7 @@ mod tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: None, } diff --git a/src/labels/parser.rs b/src/labels/parser.rs index d542d30..72f6a74 100644 --- a/src/labels/parser.rs +++ b/src/labels/parser.rs @@ -213,6 +213,7 @@ fn build_entrypoint( compress, entrypoint: None, methods, + acme: None, }, source: None, }) @@ -274,6 +275,7 @@ fn build_tcp_entrypoint( compress: false, entrypoint: Some(entrypoint_ref), methods: Vec::new(), + acme: None, }, source: None, }) diff --git a/src/main.rs b/src/main.rs index a30c842..ca916b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,15 @@ mod provider; mod proxy; mod util; +/// Shared lock serialising every test that mutates `std::env`. Tests across +/// modules race on the global environment otherwise (and edition 2024 marks +/// `set_var`/`remove_var` `unsafe` for exactly this reason). +#[cfg(test)] +pub(crate) mod test_env { + use std::sync::Mutex; + pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(()); +} + pub use model::*; #[tokio::main] diff --git a/src/model/entrypoint.rs b/src/model/entrypoint.rs index 38d464f..af4f3d1 100644 --- a/src/model/entrypoint.rs +++ b/src/model/entrypoint.rs @@ -94,6 +94,17 @@ pub struct EntrypointConfig { /// (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, TRACE). #[serde(default)] pub methods: Vec, + /// Per-entrypoint ACME settings. When `tls: true` and this is `None`, + /// the legacy HTTP-01 fallback on `acme.challenge_port` is used. + #[serde(default)] + pub acme: Option, +} + +/// Selects which ACME resolver (from `acme.resolvers`) issues certs for this +/// entrypoint. Wildcard hostnames require a `dns-01` resolver. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct EntrypointAcmeConfig { + pub resolver: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] diff --git a/src/provider/docker.rs b/src/provider/docker.rs index e274dbe..883a9d1 100644 --- a/src/provider/docker.rs +++ b/src/provider/docker.rs @@ -871,6 +871,7 @@ mod merge_tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: None, } diff --git a/src/provider/http.rs b/src/provider/http.rs index 4f27396..765e212 100644 --- a/src/provider/http.rs +++ b/src/provider/http.rs @@ -161,6 +161,7 @@ mod tests { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: None, }]) diff --git a/src/provider/kubernetes/gateway.rs b/src/provider/kubernetes/gateway.rs index f6486ce..217b310 100644 --- a/src/provider/kubernetes/gateway.rs +++ b/src/provider/kubernetes/gateway.rs @@ -1091,6 +1091,7 @@ fn rule_to_entrypoints( compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: Some(id), } diff --git a/src/provider/kubernetes/mod.rs b/src/provider/kubernetes/mod.rs index 289a44e..06282e7 100644 --- a/src/provider/kubernetes/mod.rs +++ b/src/provider/kubernetes/mod.rs @@ -1008,6 +1008,7 @@ impl IngressParseCtx<'_> { compress: false, entrypoint: None, methods: Vec::new(), + acme: None, }, source: Some(self.provider.name.to_string()), };