gatewayd: main, serve --config with its start checks

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-24 01:00:22 -07:00
parent ed361db718
commit 09b074c121
3 changed files with 264 additions and 3 deletions
+92 -3
View File
@@ -1,4 +1,93 @@
fn main() {
eprintln!("gatewayd: not implemented until M4");
std::process::exit(2);
//! `gatewayd serve --config <path>`: the Mattermost channel. It loads its configuration and its
//! token, prepares its directory, then serves until it must stop (exit 1).
use std::os::unix::fs::DirBuilderExt;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use gatewayd::config::{Config, MATTERMOST_TOKEN};
use gatewayd::secrets;
use gatewayd::serve::{START_FAILED, Tuning, run};
fn main() -> ExitCode {
// `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
match (words.as_slice(), args.get(2)) {
([Some("serve"), Some("--config"), _], Some(path)) => serve(Path::new(path)),
_ => {
eprintln!("usage: gatewayd serve --config <path>");
ExitCode::from(2)
}
}
}
fn serve(path: &Path) -> ExitCode {
// Each failure prints its message with `eprintln!` and returns `ExitCode::from(1)`.
// 1. `let config = match Config::load(path) { ... }`: an Err(e) prints
// "gatewayd: {e}\n{START_FAILED}".
// 2. `let source = match config.token_source() { ... }`: an Err(why) prints
// "gatewayd: {}: {why}\n{START_FAILED}" with `path.display()`.
// 3. `let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`:
// an Err(e) prints "gatewayd: {e}" (the error carries its own pointer).
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
// 4. The state file's directory:
// `let dir = config.state_path().parent().map(Path::to_path_buf).unwrap_or_default();`
// `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)`: an Err(e) prints
// "gatewayd: cannot prepare {}: {e}\n{START_FAILED}" with `dir.display()`.
// 5. `let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));`
// `let stop = run(config, loaded.secret, Tuning::default(), log, &AtomicBool::new(false));`
// `eprintln!("{stop}");` and `ExitCode::from(1)`.
let config = match Config::load(path) {
Ok(config) => config,
Err(e) => {
eprintln!("gatewayd: {e}\n{START_FAILED}");
return ExitCode::from(1);
}
};
let source = match config.token_source() {
Ok(source) => source,
Err(why) => {
eprintln!("gatewayd: {}: {why}\n{START_FAILED}", path.display());
return ExitCode::from(1);
}
};
let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k)) {
Ok(loaded) => loaded,
Err(e) => {
eprintln!("gatewayd: {e}");
return ExitCode::from(1);
}
};
if let Some(warning) = &loaded.warning {
eprintln!("{warning}");
}
let dir = config
.state_path()
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if let Err(e) = std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&dir)
{
eprintln!(
"gatewayd: cannot prepare {}: {e}\n{START_FAILED}",
dir.display()
);
return ExitCode::from(1);
}
let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));
let stop = run(
config,
loaded.secret,
Tuning::default(),
log,
&AtomicBool::new(false),
);
eprintln!("{stop}");
ExitCode::from(1)
}
+171
View File
@@ -0,0 +1,171 @@
//! The `gatewayd` program: its usage, what stops it at start, the warning for a secret in a file,
//! and that the token never reaches its output (M4a spec, sections 3, 4 and 10). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tmp::TempDir;
const KYLE: &str = "k0000000000000000000000000";
const TOKEN: &str = "tok-3f9a1c7e5b2d4f6a8c0e";
fn gatewayd() -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_gatewayd"));
c.env_remove("GW_TEST_TOKEN")
.env_remove("CREDENTIALS_DIRECTORY");
c
}
fn closed_url() -> String {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
format!("http://127.0.0.1:{port}")
}
fn write_config(dir: &TempDir, secret: &str) -> std::path::PathBuf {
let text = format!(
"[mattermost]\nurl = \"{}\"\n[secrets.mattermost_token]\n{secret}\n[allow]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n",
closed_url(),
dir.path().join("home").display()
);
dir.write("gatewayd.toml", &text)
}
/// Run until `want` appears on standard error or 5 s pass, then kill it; all it printed.
fn stderr_until(mut cmd: Command, want: &str) -> String {
let mut child = cmd
.stderr(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let mut reader = BufReader::new(child.stderr.take().unwrap());
let until = Instant::now() + Duration::from_secs(5);
let mut all = String::new();
while Instant::now() < until && !all.contains(want) {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
all.push_str(&line);
}
let _ = child.kill();
let _ = child.wait();
all
}
#[test]
fn usage() {
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["run", "--config", "x"][..],
] {
let out = gatewayd().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}");
assert_eq!(
String::from_utf8_lossy(&out.stderr),
"usage: gatewayd serve --config <path>\n"
);
}
}
#[test]
fn a_bad_config_stops_at_start() {
let dir = TempDir::new("main-config");
let missing = dir.path().join("nope.toml");
let bad = dir.write("bad.toml", "[mattermost]\nurl = \"ftp://x\"\n");
for path in [missing, bad] {
let out = gatewayd()
.arg("serve")
.arg("--config")
.arg(&path)
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{err}");
assert!(
err.starts_with(&format!("gatewayd: {}: ", path.display())),
"{err}"
);
assert!(
err.ends_with("\nsee docs/runbook.md#gatewayd-start-failed\n"),
"{err}"
);
}
}
#[test]
fn a_missing_secret_stops_at_start() {
let dir = TempDir::new("main-secret");
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
let out = gatewayd()
.arg("serve")
.arg("--config")
.arg(&config)
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{err}");
assert!(
err.starts_with("gatewayd: secret mattermost_token: "),
"{err}"
);
assert!(
err.ends_with("\nsee docs/runbook.md#secret-unavailable\n"),
"{err}"
);
assert!(
!dir.path().join("home").exists(),
"nothing is made before the secret is read"
);
}
#[test]
fn a_secret_in_a_file_warns_and_the_token_is_never_printed() {
let dir = TempDir::new("main-file");
let secret = dir.write("token", &format!("{TOKEN}\n"));
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600)).unwrap();
let config = write_config(&dir, &format!("file = \"{}\"", secret.display()));
let mut cmd = gatewayd();
cmd.arg("serve").arg("--config").arg(&config);
let err = stderr_until(cmd, "trying again");
let warning = format!(
"gatewayd: warning: secret mattermost_token is read in plaintext from {}; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)\n",
secret.display()
);
assert!(err.starts_with(&warning), "{err}");
assert!(
err.contains("gatewayd: cannot reach http://127.0.0.1:"),
"{err}"
);
assert!(!err.contains(TOKEN), "{err}");
let mode = std::fs::metadata(dir.path().join("home/gateway"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn a_secret_from_the_environment_has_no_warning() {
let dir = TempDir::new("main-env");
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
let mut cmd = gatewayd();
cmd.arg("serve")
.arg("--config")
.arg(&config)
.env("GW_TEST_TOKEN", TOKEN);
let err = stderr_until(cmd, "trying again");
assert!(err.starts_with("gatewayd: cannot reach "), "{err}");
assert!(!err.contains("warning") && !err.contains(TOKEN), "{err}");
}