gatewayd: net, TCP or verified TLS to the Mattermost server
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -16,3 +16,6 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rustls.workspace = true
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod net;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
|||||||
@@ -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<StreamOwned<ClientConnection, TcpStream>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Duration>) -> std::io::Result<()> {
|
||||||
|
self.tcp().set_read_timeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for Stream {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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<Arc<ClientConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Connector, NetError> {
|
||||||
|
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<Stream, NetError> {
|
||||||
|
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<ClientConfig, NetError> {
|
||||||
|
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<'static>> = CertificateDer::pem_file_iter(path)
|
||||||
|
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.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)
|
||||||
|
}
|
||||||
@@ -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(_))));
|
||||||
|
}
|
||||||
@@ -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<ServerConfig> {
|
||||||
|
let certs: Vec<CertificateDer<'static>> =
|
||||||
|
CertificateDer::pem_file_iter(fixture(&format!("{which}.pem")))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.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<T: Read + Write + Send> Conn for T {}
|
||||||
|
|
||||||
|
/// Serve every connection on 127.0.0.1 with `handle`, in plain TCP (`tls` None) or TLS.
|
||||||
|
pub fn serve<F>(tls: Option<Arc<ServerConfig>>, handle: F) -> SocketAddr
|
||||||
|
where
|
||||||
|
F: Fn(Box<dyn Conn>) + 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<ServerConnection, TcpStream> =
|
||||||
|
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<dyn Conn>) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| 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 <path>", 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/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 <path>", 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`: `"<path>: <why>"` 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 "<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 `<home>/run/loop/loop.sock`; `state_path` is `<home>/gateway/state.json`. All 7 config tests pass; `make gate` prints `gate: ok`. | ? |
|
| M4a/03-gatewayd-config | 2026-09-23 | done | 2 | fail | none | Filled the copied `crates/gatewayd/src/config.rs` skeleton. `ConfigError::fmt`: `"<path>: <why>"` 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 "<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 `<home>/run/loop/loop.sock`; `state_path` is `<home>/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. | ? |
|
| 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. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user