ADR-027 resolves the architectural gap surfaced when ACME integration became a concrete target: 1. TlsIdentity::Acme variant — static config data (domains, cache_dir, directory, contact) with async AcmeState constructed at endpoint setup via two-phase TlsSetup (not stuffed into the Clone-able enum). 2. TlsIdentity::RawKey decoupled from the iroh feature — uses Ed25519SecretKey (alknet-core-owned wrapper over ed25519_dalek) instead of iroh::SecretKey. Raw-key TLS identity (RFC 7250, the default for most alknet nodes) now works in quinn-only builds. iroh transport converts via SecretKey::from_bytes. 3. ACME feature-gated behind new acme feature (rustls-acme optional dep). Non-ACME builds don't compile it. 4. dispatch_quinn guard for acme-tls/1 challenge connections — TLS-ALPN-01 is handled at the rustls cert resolver layer during the handshake; the guard closes challenge connections gracefully instead of logging a misleading "no handler" warning. Research confirmed QUIC (quinn) handles ACME challenges differently than TCP (reverse-proxy): quinn gives no ClientHello peek hook, but the challenge is fully answered at the cert resolution step before the connection surfaces to the application. No handler registration needed. Spec updates: config.md, endpoint.md, open-questions.md (OQ-12), overview.md + README.md (ADR index), ADR-010 (cross-ref). Tasks: core/rawkey-decouple-from-iroh (gen 1, no deps), core/acme-integration (gen 2, depends on rawkey). Graph: 36 tasks.
7.8 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| core/acme-integration | Add ACME auto-provisioning via rustls-acme (ADR-027) | pending |
|
moderate | medium | component | implementation |
Description
Implement ACME auto-provisioning (Let's Encrypt) for alknet endpoints,
following ADR-027. Adds TlsIdentity::Acme, a new acme feature gate,
a two-phase server-config construction (TlsSetup), and a
dispatch_quinn guard for acme-tls/1 challenge connections.
The reverse-proxy project (/workspace/@alkdev/reverse-proxy/src/tls/)
demonstrates the proven pattern: AcmeConfig, AcmeState event loop,
ResolvesServerCertAcme, TLS-ALPN-01 challenge handling, DirCache for
cert persistence. This task adapts that pattern to alknet's quinn-based
endpoint.
Implementation steps
-
Add
acmefeature to alknet-coreCargo.toml:[features] acme = ["dep:rustls-acme"] [dependencies] rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] }Use the same version as reverse-proxy (
=0.12.1or compatible). Confirm the exact version against the latest available and the reverse-proxy'sCargo.toml. -
Add
TlsIdentity::Acmevariant and supporting types inconfig.rs:pub enum TlsIdentity { X509 { cert: PathBuf, key: PathBuf }, RawKey(Ed25519SecretKey), SelfSigned, Acme { domains: Vec<String>, cache_dir: PathBuf, directory: AcmeDirectory, contact: Vec<String>, }, } pub enum AcmeDirectory { Production, Staging, Custom(String), }Acmeholds only static,Clone/Debug-safe data. NoAcmeState. -
Introduce
TlsSetupinendpoint.rs— the two-phase construction (ADR-027 Decision 2):struct TlsSetup { server_config: rustls::ServerConfig, acme_state_handle: Option<tokio::task::JoinHandle<()>>, } impl TlsSetup { async fn new( tls_identity: &TlsIdentity, alpns: &[Vec<u8>], ) -> Result<Self, EndpointError> { match tls_identity { TlsIdentity::X509 { .. } | TlsIdentity::SelfSigned | TlsIdentity::RawKey(_) => { // synchronous path (current build_rustls_server_config) let config = build_rustls_server_config(tls_identity, alpns)?; Ok(Self { server_config: config, acme_state_handle: None }) } TlsIdentity::Acme { domains, cache_dir, directory, contact } => { #[cfg(feature = "acme")] { Self::new_acme(domains, cache_dir, directory, contact, alpns).await } #[cfg(not(feature = "acme"))] { Err(EndpointError::TlsConfig(io::Error::other("ACME feature not enabled"))) } } } } } -
Implement
TlsSetup::new_acme(#[cfg(feature = "acme")]):- Build
AcmeConfig::new(domains)withDirCache::new(cache_dir), directory URL (fromAcmeDirectory), and contact. - Get
state = acme_config.state()andresolver = state.resolver(). - Build
rustls::ServerConfigwithwith_cert_resolver(resolver)(NOTwith_single_cert). - Append
b"acme-tls/1"toalpn_protocolsalongside handler ALPNs. - Spawn the
AcmeStateevent loop as a tokio task (pattern fromreverse-proxy/src/tls/acme.rs:spawn_acme_state). LogDeployedCachedCert,DeployedNewCert, and error events. - Return
TlsSetup { server_config, acme_state_handle: Some(handle) }.
- Build
-
Wire
TlsSetupinto the endpoint construction: replace the directbuild_quinn_server_configcall in the accept loop setup withTlsSetup::new(...).await?. Theacme_state_handleis stored onAlknetEndpoint(or the accept loop context) so it can be aborted on shutdown. -
Add
acme-tls/1guard indispatch_quinn(ADR-027 Decision 5):if alpn == b"acme-tls/1" { debug!("acme-tls/1 challenge connection completed at TLS layer; closing"); connection.close(0u32.into(), b"acme done"); return; }Place this before the
handlers.get(&alpn)lookup. This is#[cfg(feature = "acme")]— without the feature, the guard is absent andacme-tls/1is never in the ALPN list. -
Shutdown: abort the
acme_state_handleJoinHandle inAlknetEndpoint::shutdown()alongside the existing shutdown logic.
ACME challenge handling (from research)
The ResolvesServerCertAcme resolver intercepts TLS-ALPN-01 challenges
at the cert resolution step — during the TLS handshake, before the
connection surfaces to the application. The challenge cert (with the
SHA-256 key authorization in its SAN) is served by the resolver; the CA
validates it during the handshake. By the time dispatch_quinn runs,
the challenge already succeeded. The acme-tls/1 guard just closes the
connection gracefully instead of logging a misleading "no handler"
warning.
Key constraint: ACME requires with_cert_resolver, not
with_single_cert. The acme-tls/1 ALPN must be in
alpn_protocols or the challenge handshake aborts with
no_application_protocol.
What NOT to change
TlsIdentity::X509,RawKey,SelfSignedconstruction paths — unchanged (the RawKey decoupling is done by the predecessor task).- iroh endpoint — ACME is quinn-only (iroh uses its own TLS).
endpoint-request-client-cert— independent task, can proceed in parallel.
Acceptance Criteria
acmefeature added to alknet-core withrustls-acmeas optional depTlsIdentity::Acmevariant exists withdomains,cache_dir,directory,contactAcmeDirectoryenum exists (Production, Staging, Custom)TlsSetuptwo-phase construction: synchronous for X509/RawKey/SelfSigned, async for Acme- ACME path uses
with_cert_resolver(ResolvesServerCertAcme), notwith_single_cert acme-tls/1added toalpn_protocolswhen ACME is configureddispatch_quinnhasacme-tls/1guard (closes silently, no "no handler" warning)- ACME state machine spawned as tokio task, aborted on endpoint shutdown
TlsIdentity::Acmewithoutacmefeature returns a clear error at endpoint construction- Unit test:
AcmeDirectoryresolves to correct Let's Encrypt URLs (staging vs production) - Unit test:
TlsSetup::newwithX509/RawKey/SelfSignedreturnsacme_state_handle: None cargo build -p alknet-core --features quinn(no acme) succeeds — no rustls-acme compiledcargo build -p alknet-core --features "quinn acme"succeedscargo test -p alknet-core --all-featuressucceedscargo clippy -p alknet-core --all-features --all-targetscleancargo clippy -p alknet-core --features quinn --all-targetsclean (no acme, no warnings)
References
- ADR-027 — full design (two-phase construction, challenge handling, feature gate)
- /workspace/@alkdev/reverse-proxy/src/tls/acme.rs —
AcmeTlsConfig,spawn_acme_state(proven pattern) - /workspace/@alkdev/reverse-proxy/src/tls/acceptor.rs —
build_acme_server_config,acme-tls/1ALPN - crates/alknet-core/src/endpoint.rs:286-314 —
dispatch_quinn(guard insertion site) - crates/alknet-core/src/endpoint.rs:464-509 —
build_rustls_server_config(TlsSetup replaces this for Acme) - crates/alknet-core/src/config.rs:33-41 —
TlsIdentityenum (new Acme variant)
Notes
Depends on
core/rawkey-decouple-from-irohbecause both modifyTlsIdentityandbuild_rustls_server_config. The decoupling task cleans up the enum shape first; this task adds the Acme variant on top. Theacmefeature gate is critical — it keepsrustls-acmeand its deps out of non-ACME builds. The reverse-proxy project is the reference implementation; adapt its event loop logging and cache patterns.