diff --git a/daemon/src/config.rs b/daemon/src/config.rs index df96842..61d9488 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -4,6 +4,10 @@ use std::path::{Path, PathBuf}; +use fumi_core::address::Address; + +use crate::state::Mode; + #[derive(Debug, Clone)] pub struct Config { /// HTTP listen address for the feed server. @@ -25,6 +29,24 @@ pub struct Config { pub poll_secs: u64, /// Network timeout for smolmail operations, seconds. pub timeout: u64, + /// Whether strangers may register themselves. Only the *initial* value: + /// an admin's `Twt-Registration` is persisted in state.json and outranks + /// it, so the mode can change without a redeploy. + pub registration: Mode, + /// Daemon-wide cap on registration attempts that reach the network, per + /// hour. Attempts, not successes: a failed one costs the same dial. + pub registrations_per_hour: u32, + /// Daemon-wide cap on info replies to unknown senders, per hour. + pub info_replies_per_hour: u32, + /// Addresses allowed to send `Twt-Admit`, `Twt-Block` and + /// `Twt-Registration`, in `user@host` short form. + pub admins: Vec, + /// Mail each admin whenever a feed is created. + pub admin_notify: bool, + /// Hosts whose users may register. Empty means any host, which also means + /// the daemon will dial a host an unknown sender chose — see the + /// registration handler. + pub register_hosts: Vec, } impl Default for Config { @@ -41,6 +63,15 @@ impl Default for Config { cap_bytes: 1_048_576, poll_secs: 60, timeout: 30, + // Silent by default, so enabling self-service is always a + // deliberate act: anything else would have an upgrade start + // mailing every stranger who writes in. + registration: Mode::Silent, + registrations_per_hour: 10, + info_replies_per_hour: 30, + admins: Vec::new(), + admin_notify: true, + register_hosts: Vec::new(), } } } @@ -72,6 +103,14 @@ impl Config { "cap_bytes" => cfg.cap_bytes = value.parse().map_err(&bad)?, "poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?, "timeout" => cfg.timeout = value.parse().map_err(&bad)?, + "registration" => cfg.registration = mode(value).ok_or_else(|| format!("line {}: bad value for `{key}`", n + 1))?, + "registrations_per_hour" => { + cfg.registrations_per_hour = value.parse().map_err(&bad)? + } + "info_replies_per_hour" => cfg.info_replies_per_hour = value.parse().map_err(&bad)?, + "admins" => cfg.admins = addresses(value).map_err(|e| format!("line {}: {e}", n + 1))?, + "admin_notify" => cfg.admin_notify = on_off(value).ok_or_else(|| format!("line {}: bad value for `{key}`", n + 1))?, + "register_hosts" => cfg.register_hosts = list(value), other => return Err(format!("line {}: unknown key `{other}`", n + 1)), } } @@ -79,6 +118,50 @@ impl Config { } } +/// A comma-separated list. Empty items are dropped rather than kept as empty +/// strings, so `admins =` yields no admins — which is what the Nix module +/// emits when the option is left unset. +fn list(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_owned) + .collect() +} + +/// A list of smolmail addresses, each validated now rather than at first use: +/// a typo in `admins` would otherwise mean an admin command silently never +/// matches. +fn addresses(value: &str) -> Result, String> { + list(value) + .into_iter() + .map(|item| { + Address::parse(&item) + .map(|addr| addr.short()) + .map_err(|e| format!("bad address `{item}`: {e}")) + }) + .collect() +} + +/// Word-valued, in the spirit of `Twt-Prune: fifo|off`. +fn mode(value: &str) -> Option { + match value { + "silent" => Some(Mode::Silent), + "open" => Some(Mode::Open), + "closed" => Some(Mode::Closed), + _ => None, + } +} + +fn on_off(value: &str) -> Option { + match value { + "on" => Some(true), + "off" => Some(false), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -99,4 +182,68 @@ mod tests { std::fs::write("/tmp/smolboid-test.toml", "wat = 1\n").unwrap(); assert!(Config::load(Path::new("/tmp/smolboid-test.toml")).is_err()); } + + /// A unique file per call. Tests run in parallel, so a name derived from + /// the content would let two of them race on one path. + fn load_str(text: &str) -> Result { + use std::sync::atomic::{AtomicUsize, Ordering}; + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "smolboid-cfg-{}-{}.conf", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&path, text).unwrap(); + let out = Config::load(&path); + let _ = std::fs::remove_file(&path); + out + } + + #[test] + fn registration_mode_is_word_valued_and_defaults_to_silent() { + assert_eq!(load_str("").unwrap().registration, Mode::Silent); + assert_eq!(load_str("registration = open\n").unwrap().registration, Mode::Open); + assert_eq!(load_str("registration = closed\n").unwrap().registration, Mode::Closed); + assert_eq!(load_str("registration = silent\n").unwrap().registration, Mode::Silent); + // Not a bool, not a number, and not silently defaulted. + assert!(load_str("registration = yes\n").is_err()); + assert!(load_str("registration =\n").is_err()); + } + + #[test] + fn admin_notify_is_on_off() { + assert!(load_str("").unwrap().admin_notify); + assert!(!load_str("admin_notify = off\n").unwrap().admin_notify); + assert!(load_str("admin_notify = on\n").unwrap().admin_notify); + assert!(load_str("admin_notify = true\n").is_err()); + } + + /// An unset list must come out empty, because that is exactly what the Nix + /// module renders for an empty option. + #[test] + fn lists_drop_empty_items_and_validate_addresses() { + assert!(load_str("admins =\n").unwrap().admins.is_empty()); + assert!(load_str("admins = ,, \n").unwrap().admins.is_empty()); + let cfg = load_str("admins = a@b.org, c@d.org:1962 ,\n").unwrap(); + assert_eq!(cfg.admins, vec!["a@b.org", "c@d.org:1962"]); + // Normalised through Address::short(), so the default port collapses. + assert_eq!(load_str("admins = a@b.org:1961\n").unwrap().admins, vec!["a@b.org"]); + // A typo must fail at load, not silently never match an admin. + assert!(load_str("admins = not an address\n").is_err()); + assert!(load_str("admins = @nope\n").is_err()); + + assert!(load_str("register_hosts =\n").unwrap().register_hosts.is_empty()); + assert_eq!( + load_str("register_hosts = smol.place, other.org\n").unwrap().register_hosts, + vec!["smol.place", "other.org"] + ); + } + + #[test] + fn the_hourly_caps_parse() { + let cfg = load_str("registrations_per_hour = 3\ninfo_replies_per_hour = 7\n").unwrap(); + assert_eq!(cfg.registrations_per_hour, 3); + assert_eq!(cfg.info_replies_per_hour, 7); + assert!(load_str("registrations_per_hour = lots\n").is_err()); + } } diff --git a/flake.nix b/flake.nix index efbbcf6..af8eedf 100644 --- a/flake.nix +++ b/flake.nix @@ -151,6 +151,12 @@ cap_bytes = ${toString cfg.capBytes} poll_secs = ${toString cfg.pollSecs} timeout = ${toString cfg.timeout} + registration = ${cfg.registration} + registrations_per_hour = ${toString cfg.registrationsPerHour} + info_replies_per_hour = ${toString cfg.infoRepliesPerHour} + admins = ${lib.concatStringsSep ", " cfg.admins} + admin_notify = ${if cfg.adminNotify then "on" else "off"} + register_hosts = ${lib.concatStringsSep ", " cfg.registerHosts} ''; in { @@ -266,6 +272,81 @@ description = "Network timeout for smolmail operations, in seconds."; }; + registration = mkOption { + type = types.enum [ "silent" "open" "closed" ]; + default = "silent"; + description = '' + Whether strangers may register themselves by mail. `silent` + drops an unknown sender without a reply, which is how every + version before self-service behaved — so upgrading never + starts mailing strangers on its own. `open` lets them + register; `closed` tells them registration is shut. + + Only the initial value: an admin's `Twt-Registration` is + persisted in the daemon's state and outranks it, so the mode + can change by mail without a redeploy. + ''; + }; + + registrationsPerHour = mkOption { + type = types.ints.positive; + default = 10; + description = '' + Daemon-wide cap on registration attempts that reach the + network, per hour. Attempts rather than successes: one that + fails its key check costs the same outbound dial. + + This is the cap that matters — identity keys are free to + mint, so a per-sender budget does not bind an attacker. + ''; + }; + + infoRepliesPerHour = mkOption { + type = types.ints.positive; + default = 30; + description = "Daemon-wide cap on info replies to unknown senders, per hour."; + }; + + admins = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "tobias@smol.place" ]; + description = '' + Addresses allowed to send `Twt-Admit`, `Twt-Block` and + `Twt-Registration`, in `user@host` short form. Each is + validated when the daemon loads its config, so a typo fails + the start rather than silently never matching. + + Admit your own admin identity once with the CLI so the daemon + pins your key and hands you a token; until it holds one, its + acks land in your requests tier. + ''; + }; + + adminNotify = mkOption { + type = types.bool; + default = true; + description = '' + Mail every admin when a feed is created, with the author's + address and key fingerprint — and for a remote author the + unpinned server key their host presented. + ''; + }; + + registerHosts = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "smol.place" ]; + description = '' + Hosts whose users may register. Empty means any host. + + Note what empty allows: the daemon will dial a `host:port` an + unknown sender chose, a mild port-scan primitive with timing + feedback from inside this machine's network. Bounded by + `registrationsPerHour`, closed entirely by listing hosts here. + ''; + }; + openFirewall = mkOption { type = types.bool; default = false;