feat: add the registration config keys and their module options

Six keys, and the parser's first list, bool and word-valued ones:
registration, registrations_per_hour, info_replies_per_hour, admins,
admin_notify, register_hosts.

Lists drop empty items, because `admins =` is exactly what the Nix module
renders for an unset option, and addresses are validated at load rather than
at first use — a typo would otherwise pass the start and then silently never
match an admin command. Validated values are normalised through
Address::short(), so a spelling carrying the default port collapses to the one
the daemon will compare against.

registration defaults to silent, so adding these keys changes nothing for
anyone who writes in.

Verified the generator and the parser agree by feeding the module's rendered
output to the binary, in both the fully-set and all-defaults shapes — which
matters because config.rs hard-errors on an unknown key.

The bucur half waits for the lock: that host pins this flake at the rev before
these options existed, so setting them there now would break its eval. It
lands with the deploy.
This commit is contained in:
randogoth 2026-10-14 19:11:12 +03:00
parent e606b3a1e0
commit ff289671a5
2 changed files with 228 additions and 0 deletions

View file

@ -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<String>,
/// 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<String>,
}
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<String> {
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<Vec<String>, 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<Mode> {
match value {
"silent" => Some(Mode::Silent),
"open" => Some(Mode::Open),
"closed" => Some(Mode::Closed),
_ => None,
}
}
fn on_off(value: &str) -> Option<bool> {
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<Config, String> {
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());
}
}