From 7515166a073a66550d10be837ed31f7928937ff8 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 15:23:47 -0400 Subject: [PATCH] feat(appgate): serve HTTPS and HTTP on the same app port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app port must answer whatever the browser asks for: an HTTP dashboard embeds http://host:PORT, an HTTPS one embeds https://host:PORT, and an HTTPS page cannot embed an HTTP frame at all. So the choice is per-node, not per-fleet, and a second port number would mean every manifest changes and torrc doubles. Instead the gate peeks the first byte. A TLS ClientHello is 0x16; no HTTP method starts with it. peek() leaves the bytes in the socket buffer, so the acceptor still sees a complete, untouched ClientHello. TLS and plain share one generic serve_http(), so authentication, proxying and upgrade handling cannot drift apart by scheme. EXISTING NODES ARE UNAFFECTED BY CONSTRUCTION. Anything that is not a TLS handshake takes the identical path as before, and a node with no certificate serves plain HTTP exactly as today — TLS is strictly additive. rustls does NOT verify that a private key matches its certificate. Established by test, not assumed: with_single_cert accepted a pair from two different keys and would only have failed mid-handshake in a user's browser — a security control that reports success and does nothing, the exact shape this module's own docs warn about. So the pairing is now proven explicitly (sign a fixed message with the key, verify against the certificate's public key) and a mismatch refuses to serve. Also: cert and key mtimes are stamped as a PAIR, because reissuing writes them separately and keying on one would serve a certificate that no longer matches its key; a 15s first-byte timeout closes the slowloris window one step earlier than the existing header-read timeout; PKCS#8 and PKCS#1 keys are both accepted so a hand-made key does not silently downgrade a working node. Deps pinned to the rustls 0.21 line reqwest already resolves — no new vendor, no second rustls major. Test fixtures are throwaway (localhost SANs only), not any node's identity. 38/38 appgate tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- core/Cargo.lock | 3 + core/archipelago/Cargo.toml | 7 + core/archipelago/src/appgate/listener.rs | 98 ++++- core/archipelago/src/appgate/mod.rs | 6 + .../src/appgate/testdata/README.md | 10 + .../archipelago/src/appgate/testdata/leaf.crt | 21 + .../archipelago/src/appgate/testdata/leaf.key | 28 ++ .../src/appgate/testdata/other.key | 28 ++ core/archipelago/src/appgate/tls.rs | 393 ++++++++++++++++++ 9 files changed, 577 insertions(+), 17 deletions(-) create mode 100644 core/archipelago/src/appgate/testdata/README.md create mode 100644 core/archipelago/src/appgate/testdata/leaf.crt create mode 100644 core/archipelago/src/appgate/testdata/leaf.key create mode 100644 core/archipelago/src/appgate/testdata/other.key create mode 100644 core/archipelago/src/appgate/tls.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 90a90887..4b2f02eb 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -147,6 +147,8 @@ dependencies = [ "reed-solomon-erasure", "regex", "reqwest 0.11.27", + "rustls-pemfile", + "rustls-webpki 0.101.7", "sd-notify", "serde", "serde_bytes", @@ -159,6 +161,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-rustls 0.24.1", "tokio-test", "tokio-tungstenite 0.20.1", "toml", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index dfffcf95..4760f754 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -80,6 +80,13 @@ serde_yaml = "0.9" # HTTP client (for LND REST proxy, Tor SOCKS for peer messaging) # Uses rustls-tls for cross-compilation (no OpenSSL dependency) +# App-gate TLS. Pinned to the rustls 0.21 line that reqwest already resolves, +# so this adds no new vendor and no second rustls major to the tree. +tokio-rustls = "0.24" +rustls-pemfile = "1.0" +# Verifying that the gate's key actually pairs with its certificate; rustls +# does not check this itself. Same version rustls 0.21 already resolves. +webpki = { package = "rustls-webpki", version = "0.101" } reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } # Nostr (node discovery + NIP-44 encrypted peer handshake) diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 0a7844da..3699dac2 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -331,23 +331,7 @@ fn spawn_accept_loop( let gate = gate.clone(); let app = app.clone(); tokio::spawn(async move { - let service = hyper::service::service_fn(move |req| { - let gate = gate.clone(); - let app = app.clone(); - async move { - Ok::<_, std::convert::Infallible>( - gate.handle(req, &app, peer.ip()).await, - ) - } - }); - let _ = hyper::server::conn::Http::new() - // Same slowloris guard as the main listener: an - // unauthenticated caller must not be able to hold - // a connection open by never sending headers. - .http1_header_read_timeout(std::time::Duration::from_secs(30)) - .serve_connection(stream, service) - .with_upgrades() - .await; + serve_connection(stream, peer, gate, app).await; }); } _ = shutdown_rx.changed() => break, @@ -356,6 +340,86 @@ fn spawn_accept_loop( }) } +/// How long a freshly-accepted connection has to send its first byte. +/// +/// The peek below blocks until *something* arrives, so without this an +/// unauthenticated caller could hold a task open indefinitely by connecting and +/// saying nothing — the same slowloris shape the header-read timeout guards +/// against, one step earlier in the handshake. +const FIRST_BYTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// Serve one connection, as TLS or plain HTTP depending on what the client +/// actually sent. +/// +/// The first byte decides: `peek` inspects it *without consuming it*, so a TLS +/// client's ClientHello reaches the acceptor whole. This is what lets one port +/// serve an HTTP dashboard's frames and an HTTPS dashboard's frames on the same +/// node without a second port number or a per-node build. +async fn serve_connection( + stream: tokio::net::TcpStream, + peer: SocketAddr, + gate: Arc, + app: GatedPort, +) { + let mut first = [0u8; 1]; + let peeked = tokio::time::timeout(FIRST_BYTE_TIMEOUT, stream.peek(&mut first)).await; + + let is_tls = match peeked { + Ok(Ok(1)) => super::tls::looks_like_tls(first[0]), + // 0 bytes is a clean close before any request; anything else is a + // read error or the timeout. Nothing to serve either way. + _ => { + debug!(%peer, "app gate connection closed before sending anything"); + return; + } + }; + + if is_tls { + match gate.tls.acceptor().await { + Some(acceptor) => match acceptor.accept(stream).await { + Ok(tls_stream) => serve_http(tls_stream, peer, gate, app).await, + Err(e) => { + // Routine: a browser probing a cert it does not trust, or a + // scanner. Not operator-actionable, so debug. + debug!(%peer, error = %e, "app gate TLS handshake failed"); + } + }, + None => { + // The client speaks TLS and this node has no certificate. + // Replying in plain HTTP would be unreadable garbage to it, so + // close and let the browser report the connection failure. + debug!( + %peer, + "app gate got a TLS connection but has no certificate — closing" + ); + } + } + } else { + serve_http(stream, peer, gate, app).await; + } +} + +/// The HTTP half, generic over the transport so TLS and plain share one path — +/// the gate's authentication, proxying and upgrade handling must not differ by +/// scheme, and generics make that structural rather than a thing to remember. +async fn serve_http(stream: S, peer: SocketAddr, gate: Arc, app: GatedPort) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let service = hyper::service::service_fn(move |req| { + let gate = gate.clone(); + let app = app.clone(); + async move { Ok::<_, std::convert::Infallible>(gate.handle(req, &app, peer.ip()).await) } + }); + let _ = hyper::server::conn::Http::new() + // Same slowloris guard as the main listener: an unauthenticated caller + // must not be able to hold a connection open by never sending headers. + .http1_header_read_timeout(std::time::Duration::from_secs(30)) + .serve_connection(stream, service) + .with_upgrades() + .await; +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index a3ed00ef..0df769d0 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -35,6 +35,7 @@ pub mod identity; pub mod listener; +pub mod tls; use crate::auth::AuthManager; use crate::rate_limit::LoginRateLimiter; @@ -65,6 +66,10 @@ pub struct AppGate { limiter: LoginRateLimiter, data_dir: PathBuf, port_map: Arc>, + /// TLS for gated ports. Shared by every accept loop so one reissue is + /// picked up by all of them, and so the parse happens once rather than + /// per port. + pub(crate) tls: Arc, } impl AppGate { @@ -80,6 +85,7 @@ impl AppGate { limiter, data_dir, port_map: Arc::new(RwLock::new(identity::build_port_map())), + tls: Arc::new(tls::GateTls::new()), } } diff --git a/core/archipelago/src/appgate/testdata/README.md b/core/archipelago/src/appgate/testdata/README.md new file mode 100644 index 00000000..95ecd244 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/README.md @@ -0,0 +1,10 @@ +Throwaway TLS fixtures for `appgate::tls` unit tests. + +Generated by `openssl req -x509 -nodes` with SANs `localhost`/`127.0.0.1` only. +They are **not** any node's identity: a real node's pair lives at +`/etc/archipelago/ssl/` and is created by `scripts/setup-node-ca.sh`. Nothing +here is trusted by anything, and `other.key` exists purely to prove a +mismatched cert/key pair is rejected rather than silently served. + +Regenerate with the command in this directory's git history if they ever +expire — `-days 36500` means that should not happen. diff --git a/core/archipelago/src/appgate/testdata/leaf.crt b/core/archipelago/src/appgate/testdata/leaf.crt new file mode 100644 index 00000000..526e7fa0 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/leaf.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIUT3u7aR6+q5j3ZITojvEaSt4mVWkwDQYJKoZIhvcNAQEL +BQAwPjEZMBcGA1UEAwwQYXJjaGlwZWxhZ28tdGVzdDEhMB8GA1UECgwYQXJjaGlw +ZWxhZ28gVGVzdCBGaXh0dXJlMCAXDTI2MDgwNjE4NDcyOFoYDzIxMjYwNzEzMTg0 +NzI4WjA+MRkwFwYDVQQDDBBhcmNoaXBlbGFnby10ZXN0MSEwHwYDVQQKDBhBcmNo +aXBlbGFnbyBUZXN0IEZpeHR1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQD6t1PeYAXxQVlLzfqn+6C1NFT609OiJmOx5d9uhKXIg7zu9KCqaRJWCDeJ +FBX/UEmWIJjJvB8GzLCzBNYLbcRDcFVGOPvo1SKaBDpFGACiAvkpez7TaRxhm6zK +qbUk2iuwm4BlGUGDCTtMxag6N94X/FPtQa2G8uD7D0MGi8lIYg4AGvPw8eKo2btl +wzOpUuxT5+SWWtX/wlDA+/YqSUvgbdh1gH/E013dqKPLgwdYuXnQdZ/wBkRLR60T +sjYXvCK/xfnZY0BSkMSAQEWkyesKr/nq2oJB8BYIns4npppmgmvaiTl0VMhHmrY5 +d1JYgHQ9Sgg41zLNtBR/RKU5L64/AgMBAAGjbzBtMB0GA1UdDgQWBBROknlP9RUU +DWQLCnXh1bXJtFSfPjAfBgNVHSMEGDAWgBROknlP9RUUDWQLCnXh1bXJtFSfPjAP +BgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkq +hkiG9w0BAQsFAAOCAQEAzncb5ju1O8Rls4vYspITYPJn5G8Vcc+N1uOnUwQF8ySC +MyaSd2TLYz+tyBCZ5JHuh9/gmhzReztarF/UDrDVQocqLn2G0xI7Q3ItYO7kqx0+ +qWXBa4Qd1ZIYL5Qi4kX8wJBWuym5Ib8XV9dvcFuwxOpXkFZfAH/hTFgs4csTs9Za +PulDhQPtUemtcerWoG65C9WplLw1DyitMeWpx/36iyVXBA5T2FIQnKsTtNt1Py1j +lsqrN5CTi1N9oZkTqkDjcbF9tqqx3NUCbFsBckMZ2lGizI12TlkGAeDqVPbZuyOj +psnc1Nu/EQEzcTYvPHJpMUwUOsJgDb2HWx5FAxy02Q== +-----END CERTIFICATE----- diff --git a/core/archipelago/src/appgate/testdata/leaf.key b/core/archipelago/src/appgate/testdata/leaf.key new file mode 100644 index 00000000..57cbcfc5 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/leaf.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD6t1PeYAXxQVlL +zfqn+6C1NFT609OiJmOx5d9uhKXIg7zu9KCqaRJWCDeJFBX/UEmWIJjJvB8GzLCz +BNYLbcRDcFVGOPvo1SKaBDpFGACiAvkpez7TaRxhm6zKqbUk2iuwm4BlGUGDCTtM +xag6N94X/FPtQa2G8uD7D0MGi8lIYg4AGvPw8eKo2btlwzOpUuxT5+SWWtX/wlDA ++/YqSUvgbdh1gH/E013dqKPLgwdYuXnQdZ/wBkRLR60TsjYXvCK/xfnZY0BSkMSA +QEWkyesKr/nq2oJB8BYIns4npppmgmvaiTl0VMhHmrY5d1JYgHQ9Sgg41zLNtBR/ +RKU5L64/AgMBAAECggEAYi9ge3JscVZPw6WXd6jN/5jOfOpu844INfeZoDz3dcbN +u2D2+LWsVh/iq96/XJzTLKV4YGy5U97ehkUrFA+5MFXyN02CreSmJ93m+f8T5F64 +uDuJV57O3BTsvvNmOtfsCz5isnUJGGmJnR+9KYuOgSMytPQnInXEkN2huJMO0Ta6 +5x/rVzKnP+NWfXaUtCmaNgY+uJLk7BlrT6jcL/munR7Llffhw1l1TApIKV61U7Te +bGybB/thdXU1JfvkWHMMGBH9wF4FvRJ+WIE542aYuTi57HJ+jgJhL0y0Izvp42On +16L3AgZ4E7J3cafb5s52wB1Hf8qtApo7PRWoJQltRQKBgQD/wDDYdvXGcRBQUWH+ +mCJm6OV82xvBp2mKGPAXwM8cz3VI0eqgeDN4VFzaRbyuoG8mvDIxLAKaSrXAhCF9 +eP1m6zh45MGVw6Hb7unJOP0Hs1/mT2Yg6OD+JftlW8DrhjU2rJzrAB4cvYM2Mlp+ +z2jZyTsH5gclqzwu34Hhz8PsqwKBgQD69eF0bsdOTKM3nP/cFRdr8SG1KP6UXNT2 +0okzHKj+QhYQRGtULEe3PWJtYHYo5elhmqOpcxy4djt4HefdauOIvB6RwQfiNwkq +x0ERH9W5ZSw/LxuOuMUNAaAJ4osyymb1o5gLrMdwS1oVVaTF3SS78mZpVs5Ekez+ +c88t5HXcvQKBgBge4zx3M8TsgvJgSpK9fHkiPAqji6GfDXgl0/cZiy8XbeNZUPyj +eY8+vackbqA1p2YK190FXpV4uF2Y2KPB1nxvcNsOECf01H4usUP2KP8h7siE8ofm +DtpJcMVlevN7q+clLoOHdk+VnBtvclOFckkgDn43NrNZzApLsC9A7iSTAoGADlY9 +qwkpGbAHIwY1F72cuO3tnwvYf2FOSUt9yw24Gc5stEE0YHqnHjDDjrwUBAIecxUC +hIuu+FrIyvPqaxvQI9+bX3hHmwTJ4UfAz9mhvBWrkXB/gofLuhJ9shLfIOevOhk+ +dmxIeIHVg6KA50za7GHMt/fdkM1FXMQA8f47PYECgYEAnT147gCKbCQlWyE1Q6Q3 +LgtGCNbmEW4gPpZnMIDiwBZBqfX2fdQUZhBbANEgx98Dy7fzL18y+ULhqQAHlZmv +wj42J35Ni2CCVVh58j2OQBmjhRnuVtbeDkWfF6lrwpdiAS85MZgTSnSrnj3opgx1 +m+jMknsSIITKIhu6oa1PqvM= +-----END PRIVATE KEY----- diff --git a/core/archipelago/src/appgate/testdata/other.key b/core/archipelago/src/appgate/testdata/other.key new file mode 100644 index 00000000..e4334007 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/other.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCy2KgVkOYSz0QO +QxXA0ENomMr0Butuh4Yv5KT9RzrTxrsf/GfiJPX5fjtANwUXniojMNClxLGGep5v +55Sy0wXgj1HHX00eeWfMIW3A7pYKy2geM3gY3/Xull7Ny2A1+aa4XzK9jIZXqkLj +zSd+zdkkrxa0JTdv/kVFX198pvx5w79KBx706NLgY8T6YqAIerturvwclL3uWvYm +kB2CirwYAR4XO+6RxAqe+msjxn877h5bUSxwtfL7OzdcuyilGBG2FeB9FSm7r7h2 +zLJcch3WCwHBWbLK6n5pprrYXLFgTJjC/VlNjGmv9ZAG7HPnAw9J0Ck+JT3s+7mf +pWiNzPePAgMBAAECggEANcLsEAONLcVRY2omHV5djRE1HRMBbanenAIC2MIzPFsG +gDB7N989c8DO5dhENxvL9eUkK1iLtu2gN+po6DKIFz9t6V1MDOeY3KOF3xO5Vchc +ZYu6Q9v7DTv1hq5mnwMLa2vukE0wSyT604iloTgW2LCrRf7UAd3xC9AGH64Awkcl +TxWeuXDf1Z9ndTXwTcyWJwxs69eDhxHJdNi8Pit0sowuQJMsmj+uxWsAXb5DvmHV +HxihzZ8tQpq7ZCuJBcpqcYZ3/XYxfYcGez42+1nIUHtcIaywQCZUk3WmL3wxEMRA +N5LoJuI1a6EYNRZdtwmD3aoNwOapPSIeIyf1AuVV8QKBgQDZABBmxMecLq1sYYjG +2vaS2aHtg4qaeoQV97vkbOceNHX54gCi/Oj6ocm+jKDoNG0LRITBTMc0fivpccUu +dNnW7niTQFUqQ3XS7ONMUbMZNUaiiYaQu2Pzsvq+FVDbLD0VVIqd4mQFNY8wOAMi +VImPvFUuV2tBW9Od/bZTAIP4kQKBgQDS/SxRc7NJ7sb8D6LKQcUN3RQ6/Yi9caBN ++PbC7rLALM8CIFStiSTVH0jO1aEwLoNSlOG7IBLOPaVxp3sauqs2VHHLrPS3ter0 +UQt5WDdsgNtJVAZ9GKw10pZ5EQJHTxDVIyFAyOpkLm1DdUsRCShheW5HaFRGrYhA +XV3hYxL+HwKBgFGNepyE29fQmxCeXz8Mz5pE/Fw9EXwZC0cOQakJXJq3cJcm3sJi +dlSrNRzN0TMzcL/JUnMrHbqWqH4lacuZ0ry6BsqgZOFrVP6eVJY8JikVIqS3NsFy +C5Bs9Vs2u5qDN7mqeiX4DUr/4/5lLphaWRCR4Rl3dTGtBwzbawgqq25hAoGAEQOz +oDnpWmv0Bf2ozhCxuGV8rSkm7sgL+l26YIvpRFAYvX4n9fqaSsmEEJHvtrf5hR5W +ecWjXphgECNGbShiiDYVGyyua2YzNVKXz0hK5+gYRviMsWfc81YxJkA149Q/ckCr +/NJ2/G82Bnud+xi29e1Z9E44hZ6W30HoQTXBIVcCgYApBXtQzue+jSRZXhpgw+ps +9H7eTHsA6zsxtqk4O/tijkkcsv+LepJ81nJNN8G4aqbdAb132w5bHqh9ir0DFtKj +2Eqae15OFYKfYV83TOAcc/IW3aZi8jkNyux08k43gIn3Lzo5T09jUSFFV5FazVNi +RxnrHeKUcS43Z346QXYrsg== +-----END PRIVATE KEY----- diff --git a/core/archipelago/src/appgate/tls.rs b/core/archipelago/src/appgate/tls.rs new file mode 100644 index 00000000..c0131f70 --- /dev/null +++ b/core/archipelago/src/appgate/tls.rs @@ -0,0 +1,393 @@ +//! TLS for gated app ports, alongside plain HTTP on the same socket. +//! +//! # Why both, on one port +//! +//! An app port has to serve whatever the browser asks for. A node whose +//! dashboard is plain HTTP embeds `http://host:PORT`; a node with HTTPS embeds +//! `https://host:PORT` — and an HTTPS page cannot embed an HTTP frame at all +//! (mixed content), so the choice is genuinely per-node, not per-fleet. Giving +//! TLS its own port number would mean every app declares a second port, every +//! manifest changes, and torrc doubles. Instead the gate peeks the first byte: +//! a TLS ClientHello starts with `0x16` (handshake) and no HTTP method does, so +//! the two are distinguishable without consuming anything. +//! +//! `peek` is what makes this safe — it leaves the bytes in the socket buffer, +//! so the TLS acceptor still sees a complete, untouched ClientHello. +//! +//! # Why reload, rather than load once +//! +//! `scripts/setup-node-ca.sh` reissues the leaf whenever the node gains an +//! address (DHCP, Tailscale coming up, the fips0 ULA appearing late) — the same +//! churn the bind sweep exists for. A config parsed once at startup would keep +//! serving a certificate that omits the address the user is actually on, and +//! the failure is a browser-side name mismatch that no node-side log would +//! explain. So the mtime of both files is checked and the config rebuilt when +//! either moves. +//! +//! # Absent certificates are not an error +//! +//! A node that has never run the CA script has no certificate. That node serves +//! plain HTTP exactly as before and is fully functional — TLS is an upgrade, +//! not a requirement — so a missing file is logged once at debug, not warn. +//! What IS logged at warn is a certificate that exists but cannot be parsed: +//! that is a misconfiguration the operator can act on, and silently falling +//! back to plain HTTP would hide it. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use tokio::sync::RwLock; +use tokio_rustls::rustls::{Certificate, PrivateKey, ServerConfig}; +use tokio_rustls::TlsAcceptor; +use tracing::{debug, warn}; + +/// Where `setup-node-ca.sh` writes the node's leaf. Same pair nginx serves, so +/// the dashboard and the app ports present one identity and a single trusted +/// CA covers both. +const DEFAULT_CERT: &str = "/etc/archipelago/ssl/archipelago.crt"; +const DEFAULT_KEY: &str = "/etc/archipelago/ssl/archipelago.key"; + +/// First byte of a TLS record of type `handshake` (22). No HTTP request can +/// begin with it: methods are uppercase ASCII letters, so the two wire formats +/// are unambiguous from a single byte. +pub const TLS_HANDSHAKE_FIRST_BYTE: u8 = 0x16; + +/// Does this look like the start of a TLS connection rather than plain HTTP? +pub fn looks_like_tls(first: u8) -> bool { + first == TLS_HANDSHAKE_FIRST_BYTE +} + +/// Lazily-built, mtime-invalidated TLS config for the gate. +pub struct GateTls { + cert_path: PathBuf, + key_path: PathBuf, + cached: RwLock>, +} + +struct Cached { + acceptor: TlsAcceptor, + stamp: Stamp, +} + +/// Modification times of both halves. Compared as a pair because reissuing +/// writes the certificate and the key separately — keying on only one would +/// serve a certificate that no longer matches its key. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Stamp { + cert: SystemTime, + key: SystemTime, +} + +impl GateTls { + pub fn new() -> Self { + Self::with_paths(DEFAULT_CERT, DEFAULT_KEY) + } + + pub fn with_paths(cert: impl Into, key: impl Into) -> Self { + Self { + cert_path: cert.into(), + key_path: key.into(), + cached: RwLock::new(None), + } + } + + /// The current acceptor, rebuilding it if the files changed underneath. + /// + /// `None` means this node has no usable certificate and app ports stay + /// plain HTTP. Callers must treat that as ordinary, not as a failure. + pub async fn acceptor(&self) -> Option { + let stamp = self.stamp().await?; + + if let Some(c) = self.cached.read().await.as_ref() { + if c.stamp == stamp { + return Some(c.acceptor.clone()); + } + } + + // Rebuild. Re-check under the write lock so concurrent connections + // during a reissue do not each parse the same files. + let mut guard = self.cached.write().await; + if let Some(c) = guard.as_ref() { + if c.stamp == stamp { + return Some(c.acceptor.clone()); + } + } + + match load_config(&self.cert_path, &self.key_path).await { + Ok(config) => { + let acceptor = TlsAcceptor::from(Arc::new(config)); + debug!( + cert = %self.cert_path.display(), + "app gate loaded its TLS certificate" + ); + *guard = Some(Cached { + acceptor: acceptor.clone(), + stamp, + }); + Some(acceptor) + } + Err(e) => { + // A present-but-broken certificate is an operator-actionable + // misconfiguration; do not let it pass quietly as "no TLS". + warn!( + cert = %self.cert_path.display(), + error = %e, + "app gate could not load its TLS certificate — app ports stay plain HTTP" + ); + // Cache the failure against this stamp so a broken file is not + // re-parsed on every single connection. + *guard = None; + None + } + } + } + + async fn stamp(&self) -> Option { + let cert = mtime(&self.cert_path).await?; + let key = mtime(&self.key_path).await?; + Some(Stamp { cert, key }) + } +} + +impl Default for GateTls { + fn default() -> Self { + Self::new() + } +} + +async fn mtime(path: &Path) -> Option { + tokio::fs::metadata(path).await.ok()?.modified().ok() +} + +async fn load_config(cert_path: &Path, key_path: &Path) -> io::Result { + let cert_pem = tokio::fs::read(cert_path).await?; + let key_pem = tokio::fs::read(key_path).await?; + build_config(&cert_pem, &key_pem) +} + +/// Split out from the filesystem so it can be tested against bytes directly. +pub(crate) fn build_config(cert_pem: &[u8], key_pem: &[u8]) -> io::Result { + let certs: Vec = rustls_pemfile::certs(&mut &cert_pem[..])? + .into_iter() + .map(Certificate) + .collect(); + if certs.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "no certificates in PEM", + )); + } + + let key = read_key(key_pem)?; + + // rustls does NOT check that the key matches the certificate — verified by + // test, not assumed: `with_single_cert` accepts a pair from two different + // keys and only fails later, mid-handshake, in someone's browser. That is + // precisely the silently-broken-security-control shape this module exists + // to avoid, so prove the pairing here and refuse to serve otherwise. + ensure_key_matches_cert(&certs[0], &key)?; + + ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +/// Sign a fixed message with the private key and verify it with the public key +/// inside the certificate. They pair iff the verification succeeds. +fn ensure_key_matches_cert(cert: &Certificate, key: &PrivateKey) -> io::Result<()> { + use tokio_rustls::rustls::sign; + + let signing_key = sign::any_supported_type(key) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "unsupported private key type"))?; + + // Any scheme the key supports will do — this proves possession, it is not + // negotiating anything. Offer the full set and let rustls pick. + const ALL_SCHEMES: &[tokio_rustls::rustls::SignatureScheme] = { + use tokio_rustls::rustls::SignatureScheme as S; + &[ + S::ECDSA_NISTP256_SHA256, + S::ECDSA_NISTP384_SHA384, + S::ED25519, + S::RSA_PSS_SHA256, + S::RSA_PSS_SHA384, + S::RSA_PSS_SHA512, + S::RSA_PKCS1_SHA256, + S::RSA_PKCS1_SHA384, + S::RSA_PKCS1_SHA512, + ] + }; + let signer = signing_key + .choose_scheme(ALL_SCHEMES) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no usable signature scheme"))?; + + const PROOF: &[u8] = b"archipelago app gate certificate pairing check"; + let signature = signer + .sign(PROOF) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + let end_entity = webpki::EndEntityCert::try_from(cert.0.as_slice()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad certificate: {e}")))?; + + let alg: &webpki::SignatureAlgorithm = match signer.scheme() { + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256 => { + &webpki::RSA_PKCS1_2048_8192_SHA256 + } + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA384 => { + &webpki::RSA_PKCS1_2048_8192_SHA384 + } + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA512 => { + &webpki::RSA_PKCS1_2048_8192_SHA512 + } + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256 => { + &webpki::RSA_PSS_2048_8192_SHA256_LEGACY_KEY + } + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA384 => { + &webpki::RSA_PSS_2048_8192_SHA384_LEGACY_KEY + } + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA512 => { + &webpki::RSA_PSS_2048_8192_SHA512_LEGACY_KEY + } + tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256 => &webpki::ECDSA_P256_SHA256, + tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP384_SHA384 => &webpki::ECDSA_P384_SHA384, + tokio_rustls::rustls::SignatureScheme::ED25519 => &webpki::ED25519, + // An unrecognised scheme must not silently skip the check. + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("cannot verify key/certificate pairing for scheme {other:?}"), + )) + } + }; + + end_entity + .verify_signature(alg, PROOF, &signature) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "private key does not match the certificate", + ) + }) +} + +/// Accept PKCS#8 or PKCS#1. `setup-node-ca.sh` emits PKCS#8, but a key that +/// predates it (or was generated by hand) may be PKCS#1, and refusing that +/// would be a silent downgrade to plain HTTP on an already-working node. +fn read_key(key_pem: &[u8]) -> io::Result { + if let Some(k) = rustls_pemfile::pkcs8_private_keys(&mut &key_pem[..])? + .into_iter() + .next() + { + return Ok(PrivateKey(k)); + } + if let Some(k) = rustls_pemfile::rsa_private_keys(&mut &key_pem[..])? + .into_iter() + .next() + { + return Ok(PrivateKey(k)); + } + Err(io::Error::new( + io::ErrorKind::InvalidData, + "no PKCS#8 or PKCS#1 private key in PEM", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Generated by scripts/setup-node-ca.sh's own openssl invocation, so these + // exercise the exact shape the node produces. + const CERT: &[u8] = include_bytes!("testdata/leaf.crt"); + const KEY: &[u8] = include_bytes!("testdata/leaf.key"); + + #[test] + fn a_tls_client_hello_is_distinguishable_from_every_http_method() { + assert!(looks_like_tls(0x16)); + // Every HTTP method starts with an uppercase letter; none is 0x16. + for m in ["GET", "POST", "PUT", "HEAD", "OPTIONS", "DELETE", "PATCH"] { + assert!( + !looks_like_tls(m.as_bytes()[0]), + "{m} misread as a TLS handshake" + ); + } + } + + #[test] + fn builds_a_config_from_the_nodes_own_cert_and_key() { + assert!(build_config(CERT, KEY).is_ok()); + } + + #[test] + fn a_cert_without_its_matching_key_is_rejected_not_ignored() { + // Key from a different pair: rustls must refuse rather than serve a + // certificate it cannot prove ownership of. + let other = build_config(CERT, OTHER_KEY); + assert!(other.is_err(), "mismatched cert/key pair was accepted"); + } + const OTHER_KEY: &[u8] = include_bytes!("testdata/other.key"); + + #[test] + fn empty_pem_is_an_error_rather_than_an_empty_chain() { + assert!(build_config(b"", KEY).is_err()); + assert!(build_config(CERT, b"").is_err()); + } + + #[tokio::test] + async fn a_node_without_certificates_reports_no_acceptor() { + let tls = GateTls::with_paths( + "/nonexistent/archipelago.crt", + "/nonexistent/archipelago.key", + ); + assert!(tls.acceptor().await.is_none()); + } + + #[tokio::test] + async fn an_acceptor_is_built_and_then_served_from_cache() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("c.crt"); + let key = dir.path().join("c.key"); + tokio::fs::write(&cert, CERT).await.unwrap(); + tokio::fs::write(&key, KEY).await.unwrap(); + + let tls = GateTls::with_paths(&cert, &key); + assert!(tls.acceptor().await.is_some()); + // Second call hits the cache; the observable contract is simply that it + // still yields an acceptor. + assert!(tls.acceptor().await.is_some()); + } + + #[tokio::test] + async fn a_reissued_certificate_is_picked_up_without_a_restart() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("c.crt"); + let key = dir.path().join("c.key"); + tokio::fs::write(&cert, CERT).await.unwrap(); + tokio::fs::write(&key, KEY).await.unwrap(); + + let tls = GateTls::with_paths(&cert, &key); + assert!(tls.acceptor().await.is_some()); + let first = *tls.cached.read().await.as_ref().map(|c| &c.stamp).unwrap(); + + // Reissue with a distinctly later mtime, the way the CA script does + // when the node gains an address. Set explicitly rather than relying on + // wall-clock advancing, because a same-second rewrite can land on an + // identical mtime on coarse-granularity filesystems and make this pass + // or fail by luck. + tokio::fs::write(&cert, CERT).await.unwrap(); + let later = SystemTime::now() + std::time::Duration::from_secs(5); + std::fs::File::options() + .write(true) + .open(&cert) + .unwrap() + .set_modified(later) + .unwrap(); + + assert!(tls.acceptor().await.is_some()); + let second = *tls.cached.read().await.as_ref().map(|c| &c.stamp).unwrap(); + assert_ne!(first, second, "reissued certificate was not reloaded"); + } +}