feat: add smolboid daemon: mail loop, feed store and HTTP listener
This commit is contained in:
parent
fd3f9bcf24
commit
8330b65ff9
8 changed files with 1981 additions and 0 deletions
12
daemon/Cargo.toml
Normal file
12
daemon/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "smolboid"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "smolboid daemon: twtxt feeds published by smolmail"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
smolboid-core = { path = "../core" }
|
||||
fumi-core = { path = "../../fumi/core" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
102
daemon/src/config.rs
Normal file
102
daemon/src/config.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
//! Instance configuration: one flat `key = value` file, `#` comments. The
|
||||
//! daemon's smolmail identity is bootstrapped out of band with the fumi CLI
|
||||
//! against the same master key and store (`fumi trust`, `fumi register`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// HTTP listen address for the feed server.
|
||||
pub listen: String,
|
||||
/// Canonical origin the `# url` metadata carries.
|
||||
pub base_url: String,
|
||||
/// Host serving the daemon's smolmail mailbox.
|
||||
pub smolmail_host: String,
|
||||
/// Local part of the daemon's mailbox, for display.
|
||||
pub mailbox: String,
|
||||
/// 32-byte master secret, the fumi convention.
|
||||
pub master: PathBuf,
|
||||
/// State directory: state.json, mail.db and feeds/.
|
||||
pub data: PathBuf,
|
||||
pub posts_per_day: u32,
|
||||
pub reposts_per_day: u32,
|
||||
/// Per-feed size cap in bytes (PLAN.md, Retention).
|
||||
pub cap_bytes: usize,
|
||||
pub poll_secs: u64,
|
||||
/// Network timeout for smolmail operations, seconds.
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
listen: "127.0.0.1:8080".into(),
|
||||
base_url: "http://localhost:8080".into(),
|
||||
smolmail_host: "localhost".into(),
|
||||
mailbox: "twtxt".into(),
|
||||
master: PathBuf::from("master.key"),
|
||||
data: PathBuf::from("data"),
|
||||
posts_per_day: 50,
|
||||
reposts_per_day: 50,
|
||||
cap_bytes: 1_048_576,
|
||||
poll_secs: 60,
|
||||
timeout: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Config, String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
let mut cfg = Config::default();
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
return Err(format!("line {}: expected `key = value`", n + 1));
|
||||
};
|
||||
let (key, value) = (key.trim(), value.trim());
|
||||
let bad = |_| format!("line {}: bad value for `{key}`", n + 1);
|
||||
match key {
|
||||
"listen" => cfg.listen = value.into(),
|
||||
"base_url" => cfg.base_url = value.into(),
|
||||
"smolmail_host" => cfg.smolmail_host = value.into(),
|
||||
"mailbox" => cfg.mailbox = value.into(),
|
||||
"master" => cfg.master = PathBuf::from(value),
|
||||
"data" => cfg.data = PathBuf::from(value),
|
||||
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
|
||||
"reposts_per_day" => cfg.reposts_per_day = value.parse().map_err(&bad)?,
|
||||
"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)?,
|
||||
other => return Err(format!("line {}: unknown key `{other}`", n + 1)),
|
||||
}
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_defaults() {
|
||||
let cfg = Config::load(Path::new("no-such-file")).unwrap_err();
|
||||
assert!(cfg.contains("cannot read"));
|
||||
|
||||
let text = "# instance\nlisten = 0.0.0.0:9000\nbase_url = https://smol.place\ncap_bytes = 4096\n";
|
||||
std::fs::write("/tmp/smolboid-test.toml", text).unwrap();
|
||||
let cfg = Config::load(Path::new("/tmp/smolboid-test.toml")).unwrap();
|
||||
assert_eq!(cfg.listen, "0.0.0.0:9000");
|
||||
assert_eq!(cfg.base_url, "https://smol.place");
|
||||
assert_eq!(cfg.cap_bytes, 4096);
|
||||
assert_eq!(cfg.poll_secs, 60, "unset keys keep defaults");
|
||||
|
||||
std::fs::write("/tmp/smolboid-test.toml", "wat = 1\n").unwrap();
|
||||
assert!(Config::load(Path::new("/tmp/smolboid-test.toml")).is_err());
|
||||
}
|
||||
}
|
||||
290
daemon/src/dispatch.rs
Normal file
290
daemon/src/dispatch.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
//! Mail-to-action translation (PLAN.md, Management mail): a post mail is
|
||||
//! bare text or a block whose only field is Reply-To; a command mail opens a
|
||||
//! §5.5 block. Pure — the caller owns admission, retention and error replies.
|
||||
|
||||
use smolboid_core::feed::MailLine;
|
||||
use smolboid_core::rules::validate_text;
|
||||
use smolboid_core::time;
|
||||
use smolboid_core::{repost_body, reply_to_body};
|
||||
|
||||
use crate::state::PruneMode;
|
||||
|
||||
/// One fetched mail, opened and verified by the caller.
|
||||
pub struct Incoming {
|
||||
pub sender: [u8; 32],
|
||||
pub epk: [u8; 32],
|
||||
pub sig: [u8; 64],
|
||||
pub time: i64,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
pub enum Action {
|
||||
Post {
|
||||
timestamp: String,
|
||||
text: String,
|
||||
/// The Reply-To value the body carried, when the ordinary-send block
|
||||
/// form was used; bound in the feed's `smol:reply_to` metadata.
|
||||
reply_to: Option<String>,
|
||||
mail: MailLine,
|
||||
},
|
||||
Repost {
|
||||
timestamp: String,
|
||||
args: String,
|
||||
reply_to: Option<String>,
|
||||
mail: MailLine,
|
||||
},
|
||||
Commands(Vec<Command>),
|
||||
/// Nothing to do: recognized by the rules as benign, never published.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
pub enum Command {
|
||||
Nick(String),
|
||||
Delete(String),
|
||||
Prune(PruneMode),
|
||||
}
|
||||
|
||||
/// The `Twt-` frontmatter fields a command mail may carry; unknown keys are
|
||||
/// preserved upstream and never alter behaviour (SPEC.md §5.5).
|
||||
const F_NICK: &str = "twt-nick";
|
||||
const F_DELETE: &str = "twt-delete";
|
||||
const F_REPOST: &str = "twt-repost";
|
||||
const F_PRUNE: &str = "twt-prune";
|
||||
const F_REPLY_TO: &str = "reply-to";
|
||||
|
||||
/// Fails closed: a block with unreconstructible fields is never published.
|
||||
pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
|
||||
let text = std::str::from_utf8(&incoming.body).map_err(|_| "body is not UTF-8".to_owned())?;
|
||||
|
||||
if !text.starts_with("---\n") {
|
||||
// The bare post form: the body is the twt, byte for byte.
|
||||
return post(incoming, text, None);
|
||||
}
|
||||
|
||||
let (fields, rest) = fumi_core::message::parse_frontmatter(text);
|
||||
if fields.is_empty() {
|
||||
// An empty or malformed block: nothing reliable, nothing published.
|
||||
return Ok(Action::Ignore);
|
||||
}
|
||||
|
||||
let reply_to = fields.get(F_REPLY_TO).cloned();
|
||||
let beyond_reply_to: Vec<&String> = fields
|
||||
.keys()
|
||||
.filter(|k| k.as_str() != F_REPLY_TO)
|
||||
.collect();
|
||||
|
||||
// A post in the ordinary-send form: a block whose only field is Reply-To.
|
||||
if beyond_reply_to.is_empty() {
|
||||
if let Some(reply_to) = &reply_to {
|
||||
if text != reply_to_body(reply_to, &rest) {
|
||||
return Err("post mail is not in canonical form".to_owned());
|
||||
}
|
||||
return post(incoming, &rest, Some(reply_to.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// A repost: the Twt-Repost field, optionally with the same Reply-To an
|
||||
// ordinary send carries; both must reconstruct byte-exactly.
|
||||
if fields.len() <= 2 {
|
||||
if let Some(args) = fields.get(F_REPOST) {
|
||||
let canonical_no = repost_body(args, None);
|
||||
let canonical_yes = repost_body(args, reply_to.as_deref());
|
||||
if text == canonical_no || text == canonical_yes {
|
||||
validate_text(args).map_err(|e| e.to_string())?;
|
||||
return Ok(Action::Repost {
|
||||
timestamp: time::render(incoming.time),
|
||||
args: args.clone(),
|
||||
reply_to,
|
||||
mail: MailLine {
|
||||
sender: incoming.sender,
|
||||
epk: incoming.epk,
|
||||
sig: incoming.sig,
|
||||
},
|
||||
});
|
||||
}
|
||||
return Err("repost mail is not in canonical form".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
// A block with extra fields and no Twt field: the author plainly meant to
|
||||
// publish, and silence would hide the problem.
|
||||
if !fields.contains_key(F_NICK)
|
||||
&& !fields.contains_key(F_DELETE)
|
||||
&& !fields.contains_key(F_PRUNE)
|
||||
&& !fields.contains_key(F_REPOST)
|
||||
{
|
||||
return Err(
|
||||
"post mail carries fields beyond Reply-To (Subject, headers or an Accept token); \
|
||||
resend the bare text"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut commands = Vec::new();
|
||||
if let Some(nick) = fields.get(F_NICK) {
|
||||
if !crate::feedstore::valid_username(nick) {
|
||||
return Err("nick must be 1-30 bytes of a-z, 0-9 or hyphen".to_owned());
|
||||
}
|
||||
commands.push(Command::Nick(nick.clone()));
|
||||
}
|
||||
if let Some(timestamp) = fields.get(F_DELETE) {
|
||||
if time::parse(timestamp).is_none() {
|
||||
return Err("Twt-Delete needs an RFC 3339 UTC timestamp".to_owned());
|
||||
}
|
||||
commands.push(Command::Delete(timestamp.clone()));
|
||||
}
|
||||
if let Some(mode) = fields.get(F_PRUNE) {
|
||||
commands.push(Command::Prune(match mode.as_str() {
|
||||
"fifo" => PruneMode::Fifo,
|
||||
"off" => PruneMode::Off,
|
||||
other => return Err(format!("unknown prune mode `{other}`, want fifo or off")),
|
||||
}));
|
||||
}
|
||||
// A mixed mail is a command mail; the body is ignored, never posted.
|
||||
if commands.is_empty() {
|
||||
Ok(Action::Ignore)
|
||||
} else {
|
||||
Ok(Action::Commands(commands))
|
||||
}
|
||||
}
|
||||
|
||||
fn post(incoming: &Incoming, text: &str, reply_to: Option<String>) -> Result<Action, String> {
|
||||
validate_text(text).map_err(|e| e.to_string())?;
|
||||
Ok(Action::Post {
|
||||
timestamp: time::render(incoming.time),
|
||||
text: text.to_owned(),
|
||||
reply_to,
|
||||
mail: MailLine {
|
||||
sender: incoming.sender,
|
||||
epk: incoming.epk,
|
||||
sig: incoming.sig,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn incoming(body: &str) -> Incoming {
|
||||
Incoming {
|
||||
sender: [1; 32],
|
||||
epk: [2; 32],
|
||||
sig: [3; 64],
|
||||
time: 1_760_000_000,
|
||||
body: body.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_body_is_a_post() {
|
||||
let Action::Post { timestamp, text, reply_to, mail } = dispatch(&incoming("hello smolverse")).unwrap() else {
|
||||
panic!("expected a post");
|
||||
};
|
||||
assert_eq!(timestamp, "2025-10-09T08:53:20Z");
|
||||
assert_eq!(text, "hello smolverse");
|
||||
assert!(reply_to.is_none());
|
||||
assert_eq!(mail.sender, [1; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_send_form_is_a_post() {
|
||||
// What `fumi send --body x` emits: a block with only Reply-To.
|
||||
let body = "---\nReply-To: smol://tobias@smol.place/k\n---\nhello smolverse";
|
||||
let Action::Post { text, reply_to, .. } = dispatch(&incoming(body)).unwrap() else {
|
||||
panic!("expected a post");
|
||||
};
|
||||
assert_eq!(text, "hello smolverse");
|
||||
assert_eq!(reply_to.as_deref(), Some("smol://tobias@smol.place/k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashed_body_is_still_a_post() {
|
||||
// A single line can never open a block, so nothing is escaped.
|
||||
let Action::Post { text, .. } = dispatch(&incoming("--- dashed opening")).unwrap() else {
|
||||
panic!("expected a post");
|
||||
};
|
||||
assert_eq!(text, "--- dashed opening");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_rules_are_enforced_with_reasons() {
|
||||
assert!(dispatch(&incoming(&"a".repeat(161))).is_err());
|
||||
assert!(dispatch(&incoming("a\tb")).is_err());
|
||||
let bad = Incoming { body: vec![0xff, 0xfe], ..incoming("") };
|
||||
assert!(dispatch(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_fields_make_a_rejected_post() {
|
||||
// A Subject, or the Accept token of an accepted recipient.
|
||||
assert!(dispatch(&incoming("---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n")).is_err());
|
||||
assert!(dispatch(&incoming("---\nAccept: token\n---\nbody\n")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_repost_command_publishes() {
|
||||
let args = "@<dana https://smol.place/twtxt/dana.txt> #abc1234";
|
||||
let no_reply_to = format!("---\nTwt-Repost: {args}\n---\n");
|
||||
let Action::Repost { args: got, reply_to, mail, .. } =
|
||||
dispatch(&incoming(&no_reply_to)).unwrap() else {
|
||||
panic!("expected a repost");
|
||||
};
|
||||
assert_eq!(got, args);
|
||||
assert!(reply_to.is_none());
|
||||
assert_eq!(mail.sig, [3; 64]);
|
||||
|
||||
// The ordinary-send form, Twt-Repost before Reply-To as fumi emits.
|
||||
let with_reply_to = format!("---\nTwt-Repost: {args}\nReply-To: smol://tobias@s/k\n---\n");
|
||||
let Action::Repost { args: got, reply_to, .. } =
|
||||
dispatch(&incoming(&with_reply_to)).unwrap() else {
|
||||
panic!("expected a repost");
|
||||
};
|
||||
assert_eq!(got, args);
|
||||
assert_eq!(reply_to.as_deref(), Some("smol://tobias@s/k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncanonical_reposts_are_rejected() {
|
||||
let args = "@<d u> #abc";
|
||||
assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args} \n---\n"))).is_err());
|
||||
assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args}\n---\nextra\n"))).is_err());
|
||||
assert!(dispatch(&incoming(&format!(
|
||||
"---\nTwt-Repost: {args}\nTwt-Nick: x\n---\n"
|
||||
)))
|
||||
.is_err());
|
||||
// Reply-To after the closing marker, wrong order relative to the
|
||||
// canonical forms.
|
||||
assert!(dispatch(&incoming(&format!(
|
||||
"---\nReply-To: smol://a/b\nTwt-Repost: {args}\n---\n"
|
||||
)))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_mails_apply_all_recognized_fields() {
|
||||
let body = "---\nTwt-Nick: toby\nTwt-Prune: fifo\nSubject: ignored\n---\nignored body\n";
|
||||
let Action::Commands(cmds) = dispatch(&incoming(body)).unwrap() else {
|
||||
panic!("expected commands");
|
||||
};
|
||||
assert_eq!(cmds.len(), 2);
|
||||
assert!(matches!(cmds[0], Command::Nick(ref n) if n == "toby"));
|
||||
assert!(matches!(cmds[1], Command::Prune(PruneMode::Fifo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_values_are_validated() {
|
||||
assert!(dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).is_err());
|
||||
assert!(dispatch(&incoming("---\nTwt-Delete: not-a-date\n---\n")).is_err());
|
||||
assert!(dispatch(&incoming("---\nTwt-Prune: sometimes\n---\n")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_mail_shapes_fail_closed_to_ignore() {
|
||||
// Malformed block, empty escape block, only unknown fields with a
|
||||
// Twt- field absent — but pure unknown-field blocks are rejected
|
||||
// as posts, so the ignore set is the malformed and empty ones.
|
||||
assert!(matches!(dispatch(&incoming("---\nno colon here\n---\n")), Ok(Action::Ignore)));
|
||||
assert!(matches!(dispatch(&incoming("---\n---\n")), Ok(Action::Ignore)));
|
||||
}
|
||||
}
|
||||
129
daemon/src/feedstore.rs
Normal file
129
daemon/src/feedstore.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//! The feed directory: one `twtxt.txt` per username under `<data>/feeds/`.
|
||||
//! The path is the verified smolmail username, so usernames are validated
|
||||
//! before ever touching the filesystem.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use smolboid_core::Feed;
|
||||
|
||||
/// 1-30 bytes of `[a-z0-9-]`: a valid feed path segment and nick.
|
||||
pub fn valid_username(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 30
|
||||
&& name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FeedStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FeedStore {
|
||||
pub fn new(data: &Path) -> io::Result<FeedStore> {
|
||||
let root = data.join("feeds");
|
||||
std::fs::create_dir_all(&root)?;
|
||||
Ok(FeedStore { root })
|
||||
}
|
||||
|
||||
fn path(&self, username: &str) -> Result<PathBuf, String> {
|
||||
if !valid_username(username) {
|
||||
return Err(format!("invalid username `{username}`"));
|
||||
}
|
||||
Ok(self.root.join(format!("{username}.txt")))
|
||||
}
|
||||
|
||||
/// The feed file's mtime for `Last-Modified`; missing feeds are epoch.
|
||||
pub fn path_mtime(&self, username: &str) -> Option<std::time::SystemTime> {
|
||||
self.path(username).ok().and_then(|p| p.metadata().ok())
|
||||
.and_then(|m| m.modified().ok())
|
||||
}
|
||||
|
||||
pub fn exists(&self, username: &str) -> Result<bool, String> {
|
||||
Ok(self.path(username)?.exists())
|
||||
}
|
||||
|
||||
pub fn load(&self, username: &str) -> Result<Feed, String> {
|
||||
let path = self.path(username)?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => Ok(Feed::parse(&text)),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Feed::new()),
|
||||
Err(e) => Err(format!("cannot read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic replace, so a concurrent HTTP reader sees the old or the new
|
||||
/// file, never a half-written one.
|
||||
pub fn save(&self, username: &str, feed: &Feed) -> Result<(), String> {
|
||||
let path = self.path(username)?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, feed.render())
|
||||
.map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("cannot replace {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Creates the feed skeleton at `admit` time: metadata only, no records.
|
||||
pub fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
nick: &str,
|
||||
address: &str,
|
||||
base_url: &str,
|
||||
host_key: &[u8; 32],
|
||||
) -> Result<(), String> {
|
||||
if self.exists(username)? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut feed = Feed::new();
|
||||
feed.set_meta("nick", nick);
|
||||
feed.set_meta("url", &format!("{base_url}/twtxt/{username}.txt"));
|
||||
feed.set_meta("smol:author", address);
|
||||
feed.set_meta("smol:host_key", &fumi_core::crypto::b32(host_key));
|
||||
self.save(username, &feed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn usernames_gate_the_filesystem_path() {
|
||||
assert!(valid_username("tobias"));
|
||||
assert!(valid_username("a-b2"));
|
||||
assert!(!valid_username(""));
|
||||
assert!(!valid_username("Tobias"));
|
||||
assert!(!valid_username("a_b"));
|
||||
assert!(!valid_username("../../etc/passwd"));
|
||||
assert!(!valid_username(&"a".repeat(31)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_save_load_round_trip() {
|
||||
let dir = std::env::temp_dir().join("smolboid-feeds-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let feeds = FeedStore::new(&dir).unwrap();
|
||||
|
||||
feeds
|
||||
.create("tobias", "tobias", "tobias@smol.place", "https://x", &[9u8; 32])
|
||||
.unwrap();
|
||||
assert!(feeds.exists("tobias").unwrap());
|
||||
assert!(!feeds.exists("dana").unwrap());
|
||||
|
||||
let feed = feeds.load("tobias").unwrap();
|
||||
assert_eq!(feed.author(), Some("tobias@smol.place"));
|
||||
assert_eq!(
|
||||
feed.meta("url"),
|
||||
Some("https://x/twtxt/tobias.txt")
|
||||
);
|
||||
|
||||
let mut feed = feeds.load("tobias").unwrap();
|
||||
feed.set_meta("nick", "toby");
|
||||
feeds.save("tobias", &feed).unwrap();
|
||||
assert_eq!(feeds.load("tobias").unwrap().meta("nick"), Some("toby"));
|
||||
|
||||
// Path traversal is refused before any I/O.
|
||||
assert!(feeds.load("../etc/passwd").is_err());
|
||||
}
|
||||
}
|
||||
315
daemon/src/http.rs
Normal file
315
daemon/src/http.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
//! The HTTP listener: one route, `GET /twtxt/<username>.txt`, with the strong
|
||||
//! ETag and caching behaviour the plan fixes. Hand-rolled on purpose — one
|
||||
//! static file with conditional requests is smaller than any framework.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use fumi_core::crypto::sha256;
|
||||
use smolboid_core::time;
|
||||
|
||||
use crate::feedstore::FeedStore;
|
||||
|
||||
const MAX_REQUEST: usize = 8 * 1024;
|
||||
|
||||
pub fn serve(listener: TcpListener, feeds: FeedStore) -> ! {
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let feeds = feeds.clone();
|
||||
std::thread::spawn(move || handle(stream, feeds));
|
||||
}
|
||||
Err(e) => eprintln!("http: accept failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, feeds: FeedStore) {
|
||||
// Bounded slow-client exposure: each connection gets its own thread, so
|
||||
// a client that dribbles bytes must not hold it forever.
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let request = match read_request(&mut stream) {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
let (method, path, headers) = match parse_request(&request) {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
let _ = write_simple(&mut stream, 400, "Bad Request", "bad request\n");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if method != "GET" {
|
||||
let _ = write_simple(&mut stream, 405, "Method Not Allowed", "GET only\n");
|
||||
return;
|
||||
}
|
||||
let Some(username) = username_of(path) else {
|
||||
let _ = write_simple(&mut stream, 404, "Not Found", "not found\n");
|
||||
return;
|
||||
};
|
||||
// An admitted author's feed exists from `admit` on; anything else,
|
||||
// including a valid username with no feed, is not there to be served.
|
||||
if !feeds.exists(&username).unwrap_or(false) {
|
||||
let _ = write_simple(&mut stream, 404, "Not Found", "not found\n");
|
||||
return;
|
||||
}
|
||||
let Ok(feed) = feeds.load(&username) else {
|
||||
let _ = write_simple(&mut stream, 404, "Not Found", "not found\n");
|
||||
return;
|
||||
};
|
||||
let body = feed.render().into_bytes();
|
||||
let etag = hex(&sha256(&[&body]));
|
||||
let modified = mtime(&feeds, &username);
|
||||
let cached = matches_cache(&headers, &etag, modified);
|
||||
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {} {}\r\nETag: \"{etag}\"\r\nLast-Modified: {}\r\nCache-Control: max-age=300\r\nAccess-Control-Allow-Origin: *\r\n",
|
||||
if cached { 304 } else { 200 },
|
||||
if cached { "Not Modified" } else { "OK" },
|
||||
http_date(modified),
|
||||
);
|
||||
if cached {
|
||||
head.push_str("\r\n");
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
return;
|
||||
}
|
||||
head.push_str(&format!(
|
||||
"Content-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
));
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(&body);
|
||||
}
|
||||
|
||||
/// `If-None-Match` takes precedence; `If-Modified-Since` applies only without
|
||||
/// it (PLAN.md, Serving).
|
||||
fn matches_cache(headers: &[(String, String)], etag: &str, modified: i64) -> bool {
|
||||
if let Some((_, inm)) = headers.iter().find(|(k, _)| k == "if-none-match") {
|
||||
return inm == "*" || inm.contains(&format!("\"{etag}\""));
|
||||
}
|
||||
if let Some((_, ims)) = headers.iter().find(|(k, _)| k == "if-modified-since") {
|
||||
if let Some(since) = parse_http_date(ims) {
|
||||
return modified <= since;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn username_of(path: &str) -> Option<String> {
|
||||
let name = path.strip_prefix("/twtxt/")?.strip_suffix(".txt")?;
|
||||
crate::feedstore::valid_username(name).then(|| name.to_owned())
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut TcpStream) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 512];
|
||||
loop {
|
||||
let n = stream.read(&mut chunk).ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > MAX_REQUEST {
|
||||
return Some(buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_request(request: &[u8]) -> Option<(&str, &str, Vec<(String, String)>)> {
|
||||
let text = std::str::from_utf8(request).ok()?;
|
||||
let mut lines = text.split("\r\n");
|
||||
let request_line = lines.next()?;
|
||||
let mut parts = request_line.split(' ');
|
||||
let method = parts.next()?;
|
||||
let path = parts.next()?;
|
||||
let mut headers = Vec::new();
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
headers.push((name.trim().to_ascii_lowercase(), value.trim().to_owned()));
|
||||
}
|
||||
}
|
||||
Some((method, path, headers))
|
||||
}
|
||||
|
||||
fn write_simple(stream: &mut TcpStream, code: u16, label: &str, body: &str) -> std::io::Result<()> {
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {code} {label}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn mtime(feeds: &FeedStore, username: &str) -> i64 {
|
||||
feeds
|
||||
.path_mtime(username)
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
/// IMF-fixdate, the only Last-Modified form (RFC 9110).
|
||||
fn http_date(unix: i64) -> String {
|
||||
let (y, mo, d, h, mi, s) = time::utc_parts(unix);
|
||||
let wday = (unix.div_euclid(86_400) + 4).rem_euclid(7) as usize; // 1970-01-01: Thursday
|
||||
format!(
|
||||
"{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
|
||||
DAYS[wday], d, MONTHS[(mo - 1) as usize], y, h, mi, s
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_http_date(text: &str) -> Option<i64> {
|
||||
let rest = text.split(", ").nth(1)?;
|
||||
let mut parts = rest.split(' ');
|
||||
let date = parts.next()?;
|
||||
let month = parts.next()?;
|
||||
let year = parts.next()?;
|
||||
let clock = parts.next()?;
|
||||
if parts.next() != Some("GMT") {
|
||||
return None;
|
||||
}
|
||||
let (d, mo) = month_of(date, month)?;
|
||||
let mut clock = clock.split(':');
|
||||
let (h, mi, s) = (
|
||||
clock.next()?.parse().ok()?,
|
||||
clock.next()?.parse().ok()?,
|
||||
clock.next()?.parse().ok()?,
|
||||
);
|
||||
time::from_parts(year.parse().ok()?, mo, d, h, mi, s)
|
||||
}
|
||||
|
||||
fn month_of(date: &str, month: &str) -> Option<(u32, u32)> {
|
||||
let d: u32 = date.parse().ok()?;
|
||||
let mo = MONTHS.iter().position(|m| *m == month)? as u32 + 1;
|
||||
Some((d, mo))
|
||||
}
|
||||
|
||||
fn hex(raw: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(raw.len() * 2);
|
||||
for byte in raw {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("smolboid-http-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn start(feeds: FeedStore) -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || serve(listener, feeds));
|
||||
addr
|
||||
}
|
||||
|
||||
fn get(addr: &std::net::SocketAddr, request: &str) -> String {
|
||||
use std::net::ToSocketAddrs;
|
||||
let mut stream = TcpStream::connect(addr.to_socket_addrs().unwrap().next().unwrap())
|
||||
.expect("connect");
|
||||
stream.write_all(request.as_bytes()).unwrap();
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).unwrap();
|
||||
String::from_utf8_lossy(&response).into_owned()
|
||||
}
|
||||
|
||||
fn with_feed(name: &str) -> (FeedStore, String) {
|
||||
let feeds = FeedStore::new(&dir(name)).unwrap();
|
||||
feeds.create("tobias", "tobias", "tobias@smol.place", "https://x", &[9; 32]).unwrap();
|
||||
let addr = start(feeds.clone());
|
||||
(feeds, format!("{addr}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_the_feed_with_the_planned_headers() {
|
||||
let (_, addr) = with_feed("headers");
|
||||
let response = get(&addr.parse().unwrap(), "GET /twtxt/tobias.txt HTTP/1.1\r\nHost: x\r\n\r\n");
|
||||
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
|
||||
assert!(response.contains("ETag: \""));
|
||||
assert!(response.contains("Cache-Control: max-age=300"));
|
||||
assert!(response.contains("Access-Control-Allow-Origin: *"));
|
||||
assert!(response.contains("Content-Type: text/plain; charset=utf-8"));
|
||||
assert!(response.contains("# smol:author = tobias@smol.place\n"));
|
||||
assert!(response.ends_with(&format!(
|
||||
"# smol:host_key = {}\n",
|
||||
fumi_core::crypto::b32(&[9u8; 32])
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn etag_match_answers_304() {
|
||||
let (_, addr) = with_feed("etag");
|
||||
let addr: std::net::SocketAddr = addr.parse().unwrap();
|
||||
let first = get(&addr, "GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n");
|
||||
let etag = first
|
||||
.split("\r\n")
|
||||
.find(|l| l.starts_with("ETag: "))
|
||||
.unwrap()
|
||||
.trim_start_matches("ETag: ");
|
||||
let hit = get(
|
||||
&addr,
|
||||
&format!("GET /twtxt/tobias.txt HTTP/1.1\r\nIf-None-Match: {etag}\r\n\r\n"),
|
||||
);
|
||||
assert!(hit.starts_with("HTTP/1.1 304"), "{hit}");
|
||||
assert!(hit.contains("ETag: "));
|
||||
assert!(!hit.contains("ignored-body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_none_match_takes_precedence_over_if_modified_since() {
|
||||
let (_, addr) = with_feed("precedence");
|
||||
let addr: std::net::SocketAddr = addr.parse().unwrap();
|
||||
// An IMS after the file's mtime alone yields 304; a mismatching INM
|
||||
// must win over that matching IMS.
|
||||
let ahead = "Mon, 01 Jan 2035 00:00:00 GMT";
|
||||
let hit = get(
|
||||
&addr,
|
||||
&format!("GET /twtxt/tobias.txt HTTP/1.1\r\nIf-Modified-Since: {ahead}\r\n\r\n"),
|
||||
);
|
||||
assert!(hit.starts_with("HTTP/1.1 304"), "{hit}");
|
||||
|
||||
let miss = get(
|
||||
&addr,
|
||||
&format!(
|
||||
"GET /twtxt/tobias.txt HTTP/1.1\r\nIf-None-Match: \"deadbeef\"\r\nIf-Modified-Since: {ahead}\r\n\r\n"
|
||||
),
|
||||
);
|
||||
assert!(miss.starts_with("HTTP/1.1 200"), "{miss}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_paths_and_methods_do_not_publish() {
|
||||
let (_, addr) = with_feed("paths");
|
||||
let addr: std::net::SocketAddr = addr.parse().unwrap();
|
||||
assert!(get(&addr, "GET /twtxt/../etc/passwd HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 404"));
|
||||
assert!(get(&addr, "GET /nope.txt HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 404"));
|
||||
// A valid username without a feed is not served as an empty feed.
|
||||
assert!(get(&addr, "GET /twtxt/nobody.txt HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 404"));
|
||||
assert!(get(&addr, "POST /twtxt/tobias.txt HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 405"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_dates_round_trip() {
|
||||
for unix in [0i64, 1_760_000_000, 951_782_400] {
|
||||
let date = http_date(unix);
|
||||
assert_eq!(parse_http_date(&date), Some(unix), "{date}");
|
||||
}
|
||||
assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
|
||||
}
|
||||
}
|
||||
595
daemon/src/mailloop.rs
Normal file
595
daemon/src/mailloop.rs
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
//! The mail loop: fetch from the daemon's mailbox, admit by ownership, apply
|
||||
//! each mail to its feed, and send rate-limited error replies. The apply and
|
||||
//! retention logic is pure and unit-tested; network glue stays thin.
|
||||
|
||||
use fumi_core::account::Account;
|
||||
use fumi_core::address::Address;
|
||||
use fumi_core::client;
|
||||
use fumi_core::store::{now, Store};
|
||||
use fumi_core::error::Error as FumiError;
|
||||
use smolboid_core::feed::{Feed, MailLine, Record};
|
||||
use smolboid_core::envelope;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::dispatch::{self, Action, Command, Incoming};
|
||||
use crate::feedstore::FeedStore;
|
||||
use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State};
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Everything needed to publish one record, before retention gets a say.
|
||||
pub struct PendingRecord {
|
||||
pub timestamp: String,
|
||||
pub text: String,
|
||||
pub repost: bool,
|
||||
/// The Reply-To the mail carried, when the ordinary-send form was used.
|
||||
pub reply_to: Option<String>,
|
||||
pub mail: MailLine,
|
||||
}
|
||||
|
||||
/// Failures that draw an error reply (PLAN.md, Management mail).
|
||||
#[derive(Debug)]
|
||||
pub enum ApplyError {
|
||||
OverQuota(bool),
|
||||
Collision(String),
|
||||
FeedFull,
|
||||
/// The author's Reply-To changed after the feed bound one: published
|
||||
/// records would stop reconstructing (PLAN.md, smol:reply_to).
|
||||
ReplyToChanged,
|
||||
}
|
||||
|
||||
/// Applies a published record to the feed: quota, uniqueness, retention
|
||||
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
|
||||
/// Returns the rendered size after appending, for the 90% warning.
|
||||
pub fn apply_record(
|
||||
feed: &mut Feed,
|
||||
fs: &mut FeedState,
|
||||
cfg: &Config,
|
||||
record: PendingRecord,
|
||||
now_unix: i64,
|
||||
) -> Result<(), ApplyError> {
|
||||
State::roll_counters(fs, now_unix);
|
||||
let (counter, limit) = if record.repost {
|
||||
(&mut fs.reposts, cfg.reposts_per_day)
|
||||
} else {
|
||||
(&mut fs.posts, cfg.posts_per_day)
|
||||
};
|
||||
if *counter >= limit {
|
||||
return Err(ApplyError::OverQuota(record.repost));
|
||||
}
|
||||
if feed.has_timestamp(&record.timestamp) {
|
||||
// The signature pins the time, so a bump is impossible; the author
|
||||
// resends a second later (post rule 2).
|
||||
return Err(ApplyError::Collision(record.timestamp));
|
||||
}
|
||||
|
||||
// The Reply-To must be the author's own address (key = sender), and the
|
||||
// verifier derives it from `smol:author` plus the record's sender key. The
|
||||
// first publishing mail adopts the author's own spelling of the address;
|
||||
// a later change cannot be adopted without invalidating old records.
|
||||
if let Some(reply_to) = &record.reply_to {
|
||||
let Ok(addr) = Address::parse(reply_to) else {
|
||||
return Err(ApplyError::ReplyToChanged);
|
||||
};
|
||||
if addr.identity != Some(record.mail.sender) || addr.short() != feed.author().unwrap_or_default() {
|
||||
if feed.entries().is_empty() {
|
||||
feed.set_meta("smol:author", &addr.short());
|
||||
} else {
|
||||
return Err(ApplyError::ReplyToChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let entry = if record.repost {
|
||||
Record::Repost {
|
||||
timestamp: record.timestamp.clone(),
|
||||
args: record.text.clone(),
|
||||
}
|
||||
} else {
|
||||
Record::Post {
|
||||
timestamp: record.timestamp.clone(),
|
||||
text: record.text.clone(),
|
||||
}
|
||||
};
|
||||
let rendered = entry.render();
|
||||
let addition = rendered.len() + 1 + record.mail.render().len() + 1;
|
||||
let mut size = feed.render().len() + addition;
|
||||
if size > cfg.cap_bytes {
|
||||
if fs.prune == PruneMode::Off {
|
||||
return Err(ApplyError::FeedFull);
|
||||
}
|
||||
// FIFO: drop oldest records until the new one fits. If even an empty
|
||||
// feed cannot hold it, the record simply exceeds the cap.
|
||||
while size > cfg.cap_bytes {
|
||||
let Some(oldest) = feed.entries().first().map(|e| e.record.timestamp().to_owned())
|
||||
else {
|
||||
return Err(ApplyError::FeedFull);
|
||||
};
|
||||
feed.delete(&oldest);
|
||||
size = feed.render().len() + addition;
|
||||
}
|
||||
}
|
||||
|
||||
feed.append(entry, Some(record.mail));
|
||||
*counter += 1;
|
||||
|
||||
// The 90% warning fires once per growth episode (PLAN.md, Retention).
|
||||
let size = feed.render().len();
|
||||
if size * 10 >= cfg.cap_bytes * 9 {
|
||||
if !fs.warned {
|
||||
fs.warned = true;
|
||||
}
|
||||
} else if fs.warned {
|
||||
fs.warned = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies command fields to feed and state. Deletion failure is reported so
|
||||
/// the caller can reply.
|
||||
pub fn apply_commands(
|
||||
feed: &mut Feed,
|
||||
fs: &mut FeedState,
|
||||
commands: &[Command],
|
||||
) -> Result<usize, String> {
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::Nick(nick) => {
|
||||
fs.nick = nick.clone();
|
||||
feed.set_meta("nick", nick);
|
||||
}
|
||||
Command::Delete(timestamp) => {
|
||||
if !feed.delete(timestamp) {
|
||||
return Err(format!("no record at {timestamp}"));
|
||||
}
|
||||
}
|
||||
Command::Prune(mode) => fs.prune = *mode,
|
||||
}
|
||||
}
|
||||
Ok(commands.len())
|
||||
}
|
||||
|
||||
pub struct Daemon {
|
||||
pub cfg: Config,
|
||||
pub store: Store,
|
||||
pub account: Account,
|
||||
pub state: Mutex<State>,
|
||||
pub feeds: FeedStore,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// One poll cycle. Returns a human summary line.
|
||||
pub fn poll(&self) -> Result<String, String> {
|
||||
let summary = client::fetch(&self.store, &self.account, false, false, self.cfg.timeout)
|
||||
.map_err(|e| format!("fetch: {e}"))?;
|
||||
if summary.stored == 0 {
|
||||
return Ok("no new mail".into());
|
||||
}
|
||||
let mut applied = 0;
|
||||
let mut dropped = 0;
|
||||
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
||||
|
||||
let inbox = self
|
||||
.store
|
||||
.mail("inbox")
|
||||
.map_err(|e| format!("inbox: {e}"))?;
|
||||
for stored in &inbox {
|
||||
let outcome = self.process(stored.id, &stored.envelope, &mut replies);
|
||||
let _ = self.store.delete_local("inbox", &stored.id);
|
||||
match outcome {
|
||||
Ok(true) => applied += 1,
|
||||
Ok(false) => dropped += 1,
|
||||
Err(e) => eprintln!("mail: {e}"),
|
||||
}
|
||||
}
|
||||
self.send_replies(replies)?;
|
||||
Ok(format!(
|
||||
"{} applied, {} dropped",
|
||||
applied, dropped
|
||||
))
|
||||
}
|
||||
|
||||
/// Processes one stored envelope end to end. Ok(true) when the feed or
|
||||
/// state changed.
|
||||
fn process(
|
||||
&self,
|
||||
id: [u8; 32],
|
||||
envelope: &[u8],
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) -> Result<bool, String> {
|
||||
let opened = envelope::open(self.account.keys(), envelope, now())
|
||||
.map_err(|e| format!("open {}: {e}", &fumi_core::crypto::b32(&id)[..8]))?;
|
||||
let incoming = Incoming {
|
||||
sender: opened.sender,
|
||||
epk: opened.epk,
|
||||
sig: opened.sig,
|
||||
time: opened.time,
|
||||
body: opened.body,
|
||||
};
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Admission: a feed must exist and its owner must still be accepted.
|
||||
// Requests-tier or blocked mail has neither and is dropped without a
|
||||
// reply (PLAN.md, Admission and Error replies) — main-tier inbox rows
|
||||
// are all `mail("inbox")` ever returns.
|
||||
let index = match state.feed_of(&opened.sender) {
|
||||
Some(index) => index,
|
||||
None => match self.follow_rotation(&incoming, &mut state)? {
|
||||
Some(index) => index,
|
||||
None => return Ok(false),
|
||||
},
|
||||
};
|
||||
let feed_state = state.feeds[index].clone();
|
||||
if !self.accepted(&feed_state.address)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.apply(&incoming, index, &mut state, replies)
|
||||
}
|
||||
|
||||
fn accepted(&self, address: &str) -> Result<bool, String> {
|
||||
match self.store.accepted_state(address) {
|
||||
Ok(Some(true)) => Ok(true),
|
||||
Ok(_) => Ok(false),
|
||||
Err(e) => Err(format!("accepted_state: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown sender may be a rotated owner: a signed Reply-To names the
|
||||
/// address, and a verified chain from the key we hold to this one
|
||||
/// (SPEC.md §7) moves the feed to the new key, after which the very mail
|
||||
/// that announced the rotation applies as the rotated owner. Anything
|
||||
/// else is dropped.
|
||||
fn follow_rotation(
|
||||
&self,
|
||||
incoming: &Incoming,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
) -> Result<Option<usize>, String> {
|
||||
let Ok(text) = std::str::from_utf8(&incoming.body) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (fields, _) = fumi_core::message::parse_frontmatter(text);
|
||||
let Some(reply_to) = fields.get("reply-to") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(addr) = Address::parse(reply_to) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(user_key) = addr.identity else {
|
||||
return Ok(None);
|
||||
};
|
||||
if user_key != incoming.sender {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(index) = state.feeds.iter().position(|f| f.address == addr.short()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(old) = state.feeds[index].latest_owner() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut session = client::connect(&self.store, &addr, false, self.cfg.timeout)
|
||||
.map_err(|e| format!("rotation connect: {e}"))?;
|
||||
let resolved = client::resolve(session.transport(), &addr.user)
|
||||
.map_err(|e| format!("rotation resolve: {e}"))?;
|
||||
session.close();
|
||||
let (current, chain) = resolved;
|
||||
if !client::walk_chain(&addr.user, &old, ¤t, &chain) {
|
||||
return Ok(None);
|
||||
}
|
||||
if state.feeds[index].owner_keys.last().map(String::as_str)
|
||||
!= Some(fumi_core::crypto::b32(¤t).as_str())
|
||||
{
|
||||
state.feeds[index]
|
||||
.owner_keys
|
||||
.push(fumi_core::crypto::b32(¤t));
|
||||
}
|
||||
Ok(Some(index))
|
||||
}
|
||||
|
||||
fn apply(
|
||||
&self,
|
||||
incoming: &Incoming,
|
||||
index: usize,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) -> Result<bool, String> {
|
||||
let username = state.feeds[index].username.clone();
|
||||
let address = state.feeds[index].address.clone();
|
||||
let mut feed = self.feeds.load(&username)?;
|
||||
let before = feed.render();
|
||||
|
||||
let outcome = match dispatch::dispatch(incoming) {
|
||||
Ok(Action::Post { timestamp, text, reply_to, mail }) => {
|
||||
self.publish(
|
||||
state, index, &mut feed, &address, incoming, replies,
|
||||
PendingRecord { timestamp, text, reply_to, repost: false, mail },
|
||||
)?
|
||||
}
|
||||
Ok(Action::Repost { timestamp, args, reply_to, mail }) => {
|
||||
self.publish(
|
||||
state, index, &mut feed, &address, incoming, replies,
|
||||
PendingRecord { timestamp, text: args, reply_to, repost: true, mail },
|
||||
)?
|
||||
}
|
||||
Ok(Action::Commands(commands)) => {
|
||||
match apply_commands(&mut feed, &mut state.feeds[index], &commands) {
|
||||
Ok(n) => n > 0,
|
||||
Err(reason) => {
|
||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Action::Ignore) => false,
|
||||
Err(reason) => {
|
||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let after = feed.render();
|
||||
if after != before {
|
||||
self.feeds.save(&username, &feed)?;
|
||||
}
|
||||
// Counters, ledgers and prune modes change even when no record did.
|
||||
state
|
||||
.save(&self.cfg.data.join("state.json"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn publish(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
index: usize,
|
||||
feed: &mut Feed,
|
||||
address: &str,
|
||||
incoming: &Incoming,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
record: PendingRecord,
|
||||
) -> Result<bool, String> {
|
||||
let was_warned = state.feeds[index].warned;
|
||||
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
|
||||
Ok(()) => {
|
||||
// The transition false -> true sends the cap warning once per
|
||||
// growth episode (PLAN.md, Retention).
|
||||
if state.feeds[index].warned && !was_warned {
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
"twtxt: feed at 90% of its size cap".into(),
|
||||
format!(
|
||||
"Your feed has reached 90% of the {}-byte cap.\n\n\
|
||||
In hard-limit mode (the default) a full feed rejects new records; \
|
||||
delete some with Twt-Delete, or choose FIFO pruning with \
|
||||
Twt-Prune: fifo, which drops the oldest records to make room.",
|
||||
self.cfg.cap_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = match &e {
|
||||
ApplyError::OverQuota(repost) => format!(
|
||||
"daily {} quota exceeded; try tomorrow",
|
||||
if *repost { "repost" } else { "post" }
|
||||
),
|
||||
ApplyError::Collision(ts) => {
|
||||
format!("timestamp {ts} already exists; wait a second and resend")
|
||||
}
|
||||
ApplyError::FeedFull => "feed is at its size cap; delete records with Twt-Delete or choose Twt-Prune: fifo".to_owned(),
|
||||
ApplyError::ReplyToChanged => format!(
|
||||
"your Reply-To does not match the feed's bound author {}; \
|
||||
send from that address or mail the operator",
|
||||
feed.author().unwrap_or_default()
|
||||
),
|
||||
};
|
||||
self.queue_reply(state, incoming, address, &reason, replies);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One error reply per rejected payload, within the hourly budget.
|
||||
fn queue_reply(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
incoming: &Incoming,
|
||||
address: &str,
|
||||
reason: &str,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
|
||||
if !reply_allowed(&mut **state, &incoming.sender, &key, now()) {
|
||||
return;
|
||||
}
|
||||
let text = std::str::from_utf8(&incoming.body)
|
||||
.map(|t| t.chars().take(200).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
format!("twtxt: {reason}"),
|
||||
format!(
|
||||
"Your mail could not be applied.\n\nReason: {reason}\n\nQuoted mail:\n{text}\n"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
fn send_replies(&self, replies: Vec<(String, String, String)>) -> Result<(), String> {
|
||||
for (address, subject, body) in replies {
|
||||
let addr = Address::parse(&address).map_err(|e| format!("reply address: {e}"))?;
|
||||
client::send(
|
||||
&self.store,
|
||||
&self.account,
|
||||
&client::SendDraft {
|
||||
address: &addr,
|
||||
text: body,
|
||||
subject: Some(&subject),
|
||||
reply_to: None,
|
||||
headers: &[],
|
||||
anonymous: false,
|
||||
no_pad: true,
|
||||
},
|
||||
self.cfg.timeout,
|
||||
)
|
||||
.map_err(|e: FumiError| format!("reply to {address}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::state::State;
|
||||
use smolboid_core::verify;
|
||||
|
||||
fn feed_state() -> FeedState {
|
||||
FeedState {
|
||||
username: "tobias".into(),
|
||||
address: "tobias@smol.place".into(),
|
||||
owner_keys: vec![fumi_core::crypto::b32(&[1; 32])],
|
||||
nick: "tobias".into(),
|
||||
prune: PruneMode::Off,
|
||||
warned: false,
|
||||
day: State::day(0),
|
||||
posts: 0,
|
||||
reposts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A record renders at roughly 260 bytes: the post line plus a
|
||||
/// `# smol:mail` line of three base32 values.
|
||||
fn config(cap: usize) -> Config {
|
||||
Config {
|
||||
cap_bytes: cap,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn record(ts: &str, text: &str) -> PendingRecord {
|
||||
PendingRecord {
|
||||
timestamp: ts.into(),
|
||||
text: text.into(),
|
||||
repost: false,
|
||||
reply_to: None,
|
||||
mail: MailLine {
|
||||
sender: [1; 32],
|
||||
epk: [2; 32],
|
||||
sig: [3; 64],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posts_publish_and_verify() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:47:00Z", "hello"), 0).unwrap();
|
||||
assert_eq!(fs.posts, 1);
|
||||
let report = verify(&feed);
|
||||
assert_eq!(report.states, vec![smolboid_core::State::Unsigned]);
|
||||
assert_eq!(feed.entries().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reposts_publish_as_repost_records() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let record = PendingRecord {
|
||||
timestamp: "2026-10-01T12:47:00Z".into(),
|
||||
text: "@<dana https://x/d.txt> #abc1234".into(),
|
||||
repost: true,
|
||||
reply_to: None,
|
||||
mail: MailLine { sender: [1; 32], epk: [2; 32], sig: [3; 64] },
|
||||
};
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record, 0).unwrap();
|
||||
let entry = &feed.entries()[0];
|
||||
assert!(matches!(&entry.record, Record::Repost { args, .. } if args == "@<dana https://x/d.txt> #abc1234"));
|
||||
assert_eq!(fs.reposts, 1);
|
||||
assert_eq!(fs.posts, 0);
|
||||
assert!(feed.render().starts_with("# smol:repost 2026-10-01T12:47:00Z @<dana https://x/d.txt> #abc1234\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collisions_and_quotas_reject_without_appending() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let cfg = config(1 << 20);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "one"), 0).unwrap();
|
||||
assert!(matches!(
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "two"), 0),
|
||||
Err(ApplyError::Collision(_))
|
||||
));
|
||||
assert_eq!(feed.entries().len(), 1);
|
||||
|
||||
let mut fs = feed_state();
|
||||
fs.posts = 50;
|
||||
assert!(matches!(
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:48:00Z", "x"), 0),
|
||||
Err(ApplyError::OverQuota(_))
|
||||
));
|
||||
// A new UTC day rolls the counters.
|
||||
let mut fs = feed_state();
|
||||
fs.posts = 50;
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:48:00Z", "x"), 1_760_000_000).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_limit_rejects_and_fifo_prunes_oldest() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let cfg = config(300);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "first post here"), 0).unwrap();
|
||||
assert!(matches!(
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:48:00Z", "second post"), 0),
|
||||
Err(ApplyError::FeedFull)
|
||||
));
|
||||
assert_eq!(feed.entries().len(), 1, "off mode removes nothing");
|
||||
|
||||
fs.prune = PruneMode::Fifo;
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:48:00Z", "second post"), 0).unwrap();
|
||||
let entries = feed.entries();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].record.timestamp(), "2026-10-01T12:48:00Z");
|
||||
// The quota counts published posts; pruning does not refund it.
|
||||
assert_eq!(fs.posts, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_flag_flips_at_ninety_percent() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let cfg = config(1300);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
|
||||
assert!(!fs.warned);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();
|
||||
assert!(fs.warned, "crossing 90% sets the flag once");
|
||||
feed.delete("2026-10-01T12:47:01Z");
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:02Z", "z"), 0).unwrap();
|
||||
assert!(!fs.warned, "shrinking below the threshold resets it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_touch_feed_and_state() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:47:00Z", "gone soon"), 0).unwrap();
|
||||
|
||||
apply_commands(
|
||||
&mut feed,
|
||||
&mut fs,
|
||||
&[Command::Nick("toby".into()), Command::Prune(PruneMode::Fifo)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs.nick, "toby");
|
||||
assert_eq!(feed.meta("nick"), Some("toby"));
|
||||
assert_eq!(fs.prune, PruneMode::Fifo);
|
||||
|
||||
apply_commands(&mut feed, &mut fs, &[Command::Delete("2026-10-01T12:47:00Z".into())]).unwrap();
|
||||
assert_eq!(feed.entries().len(), 0);
|
||||
assert!(apply_commands(&mut feed, &mut fs, &[Command::Delete("2026-10-01T12:47:00Z".into())]).is_err());
|
||||
}
|
||||
}
|
||||
301
daemon/src/main.rs
Normal file
301
daemon/src/main.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
//! smolboid: serve twtxt feeds published by smolmail (PLAN.md).
|
||||
//!
|
||||
//! Bootstrap with the fumi CLI against the same master key and store before
|
||||
//! the first `serve`: `fumi --key <master> --db <data>/mail.db trust <host>
|
||||
//! <server-key>` then `fumi ... register <mailbox>@<host>`.
|
||||
|
||||
mod config;
|
||||
mod dispatch;
|
||||
mod feedstore;
|
||||
mod http;
|
||||
mod mailloop;
|
||||
mod state;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use config::Config;
|
||||
use feedstore::FeedStore;
|
||||
use fumi_core::account::Account;
|
||||
use fumi_core::address::Address;
|
||||
use fumi_core::client::{self, Pushed};
|
||||
use fumi_core::crypto::{b32, fingerprint};
|
||||
use fumi_core::store::Store;
|
||||
use mailloop::Daemon;
|
||||
use state::{FeedState, State};
|
||||
|
||||
const USAGE: &str = "\
|
||||
smolboid — twtxt feeds published by smolmail
|
||||
|
||||
smolboid serve --config <file> run the daemon (default ./smolboid.toml)
|
||||
smolboid admit --config <file> <address> verify, accept, and create the feed
|
||||
smolboid block --config <file> <address> withdraw the accept token
|
||||
smolboid keygen <path> write a fresh 32-byte master
|
||||
smolboid check <feed-file> verify a feed's records
|
||||
";
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: &[String] = &args;
|
||||
let command = args.first().map(String::as_str).unwrap_or("");
|
||||
let result = match command {
|
||||
"serve" => serve(config_of(&rest[1..])),
|
||||
"admit" => admit(config_of(&rest[1..])),
|
||||
"block" => block(config_of(&rest[1..])),
|
||||
"keygen" if rest.len() == 2 => keygen(&PathBuf::from(&rest[1])),
|
||||
"check" if rest.len() == 2 => check(&PathBuf::from(&rest[1])),
|
||||
_ => {
|
||||
println!("{USAGE}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(message) = result {
|
||||
eprintln!("smolboid: {message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The last non-flag argument of `admit`/`block` is the address; `--config`
|
||||
/// or the default file names the instance.
|
||||
fn config_of(rest: &[String]) -> ConfigAndArg {
|
||||
let mut path = PathBuf::from("smolboid.toml");
|
||||
let mut arg = String::new();
|
||||
let mut iter = rest.iter();
|
||||
while let Some(flag) = iter.next() {
|
||||
match flag.as_str() {
|
||||
"--config" => path = PathBuf::from(iter.next().expect("--config needs a file")),
|
||||
other => arg = other.to_owned(),
|
||||
}
|
||||
}
|
||||
ConfigAndArg { path, arg }
|
||||
}
|
||||
|
||||
struct ConfigAndArg {
|
||||
path: PathBuf,
|
||||
arg: String,
|
||||
}
|
||||
|
||||
fn load(config: &ConfigAndArg) -> Result<(Config, Store, Account, State, FeedStore), String> {
|
||||
let cfg = Config::load(&config.path)?;
|
||||
std::fs::create_dir_all(&cfg.data).map_err(|e| format!("data dir: {e}"))?;
|
||||
let master = std::fs::read(&cfg.master)
|
||||
.map_err(|e| format!("master at {}: {e}; run keygen", cfg.master.display()))?;
|
||||
let master: [u8; 32] = master
|
||||
.try_into()
|
||||
.map_err(|v: Vec<u8>| format!("master is {} bytes, want 32", v.len()))?;
|
||||
let store = Store::open(&cfg.data.join("mail.db")).map_err(|e| format!("store: {e}"))?;
|
||||
let account = Account::new(master, store.rotations().map_err(|e| format!("store: {e}"))?)
|
||||
.map_err(|e| format!("identity: {e}"))?;
|
||||
let state = State::load(&cfg.data.join("state.json")).map_err(|e| format!("state: {e}"))?;
|
||||
let feeds = FeedStore::new(&cfg.data).map_err(|e| format!("feeds: {e}"))?;
|
||||
Ok((cfg, store, account, state, feeds))
|
||||
}
|
||||
|
||||
fn serve(config: ConfigAndArg) -> Result<(), String> {
|
||||
let (cfg, store, account, state, feeds) = load(&config)?;
|
||||
let daemon = Daemon {
|
||||
cfg: cfg.clone(),
|
||||
store,
|
||||
account,
|
||||
state: Mutex::new(state),
|
||||
feeds: feeds.clone(),
|
||||
};
|
||||
let listener = std::net::TcpListener::bind(&cfg.listen)
|
||||
.map_err(|e| format!("cannot listen on {}: {e}", cfg.listen))?;
|
||||
println!(
|
||||
"smolboid serving {} at http://{} as {}@{}",
|
||||
cfg.base_url,
|
||||
listener.local_addr().map_err(|e| e.to_string())?,
|
||||
cfg.mailbox,
|
||||
cfg.smolmail_host
|
||||
);
|
||||
std::thread::spawn(move || http::serve(listener, feeds));
|
||||
loop {
|
||||
match daemon.poll() {
|
||||
Ok(summary) => println!("{} {}", now_stamp(), summary),
|
||||
Err(e) => eprintln!("{} poll: {e}", now_stamp()),
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(cfg.poll_secs));
|
||||
}
|
||||
}
|
||||
|
||||
fn admit(config: ConfigAndArg) -> Result<(), String> {
|
||||
let (cfg, store, account, mut state, feeds) = load(&config)?;
|
||||
let addr = Address::parse(&config.arg).map_err(|e| format!("address: {e}"))?;
|
||||
|
||||
// Verify the author's key against their own server, then accept them.
|
||||
let mut session = client::connect(&store, &addr, false, cfg.timeout)
|
||||
.map_err(|e| format!("connecting to {}: {e}", addr.host))?;
|
||||
let (key, change) = client::trust_key(&store, session.transport(), &addr)
|
||||
.map_err(|e| format!("resolving {}: {e}", config.arg))?;
|
||||
session.close();
|
||||
store
|
||||
.accept(&addr.short(), &key)
|
||||
.map_err(|e| format!("accepting: {e}"))?;
|
||||
match client::push_tokens(&store, &account, None, cfg.timeout) {
|
||||
Ok(Pushed::Held(held)) => println!("server now holds {held} accept token(s)"),
|
||||
Ok(Pushed::NotRegistered) => println!("not registered; tokens travel with the first fetch"),
|
||||
Err(e) => return Err(format!("pushing tokens: {e}")),
|
||||
}
|
||||
println!(
|
||||
"{}: {} ({:?})",
|
||||
config.arg,
|
||||
b32(&key),
|
||||
change
|
||||
);
|
||||
|
||||
// The username is the feed path; a second owner for one username is a
|
||||
// collision the operator must see.
|
||||
let existing = state.feed_by_username(&addr.user);
|
||||
if let Some(existing) = existing {
|
||||
if existing.address != addr.short() {
|
||||
return Err(format!(
|
||||
"username `{}` already has a feed from {}",
|
||||
addr.user, existing.address
|
||||
));
|
||||
}
|
||||
println!("feed already exists; re-accepted");
|
||||
}
|
||||
|
||||
let feed_state = FeedState {
|
||||
username: addr.user.clone(),
|
||||
address: addr.short(),
|
||||
owner_keys: vec![b32(&key)],
|
||||
nick: addr.user.clone(),
|
||||
prune: Default::default(),
|
||||
warned: false,
|
||||
day: State::day(0),
|
||||
posts: 0,
|
||||
reposts: 0,
|
||||
};
|
||||
if existing.is_none() {
|
||||
feeds.create(
|
||||
&feed_state.username,
|
||||
&feed_state.nick,
|
||||
&feed_state.address,
|
||||
&cfg.base_url,
|
||||
&account.me().pk(),
|
||||
)?;
|
||||
state.feeds.push(feed_state);
|
||||
}
|
||||
state
|
||||
.save(&cfg.data.join("state.json"))
|
||||
.map_err(|e| format!("state: {e}"))?;
|
||||
println!(
|
||||
"feed created: {}/twtxt/{}.txt (host key {})",
|
||||
cfg.base_url,
|
||||
addr.user,
|
||||
b32(&account.me().pk())
|
||||
);
|
||||
|
||||
// The welcome mail carries the Accept token (sec 5.8), which is what
|
||||
// moves the author's posts into the daemon's main tier; without it their
|
||||
// mail would stay in requests and never publish.
|
||||
let sent = client::send(
|
||||
&store,
|
||||
&account,
|
||||
&client::SendDraft {
|
||||
address: &addr,
|
||||
text: format!(
|
||||
"Your smolboid feed is live: {}/twtxt/{}.txt\n\n\
|
||||
Post by sending mail to this address with the twt as the body. Manage it with\n\
|
||||
Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields (PLAN.md).",
|
||||
cfg.base_url, addr.user
|
||||
),
|
||||
subject: Some("twtxt: your feed is ready"),
|
||||
reply_to: None,
|
||||
headers: &[],
|
||||
anonymous: false,
|
||||
no_pad: true,
|
||||
},
|
||||
cfg.timeout,
|
||||
)
|
||||
.map_err(|e| format!("welcome mail: {e}"))?;
|
||||
println!(
|
||||
"welcome mail sent{}; fetch it before your first post",
|
||||
if sent.token_used { " with the accept token" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn block(config: ConfigAndArg) -> Result<(), String> {
|
||||
let (cfg, store, account, _state, _feeds) = load(&config)?;
|
||||
if !store.block(&config.arg).map_err(|e| format!("block: {e}"))? {
|
||||
return Err(format!("{} was never accepted", config.arg));
|
||||
}
|
||||
match client::push_tokens(&store, &account, None, cfg.timeout) {
|
||||
Ok(Pushed::Held(held)) => println!("server now holds {held} accept token(s)"),
|
||||
Ok(Pushed::NotRegistered) => println!("not registered; tokens travel with the first fetch"),
|
||||
Err(e) => return Err(format!("pushing tokens: {e}")),
|
||||
}
|
||||
println!(
|
||||
"blocked {arg}; their mail lands in requests from their next message on",
|
||||
arg = config.arg
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keygen(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
return Err(format!("{} exists; refusing to overwrite", path.display()));
|
||||
}
|
||||
let mut master = [0u8; 32];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut f| {
|
||||
use std::io::Read;
|
||||
f.read_exact(&mut master)
|
||||
})
|
||||
.map_err(|e| format!("urandom: {e}"))?;
|
||||
std::fs::write(path, master).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
set_private(path);
|
||||
let account = Account::new(master, 0).map_err(|e| format!("identity: {e}"))?;
|
||||
println!("master: {} (back this up; it is the only secret)", path.display());
|
||||
println!("public key: {}", b32(&account.me().pk()));
|
||||
println!("fingerprint: {}", fingerprint(&account.me().pk()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private(_path: &Path) {}
|
||||
|
||||
/// Verifies every record of a feed file and reports the three states; a
|
||||
/// nonzero exit marks forged records, so scripts can gate on it.
|
||||
fn check(path: &Path) -> Result<(), String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
let feed = smolboid_core::Feed::parse(&text);
|
||||
let report = smolboid_core::verify(&feed);
|
||||
if report.legacy {
|
||||
println!("legacy feed: no smol metadata, nothing to verify");
|
||||
return Ok(());
|
||||
}
|
||||
let entries = feed.entries();
|
||||
let mut counts = [0usize; 3];
|
||||
for (entry, state) in entries.iter().zip(&report.states) {
|
||||
let index = match state {
|
||||
smolboid_core::State::Verified => 0,
|
||||
smolboid_core::State::Unsigned => 1,
|
||||
smolboid_core::State::Invalid => 2,
|
||||
};
|
||||
counts[index] += 1;
|
||||
println!("{} {}", entry.record.timestamp(), state.as_str());
|
||||
}
|
||||
println!(
|
||||
"{} verified, {} unsigned, {} invalid",
|
||||
counts[0], counts[1], counts[2]
|
||||
);
|
||||
if counts[2] > 0 {
|
||||
return Err(format!("{} record(s) do not verify", counts[2]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_stamp() -> String {
|
||||
smolboid_core::render_timestamp(fumi_core::store::now())
|
||||
}
|
||||
237
daemon/src/state.rs
Normal file
237
daemon/src/state.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
//! Persistent instance state: one record per feed (ownership, nick, prune
|
||||
//! mode, daily counters) plus the reply ledgers. Kept as one JSON file with
|
||||
//! atomic replace, the feed files being the only other on-disk truth.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use fumi_core::crypto::{b32, sha256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// At most this many replies per sender per hour (PLAN.md, Error replies).
|
||||
pub const REPLIES_PER_HOUR: usize = 10;
|
||||
/// Rejected-payload keys remembered for reply dedup, a bounded ring.
|
||||
pub const DEDUPE_MAX: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeedState {
|
||||
pub username: String,
|
||||
/// The owner's smolmail address, `user@host` short form.
|
||||
pub address: String,
|
||||
/// Owner identity keys, oldest first; a rotation appends (SPEC.md §7).
|
||||
pub owner_keys: Vec<String>,
|
||||
/// Display nick; the feed path is the username and never changes.
|
||||
pub nick: String,
|
||||
pub prune: PruneMode,
|
||||
/// The 90% cap warning has been sent since the feed last shrank.
|
||||
pub warned: bool,
|
||||
/// Daily counters, UTC-day keyed: `YYYY-MM-DD`.
|
||||
pub day: String,
|
||||
pub posts: u32,
|
||||
pub reposts: u32,
|
||||
}
|
||||
|
||||
impl FeedState {
|
||||
pub fn latest_owner(&self) -> Option<[u8; 32]> {
|
||||
self.owner_keys
|
||||
.last()
|
||||
.and_then(|k| fumi_core::crypto::unb32(k).ok())
|
||||
.and_then(|k| k.try_into().ok())
|
||||
}
|
||||
|
||||
/// True when this identity key owns the feed, at any rotation index.
|
||||
pub fn owned_by(&self, key: &[u8; 32]) -> bool {
|
||||
self.owner_keys
|
||||
.iter()
|
||||
.any(|k| fumi_core::crypto::unb32(k).map(|raw| raw == key).unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PruneMode {
|
||||
/// The default: a full feed rejects new records (PLAN.md, Retention).
|
||||
#[default]
|
||||
Off,
|
||||
/// A full feed drops its oldest records until the new one fits.
|
||||
Fifo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub feeds: Vec<FeedState>,
|
||||
#[serde(default)]
|
||||
replies: Vec<ReplyLedger>,
|
||||
#[serde(default)]
|
||||
dedupe: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ReplyLedger {
|
||||
sender: String,
|
||||
times: Vec<i64>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn load(path: &Path) -> io::Result<State> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => serde_json::from_str(&text).map_err(|e| io::Error::other(e)),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(State::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_string_pretty(self).unwrap())?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
pub fn feed_of(&self, key: &[u8; 32]) -> Option<usize> {
|
||||
self.feeds.iter().position(|f| f.owned_by(key))
|
||||
}
|
||||
|
||||
pub fn feed_by_username(&self, username: &str) -> Option<&FeedState> {
|
||||
self.feeds.iter().find(|f| f.username == username)
|
||||
}
|
||||
|
||||
/// The UTC day of a timestamp, counter key.
|
||||
pub fn day(now: i64) -> String {
|
||||
smolboid_core::render_timestamp(now)[..10].to_owned()
|
||||
}
|
||||
|
||||
/// Rolls the daily counters when the day changed.
|
||||
pub fn roll_counters(feed: &mut FeedState, now: i64) {
|
||||
let today = State::day(now);
|
||||
if feed.day != today {
|
||||
feed.day = today;
|
||||
feed.posts = 0;
|
||||
feed.reposts = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rejected mail, identified by signer, payload time and body: an
|
||||
/// identical resubmission is a new envelope with the same payload, so it is
|
||||
/// matched here rather than by message id.
|
||||
pub fn payload_key(sender: &[u8; 32], time: i64, body: &[u8]) -> String {
|
||||
b32(&sha256(&[sender, &time.to_be_bytes(), body]))
|
||||
}
|
||||
|
||||
/// Whether an error reply may still be sent: within the per-sender hourly
|
||||
/// budget and never for a payload already answered. Records the decision.
|
||||
pub fn reply_allowed(state: &mut State, sender: &[u8; 32], payload: &str, now: i64) -> bool {
|
||||
if state.dedupe.iter().any(|seen| seen == payload) {
|
||||
return false;
|
||||
}
|
||||
let sender_b32 = b32(sender);
|
||||
if !state.replies.iter().any(|l| l.sender == sender_b32) {
|
||||
state.replies.push(ReplyLedger {
|
||||
sender: sender_b32.clone(),
|
||||
times: Vec::new(),
|
||||
});
|
||||
}
|
||||
let ledger = state
|
||||
.replies
|
||||
.iter_mut()
|
||||
.find(|l| l.sender == sender_b32)
|
||||
.expect("just pushed");
|
||||
ledger.times.retain(|t| now - t < 3600);
|
||||
if ledger.times.len() >= REPLIES_PER_HOUR {
|
||||
return false;
|
||||
}
|
||||
ledger.times.push(now);
|
||||
state.dedupe.push(payload.to_owned());
|
||||
let excess = state.dedupe.len().saturating_sub(DEDUPE_MAX);
|
||||
state.dedupe.drain(..excess);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn feed(username: &str, key: u8) -> FeedState {
|
||||
FeedState {
|
||||
username: username.into(),
|
||||
address: format!("{username}@smol.place"),
|
||||
owner_keys: vec![b32(&[key; 32])],
|
||||
nick: username.into(),
|
||||
prune: PruneMode::default(),
|
||||
warned: false,
|
||||
day: State::day(0),
|
||||
posts: 0,
|
||||
reposts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_feeds_by_any_rotation_era_key() {
|
||||
let mut fs = feed("tobias", 1);
|
||||
let rotated = b32(&[2u8; 32]);
|
||||
assert!(!fs.owned_by(&[2; 32]));
|
||||
fs.owner_keys.push(rotated);
|
||||
let mut state = State::default();
|
||||
state.feeds.push(fs);
|
||||
assert_eq!(state.feed_of(&[1; 32]), Some(0));
|
||||
assert_eq!(state.feed_of(&[2; 32]), Some(0));
|
||||
assert_eq!(state.feed_of(&[3; 32]), None);
|
||||
assert_eq!(state.feed_by_username("tobias").unwrap().address, "tobias@smol.place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counters_roll_over_at_utc_midnight() {
|
||||
let mut fs = feed("tobias", 1);
|
||||
fs.day = "1970-01-01".into();
|
||||
fs.posts = 9;
|
||||
State::roll_counters(&mut fs, 1_760_000_000);
|
||||
assert_eq!(fs.posts, 0);
|
||||
assert_eq!(fs.day, State::day(1_760_000_000));
|
||||
fs.posts = 1;
|
||||
State::roll_counters(&mut fs, 1_760_000_000);
|
||||
assert_eq!(fs.posts, 1, "same day keeps counters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_budget_and_dedupe() {
|
||||
let mut state = State::default();
|
||||
let sender = [7u8; 32];
|
||||
let payload = payload_key(&sender, 0, b"body");
|
||||
for i in 0..REPLIES_PER_HOUR {
|
||||
let p = payload_key(&sender, i as i64, b"body");
|
||||
assert!(reply_allowed(&mut state, &sender, &p, 1000));
|
||||
}
|
||||
// Budget exhausted for this hour.
|
||||
assert!(!reply_allowed(
|
||||
&mut state,
|
||||
&sender,
|
||||
&payload_key(&sender, 99, b"other"),
|
||||
1000
|
||||
));
|
||||
// An identical payload never draws a second reply, even later.
|
||||
assert!(!reply_allowed(&mut state, &sender, &payload, 5000));
|
||||
// A new hour opens the budget again, for a new payload.
|
||||
assert!(reply_allowed(
|
||||
&mut state,
|
||||
&sender,
|
||||
&payload_key(&sender, 100, b"fresh"),
|
||||
1000 + 3601
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_round_trips_through_json() {
|
||||
let mut state = State::default();
|
||||
state.feeds.push(feed("tobias", 1));
|
||||
let path = std::env::temp_dir().join("smolboid-state-test.json");
|
||||
state.save(&path).unwrap();
|
||||
let back = State::load(&path).unwrap();
|
||||
assert_eq!(back.feeds[0].username, "tobias");
|
||||
assert!(State::load(&std::env::temp_dir().join("smolboid-absent.json"))
|
||||
.unwrap()
|
||||
.feeds
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue