diff --git a/Cargo.lock b/Cargo.lock index 3463679..ee9d658 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,8 +163,10 @@ dependencies = [ "log", "rand_core", "rusqlite", + "serde", "sha2", "snow", + "toml", "x25519-dalek", ] @@ -432,6 +434,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -496,13 +504,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -511,6 +525,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inout" version = "0.1.4" @@ -779,6 +803,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -891,6 +924,47 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.1" @@ -952,6 +1026,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "x25519-dalek" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4544fa1..5c482ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ clap = { version = "4", features = ["derive"] } log = "0.4" env_logger = "0.11" anyhow = "1" +serde = { version = "1", features = ["derive"] } +toml = "0.8" [build-dependencies] cc = { version = "1", optional = true } diff --git a/README.md b/README.md index 6334b56..bd6338d 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,50 @@ Add bunshin as a flake input and import the module: } ``` -`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends` and `rateTokens`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key. +`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends`, `rateTokens` and `domains`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key. For the RNS carrier, build `packages.rns`, set `services.bunshin.package` to it, and enable `services.bunshin.rns` with its `keyFile`; see [RNS.md](RNS.md) for the protocol and the remaining options. +## Multiple domains + +One process can serve several logical servers, each with its own static key, port and mailbox database. bunshin routes by port — the wire protocol has no domain field, and each domain's clients simply pin `user@host → host:port, public key`. How a `user@domain` address resolves to that `host:port` (published addresses, DNS SRV records, a proxy) is outside the server. + +The agreed discovery convention is a DNS SRV record: `_smolmail._tcp.. SRV 0 1 `, lowest priority wins, and no record falls back to the default port 1961. SRV is discovery only — a lying record lands the client on a server with the wrong static key and the handshake aborts, so the pin stays out of band. Client-side only: `fumi` implements the lookup (rev `1468f3c`), pinning by `host:port` with the default port inheriting the bare-host pin and other ports trust-on-first-use; bunshin serves ports and pins keys, and needs nothing for it. + +`serve --config ` takes a TOML file: one `[defaults]` table inherited by every `[domains.]` table (names are 1-63 bytes of `[a-z0-9._-]`). Per domain, `key` and `port` are required; `db` defaults to `mail-.db`, and any of the other settings can be overridden per domain: + +```toml +[defaults] +host = "127.0.0.1" +quota = 67108864 + +[domains.example_org] +key = "/var/lib/bunshin/example_org.key" +port = 11961 + +[domains.example_net] +key = "/var/lib/bunshin/example_net.key" +port = 11962 +invite_token_file = "/run/secrets/example_net-invite" +``` + +Every knob settable on the command line is settable in either table; unknown settings are rejected, not silently defaulted. `--config` cannot be combined with the single-domain flags or the `--rns-*` flags — the RNS carrier is single-domain, so it stays on the legacy flags. All keys are read and all ports bound before any domain serves, so a missing key or a taken port fails the process at startup. + +On NixOS, set `services.bunshin.domains` instead; the module renders the TOML, gives each domain a database under `dataDir`, opens each domain's port with `openFirewall`, and loads per-domain invite token files via `LoadCredential`. The existing flat options keep serving the single-domain case unchanged. + +```nix +services.bunshin = { + enable = true; + domains = { + example_org.keyFile = "/var/lib/bunshin/example_org.key"; + example_org.port = 11961; + example_net.keyFile = "/var/lib/bunshin/example_net.key"; + example_net.port = 11962; + example_net.inviteTokenFile = "/run/keys/example_net-invite"; + }; +}; +``` + Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`: ``` diff --git a/flake.nix b/flake.nix index 71e4afd..4e68097 100644 --- a/flake.nix +++ b/flake.nix @@ -132,11 +132,74 @@ }; keyFile = mkOption { - type = types.path; + type = types.nullOr types.path; + default = null; description = '' Path to the server's static Noise X25519 private key (32 raw bytes, generated with `bunshin keygen`). Provisioned - out of band; this module does not generate it. + out of band; this module does not generate it. Required + when no `domains` are configured; per-domain keys live in + `domains..keyFile` otherwise. + ''; + }; + + domains = mkOption { + type = types.attrsOf (types.submodule { + options = { + keyFile = mkOption { + type = types.path; + description = '' + Path to this domain's static Noise X25519 private key + (32 raw bytes, `bunshin keygen` per domain). + ''; + }; + + port = mkOption { + type = types.port; + description = "TCP port this domain listens on (unique per domain)."; + }; + + host = mkOption { + type = types.nullOr types.str; + default = null; + description = "Address this domain listens on; null inherits the top-level host."; + }; + + dbFile = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Mailbox database path, absolute or relative to + `dataDir`; null defaults to + `/mail-.db`. Databases are never shared + between domains. + ''; + }; + + inviteToken = mkOption { + type = types.nullOr types.str; + default = null; + description = "Registration invite token for this domain; null inherits the top-level token."; + }; + + inviteTokenFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file (readable by the service via + LoadCredential) holding this domain's registration + invite token. Takes precedence over `inviteToken`. + ''; + }; + }; + }); + default = { }; + description = '' + Extra domains served by one process, each with its own + key, port and mailbox database. bunshin routes by port, so + every domain needs a unique port; the top-level settings + act as shared defaults. With domains configured, `keyFile` + and `port` at the top level are unused. ''; }; @@ -316,68 +379,137 @@ assertion = !(cfg.inviteToken != null && cfg.inviteTokenFile != null); message = "services.bunshin: set only one of inviteToken or inviteTokenFile."; } + { + assertion = cfg.domains == { } -> cfg.keyFile != null; + message = "services.bunshin: keyFile is required when no domains are configured; per-domain keys live in domains..keyFile."; + } + { + assertion = cfg.domains == { } || !cfg.rns.enable; + message = "services.bunshin: the RNS carrier is single-domain and cannot be combined with domains."; + } + { + assertion = lib.length (lib.unique (lib.mapAttrsToList (_: d: d.port) cfg.domains)) + == lib.length (lib.attrValues cfg.domains); + message = "services.bunshin.domains: every domain needs a unique port; bunshin routes by port."; + } + { + assertion = lib.all (d: !(d.inviteToken != null && d.inviteTokenFile != null)) + (lib.attrValues cfg.domains); + message = "services.bunshin.domains: set only one of inviteToken or inviteTokenFile per domain."; + } ]; - systemd.services.bunshin = { - description = "bunshin Smol Mail server"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; + systemd.services.bunshin = + let + tomlFormat = pkgs.formats.toml { }; + # Rendered into the store without secrets: invite tokens + # travel through LoadCredential paths, never the file. + tomlFile = tomlFormat.generate "bunshin.toml" { + defaults = { + host = cfg.host; + max_envelope = cfg.maxEnvelope; + quota = cfg.quota; + requests_quota = cfg.requestsQuota; + retention_days = cfg.retentionDays; + requests_retention_days = cfg.requestsRetentionDays; + max_tokens = cfg.maxTokens; + rate_connections = cfg.rateConnections; + rate_sends = cfg.rateSends; + rate_tokens = cfg.rateTokens; + } + // lib.optionalAttrs (cfg.inviteToken != null) { invite_token = cfg.inviteToken; } + // lib.optionalAttrs (cfg.inviteTokenFile != null) { + invite_token_file = "/run/credentials/bunshin.service/invite-token"; + }; + domains = lib.mapAttrs (name: d: { + key = toString d.keyFile; + port = d.port; + # Always absolute: the service has no working directory. + db = if d.dbFile != null && lib.hasPrefix "/" d.dbFile + then d.dbFile + else "${cfg.dataDir}/${if d.dbFile != null then d.dbFile else "mail-${name}.db"}"; + } + // lib.optionalAttrs (d.host != null) { host = d.host; } + // lib.optionalAttrs (d.inviteToken != null) { invite_token = d.inviteToken; } + // lib.optionalAttrs (d.inviteTokenFile != null) { + invite_token_file = "/run/credentials/bunshin.service/invite-${name}"; + }) cfg.domains; + }; + domainCredentials = lib.concatLists (lib.mapAttrsToList + (name: d: lib.optional (d.inviteTokenFile != null) + "invite-${name}:${toString d.inviteTokenFile}") + cfg.domains); + in + { + description = "bunshin Smol Mail server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; - serviceConfig = { - ExecStart = pkgs.writeShellScript "bunshin-serve" '' - set -euo pipefail - args=( - serve - --key ${cfg.keyFile} - --db ${cfg.dataDir}/mail.db - --host ${cfg.host} - --port ${toString cfg.port} - --max-envelope ${toString cfg.maxEnvelope} - --quota ${toString cfg.quota} - --requests-quota ${toString cfg.requestsQuota} - --retention-days ${toString cfg.retentionDays} - --requests-retention-days ${toString cfg.requestsRetentionDays} - --max-tokens ${toString cfg.maxTokens} - --rate-connections ${toString cfg.rateConnections} - --rate-sends ${toString cfg.rateSends} - --rate-tokens ${toString cfg.rateTokens} - ) - ${lib.optionalString cfg.rns.enable '' - args+=( - --rns - --rns-key ${cfg.rns.keyFile} - --rns-max-envelope ${toString cfg.rns.maxEnvelope} - --rns-fetch-budget ${toString cfg.rns.fetchBudget} - --rns-max-links ${toString cfg.rns.maxLinks} - --rns-rate-link-requests ${toString cfg.rns.rateLinkRequests} - --rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes} - --rns-link-idle ${toString cfg.rns.linkIdleSecs} - --rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort} + serviceConfig = { + ExecStart = if cfg.domains == { } then pkgs.writeShellScript "bunshin-serve" '' + set -euo pipefail + args=( + serve + --key ${cfg.keyFile} + --db ${cfg.dataDir}/mail.db + --host ${cfg.host} + --port ${toString cfg.port} + --max-envelope ${toString cfg.maxEnvelope} + --quota ${toString cfg.quota} + --requests-quota ${toString cfg.requestsQuota} + --retention-days ${toString cfg.retentionDays} + --requests-retention-days ${toString cfg.requestsRetentionDays} + --max-tokens ${toString cfg.maxTokens} + --rate-connections ${toString cfg.rateConnections} + --rate-sends ${toString cfg.rateSends} + --rate-tokens ${toString cfg.rateTokens} ) - ''} - ${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null) - ''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''} - ${lib.optionalString cfg.verbose - ''args+=(--verbose)''} - ${lib.optionalString (cfg.inviteToken != null) - ''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} - ${lib.optionalString (cfg.inviteTokenFile != null) - ''args+=(--invite-token "$(cat "$CREDENTIALS_DIRECTORY/invite-token")")''} - exec ${cfg.package}/bin/bunshin "''${args[@]}" - ''; - DynamicUser = true; - StateDirectory = "bunshin"; - StateDirectoryMode = "0700"; - Restart = "on-failure"; - } // lib.optionalAttrs (cfg.inviteTokenFile != null) { - LoadCredential = "invite-token:${cfg.inviteTokenFile}"; + ${lib.optionalString cfg.rns.enable '' + args+=( + --rns + --rns-key ${cfg.rns.keyFile} + --rns-max-envelope ${toString cfg.rns.maxEnvelope} + --rns-fetch-budget ${toString cfg.rns.fetchBudget} + --rns-max-links ${toString cfg.rns.maxLinks} + --rns-rate-link-requests ${toString cfg.rns.rateLinkRequests} + --rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes} + --rns-link-idle ${toString cfg.rns.linkIdleSecs} + --rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort} + ) + ''} + ${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null) + ''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''} + ${lib.optionalString cfg.verbose + ''args+=(--verbose)''} + ${lib.optionalString (cfg.inviteToken != null) + ''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} + ${lib.optionalString (cfg.inviteTokenFile != null) + ''args+=(--invite-token "$(cat "$CREDENTIALS_DIRECTORY/invite-token")")''} + exec ${cfg.package}/bin/bunshin "''${args[@]}" + '' else pkgs.writeShellScript "bunshin-serve" '' + set -euo pipefail + args=(serve --config ${tomlFile}) + ${lib.optionalString cfg.verbose + ''args+=(--verbose)''} + exec ${cfg.package}/bin/bunshin "''${args[@]}" + ''; + DynamicUser = true; + StateDirectory = "bunshin"; + StateDirectoryMode = "0700"; + Restart = "on-failure"; + } // lib.optionalAttrs (cfg.inviteTokenFile != null || domainCredentials != [ ]) { + LoadCredential = lib.optionals (cfg.inviteTokenFile != null) + [ "invite-token:${cfg.inviteTokenFile}" ] ++ domainCredentials; + }; }; - }; # RNS needs no TCP port; only its UDP interface may be opened. networking.firewall.allowedUDPPorts = mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ]; - networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + # In domain mode only the domain ports are listened on. + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall + (if cfg.domains == { } then [ cfg.port ] + else lib.mapAttrsToList (_: d: d.port) cfg.domains); }; }; }; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a4c94d9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,332 @@ +//! Multi-domain TOML configuration: one `[defaults]` table inherited by +//! every `[domains.]` table. +//! +//! bunshin routes by port, not by name: a domain is a listener with its own +//! static key, mailbox database and policy knobs. Names exist for logs, +//! defaults inheritance and the derived database path. Per-domain settings +//! that matter are `key` and `port` (required), the database, the invite +//! token and the quota/retention knobs; the remaining knobs are operational +//! and belong in `[defaults]`. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::proto::valid_username; + +// Fallbacks mirroring the CLI flags' defaults. +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_MAX_ENVELOPE: usize = 768 << 10; +const DEFAULT_QUOTA: i64 = 64 << 20; +const DEFAULT_REQUESTS_QUOTA: i64 = 2 << 20; +const DEFAULT_RETENTION_DAYS: i64 = 30; +const DEFAULT_REQUESTS_RETENTION_DAYS: i64 = 7; +const DEFAULT_MAX_TOKENS: u16 = 1024; +const DEFAULT_RATE_CONNECTIONS: u32 = 120; +const DEFAULT_RATE_SENDS: u32 = 60; +const DEFAULT_RATE_TOKENS: u32 = 30; + +/// One resolved domain, ready to serve. +pub struct DomainConfig { + pub name: String, + pub key_path: String, + pub db_path: String, + pub host: String, + pub port: u16, + pub max_envelope: usize, + pub quota: i64, + pub requests_quota: i64, + pub retention_days: i64, + pub requests_retention_days: i64, + pub max_tokens: u16, + pub invite_token: Option>, + pub rate_connections: u32, + pub rate_sends: u32, + pub rate_tokens: u32, +} + +/// The union of every setting either table accepts; `deny_unknown_fields` +/// catches typos, which on a quota would otherwise silently fall back to a +/// built-in default. The same shape serves `[defaults]` and each domain: the +/// presence checks in `load` tell them apart. +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct Table { + key: Option, + port: Option, + host: Option, + db: Option, + max_envelope: Option, + quota: Option, + requests_quota: Option, + retention_days: Option, + requests_retention_days: Option, + max_tokens: Option, + invite_token: Option, + invite_token_file: Option, + rate_connections: Option, + rate_sends: Option, + rate_tokens: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigFile { + #[serde(default)] + defaults: Table, + domains: BTreeMap, +} + +/// Resolves `invite_token`/`invite_token_file` into one token; exactly one +/// may be set, or the mailbox is open to registration. +fn invite_of(table: &Table, ctx: &str) -> anyhow::Result>> { + anyhow::ensure!( + table.invite_token.is_none() || table.invite_token_file.is_none(), + "{ctx}: set only one of invite_token or invite_token_file" + ); + if let Some(token) = &table.invite_token { + return Ok(Some(token.clone().into_bytes())); + } + if let Some(path) = &table.invite_token_file { + return std::fs::read(path) + .map(Some) + .map_err(|e| anyhow::anyhow!("{ctx}: cannot read invite token file {path}: {e}")); + } + Ok(None) +} + +/// Loads and validates a config file. Every key file must exist and every +/// port must be bindable before any domain serves, so validation here is +/// about the file's own consistency; key readability is checked by the +/// server before it binds anything. +pub fn load(path: &str) -> anyhow::Result> { + let raw = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?; + let file: ConfigFile = + toml::from_str(&raw).map_err(|e| anyhow::anyhow!("invalid config {path}: {e}"))?; + + anyhow::ensure!( + !file.domains.is_empty(), + "{path}: no [domains.] tables" + ); + anyhow::ensure!( + file.defaults.key.is_none() && file.defaults.port.is_none(), + "{path}: [defaults] cannot set key or port" + ); + let default_invite = invite_of(&file.defaults, "[defaults]")?; + + let mut seen_ports = std::collections::HashSet::new(); + let mut seen_dbs = std::collections::HashSet::new(); + let mut domains = Vec::with_capacity(file.domains.len()); + for (name, table) in &file.domains { + anyhow::ensure!( + valid_username(name), + "{path}: domain name {name} must be 1-63 bytes of [a-z0-9._-]" + ); + let (key, port) = match (&table.key, table.port) { + (Some(key), Some(port)) => (key.clone(), port), + _ => anyhow::bail!("{path}: domains.{name} must set both key and port"), + }; + let host = table + .host + .clone() + .or_else(|| file.defaults.host.clone()) + .unwrap_or_else(|| DEFAULT_HOST.to_string()); + anyhow::ensure!( + seen_ports.insert((host.clone(), port)), + "{path}: domains.{name} reuses {host}:{port}" + ); + let db_path = table + .db + .clone() + .or_else(|| file.defaults.db.clone()) + .unwrap_or_else(|| format!("mail-{name}.db")); + anyhow::ensure!( + seen_dbs.insert(db_path.clone()), + "{path}: domains.{name} shares database {db_path} with another domain" + ); + + let invite = invite_of(table, &format!("domains.{name}"))?.or(default_invite.clone()); + domains.push(DomainConfig { + name: name.clone(), + key_path: key, + db_path, + host, + port, + max_envelope: table + .max_envelope + .or(file.defaults.max_envelope) + .unwrap_or(DEFAULT_MAX_ENVELOPE), + quota: table.quota.or(file.defaults.quota).unwrap_or(DEFAULT_QUOTA), + requests_quota: table + .requests_quota + .or(file.defaults.requests_quota) + .unwrap_or(DEFAULT_REQUESTS_QUOTA), + retention_days: table + .retention_days + .or(file.defaults.retention_days) + .unwrap_or(DEFAULT_RETENTION_DAYS), + requests_retention_days: table + .requests_retention_days + .or(file.defaults.requests_retention_days) + .unwrap_or(DEFAULT_REQUESTS_RETENTION_DAYS), + max_tokens: table + .max_tokens + .or(file.defaults.max_tokens) + .unwrap_or(DEFAULT_MAX_TOKENS), + invite_token: invite, + rate_connections: table + .rate_connections + .or(file.defaults.rate_connections) + .unwrap_or(DEFAULT_RATE_CONNECTIONS), + rate_sends: table + .rate_sends + .or(file.defaults.rate_sends) + .unwrap_or(DEFAULT_RATE_SENDS), + rate_tokens: table + .rate_tokens + .or(file.defaults.rate_tokens) + .unwrap_or(DEFAULT_RATE_TOKENS), + }); + } + Ok(domains) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_config(name: &str, body: &str) -> String { + let path = std::env::temp_dir() + .join(format!("bunshin-config-{name}-{}.toml", std::process::id())) + .to_string_lossy() + .into_owned(); + std::fs::write(&path, body).unwrap(); + path + } + + #[test] + fn minimal_domain_uses_built_in_defaults() { + let path = write_config("minimal", "[domains.example]\nkey = \"k\"\nport = 1961\n"); + let domains = load(&path).unwrap(); + assert_eq!(domains.len(), 1); + let d = &domains[0]; + assert_eq!(d.name, "example"); + assert_eq!(d.key_path, "k"); + assert_eq!(d.db_path, "mail-example.db"); + assert_eq!(d.host, "127.0.0.1"); + assert_eq!(d.max_envelope, 768 << 10); + assert_eq!(d.quota, 64 << 20); + assert_eq!(d.rate_tokens, 30); + assert!(d.invite_token.is_none()); + } + + #[test] + fn domain_overrides_inherit_from_defaults() { + let path = write_config( + "inherit", + "[defaults]\nquota = 1000\nhost = \"0.0.0.0\"\nrate_sends = 5\n\ + [domains.a]\nkey = \"ka\"\nport = 1\nquota = 2000\n\ + [domains.b]\nkey = \"kb\"\nport = 2\n", + ); + let domains = load(&path).unwrap(); + assert_eq!(domains[0].quota, 2000); + assert_eq!(domains[0].host, "0.0.0.0"); + assert_eq!(domains[0].rate_sends, 5); + assert_eq!(domains[1].quota, 1000); + assert_eq!(domains[1].host, "0.0.0.0"); + } + + #[test] + fn unknown_setting_is_rejected() { + let path = write_config("typo", "[domains.a]\nkey = \"k\"\nport = 1\nqouta = 5\n"); + assert!(load(&path).is_err()); + } + + #[test] + fn duplicate_port_is_rejected() { + let path = write_config( + "dup-port", + "[domains.a]\nkey = \"ka\"\nport = 1\n\ + [domains.b]\nkey = \"kb\"\nport = 1\n", + ); + assert!(load(&path).is_err()); + } + + #[test] + fn duplicate_port_on_different_hosts_is_allowed() { + let path = write_config( + "dup-port-hosts", + "[domains.a]\nkey = \"ka\"\nport = 1\n\ + [domains.b]\nkey = \"kb\"\nport = 1\nhost = \"127.0.0.2\"\n", + ); + assert!(load(&path).is_ok()); + } + + #[test] + fn shared_database_is_rejected() { + let path = write_config( + "dup-db", + "[domains.a]\nkey = \"ka\"\nport = 1\ndb = \"shared.db\"\n\ + [domains.b]\nkey = \"kb\"\nport = 2\ndb = \"shared.db\"\n", + ); + assert!(load(&path).is_err()); + } + + #[test] + fn invalid_domain_name_is_rejected() { + let path = write_config("bad-name", "[domains.Example]\nkey = \"k\"\nport = 1\n"); + assert!(load(&path).is_err()); + } + + #[test] + fn defaults_cannot_set_key_or_port() { + let path = write_config( + "defaults-port", + "[defaults]\nport = 1\n[domains.a]\nkey = \"k\"\nport = 1\n", + ); + assert!(load(&path).is_err()); + } + + #[test] + fn missing_key_or_port_is_rejected() { + let no_port = write_config("no-port", "[domains.a]\nkey = \"k\"\n"); + assert!(load(&no_port).is_err()); + let no_key = write_config("no-key", "[domains.a]\nport = 1\n"); + assert!(load(&no_key).is_err()); + } + + #[test] + fn empty_config_is_rejected() { + let path = write_config("empty", "[defaults]\nquota = 1\n"); + assert!(load(&path).is_err()); + } + + #[test] + fn invite_token_and_file_together_are_rejected() { + let path = write_config( + "invite-conflict", + "[domains.a]\nkey = \"k\"\nport = 1\n\ + invite_token = \"t\"\ninvite_token_file = \"f\"\n", + ); + assert!(load(&path).is_err()); + } + + #[test] + fn invite_token_file_is_read() { + let token_path = std::env::temp_dir() + .join(format!("bunshin-config-token-{}.txt", std::process::id())) + .to_string_lossy() + .into_owned(); + std::fs::write(&token_path, "sekrit").unwrap(); + let path = write_config( + "invite-file", + &format!("[domains.a]\nkey = \"k\"\nport = 1\ninvite_token_file = \"{token_path}\"\n"), + ); + let domains = load(&path).unwrap(); + assert_eq!( + domains[0].invite_token.as_deref(), + Some(b"sekrit".as_slice()) + ); + } +} diff --git a/src/harness.rs b/src/harness.rs index a8959a3..7bcbbbe 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -20,9 +20,9 @@ use snow::{Builder, TransportState}; use crate::crypto::generate_static_key; use crate::proto::{ - AUTH_REQUIRED, ENVELOPE_MAGIC, ENVELOPE_VERSION, ID_LEN, LABEL_AUTH, LABEL_REGISTER, - MALFORMED, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, OP_AUTH, OP_FETCH, OP_REGISTER, OP_RESOLVE, - OP_SEND, PROLOGUE, TOO_LARGE, + AUTH_REQUIRED, ENVELOPE_MAGIC, ENVELOPE_VERSION, ID_LEN, LABEL_AUTH, LABEL_REGISTER, MALFORMED, + MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, OP_AUTH, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, + PROLOGUE, TOO_LARGE, UNKNOWN_USER, }; use crate::ratelimit::RateLimiter; use crate::server::handle_connection; @@ -70,13 +70,7 @@ impl Target { // The harness deliberately produces hostile connections; // a panic in one must not take the accept loop with it. let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - handle_connection( - stream, - config, - &static_private, - &db_path, - "127.0.0.1", - ) + handle_connection(stream, config, &static_private, &db_path, "127.0.0.1") })); }); } @@ -92,7 +86,8 @@ impl Target { fn assert_alive(&self) { let mut client = Client::connect(self.port); client.handshake().expect("server survived"); - let (status, _) = client.request(OP_RESOLVE, &[7, b'h', b'a', b'r', b'n', b'e', b's', b's']); + let (status, _) = + client.request(OP_RESOLVE, &[7, b'h', b'a', b'r', b'n', b'e', b's', b's']); assert_ne!(status, 0xFF, "server answered with a status byte"); } } @@ -143,7 +138,9 @@ impl Client { let mut packet = vec![0u8; payload.len() + 16]; let n = transport.write_message(payload, &mut packet).unwrap(); packet.truncate(n); - self.stream.write_all(&(packet.len() as u16).to_be_bytes()).unwrap(); + self.stream + .write_all(&(packet.len() as u16).to_be_bytes()) + .unwrap(); self.stream.write_all(&packet).unwrap(); } @@ -226,7 +223,10 @@ fn short_garbage_handshake_is_refused_and_server_survives() { client.write_raw(&(10u16).to_be_bytes()); client.write_raw(&[0xA5; 10]); let mut sink = [0u8; 16]; - assert!(client.stream.read(&mut sink).unwrap_or(0) == 0, "server closed"); + assert!( + client.stream.read(&mut sink).unwrap_or(0) == 0, + "server closed" + ); target.assert_alive(); } @@ -299,7 +299,10 @@ fn zero_length_frame_gets_malformed_then_close() { assert_eq!(op, 0); assert_eq!(body[0], MALFORMED); let mut sink = [0u8; 16]; - assert!(client.stream.read(&mut sink).unwrap_or(0) == 0, "closed after bad frame"); + assert!( + client.stream.read(&mut sink).unwrap_or(0) == 0, + "closed after bad frame" + ); target.assert_alive(); } @@ -414,7 +417,11 @@ fn registered_flow_survives_absurd_future_cursor() { let (op, resp) = client.request(OP_FETCH, &body); assert_eq!(op, OP_FETCH); assert_eq!(resp[0], 0, "status OK"); - assert_eq!(u16::from_be_bytes(resp[1..3].try_into().unwrap()), 0, "empty page"); + assert_eq!( + u16::from_be_bytes(resp[1..3].try_into().unwrap()), + 0, + "empty page" + ); // And the registered name resolves — the store came through intact. let (op, resp) = client.request(OP_RESOLVE, &[7, b'h', b'a', b'r', b'n', b'e', b's', b's']); @@ -423,7 +430,6 @@ fn registered_flow_survives_absurd_future_cursor() { target.assert_alive(); } - // --------------------------------------------------------------------------- // Long-lived multi-session driver: the last agreed stress-campaign phase. // A dozen authenticated sessions live on one server simultaneously, run @@ -615,3 +621,108 @@ fn partial_frame_is_dropped_after_the_header_deadline() { ); target.assert_alive(); } + +// --------------------------------------------------------------------------- +// Multi-domain: one process, one listener/key/database per domain. The +// well-behaved client drives the real serve_domains path (config file, real +// key files, real listeners) and must see two isolated namespaces. + +/// Reserves a free localhost port by binding to :0 and dropping the socket. +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Connects to a domain's listener, tolerating the window between +/// serve_domains starting and binding its socket. +fn connect_domain(port: u16) -> TcpStream { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match TcpStream::connect(("127.0.0.1", port)) { + Ok(stream) => return stream, + Err(_) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Err(e) => panic!("cannot connect to domain port {port}: {e}"), + } + } +} + +#[test] +fn domains_isolate_namespaces() { + let dir = std::env::temp_dir() + .join(format!("bunshin-domains-{}", std::process::id())) + .join("isolate"); + std::fs::create_dir_all(&dir).unwrap(); + let (key_a, pub_a) = generate_static_key(); + let (key_b, _pub_b) = generate_static_key(); + std::fs::write(dir.join("a.key"), key_a).unwrap(); + std::fs::write(dir.join("b.key"), key_b).unwrap(); + let port_a = free_port(); + let port_b = free_port(); + let config_path = dir.join("bunshin.toml"); + std::fs::write( + &config_path, + format!( + "[domains.a]\nkey = \"{}\"\nport = {port_a}\ndb = \"{}\"\n\ + [domains.b]\nkey = \"{}\"\nport = {port_b}\ndb = \"{}\"\n", + dir.join("a.key").display(), + dir.join("a.db").display(), + dir.join("b.key").display(), + dir.join("b.db").display() + ), + ) + .unwrap(); + + let domains = crate::config::load(config_path.to_str().unwrap()).unwrap(); + std::thread::spawn(move || crate::server::serve_domains(domains).unwrap()); + + let register = |mut client: Client, public: &[u8; 32]| { + client.handshake().unwrap(); + let key = SigningKey::from_bytes(&[11u8; 32]); + let verifying = key.verifying_key(); + let identity: &[u8] = verifying.as_bytes(); + let mut pop = LABEL_REGISTER.to_vec(); + pop.extend_from_slice(public); + pop.extend_from_slice(b"alice"); + pop.extend_from_slice(identity); + let mut body = vec![5]; + body.extend_from_slice(b"alice"); + body.extend_from_slice(identity); + body.extend_from_slice(&key.sign(&pop).to_bytes()); + body.push(0); + body.push(0); + let (op, resp) = client.request(OP_REGISTER, &body); + assert_eq!((op, resp[0]), (OP_REGISTER, 0), "REGISTER accepted"); + client + }; + + // alice exists only on domain a; the same wire bytes against domain b + // must find no such user. + let mut client = register( + Client { + stream: connect_domain(port_a), + transport: None, + handshake_hash: Vec::new(), + }, + &pub_a, + ); + let (op, resp) = client.request(OP_RESOLVE, &[5, b'a', b'l', b'i', b'c', b'e']); + assert_eq!((op, resp[0]), (OP_RESOLVE, 0), "RESOLVE on own domain"); + + let mut client = Client { + stream: connect_domain(port_b), + transport: None, + handshake_hash: Vec::new(), + }; + client.handshake().unwrap(); + let (op, resp) = client.request(OP_RESOLVE, &[5, b'a', b'l', b'i', b'c', b'e']); + assert_eq!( + (op, resp[0]), + (OP_RESOLVE, UNKNOWN_USER), + "b has no alice: databases are isolated" + ); +} diff --git a/src/main.rs b/src/main.rs index 5ec7d66..dda8f6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod bind; mod channel; +mod config; mod crypto; #[cfg(test)] mod harness; @@ -42,6 +43,11 @@ enum Command { }, /// Run the mailbox server Serve { + /// Serve one domain per [domains.] table; every domain has its + /// own key, port and mailbox database + #[arg(long, conflicts_with_all = ["key", "db", "host", "port", "max_envelope", "quota", "requests_quota", "retention_days", "requests_retention_days", "max_tokens", "invite_token", "rate_connections", "rate_sends", "rate_tokens"])] + #[cfg_attr(feature = "rns", arg(conflicts_with_all = ["enabled", "rns_key", "rns_max_envelope", "rns_fetch_budget", "rns_max_links", "rns_rate_link_requests", "rns_rate_link_bytes", "rns_link_idle", "rns_udp", "rns_udp_forward"]))] + config: Option, #[arg(long, default_value = "server.key")] key: String, #[arg(long, default_value = "mail.db")] @@ -132,6 +138,7 @@ fn main() -> anyhow::Result<()> { Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force), #[cfg(not(feature = "rns"))] Command::Serve { + config, key, db, host, @@ -146,24 +153,30 @@ fn main() -> anyhow::Result<()> { rate_connections, rate_sends, rate_tokens, - } => server::run(server::ServeArgs { - key_path: key, - db_path: db, - host, - port, - max_envelope, - quota, - requests_quota, - retention_days, - requests_retention_days, - max_tokens, - invite_token, - rate_connections, - rate_sends, - rate_tokens, - }), + } => { + if let Some(path) = config { + return server::serve_domains(config::load(&path)?); + } + server::run(server::ServeArgs { + key_path: key, + db_path: db, + host, + port, + max_envelope, + quota, + requests_quota, + retention_days, + requests_retention_days, + max_tokens, + invite_token, + rate_connections, + rate_sends, + rate_tokens, + }) + } #[cfg(feature = "rns")] Command::Serve { + config, key, db, host, @@ -180,6 +193,9 @@ fn main() -> anyhow::Result<()> { rate_tokens, rns, } => { + if let Some(path) = config { + return server::serve_domains(config::load(&path)?); + } // One process serves both carriers (RNS.md sec 7.4): shared // Store, shared config, single purge loop in server::run. if rns.enabled { diff --git a/src/server.rs b/src/server.rs index 50389ca..b0bd510 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,11 +1,12 @@ //! TCP accept loop, per-connection handling, and the background purge loop. -use std::net::TcpStream; +use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::time::Duration; use crate::bind::TransportBindValues; use crate::channel::{handshake, Channel}; +use crate::config::DomainConfig; use crate::crypto::{b32, derive_public}; use crate::proto::{FETCH_BUDGET, HEADER_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS}; use crate::ratelimit::RateLimiter; @@ -29,41 +30,97 @@ pub struct ServeArgs { pub rate_tokens: u32, } +/// The single-domain CLI path: one anonymous domain. pub fn run(args: ServeArgs) -> anyhow::Result<()> { - let static_key = std::fs::read(&args.key_path) - .map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?; - anyhow::ensure!( - static_key.len() == KEY_LEN, - "server key must be {KEY_LEN} raw bytes, got {}", - static_key.len() - ); - - let key_array: [u8; KEY_LEN] = static_key.clone().try_into().unwrap(); - let server_static = derive_public(&key_array); - - let config = Arc::new(ServerConfig { + serve_domains(vec![DomainConfig { + name: "default".to_string(), + key_path: args.key_path, + db_path: args.db_path, + host: args.host, + port: args.port, max_envelope: args.max_envelope, - fetch_budget: FETCH_BUDGET, - main_quota: args.quota, + quota: args.quota, requests_quota: args.requests_quota, + retention_days: args.retention_days, + requests_retention_days: args.requests_retention_days, max_tokens: args.max_tokens, invite_token: args.invite_token.map(String::into_bytes), - conn_limiter: RateLimiter::new(args.rate_connections), - send_limiter: RateLimiter::new(args.rate_sends), - token_limiter: RateLimiter::new(args.rate_tokens), - }); + rate_connections: args.rate_connections, + rate_sends: args.rate_sends, + rate_tokens: args.rate_tokens, + }]) +} - let main_retention_secs = args.retention_days * 86400; - let requests_retention_secs = args.requests_retention_days * 86400; - let purge_db_path = args.db_path.clone(); - std::thread::spawn(move || { - purge_loop(purge_db_path, main_retention_secs, requests_retention_secs) - }); +/// Serves every domain: one listener, key, mailbox database, config and +/// purge loop per domain, nothing shared between them. +pub fn serve_domains(domains: Vec) -> anyhow::Result<()> { + // Read every key and bind every listener first, so a missing key or a + // taken port fails the whole process before any domain serves. + let mut bound = Vec::with_capacity(domains.len()); + for domain in &domains { + let static_key = read_static_key(&domain.key_path)?; + let listener = TcpListener::bind((domain.host.as_str(), domain.port))?; + let config = Arc::new(ServerConfig { + max_envelope: domain.max_envelope, + fetch_budget: FETCH_BUDGET, + main_quota: domain.quota, + requests_quota: domain.requests_quota, + max_tokens: domain.max_tokens, + invite_token: domain.invite_token.clone(), + conn_limiter: RateLimiter::new(domain.rate_connections), + send_limiter: RateLimiter::new(domain.rate_sends), + token_limiter: RateLimiter::new(domain.rate_tokens), + }); + bound.push((domain, static_key, listener, config)); + } - let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?; - log::info!("listening on {}:{}", args.host, args.port); - log::info!("server public key: {}", b32(&server_static)); + for (domain, static_key, listener, config) in bound { + log::info!( + "domain {}: listening on {}:{}", + domain.name, + domain.host, + domain.port + ); + log::info!( + "domain {}: server public key: {}", + domain.name, + b32(&derive_public(&static_key)) + ); + let purge_db_path = domain.db_path.clone(); + let main_retention_secs = domain.retention_days * 86400; + let requests_retention_secs = domain.requests_retention_days * 86400; + std::thread::spawn(move || { + purge_loop(purge_db_path, main_retention_secs, requests_retention_secs) + }); + + let db_path = domain.db_path.clone(); + std::thread::spawn(move || accept_loop(listener, config, static_key, db_path)); + } + + // Each domain's accept loop runs in its own thread; nothing fails here. + loop { + std::thread::sleep(Duration::from_secs(3600)); + } +} + +fn read_static_key(path: &str) -> anyhow::Result<[u8; KEY_LEN]> { + let key = + std::fs::read(path).map_err(|e| anyhow::anyhow!("cannot read server key {path}: {e}"))?; + anyhow::ensure!( + key.len() == KEY_LEN, + "server key {path} must be {KEY_LEN} raw bytes, got {}", + key.len() + ); + Ok(key.try_into().unwrap()) +} + +fn accept_loop( + listener: TcpListener, + config: Arc, + static_key: [u8; KEY_LEN], + db_path: String, +) { for incoming in listener.incoming() { let stream = match incoming { Ok(s) => s, @@ -83,15 +140,13 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> { } let config = Arc::clone(&config); - let static_key = key_array; - let db_path = args.db_path.clone(); + let db_path = db_path.clone(); std::thread::spawn(move || { if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) { log::info!("connection error from {peer_ip}: {e}"); } }); } - Ok(()) } /// Also the hostile harness's entry point: it drives real connections