diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index 47fd6e5..23cfcd2 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -2,3 +2,4 @@ //! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`. pub mod config; +pub mod secrets; diff --git a/crates/gatewayd/src/secrets.rs b/crates/gatewayd/src/secrets.rs new file mode 100644 index 0000000..9237336 --- /dev/null +++ b/crates/gatewayd/src/secrets.rs @@ -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); + +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, +} + +#[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, +) -> Result { + // By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/ (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) -> Result { + // 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())) +} diff --git a/crates/gatewayd/tests/secrets.rs b/crates/gatewayd/tests/secrets.rs new file mode 100644 index 0000000..abdb57e --- /dev/null +++ b/crates/gatewayd/tests/secrets.rs @@ -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 + use<> { + let map: HashMap = 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, 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}"); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 68d5b36..bed1d31 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/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. | ? | | 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`. | ? | diff --git a/docs/runbook.md b/docs/runbook.md index a855f0c..e475d56 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -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. **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 `/gateway/`. It will not guess at a configuration +that decides who it answers. + +**Confirm.** The line says which: `: …` for the file (unknown keys are errors; check +it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare : …` for the +directory (`ls -ld "$(dirname )"`). + +**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/`. + +**Check.** `gatewayd serve --config ` prints `gatewayd: connected to as `. + +## secret-unavailable + +**What you see.** `gatewayd` exits 1 at start with `secret : `, 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.]` 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 ; id -u # file: owner and mode +``` + +**Fix.** For a credential: `systemd-creds --user encrypt --name= - `, type the +token, then give the unit `LoadCredentialEncrypted=:`. For a file: +`chmod 600 ` 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 as `. + +## secret-in-a-file + +**What you see.** At start: `gatewayd: warning: secret is read in plaintext from ; 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.]` 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 = ""`, 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 : ; trying again in 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 /api/v4/system/ping # the server answers +tailscale status # for a tailnet url: the tailnet is up +openssl s_client -connect :443 -servername as `, then a direct message to Boxmaker is answered. + +## mattermost-auth-failed + +**What you see.** `gatewayd` exits 1 with `gatewayd: Mattermost refused the token ()`, 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" /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 as `. + +## gateway-state-damaged + +**What you see.** `gatewayd` exits 1 with `/gateway/state.json: `, 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 `; 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.