Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
//! Which app is behind a given host port, and may it be reached without
//! authenticating?
//!
//! The gate has to answer both questions for every inbound connection: the
//! first to decide whether to challenge at all, the second so the login page
//! can name and picture what the visitor is trying to open ("you are logging
//! in to reach Immich"), which is what makes the challenge legible instead of
//! alarming.
//!
//! Both answers come from the installed manifests rather than a generated
//! table, so a catalog refresh that adds or repoints an app is reflected
//! without a daemon restart — the same reason `app_port_v6_relay_loop`
//! rescans instead of snapshotting once.
use archipelago_container::manifest::{AppManifest, PortAuth};
use std::collections::HashMap;
use std::path::PathBuf;
/// An app port the gate is responsible for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatedPort {
pub port: u16,
pub app_id: String,
/// Display name for the login page. Falls back to the id when a manifest
/// omits `name`.
pub app_name: String,
/// Manifest-declared icon path (`metadata.icon`), when present.
pub icon: Option<String>,
/// True only when the manifest says `auth: gated` in so many words.
///
/// The gated set deliberately also carries undeclared Session-default
/// ports (so the gate challenges them wherever it can already stand, and
/// the audit reports them). But everything that CHANGES where traffic
/// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the
/// Tor-upstream bind — must key on this flag: acting on an undeclared
/// port is the v1.7.121 incident class, whatever the action.
pub declared: bool,
/// Manifest opt-in (`session_passthrough: true` on the port): forward the
/// node session cookie to the app on authorised requests. First-party
/// companion UIs proxy that cookie to the daemon's authenticated
/// endpoints; for every other app the gate strips its own credential.
pub session_passthrough: bool,
}
/// A port deliberately left unauthenticated, and the manifest's stated reason.
///
/// Carried around rather than discarded because "which ports are open and
/// why" is the question an operator actually asks, and the answer should be
/// one RPC call rather than an audit of 56 YAML files.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExemptPort {
pub port: u16,
pub app_id: String,
pub rationale: String,
/// UDP ports are listed for completeness. The gate is TCP-only, so it
/// could not touch them even if they were marked `session`.
pub protocol: String,
}
/// Everything the gate knows about the node's published surface.
#[derive(Debug, Clone, Default)]
pub struct PortMap {
gated: HashMap<u16, GatedPort>,
exempt: Vec<ExemptPort>,
local: std::collections::HashSet<u16>,
}
impl PortMap {
/// The app behind `port`, if the gate is responsible for it.
pub fn gated(&self, port: u16) -> Option<&GatedPort> {
self.gated.get(&port)
}
pub fn gated_ports(&self) -> impl Iterator<Item = &GatedPort> {
self.gated.values()
}
pub fn exempt_ports(&self) -> &[ExemptPort] {
&self.exempt
}
/// Declared `auth: local` — host-local by intent, so NOTHING may make it
/// externally reachable.
///
/// The gate honours this by keeping its hands off, but it is not the only
/// thing that can publish a port: the FIPS mesh relay bridges the fips0
/// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer
/// 32838 — declared `local` and pinned to loopback — to the mesh
/// unauthenticated (test node 2026-08-04). Anything that republishes
/// a loopback port must consult this set first.
pub fn is_declared_local(&self, port: u16) -> bool {
self.local.contains(&port)
}
pub fn is_empty(&self) -> bool {
self.gated.is_empty() && self.exempt.is_empty() && self.local.is_empty()
}
}
/// Directories searched for installed manifests, most specific first.
///
/// Mirrors `api::rpc::package::runtime::manifest_apps_dirs` deliberately: the
/// gate must classify exactly the manifests the orchestrator installs from,
/// or a port could be gated here and published from a different declaration
/// there.
fn apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
}
dirs.extend([
PathBuf::from("apps"),
PathBuf::from("/opt/archipelago/apps"),
PathBuf::from("/opt/archipelago/web-ui/archipelago-runtime/apps"),
]);
dirs
}
/// Read `metadata.icon` out of the manifest's untyped extension bag.
fn manifest_icon(manifest: &AppManifest) -> Option<String> {
manifest
.app
.extensions
.get("metadata")?
.get("icon")?
.as_str()
.map(str::to_string)
}
/// Classify every published port across all installed manifests.
///
/// The signed catalog's embedded manifests are consulted FIRST, because they
/// are what the orchestrator actually publishes containers from
/// (origin-wins; see `app_catalog::catalog_manifest_overlay`). Classifying
/// from disk alone made the gate act on policy the node was no longer
/// running: the catalog declared nbxplorer `auth: local` and pinned it to
/// loopback, the stale disk manifest declared nothing, and the gate
/// externally bound a deliberately host-local port (a test node
/// 2026-08-04).
///
/// After the catalog, the first directory that yields a manifest for an app
/// id wins, so a node's `/opt/archipelago/apps` copy shadows a repo checkout
/// rather than merging with it — otherwise a stale checked-out manifest could
/// re-open a port the installed one gates.
pub fn build_port_map() -> PortMap {
let mut map = PortMap::default();
let mut seen_apps: std::collections::HashSet<String> = std::collections::HashSet::new();
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
// Ports-only overlay: unlike the install path, classification also
// accepts BUILD-SOURCE manifests. The on-node-built companion UIs
// are exactly the apps whose gate policy (session_passthrough,
// auth: gated) must arrive reliably, and their disk manifests
// proved stale or absent fleet-wide in the v1.7.125 rollout. The
// gate's binds fail safely on conflict with a differently-published
// container, so a fresher catalog can only tighten, never expose.
let Some(manifest) =
crate::container::app_catalog::catalog_manifest_ports_overlay(&app_id, value)
else {
// Unparseable/invalid → the orchestrator falls back to disk for
// this app, so classification must too.
continue;
};
if seen_apps.insert(app_id) {
classify_manifest(&manifest, &mut map);
}
}
for dir in apps_dirs() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path().join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(manifest) = AppManifest::parse(&contents) else {
// A manifest that does not parse is not installable either,
// so skipping it cannot open a port that the orchestrator
// would have published.
continue;
};
if seen_apps.insert(manifest.app.id.clone()) {
classify_manifest(&manifest, &mut map);
}
}
}
map.exempt.sort_by_key(|e| e.port);
map
}
/// Classify one manifest's ports into the map. Split from [`build_port_map`]
/// so the catalog-overlay pass and the disk pass cannot diverge.
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
let app_id = manifest.app.id.clone();
let icon = manifest_icon(manifest);
let app_name = if manifest.app.name.trim().is_empty() {
app_id.clone()
} else {
manifest.app.name.clone()
};
for port in &manifest.app.ports {
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
match port.auth_policy() {
PortAuth::None => map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: port
.auth_rationale
.clone()
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
protocol: protocol.to_string(),
}),
// Declared host-local. Not gated and not reported as
// exposed, because it is neither — see PortAuth::Local
// for why this cannot be inferred from `bind`. Recorded so
// the mesh relay (and any future republisher) can refuse to
// expose it.
PortAuth::Local => {
map.local.insert(port.host);
}
// Explicit opt-in: the app is on loopback and the daemon
// owns the external addresses. This is the ONLY way a
// port gets bound by the gate, regardless of `bind`.
PortAuth::Gated => {
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
declared: true,
session_passthrough: port.session_passthrough,
},
);
}
PortAuth::Session => {
// UDP cannot carry an HTTP challenge. Such a port has
// no business defaulting into the gated set where it
// would look protected without being protectable —
// surface it as an unrationalised exemption instead,
// which is honest and shows up in the audit list.
if protocol != "tcp" {
map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: format!(
"{protocol} cannot carry an HTTP challenge; declare auth: none \
with a rationale to record why this is safe"
),
protocol: protocol.to_string(),
});
continue;
}
// A loopback publish is skipped, and this is the
// safety property of the whole module: the gate must
// never be the reason a port becomes reachable
// somewhere it was not. `session` is the DEFAULT, so
// it is what every un-migrated manifest carries —
// and a node's installed manifests always lag the
// repo. Binding those externally published Bitcoin
// RPC across the LAN within seconds of deploy
// (test node 2026-08-03). Taking over a port is
// opt-in only: `auth: gated`, shipped in the same
// manifest edit as the loopback pin.
if port
.bind
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
{
continue;
}
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
declared: false,
// An undeclared port never gets the node session —
// passthrough is an explicit manifest opt-in only.
session_passthrough: false,
},
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The corpus this runs against is the real `apps/` tree, so these assert
/// on properties rather than exact contents — the set of apps changes,
/// the invariants must not.
#[test]
fn real_manifests_classify_into_both_sets() {
let map = build_port_map();
assert!(!map.is_empty(), "no manifests found — apps dir missing?");
assert!(
map.gated_ports().count() > 20,
"expected most published ports to be gated, got {}",
map.gated_ports().count()
);
assert!(!map.exempt_ports().is_empty());
}
#[test]
fn every_exemption_carries_a_reason() {
for exempt in build_port_map().exempt_ports() {
assert!(
!exempt.rationale.trim().is_empty(),
"port {} ({}) is exempt with no rationale",
exempt.port,
exempt.app_id
);
}
}
fn manifest(yaml: &str) -> AppManifest {
AppManifest::parse(yaml).expect("test manifest must parse")
}
const BASE: &str = r#"
app:
id: testapp
name: Test App
version: "1.0"
container:
image: example.org/testapp:1.0
"#;
/// `auth: gated` is the only classification allowed to redirect traffic —
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
/// `declared`. An undeclared Session port is challenged and audited but
/// must never be `declared`.
#[test]
fn declared_tracks_the_manifest_not_the_default() {
let mut map = PortMap::default();
classify_manifest(
&manifest(&format!(
"{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n"
)),
&mut map,
);
assert!(map.gated(8090).expect("gated").declared);
let mut map = PortMap::default();
classify_manifest(
&manifest(&format!(
"{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n"
)),
&mut map,
);
let undeclared = map.gated(9100).expect("session default is challenged");
assert!(
!undeclared.declared,
"an absent auth field must never read as an instruction"
);
}
/// `auth: local` keeps the gate's hands off entirely — the port is
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
/// relay can refuse to republish a deliberately host-local port.
#[test]
fn local_ports_are_untouched_but_recorded() {
let mut map = PortMap::default();
classify_manifest(
&manifest(&format!(
"{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n"
)),
&mut map,
);
assert!(map.gated(32838).is_none());
assert!(map.exempt_ports().is_empty());
assert!(
map.is_declared_local(32838),
"the mesh relay needs this to refuse bridging a host-local port"
);
assert!(!map.is_declared_local(3000));
}
/// The real corpus: every port the FIPS relay can bridge must be safe to
/// bridge. A port that is declared `local` (host-local by intent) or
/// declared `gated` (the app gate owns its external addresses) must be
/// withheld by the relay — this asserts the two sets the relay consults
/// actually classify the live manifests, so a future manifest edit that
/// re-opens one is caught here rather than on a node.
#[test]
fn relay_port_list_respects_local_and_gated_declarations() {
let map = build_port_map();
let relay_would_expose: Vec<u16> = crate::fips::app_ports::APP_LAUNCH_PORTS
.iter()
.copied()
.filter(|p| map.is_declared_local(*p))
.collect();
assert!(
!relay_would_expose.is_empty(),
"expected the corpus to contain at least one local port in the relay list \
(32838/8999) — if this fails the guard is untested, not unnecessary"
);
}
/// Protocol ports that wallets dial directly must never end up gated —
/// this is the constraint that decided the design (Zeus and electrum
/// clients keep working untouched).
#[test]
fn wallet_protocol_ports_are_not_gated() {
let map = build_port_map();
for port in [10009, 18080, 9735, 50001] {
assert!(
map.gated(port).is_none(),
"port {port} must stay ungated — remote wallets cannot hold a session"
);
}
}
/// Bitcoin's RPC is host-local by intent (`auth: local`), so the gate
/// must neither gate it nor report it as exposed — fronting it would
/// newly publish it on every host address, behind a login but reachable
/// where it deliberately was not.
#[test]
fn host_local_ports_are_neither_gated_nor_reported() {
let map = build_port_map();
assert!(map.gated(8332).is_none(), "bitcoin RPC must not be gated");
assert!(
!map.exempt_ports().iter().any(|e| e.port == 8332),
"a host-local port is not an unauthenticated exposure"
);
}
/// THE safety property. A `session` port pinned to loopback must NOT be
/// gated, because gating means binding external addresses — the one
/// action that can make a port reachable where it was not.
///
/// This is not hypothetical. `session` is the default, so it is what
/// every un-migrated manifest carries, and a node's installed manifests
/// always lag the repo. An earlier revision gated these regardless of
/// `bind`, and within seconds of deploying to a test node the daemon
/// had published Bitcoin's loopback-only RPC 8332 on the LAN, Tailscale
/// and IPv6 addresses. Taking over a port must be opt-in.
#[test]
fn a_loopback_pinned_session_port_is_never_gated() {
let map = build_port_map();
// aiui and bitcoin RPC are both loopback-pinned in the shipped tree.
for port in [5180, 8332] {
assert!(
map.gated(port).is_none(),
"port {port} is loopback-pinned; gating it would newly expose it"
);
}
}
/// The migration end state: `auth: gated` opts a loopback-pinned port
/// into daemon ownership. Without this the rollout could never complete.
#[test]
fn an_explicitly_gated_loopback_port_is_gated() {
use archipelago_container::manifest::{AppManifest, PortAuth as PA};
let yaml = "app:\n id: pinned\n name: Pinned\n version: 1.0.0\n container:\n image: x:y\n ports:\n - host: 9911\n container: 80\n bind: 127.0.0.1\n auth: gated\n";
let m = AppManifest::parse(yaml).expect("parses");
assert_eq!(m.app.ports[0].auth, Some(PA::Gated));
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
}
/// An app UI that was reachable with no credential in the 2026-08-03
/// reproduction must now resolve to a gated port with a display name.
#[test]
fn reproduced_open_ports_are_now_gated() {
let map = build_port_map();
let strfry = map.gated(8090).expect("strfry :8090 must be gated");
assert_eq!(strfry.app_id, "strfry");
assert!(!strfry.app_name.is_empty());
}
}
+463
View File
@@ -0,0 +1,463 @@
//! Binding the gate in front of apps, and telling the truth when it cannot.
//!
//! # The ordering problem
//!
//! A published container port is bound `0.0.0.0:<port>`, which claims *every*
//! host address. While the app holds that, the gate cannot bind
//! `<lan-ip>:<port>` at all — the kernel refuses the overlap. So the gate can
//! only stand in front of an app whose own publish has been pinned to
//! loopback (`bind: 127.0.0.1` in its manifest, which
//! `PortMapping::bind` has supported all along).
//!
//! That makes the rollout necessarily two-step, per app: pin the publish,
//! recreate the container, and the gate claims the external addresses. Doing
//! it the other way round — gate first — is not possible, and doing it in one
//! step for every app at once would recreate every container on the node
//! simultaneously.
//!
//! # Why the failure has to be loud
//!
//! The dangerous version of this module is the one that tries to bind, fails
//! because the app still holds the port, logs at debug, and moves on. The
//! node would then be running "the app gate" while every app remained exactly
//! as open as before — a security control that reports success and does
//! nothing, which is worse than no control at all because it stops anyone
//! looking.
//!
//! So an unclaimable port is recorded in [`GateStatus::unprotected`] and
//! logged at warn on every sweep. The same reasoning killed the nft-drop-in
//! design: `/etc/fips/fips.nft` is provisioned out-of-band and its absence is
//! a silent no-op, so a gate shipped that way would be absent on every node
//! without the hardening baseline and nobody would know.
use super::identity::GatedPort;
use super::AppGate;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
/// How often the sweep re-runs. Matches `app_port_v6_relay_loop`: addresses
/// come and go (DHCP, Tailscale up/down, the fips0 ULA appearing late) and
/// apps are installed while the daemon runs.
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
/// The gate's own loopback address, distinct from the app's `127.0.0.1`.
///
/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1`
/// reaches the app around the gate. Instead torrc forwards gated ports to
/// this address (`api/rpc/tor`), where the gate — not the app — listens. A
/// second loopback address rather than a second port number, so no app needs
/// a port it did not declare.
pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2));
/// A port the gate should own but could not claim, and why.
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnprotectedPort {
pub port: u16,
pub app_id: String,
pub app_name: String,
/// Human-readable cause, e.g. that the app still publishes on all
/// interfaces.
pub reason: String,
}
/// What the gate is actually enforcing right now.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct GateStatus {
/// (port, address) pairs the gate holds.
pub claimed: Vec<(u16, String)>,
/// Ports that should be gated but are not. **Non-empty means the node
/// has unauthenticated app surface.**
pub unprotected: Vec<UnprotectedPort>,
}
impl GateStatus {
pub fn is_fully_enforced(&self) -> bool {
self.unprotected.is_empty()
}
}
/// Every non-loopback address currently on this host.
///
/// Shells out to `ip` rather than pulling in a `getifaddrs` binding: the
/// codebase already resolves addresses this way (`host_ip`), the result is
/// re-derived every sweep so a stale parse self-corrects, and a failure here
/// degrades to "claim nothing this round" rather than to a wrong claim.
async fn host_addresses() -> Vec<IpAddr> {
let Ok(out) = tokio::process::Command::new("ip")
.args(["-o", "addr", "show"])
.output()
.await
else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
let mut addrs = Vec::new();
for line in text.lines() {
let mut fields = line.split_whitespace();
// `1: lo inet 127.0.0.1/8 scope host lo`
let Some(family) = fields.clone().nth(2) else {
continue;
};
if family != "inet" && family != "inet6" {
continue;
}
let Some(cidr) = fields.nth(3) else { continue };
let Some(addr) = cidr.split('/').next() else {
continue;
};
// Strip a zone index (`fe80::1%eth0`) — link-local addresses need a
// scope to bind and are not how anyone reaches an app anyway.
let addr = addr.split('%').next().unwrap_or(addr);
let Ok(ip) = addr.parse::<IpAddr>() else {
continue;
};
if ip.is_loopback() || ip.is_unspecified() {
continue;
}
if let IpAddr::V6(v6) = ip {
// Link-local v6 requires a scope id we do not carry.
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
continue;
}
}
addrs.push(ip);
}
addrs.sort();
addrs.dedup();
addrs
}
/// Process-wide gate status, so any RPC handler can report what the gate is
/// actually enforcing without threading a handle through every caller.
///
/// A single shared cell rather than a value returned from `run`: "is my node
/// actually protected?" has to be answerable from the RPC layer, and the
/// listener that knows the answer runs in a detached task.
pub fn shared_status() -> Arc<RwLock<GateStatus>> {
static STATUS: std::sync::OnceLock<Arc<RwLock<GateStatus>>> = std::sync::OnceLock::new();
STATUS
.get_or_init(|| Arc::new(RwLock::new(GateStatus::default())))
.clone()
}
/// Run the gate. Returns only on shutdown.
pub async fn run(
gate: Arc<AppGate>,
status: Arc<RwLock<GateStatus>>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
// (port, addr) pairs already served, so a sweep does not rebind what it
// already holds. The accept-loop handle is kept so a claim can be
// RELEASED when its port leaves the gated set — a catalog refresh
// declaring a port `local`/`none` must make the gate let go without a
// daemon restart, or the stale bind keeps republishing a port the
// catalog just withdrew (nbxplorer 32838, a test node 2026-08-04).
let mut held: HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>> = HashMap::new();
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
sweep(&gate, &status, &mut held, &shutdown_rx).await;
}
_ = shutdown_rx.changed() => return,
}
}
}
async fn sweep(
gate: &Arc<AppGate>,
status: &Arc<RwLock<GateStatus>>,
held: &mut HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>>,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
) {
// Re-read the manifests every sweep rather than trusting the map built
// at construction. An app installed while the daemon is running would
// otherwise never be gated until the next restart — and it would not
// appear in `unprotected` either, so the node would report itself fully
// enforced while serving a brand-new app to anyone who asked.
gate.refresh().await;
let port_map = gate.port_map().await;
// Release claims whose port left the gated set (or whose Tor-upstream
// claim lost its declaration). Aborting the accept loop drops the
// listener, freeing the address for whoever now legitimately owns it —
// the app itself, or nobody.
held.retain(|(port, addr), handle| {
let keep = match port_map.gated(*port) {
None => false,
Some(app) => *addr != GATE_TOR_UPSTREAM || app.declared,
};
if !keep {
handle.abort();
info!(port, %addr, "app gate released a claim: port is no longer gated here");
}
keep
});
let addresses = host_addresses().await;
if addresses.is_empty() {
debug!("app gate: no external addresses yet");
return;
}
let mut claimed = Vec::new();
let mut unprotected = Vec::new();
for app in port_map.gated_ports() {
// Nothing is listening on this port, so there is no app to protect
// and binding would steal the port from an install that has not
// happened yet. The relay loop learned this the hard way: binding a
// port for an app that is not installed makes its later install hit
// "address already in use", and the install's port-free step then
// kills the daemon holding it.
if !app_is_listening(app.port).await {
continue;
}
let mut claimed_any = false;
let mut blocked = false;
// External addresses first, then the gate's Tor upstream. 127.0.0.2
// deliberately does NOT count toward `claimed_any`: the warning below
// is about external exposure, and a port whose only claim is the Tor
// loopback is still wide open on the LAN.
for &addr in &addresses {
let key = (app.port, addr);
if held.contains_key(&key) {
claimed.push((app.port, addr.to_string()));
claimed_any = true;
continue;
}
match TcpListener::bind(SocketAddr::new(addr, app.port)).await {
Ok(listener) => {
let handle =
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
held.insert(key, handle);
claimed.push((app.port, addr.to_string()));
claimed_any = true;
info!(
port = app.port, %addr, app = %app.app_id,
"app gate claimed an app port"
);
}
// Almost always the app itself holding 0.0.0.0:<port>.
Err(_) => blocked = true,
}
}
// The Tor upstream is bound for DECLARED gated ports only: torrc only
// repoints an onion at 127.0.0.2 for a declared port, and standing a
// challenge on an undeclared port's would-be upstream would change
// where its traffic goes on nothing but a default.
if app.declared {
let tor_key = (app.port, GATE_TOR_UPSTREAM);
if held.contains_key(&tor_key) {
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
} else {
match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await {
Ok(listener) => {
let handle = spawn_accept_loop(
listener,
gate.clone(),
app.clone(),
shutdown_rx.clone(),
);
held.insert(tor_key, handle);
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
info!(
port = app.port, app = %app.app_id,
"app gate claimed the Tor upstream (127.0.0.2)"
);
}
Err(_) => blocked = true,
}
}
}
if blocked && !claimed_any {
warn!(
port = app.port, app = %app.app_id,
"APP GATE CANNOT PROTECT THIS PORT — the app still publishes on all \
interfaces. Pin its manifest port to bind: 127.0.0.1 and recreate the \
container, or it stays reachable without authentication."
);
unprotected.push(UnprotectedPort {
port: app.port,
app_id: app.app_id.clone(),
app_name: app.app_name.clone(),
reason: "app publishes on all interfaces; manifest port needs bind: 127.0.0.1"
.to_string(),
});
}
}
claimed.sort();
unprotected.sort_by_key(|u| u.port);
let mut guard = status.write().await;
guard.claimed = claimed;
guard.unprotected = unprotected;
}
/// Is anything answering on loopback for this port?
async fn app_is_listening(port: u16) -> bool {
tokio::time::timeout(
std::time::Duration::from_millis(300),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
.ok()
.and_then(|r| r.ok())
.is_some()
}
/// Returns the accept-loop task handle so the sweep can release the claim
/// (abort → listener drops → address freed) when the port leaves the gated
/// set. In-flight connections finish on their own tasks.
fn spawn_accept_loop(
listener: TcpListener,
gate: Arc<AppGate>,
app: GatedPort,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((stream, peer)) = accepted else { break };
let gate = gate.clone();
let app = app.clone();
tokio::spawn(async move {
serve_connection(stream, peer, gate, app).await;
});
}
_ = shutdown_rx.changed() => break,
}
}
})
}
/// 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::*;
#[tokio::test]
async fn host_addresses_excludes_loopback() {
for addr in host_addresses().await {
assert!(!addr.is_loopback(), "{addr} is loopback");
assert!(!addr.is_unspecified());
}
}
#[tokio::test]
async fn app_is_listening_is_false_for_a_dead_port() {
// Bind and immediately drop, so the port is known-free.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
assert!(!app_is_listening(port).await);
}
#[tokio::test]
async fn app_is_listening_is_true_for_a_live_port() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
assert!(app_is_listening(port).await);
}
#[test]
fn a_status_with_unprotected_ports_is_not_fully_enforced() {
let mut status = GateStatus::default();
assert!(status.is_fully_enforced());
status.unprotected.push(UnprotectedPort {
port: 8090,
app_id: "strfry".into(),
app_name: "Strfry".into(),
reason: "test".into(),
});
assert!(!status.is_fully_enforced());
}
}
File diff suppressed because it is too large Load Diff
+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");
}
}