feat(appgate): serve HTTPS and HTTP on the same app port

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 15:23:47 -04:00
co-authored by Claude Opus 5
parent f09ff102ee
commit 7515166a07
9 changed files with 577 additions and 17 deletions
+3
View File
@@ -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",
+7
View File
@@ -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)
+81 -17
View File
@@ -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<AppGate>,
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<S>(stream: S, peer: SocketAddr, gate: Arc<AppGate>, 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::*;
+6
View File
@@ -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<RwLock<PortMap>>,
/// 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<tls::GateTls>,
}
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()),
}
}
+10
View File
@@ -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.
+21
View File
@@ -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-----
+28
View File
@@ -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-----
+28
View File
@@ -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-----
+393
View File
@@ -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<Option<Cached>>,
}
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<PathBuf>, key: impl Into<PathBuf>) -> 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<TlsAcceptor> {
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<Stamp> {
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<SystemTime> {
tokio::fs::metadata(path).await.ok()?.modified().ok()
}
async fn load_config(cert_path: &Path, key_path: &Path) -> io::Result<ServerConfig> {
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<ServerConfig> {
let certs: Vec<Certificate> = 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<PrivateKey> {
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");
}
}