diff --git a/crates/gatewayd/Cargo.toml b/crates/gatewayd/Cargo.toml index f4ce14b..bd7acef 100644 --- a/crates/gatewayd/Cargo.toml +++ b/crates/gatewayd/Cargo.toml @@ -16,3 +16,6 @@ serde.workspace = true serde_json.workspace = true toml.workspace = true zeroize.workspace = true + +[dev-dependencies] +rustls.workspace = true diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index 23cfcd2..b16147a 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -2,4 +2,5 @@ //! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`. pub mod config; +pub mod net; pub mod secrets; diff --git a/crates/gatewayd/src/net.rs b/crates/gatewayd/src/net.rs new file mode 100644 index 0000000..694f4d8 --- /dev/null +++ b/crates/gatewayd/src/net.rs @@ -0,0 +1,177 @@ +//! A connection to the Mattermost server: TCP, or TCP with TLS through `rustls`, verified against +//! the host's trusted certificates plus an optional CA file (M4a spec, section 5). Verification +//! cannot be turned off. + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned}; + +use crate::config::ServerUrl; + +#[derive(Debug)] +pub enum NetError { + /// The CA file or the host's certificates could not be loaded. + Roots(String), + /// No address of the server accepted a connection. + Connect(String), + /// The TLS handshake failed: an unknown CA, a wrong name, an old protocol. + Tls(String), +} + +impl std::fmt::Display for NetError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NetError::Roots(why) => write!(f, "cannot load trusted certificates: {why}"), + NetError::Connect(why) => write!(f, "cannot connect: {why}"), + NetError::Tls(why) => write!(f, "TLS failed: {why}"), + } + } +} + +impl std::error::Error for NetError {} + +/// A connected stream, plain or TLS. +pub enum Stream { + Plain(TcpStream), + Tls(Box>), +} + +impl Stream { + /// The TCP socket underneath, for timeouts and shutdown. + pub fn tcp(&self) -> &TcpStream { + match self { + Stream::Plain(s) => s, + Stream::Tls(s) => s.get_ref(), + } + } + + pub fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()> { + self.tcp().set_read_timeout(timeout) + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Stream::Plain(s) => s.read(buf), + Stream::Tls(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Stream::Plain(s) => s.write(buf), + Stream::Tls(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Plain(s) => s.flush(), + Stream::Tls(s) => s.flush(), + } + } +} + +/// Makes connections to one server. +#[derive(Clone)] +pub struct Connector { + server: ServerUrl, + tls: Option>, +} + +impl Connector { + /// For `https`, loads the host's trusted certificates and `ca_file`; an error in either is an + /// error here, before any connection. + pub fn new(server: ServerUrl, ca_file: Option<&Path>) -> Result { + let tls = match server.tls { + true => Some(Arc::new(client_config(ca_file)?)), + false => None, + }; + Ok(Connector { server, tls }) + } + + pub fn server(&self) -> &ServerUrl { + &self.server + } + + /// Connect, and for TLS complete the handshake, within `timeout` for each step. + pub fn connect(&self, timeout: Duration) -> Result { + let addrs = (self.server.host.as_str(), self.server.port) + .to_socket_addrs() + .map_err(|e| NetError::Connect(format!("{}: {e}", self.server.host)))?; + let mut last = format!("{} has no address", self.server.host); + let mut tcp = None; + for addr in addrs { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(s) => { + tcp = Some(s); + break; + } + Err(e) => last = format!("{addr}: {e}"), + } + } + let tcp = tcp.ok_or(NetError::Connect(last))?; + tcp.set_read_timeout(Some(timeout)) + .map_err(|e| NetError::Connect(e.to_string()))?; + tcp.set_write_timeout(Some(timeout)) + .map_err(|e| NetError::Connect(e.to_string()))?; + let _ = tcp.set_nodelay(true); + let Some(config) = &self.tls else { + return Ok(Stream::Plain(tcp)); + }; + let name = ServerName::try_from(self.server.host.clone()) + .map_err(|e| NetError::Tls(e.to_string()))?; + let conn = ClientConnection::new(Arc::clone(config), name) + .map_err(|e| NetError::Tls(e.to_string()))?; + let mut stream = StreamOwned::new(conn, tcp); + while stream.conn.is_handshaking() { + stream + .conn + .complete_io(&mut stream.sock) + .map_err(|e| NetError::Tls(e.to_string()))?; + } + Ok(Stream::Tls(Box::new(stream))) + } +} + +fn client_config(ca_file: Option<&Path>) -> Result { + let mut roots = RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + let (added, _ignored) = roots.add_parsable_certificates(native.certs); + if let Some(path) = ca_file { + let certs: Vec> = CertificateDer::pem_file_iter(path) + .map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))? + .collect::>() + .map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?; + if certs.is_empty() { + return Err(NetError::Roots(format!( + "{} holds no certificate", + path.display() + ))); + } + for cert in certs { + roots + .add(cert) + .map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?; + } + } else if added == 0 { + return Err(NetError::Roots( + "the host has no trusted certificates and no ca_file is set".to_string(), + )); + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| NetError::Roots(e.to_string()))? + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(config) +} diff --git a/crates/gatewayd/tests/net.rs b/crates/gatewayd/tests/net.rs new file mode 100644 index 0000000..e385abc --- /dev/null +++ b/crates/gatewayd/tests/net.rs @@ -0,0 +1,120 @@ +//! Connections, plain and TLS, against local servers with TEST-ONLY certificates (M4a spec, +//! section 5): the right CA is trusted through `ca_file`; an unknown CA and a wrong name are +//! refused, so verification is on. Do not edit. + +#[path = "support/tls_server.rs"] +mod tls_server; + +use std::io::{BufRead, BufReader, Write}; +use std::time::Duration; + +use gatewayd::config::ServerUrl; +use gatewayd::net::{Connector, NetError, Stream}; +use tls_server::{echo_line, fixture, serve, server_config}; + +const T: Duration = Duration::from_secs(5); + +fn url(tls: bool, host: &str, port: u16) -> ServerUrl { + ServerUrl { + tls, + host: host.to_string(), + port, + } +} + +fn round_trip(mut stream: Stream) -> String { + stream.write_all(b"hello over the wire\n").unwrap(); + stream.flush().unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + line +} + +#[test] +fn plain_tcp() { + let addr = serve(None, echo_line); + let c = Connector::new(url(false, "127.0.0.1", addr.port()), None).unwrap(); + let stream = c.connect(T).unwrap(); + assert!(matches!(stream, Stream::Plain(_))); + assert_eq!(round_trip(stream), "hello over the wire\n"); +} + +#[test] +fn tls_to_a_server_signed_by_the_ca_file() { + let addr = serve(Some(server_config("server")), echo_line); + for host in ["localhost", "127.0.0.1"] { + let c = + Connector::new(url(true, host, addr.port()), Some(&fixture("test-ca.pem"))).unwrap(); + let stream = c.connect(T).unwrap_or_else(|e| panic!("{host}: {e}")); + assert!(matches!(stream, Stream::Tls(_))); + assert_eq!(round_trip(stream), "hello over the wire\n", "{host}"); + } +} + +#[test] +fn an_unknown_ca_is_refused_at_connect() { + let addr = serve(Some(server_config("other-server")), echo_line); + let c = Connector::new( + url(true, "localhost", addr.port()), + Some(&fixture("test-ca.pem")), + ) + .unwrap(); + match c.connect(T) { + Err(NetError::Tls(why)) => assert!(why.to_lowercase().contains("certificate"), "{why}"), + Err(e) => panic!("expected a TLS error, got {e}"), + Ok(_) => panic!("a server signed by an unknown CA was accepted"), + } +} + +#[test] +fn a_wrong_name_is_refused_at_connect() { + let addr = serve(Some(server_config("wrong-name")), echo_line); + let c = Connector::new( + url(true, "localhost", addr.port()), + Some(&fixture("test-ca.pem")), + ) + .unwrap(); + assert!( + matches!(c.connect(T), Err(NetError::Tls(_))), + "a certificate for wrong.example was accepted for localhost" + ); +} + +#[test] +fn tls_to_a_plain_server_fails_and_does_not_hang() { + let addr = serve(None, |_conn| std::thread::sleep(Duration::from_secs(30))); + let c = Connector::new( + url(true, "localhost", addr.port()), + Some(&fixture("test-ca.pem")), + ) + .unwrap(); + let started = std::time::Instant::now(); + assert!(c.connect(Duration::from_millis(300)).is_err()); + assert!(started.elapsed() < Duration::from_secs(3)); +} + +#[test] +fn a_bad_ca_file_is_an_error_before_any_connection() { + let missing = fixture("no-such.pem"); + assert!(matches!( + Connector::new(url(true, "localhost", 1), Some(&missing)), + Err(NetError::Roots(_)) + )); + let not_pem = fixture("README.md"); + assert!(matches!( + Connector::new(url(true, "localhost", 1), Some(¬_pem)), + Err(NetError::Roots(_)) + )); + // A plain server needs no certificates at all. + assert!(Connector::new(url(false, "localhost", 1), Some(&missing)).is_ok()); +} + +#[test] +fn nothing_listening_is_a_connect_error() { + let port = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let c = Connector::new(url(false, "127.0.0.1", port), None).unwrap(); + assert!(matches!(c.connect(T), Err(NetError::Connect(_)))); +} diff --git a/crates/gatewayd/tests/support/tls_server.rs b/crates/gatewayd/tests/support/tls_server.rs new file mode 100644 index 0000000..aa64cb6 --- /dev/null +++ b/crates/gatewayd/tests/support/tls_server.rs @@ -0,0 +1,82 @@ +//! Small TCP and TLS servers for tests, using the TEST-ONLY certificates in `fixtures/tls/`. +//! Each serves connections on its own thread with a function of the connection. Do not edit. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::Arc; + +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::{ServerConfig, ServerConnection, StreamOwned}; + +pub fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tls") + .join(name) +} + +/// A server certificate (`server`, `wrong-name`, `other-server`) and its key. +pub fn server_config(which: &str) -> Arc { + let certs: Vec> = + CertificateDer::pem_file_iter(fixture(&format!("{which}.pem"))) + .unwrap() + .collect::>() + .unwrap(); + let key = PrivateKeyDer::from_pem_file(fixture(&format!("{which}.key"))).unwrap(); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert(certs, key) + .unwrap(); + Arc::new(config) +} + +/// Anything a test server can serve: plain TCP, or TLS over it. +pub trait Conn: Read + Write + Send {} +impl Conn for T {} + +/// Serve every connection on 127.0.0.1 with `handle`, in plain TCP (`tls` None) or TLS. +pub fn serve(tls: Option>, handle: F) -> SocketAddr +where + F: Fn(Box) + Send + Sync + 'static, +{ + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = Arc::new(handle); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let handle = Arc::clone(&handle); + let tls = tls.clone(); + std::thread::spawn(move || match tls { + None => handle(Box::new(stream)), + Some(config) => { + let conn = ServerConnection::new(config).unwrap(); + let tls_stream: StreamOwned = + StreamOwned::new(conn, stream); + handle(Box::new(tls_stream)); + } + }); + } + }); + addr +} + +/// A handler that reads one line and writes it back. +pub fn echo_line(mut conn: Box) { + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + while conn.read(&mut byte).map(|n| n == 1).unwrap_or(false) { + line.push(byte[0]); + if byte[0] == b'\n' { + break; + } + } + let _ = conn.write_all(&line); + let _ = conn.flush(); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index bed1d31..f85b038 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4a/05-gatewayd-net | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/net.rs` skeleton. `Stream::tcp`: match on the variant, `s` for Plain, `s.get_ref()` for Tls. `set_read_timeout` and `Read`/`Write`/`flush` forward to the inner stream per variant. `Connector::new`: for `server.tls` true, `Arc::new(client_config(ca_file)?)` (a bad `ca_file` or empty host certs is `Roots`, before any connection); for false, `None`. `Connector::server` returns `&self.server`. The written `connect` resolves the host, tries each address, sets read/write timeouts + nodelay, and for TLS runs `complete_io` in a loop so a bad cert fails at connect. All 7 tests in `tests/net.rs` pass (plain TCP; TLS via `ca_file`; unknown CA and wrong name refused at connect; TLS to a plain server fails without hanging; bad `ca_file` refused before connecting; nothing listening); `make gate` prints `gate: ok` first run. Added `rustls` to `[dev-dependencies]` for the test TLS server. | ? | | M4a/04-gatewayd-secrets | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/secrets.rs` skeleton. `value`: `from_utf8` else "the value is not UTF-8", one trailing `\n` stripped with `strip_suffix`, empty refused, raw bytes kept in `Zeroizing` until inside the `Secret`. `check_file` in the given order: not absolute, `symlink_metadata` else "cannot read ", symlink via `file_type().is_symlink()`, not a regular file via inherent `is_file()`, owner uid compared to `/proc/self`'s uid (`MetadataExt`), then `mode & 0o077 != 0` reporting the mode as `{:03o}`. `load` matches the three `SecretSource` forms, reading `CREDENTIALS_DIRECTORY` and the variable through the passed `env` closure (never `std::env`), every failure wrapped in `SecretError` naming the secret and never the value, file secrets setting the exact plaintext warning. Replaced the skeleton's `PermissionsExt` import with `MetadataExt` and used inherent `FileType::is_file`/`is_symlink` (Rust 1.98) so no `FileTypeExt`, `unsafe` or `libc`. All 8 tests in `tests/secrets.rs` pass; `docs/runbook.md` gained the seven gatewayd fail-closed entries (14→21 `## ` lines) and `scripts/check-runbook.sh` exits 0. | ? | | M4a/03-gatewayd-config | 2026-09-23 | done | 2 | fail | none | Filled the copied `crates/gatewayd/src/config.rs` skeleton. `ConfigError::fmt`: `": "` with `path.display()`. `load`: read (else `Read`), `toml::from_str` (else `Parse`), then `problem()` (Some is `Invalid`). `parse_url`: strip `https://`/`http://`, `rsplit_once(':')` for an optional port, `valid_host` (1..=253 bytes of a-z 0-9 . -, not starting/ending with . or -) and `parse_port` (digits, 1..=65535, equal to its own `to_string()`, via `u16::try_from`); every failure returns one `[mattermost] url "" must be...` message. `SecretSpec::source`: count the set fields (else "needs exactly one"), then validate credential (a-z0-9 _ . -), env (A-Z0-9 _) and file (absolute) in turn. `problem` checks url, ca_file, missing token, each secret's `source()`, empty users, ids in users then channels, then limits queue/typing/ping/dead. `valid_id` is 26 bytes of a-z0-9; `loop_socket` falls back to `/run/loop/loop.sock`; `state_path` is `/gateway/state.json`. All 7 config tests pass; `make gate` prints `gate: ok`. | ? | | M4a/02-gatewayd-deps | 2026-09-23 | done | 1 | pass | none | Added the dependencies gatewayd needs for TLS to Mattermost and nothing that uses them yet. Added `rustls` (0.23.45, `default-features = false` with `ring`/`std`/`tls12`), `rustls-native-certs` (0.8.4) and `zeroize` (1.9.0) to `[workspace.dependencies]` in the root `Cargo.toml`, the three plus `serde`/`serde_json`/`toml` to `crates/gatewayd/Cargo.toml`, copied `deny.toml` and the whole `crates/gatewayd/tests/fixtures/tls/` directory (9 files), replaced the one-line doc comment in `lib.rs` with the M4a spec doc, and in `docs/dependencies.md` added `gatewayd` to the `serde`/`serde_json`/`toml` rows and appended the `rustls`/`rustls-native-certs`/`zeroize` rows. `cargo build -p gatewayd` succeeded offline (all crates already in the local cache). cargo-deny reported `bans ok, licenses ok, sources ok`. `make gate` printed `gate: ok` on the first run. | ? |