gatewayd: secrets from a credential, the environment or a file; runbook entries
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
//! 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 secrets;
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! The `SecretStore`: a secret from a systemd credential, an environment variable, or an owner-only
|
||||||
|
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::config::SecretSource;
|
||||||
|
|
||||||
|
pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable";
|
||||||
|
pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file";
|
||||||
|
|
||||||
|
/// A secret's text. No `Display`; `Debug` shows nothing of it; wiped when dropped.
|
||||||
|
pub struct Secret(Zeroizing<String>);
|
||||||
|
|
||||||
|
impl Secret {
|
||||||
|
pub fn new(text: String) -> Secret {
|
||||||
|
Secret(Zeroizing::new(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text, for the one place that must send it.
|
||||||
|
pub fn expose(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Secret {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("Secret(…)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loaded secret, and the warning to print for it, if any.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Loaded {
|
||||||
|
pub secret: Secret,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SecretError {
|
||||||
|
pub name: String,
|
||||||
|
pub why: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SecretError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "secret {}: {}\n{RUNBOOK}", self.name, self.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SecretError {}
|
||||||
|
|
||||||
|
/// Load secret `name` from `source`. `env` reads an environment variable (in `gatewayd`,
|
||||||
|
/// `std::env::var_os`); tests pass their own.
|
||||||
|
pub fn load(
|
||||||
|
name: &str,
|
||||||
|
source: &SecretSource,
|
||||||
|
env: &dyn Fn(&str) -> Option<OsString>,
|
||||||
|
) -> Result<Loaded, SecretError> {
|
||||||
|
// By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through `env`,
|
||||||
|
// not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
|
||||||
|
// variable, UTF-8; unset is an error. File: `check_file` first, then read it, and set `warning`
|
||||||
|
// to the exact text in the task. Every value goes through `value`. Every error is a
|
||||||
|
// `SecretError` naming the secret, never the value.
|
||||||
|
match source {
|
||||||
|
SecretSource::Credential(cred) => {
|
||||||
|
let dir = match env("CREDENTIALS_DIRECTORY") {
|
||||||
|
Some(dir) => dir,
|
||||||
|
None => return Err(SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why: "CREDENTIALS_DIRECTORY is not set: gatewayd was not started by systemd with LoadCredentialEncrypted=".to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let path = Path::new(dir.as_os_str()).join(cred);
|
||||||
|
let bytes = match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why: format!("cannot read the credential {}: {}", path.display(), e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secret = value(bytes).map_err(|why| SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why,
|
||||||
|
})?;
|
||||||
|
Ok(Loaded {
|
||||||
|
secret,
|
||||||
|
warning: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SecretSource::Env(var) => {
|
||||||
|
let val = match env(var) {
|
||||||
|
Some(val) => val,
|
||||||
|
None => {
|
||||||
|
return Err(SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why: format!("the environment variable {} is not set", var),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let text = match val.to_str() {
|
||||||
|
Some(text) => text,
|
||||||
|
None => {
|
||||||
|
return Err(SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why: format!("the environment variable {} is not UTF-8", var),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secret = value(text.as_bytes().to_vec()).map_err(|why| SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why,
|
||||||
|
})?;
|
||||||
|
Ok(Loaded {
|
||||||
|
secret,
|
||||||
|
warning: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SecretSource::File(path) => {
|
||||||
|
if let Err(why) = check_file(path) {
|
||||||
|
return Err(SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bytes = match std::fs::read(path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why: format!("cannot read {}: {}", path.display(), e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secret = value(bytes).map_err(|why| SecretError {
|
||||||
|
name: name.to_string(),
|
||||||
|
why,
|
||||||
|
})?;
|
||||||
|
Ok(Loaded {
|
||||||
|
secret,
|
||||||
|
warning: Some(format!(
|
||||||
|
"gatewayd: warning: secret {} is read in plaintext from {}; a systemd credential keeps it encrypted at rest ({RUNBOOK_FILE})",
|
||||||
|
name,
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An owner-only regular file, not a link.
|
||||||
|
fn check_file(path: &Path) -> Result<(), String> {
|
||||||
|
// In this order, each its own error: not absolute; `symlink_metadata` fails; a symbolic link;
|
||||||
|
// not a regular file; owner uid differs from the uid of /proc/self; mode & 0o077 != 0.
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(format!("{} is not an absolute path", path.display()));
|
||||||
|
}
|
||||||
|
let meta = std::fs::symlink_metadata(path)
|
||||||
|
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
return Err(format!("{} is a symbolic link", path.display()));
|
||||||
|
}
|
||||||
|
if !meta.file_type().is_file() {
|
||||||
|
return Err(format!("{} is not a regular file", path.display()));
|
||||||
|
}
|
||||||
|
let owner = std::fs::metadata("/proc/self")
|
||||||
|
.map_err(|e| format!("cannot read /proc/self: {}", e))?
|
||||||
|
.uid();
|
||||||
|
if meta.uid() != owner {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not owned by the user gatewayd runs as",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mode = meta.mode() & 0o777;
|
||||||
|
if mode & 0o077 != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"{} has mode {:03o}; only the owner may read it (0600 or 0400)",
|
||||||
|
path.display(),
|
||||||
|
mode
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text without one trailing newline; not empty; UTF-8.
|
||||||
|
fn value(bytes: Vec<u8>) -> Result<Secret, String> {
|
||||||
|
// UTF-8 (else an error), one trailing newline removed, not empty. Keep the bytes in `Zeroizing`
|
||||||
|
// until they are inside the `Secret`.
|
||||||
|
let bytes = Zeroizing::new(bytes);
|
||||||
|
let text = std::str::from_utf8(&bytes).map_err(|_| "the value is not UTF-8".to_string())?;
|
||||||
|
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err("the value is empty".to_string());
|
||||||
|
}
|
||||||
|
Ok(Secret::new(text.to_string()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! The secret store (M4a spec, section 4). The environment is passed in as a function, so no test
|
||||||
|
//! changes the process's environment. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use gatewayd::config::SecretSource;
|
||||||
|
use gatewayd::secrets::{RUNBOOK, RUNBOOK_FILE, load};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const TOKEN: &str = "s3cret-t0ken-value";
|
||||||
|
|
||||||
|
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||||
|
let map: HashMap<String, OsString> = pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), OsString::from(v)))
|
||||||
|
.collect();
|
||||||
|
move |k| map.get(k).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_file(dir: &TempDir, name: &str, text: &str, mode: u32) -> PathBuf {
|
||||||
|
let path = dir.write(name, text);
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused(source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>, word: &str) {
|
||||||
|
let e = load("mattermost_token", source, env).expect_err(word);
|
||||||
|
let text = e.to_string();
|
||||||
|
assert!(text.contains(word), "{word}: {text}");
|
||||||
|
assert!(text.starts_with("secret mattermost_token: "), "{text}");
|
||||||
|
assert!(text.ends_with(RUNBOOK), "{text}");
|
||||||
|
assert!(
|
||||||
|
!text.contains(TOKEN),
|
||||||
|
"a refusal never shows the value: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_systemd_credential() {
|
||||||
|
let dir = TempDir::new("cred");
|
||||||
|
dir.write("creds/mattermost-token", &format!("{TOKEN}\n"));
|
||||||
|
let creds = dir.path().join("creds");
|
||||||
|
let env = env_of(&[("CREDENTIALS_DIRECTORY", creds.to_str().unwrap())]);
|
||||||
|
let got = load(
|
||||||
|
"mattermost_token",
|
||||||
|
&SecretSource::Credential("mattermost-token".into()),
|
||||||
|
&env,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
got.secret.expose(),
|
||||||
|
TOKEN,
|
||||||
|
"one trailing newline is removed"
|
||||||
|
);
|
||||||
|
assert_eq!(got.warning, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_credential_outside_systemd_or_missing_is_refused() {
|
||||||
|
let src = SecretSource::Credential("mattermost-token".into());
|
||||||
|
refused(&src, &env_of(&[]), "CREDENTIALS_DIRECTORY is not set");
|
||||||
|
let dir = TempDir::new("cred-missing");
|
||||||
|
refused(
|
||||||
|
&src,
|
||||||
|
&env_of(&[("CREDENTIALS_DIRECTORY", dir.path().to_str().unwrap())]),
|
||||||
|
"cannot read the credential",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_environment_variable() {
|
||||||
|
let got = load(
|
||||||
|
"mattermost_token",
|
||||||
|
&SecretSource::Env("MM".into()),
|
||||||
|
&env_of(&[("MM", TOKEN)]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got.secret.expose(), TOKEN);
|
||||||
|
assert_eq!(got.warning, None, "only a file warns");
|
||||||
|
refused(&SecretSource::Env("MM".into()), &env_of(&[]), "is not set");
|
||||||
|
refused(
|
||||||
|
&SecretSource::Env("MM".into()),
|
||||||
|
&env_of(&[("MM", "")]),
|
||||||
|
"empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_owner_only_file_is_read_with_a_warning() {
|
||||||
|
let dir = TempDir::new("file");
|
||||||
|
for mode in [0o600, 0o400] {
|
||||||
|
let path = owner_file(
|
||||||
|
&dir,
|
||||||
|
&format!("token-{mode:o}"),
|
||||||
|
&format!("{TOKEN}\n"),
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
let got = load(
|
||||||
|
"mattermost_token",
|
||||||
|
&SecretSource::File(path.clone()),
|
||||||
|
&env_of(&[]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got.secret.expose(), TOKEN);
|
||||||
|
let warning = got.warning.expect("a file secret warns");
|
||||||
|
assert!(
|
||||||
|
warning.starts_with(
|
||||||
|
"gatewayd: warning: secret mattermost_token is read in plaintext from "
|
||||||
|
),
|
||||||
|
"{warning}"
|
||||||
|
);
|
||||||
|
assert!(warning.contains(path.to_str().unwrap()), "{warning}");
|
||||||
|
assert!(warning.contains(RUNBOOK_FILE), "{warning}");
|
||||||
|
assert!(!warning.contains(TOKEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_anyone_else_can_read_or_that_is_not_a_plain_file_is_refused() {
|
||||||
|
let dir = TempDir::new("file-bad");
|
||||||
|
for (mode, _) in [
|
||||||
|
(0o640, "group"),
|
||||||
|
(0o604, "other"),
|
||||||
|
(0o644, "both"),
|
||||||
|
(0o660, "group write"),
|
||||||
|
] {
|
||||||
|
let path = owner_file(&dir, &format!("t-{mode:o}"), TOKEN, mode);
|
||||||
|
refused(
|
||||||
|
&SecretSource::File(path),
|
||||||
|
&env_of(&[]),
|
||||||
|
"only the owner may read it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let target = owner_file(&dir, "real", TOKEN, 0o600);
|
||||||
|
let link = dir.path().join("link");
|
||||||
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||||
|
refused(&SecretSource::File(link), &env_of(&[]), "symbolic link");
|
||||||
|
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
||||||
|
refused(
|
||||||
|
&SecretSource::File(dir.path().join("adir")),
|
||||||
|
&env_of(&[]),
|
||||||
|
"not a regular file",
|
||||||
|
);
|
||||||
|
refused(
|
||||||
|
&SecretSource::File(dir.path().join("missing")),
|
||||||
|
&env_of(&[]),
|
||||||
|
"cannot read",
|
||||||
|
);
|
||||||
|
refused(
|
||||||
|
&SecretSource::File(PathBuf::from("relative/token")),
|
||||||
|
&env_of(&[]),
|
||||||
|
"absolute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_non_utf8_values_are_refused() {
|
||||||
|
let dir = TempDir::new("value");
|
||||||
|
refused(
|
||||||
|
&SecretSource::File(owner_file(&dir, "empty", "", 0o600)),
|
||||||
|
&env_of(&[]),
|
||||||
|
"empty",
|
||||||
|
);
|
||||||
|
refused(
|
||||||
|
&SecretSource::File(owner_file(&dir, "nl", "\n", 0o600)),
|
||||||
|
&env_of(&[]),
|
||||||
|
"empty",
|
||||||
|
);
|
||||||
|
let path = dir.path().join("bin");
|
||||||
|
std::fs::write(&path, [0xff, 0xfe]).unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
refused(&SecretSource::File(path), &env_of(&[]), "not UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_one_trailing_newline_is_removed_and_spaces_stay() {
|
||||||
|
let dir = TempDir::new("trim");
|
||||||
|
let path = owner_file(&dir, "t", " a b \n\n", 0o600);
|
||||||
|
let got = load("x", &SecretSource::File(path), &env_of(&[])).unwrap();
|
||||||
|
assert_eq!(got.secret.expose(), " a b \n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_prints_nothing_of_itself() {
|
||||||
|
let got = load(
|
||||||
|
"mattermost_token",
|
||||||
|
&SecretSource::Env("MM".into()),
|
||||||
|
&env_of(&[("MM", TOKEN)]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let shown = format!("{:?} {:?}", got.secret, got);
|
||||||
|
assert!(!shown.contains(TOKEN), "{shown}");
|
||||||
|
assert!(shown.contains("Secret(…)"), "{shown}");
|
||||||
|
}
|
||||||
@@ -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/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. | ? |
|
||||||
| M4a/01-proto-sha1 | 2026-09-23 | done | 2 | fail | none | Wrote `crates/proto/src/sha1.rs`: `sha1` (new/update/finish), `Sha1 { state, block, filled, length }` with `length` counting bits. `compress`: `w: [u32; 80]` via `as_chunks::<4>()` + `from_be_bytes`, `w[i] = (w[i-3]^w[i-8]^w[i-14]^w[i-16]).rotate_left(1)`, eighty wrapping rounds with f/k by range, state added with `wrapping_add`. `update`: `wrapping_add(8u64.wrapping_mul(data.len() as u64))`, `split_at`/`get_mut(..).copy_from_slice`, copy the block out (`let block = self.block`) before `compress` so the mutable receiver and shared slice do not clash. `finish`: builds a 128-byte pad (`0x80`, zeros, 8 big-endian length bytes) sized `56-filled` or `120-filled` plus the 8 length bytes, feeds it through `update`, restores `length`, then the five words big-endian. One logic bug caught by the empty-string vector: the `w[i]` expansion rotated only `w[i-16]` instead of the whole XOR, fixed with parentheses. First gate failed on clippy `needless_range_loop` for the 0..80 round loop; switched to `w.iter().enumerate()` with a bound `&ww`. All 4 sha1 tests pass; `make gate` prints `gate: ok`. | ? |
|
| M4a/01-proto-sha1 | 2026-09-23 | done | 2 | fail | none | Wrote `crates/proto/src/sha1.rs`: `sha1` (new/update/finish), `Sha1 { state, block, filled, length }` with `length` counting bits. `compress`: `w: [u32; 80]` via `as_chunks::<4>()` + `from_be_bytes`, `w[i] = (w[i-3]^w[i-8]^w[i-14]^w[i-16]).rotate_left(1)`, eighty wrapping rounds with f/k by range, state added with `wrapping_add`. `update`: `wrapping_add(8u64.wrapping_mul(data.len() as u64))`, `split_at`/`get_mut(..).copy_from_slice`, copy the block out (`let block = self.block`) before `compress` so the mutable receiver and shared slice do not clash. `finish`: builds a 128-byte pad (`0x80`, zeros, 8 big-endian length bytes) sized `56-filled` or `120-filled` plus the 8 length bytes, feeds it through `update`, restores `length`, then the five words big-endian. One logic bug caught by the empty-string vector: the `w[i]` expansion rotated only `w[i-16]` instead of the whole XOR, fixed with parentheses. First gate failed on clippy `needless_range_loop` for the 0..80 round loop; switched to `w.iter().enumerate()` with a bound `&ww`. All 4 sha1 tests pass; `make gate` prints `gate: ok`. | ? |
|
||||||
|
|||||||
+143
@@ -415,3 +415,146 @@ Look for wrong ownership or mode, or a file that is not UTF-8 text.
|
|||||||
save it again as UTF-8.
|
save it again as UTF-8.
|
||||||
|
|
||||||
**Check.** A new session starts, and its `0.baseline.json` contains the text.
|
**Check.** A new session starts, and its `0.baseline.json` contains the text.
|
||||||
|
|
||||||
|
## gatewayd-start-failed
|
||||||
|
|
||||||
|
**What you see.** `gatewayd` exits 1 at start, before it connects, with one line naming its config
|
||||||
|
file or a directory, then this entry.
|
||||||
|
|
||||||
|
**Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a
|
||||||
|
`url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26
|
||||||
|
characters of `a-z0-9`, an empty `[allow] users`, or a secret with not exactly one of `credential`,
|
||||||
|
`env` and `file`. Or it could not create `<home>/gateway/`. It will not guess at a configuration
|
||||||
|
that decides who it answers.
|
||||||
|
|
||||||
|
**Confirm.** The line says which: `<config path>: …` for the file (unknown keys are errors; check
|
||||||
|
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare <dir>: …` for the
|
||||||
|
directory (`ls -ld <dir> "$(dirname <dir>)"`).
|
||||||
|
|
||||||
|
**Fix.** Correct the file or the directory's ownership. A user id is shown in Mattermost under the
|
||||||
|
user's profile, or by `GET /api/v4/users/username/<name>`.
|
||||||
|
|
||||||
|
**Check.** `gatewayd serve --config <path>` prints `gatewayd: connected to <url> as <user>`.
|
||||||
|
|
||||||
|
## secret-unavailable
|
||||||
|
|
||||||
|
**What you see.** `gatewayd` exits 1 at start with `secret <name>: <reason>`, then this entry. The
|
||||||
|
value is never printed.
|
||||||
|
|
||||||
|
**Why.** `gatewayd` does not start without its Mattermost token, and reads it only from the one
|
||||||
|
place `[secrets.<name>]` names. The reasons: for `credential`, `CREDENTIALS_DIRECTORY` is unset
|
||||||
|
(not started by systemd with a credential) or the file in it is missing; for `env`, the variable is
|
||||||
|
unset or empty; for `file`, the path is not absolute, is a symbolic link, is not a regular file, is
|
||||||
|
not owned by the user `gatewayd` runs as, or has any group or other permission (only 0600 or 0400
|
||||||
|
are accepted). An empty value is refused in every form.
|
||||||
|
|
||||||
|
**Confirm.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user show -p LoadCredentialEncrypted gatewayd # credential
|
||||||
|
ls -l <path>; id -u # file: owner and mode
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix.** For a credential: `systemd-creds --user encrypt --name=<credential> - <path>`, type the
|
||||||
|
token, then give the unit `LoadCredentialEncrypted=<credential>:<path>`. For a file:
|
||||||
|
`chmod 600 <path>` and `chown` it to the user `gatewayd` runs as. For an environment variable, set
|
||||||
|
it in the environment `gatewayd` starts in.
|
||||||
|
|
||||||
|
**Check.** `gatewayd` starts and prints `gatewayd: connected to <url> as <user>`.
|
||||||
|
|
||||||
|
## secret-in-a-file
|
||||||
|
|
||||||
|
**What you see.** At start: `gatewayd: warning: secret <name> is read in plaintext from <path>; a
|
||||||
|
systemd credential keeps it encrypted at rest`, then this entry. `gatewayd` runs normally.
|
||||||
|
|
||||||
|
**Why.** A file holds the token in plaintext: anyone who can read the disk, or a backup of it, can
|
||||||
|
use it. A credential is encrypted to this machine's TPM and host key. Sometimes a file is right (a
|
||||||
|
development machine, a system without systemd); the warning is there so the choice is deliberate.
|
||||||
|
|
||||||
|
**Confirm.** `[secrets.<name>]` in `gatewayd.toml` has `file = …`.
|
||||||
|
|
||||||
|
**Fix.** To keep the file: nothing; the warning stays. To move to a credential, follow the fix in
|
||||||
|
[secret-unavailable](#secret-unavailable), change the entry to `credential = "<name>"`, restart,
|
||||||
|
then delete the file and regenerate the token if the file was ever copied elsewhere.
|
||||||
|
|
||||||
|
**Check.** The warning is gone at the next start.
|
||||||
|
|
||||||
|
## mattermost-unreachable
|
||||||
|
|
||||||
|
**What you see.** `gatewayd` prints `gatewayd: cannot reach <url>: <reason>; trying again in <n> s`,
|
||||||
|
then this entry, once per attempt: after 1, 2, 5 and 10 seconds, then every 30. Posts to Boxmaker go
|
||||||
|
unanswered meanwhile; they are caught up when the connection returns.
|
||||||
|
|
||||||
|
**Why.** The TCP connection, the TLS handshake or the WebSocket upgrade failed, or the server went
|
||||||
|
silent for `dead_after_ms`. A TLS failure means the certificate did not match the host in `url` or
|
||||||
|
did not chain to the system's roots or `ca_file`; verification cannot be turned off.
|
||||||
|
|
||||||
|
**Confirm.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS <url>/api/v4/system/ping # the server answers
|
||||||
|
tailscale status # for a tailnet url: the tailnet is up
|
||||||
|
openssl s_client -connect <host>:443 -servername <host> </dev/null | head
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix.** Start Mattermost, or the tailnet. For a certificate error, correct `url` to the name on
|
||||||
|
the certificate, or give the issuing CA in `[mattermost] ca_file`. `gatewayd` keeps trying by
|
||||||
|
itself; no restart is needed.
|
||||||
|
|
||||||
|
**Check.** `gatewayd: connected to <url> as <user>`, then a direct message to Boxmaker is answered.
|
||||||
|
|
||||||
|
## mattermost-auth-failed
|
||||||
|
|
||||||
|
**What you see.** `gatewayd` exits 1 with `gatewayd: Mattermost refused the token (<status>)`, then
|
||||||
|
this entry.
|
||||||
|
|
||||||
|
**Why.** Mattermost answered 401 or 403: the token is wrong, revoked, or belongs to a deactivated
|
||||||
|
user. Retrying with the same token cannot help, so `gatewayd` stops instead.
|
||||||
|
|
||||||
|
**Confirm.** With the token in `$T` (from the same place `gatewayd` reads it; do not paste it into a
|
||||||
|
shared shell history):
|
||||||
|
`curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" <url>/api/v4/users/me`
|
||||||
|
prints 401 or 403.
|
||||||
|
|
||||||
|
**Fix.** In Mattermost, under the bot's or user's access tokens, create a new token and revoke the
|
||||||
|
old one. Store it as [secret-unavailable](#secret-unavailable) describes, then start `gatewayd`.
|
||||||
|
|
||||||
|
**Check.** `gatewayd: connected to <url> as <user>`.
|
||||||
|
|
||||||
|
## gateway-state-damaged
|
||||||
|
|
||||||
|
**What you see.** `gatewayd` exits 1 with `<home>/gateway/state.json: <reason>`, then this entry:
|
||||||
|
at start when the file cannot be read, or while running when it cannot be written
|
||||||
|
(`…: cannot write: …`).
|
||||||
|
|
||||||
|
**Why.** The state file records which posts were handled, which threads Boxmaker is in, and which
|
||||||
|
turns were in flight. If it cannot be read or kept up to date, `gatewayd` could answer old posts
|
||||||
|
twice or miss threads, so it does not guess. A missing file is a first start and is fine.
|
||||||
|
|
||||||
|
**Confirm.** `ls -l "$BOXMAKER_HOME/gateway/state.json"` and
|
||||||
|
`python3 -m json.tool "$BOXMAKER_HOME/gateway/state.json" >/dev/null`.
|
||||||
|
|
||||||
|
**Fix.** For a write failure, free space or correct the directory's ownership (`df -h`,
|
||||||
|
`ls -ld "$BOXMAKER_HOME/gateway"`), then start `gatewayd`. If only the file's ownership or mode is
|
||||||
|
wrong, correct it. If the content is damaged, move it
|
||||||
|
aside (`mv state.json state.json.damaged`) and start again. That is a first start: posts sent while
|
||||||
|
`gatewayd` was down are not answered, and threads in channels must name Boxmaker again once.
|
||||||
|
|
||||||
|
**Check.** `gatewayd` starts, and `state.json` is rewritten after the next post.
|
||||||
|
|
||||||
|
## loop-unavailable
|
||||||
|
|
||||||
|
**What you see.** In the Mattermost thread: "Boxmaker's loop is not running
|
||||||
|
(see docs/runbook.md#loop-unavailable)". The messages that were waiting are dropped.
|
||||||
|
|
||||||
|
**Why.** `gatewayd` could not connect to `loop.sock`, or the connection closed before the turn
|
||||||
|
ended. `gatewayd` does not retry: `loopd` may have finished and logged the turn, and sending it
|
||||||
|
again would run it twice.
|
||||||
|
|
||||||
|
**Confirm.** `ls -l "$BOXMAKER_HOME/run/loop/loop.sock"` (or `[loop] socket`), and whether
|
||||||
|
`loopd serve` is running. If it stopped, its last lines say why.
|
||||||
|
|
||||||
|
**Fix.** Start `loopd serve --config <path>`; if it failed, follow the entry its message names.
|
||||||
|
Then send the message again in the thread.
|
||||||
|
|
||||||
|
**Check.** A direct message to Boxmaker is answered.
|
||||||
|
|||||||
Reference in New Issue
Block a user