diff --git a/Cargo.lock b/Cargo.lock index ee9d658..3463679 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,10 +163,8 @@ dependencies = [ "log", "rand_core", "rusqlite", - "serde", "sha2", "snow", - "toml", "x25519-dalek", ] @@ -434,12 +432,6 @@ 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" @@ -504,19 +496,13 @@ 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 0.14.5", + "hashbrown", ] [[package]] @@ -525,16 +511,6 @@ 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" @@ -803,15 +779,6 @@ 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" @@ -924,47 +891,6 @@ 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" @@ -1026,15 +952,6 @@ 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 5c482ce..4544fa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,6 @@ 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 7990e1f..fcfed78 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 分身 bunshin -[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: auto](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-auto-ede9fe?labelColor=ede9fe)](https://ai-declaration.md) ![Nix Flake](https://img.shields.io/badge/Nix-Flake-5277C3?logo=nixos&logoColor=white) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: auto](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-auto-ede9fe?labelColor=ede9fe)](https://ai-declaration.md) A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection, with an optional Reticulum (RNS) mesh carrier behind the `rns` build feature. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client. @@ -34,48 +34,10 @@ 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`, `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. +`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. 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. - -`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 4e68097..71e4afd 100644 --- a/flake.nix +++ b/flake.nix @@ -132,74 +132,11 @@ }; keyFile = mkOption { - type = types.nullOr types.path; - default = null; + type = types.path; 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. 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. + out of band; this module does not generate it. ''; }; @@ -379,137 +316,68 @@ 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 = - 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" ]; + systemd.services.bunshin = { + description = "bunshin Smol Mail server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; - 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} + 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} ) - ${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; - }; + ''} + ${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}"; }; + }; # RNS needs no TCP port; only its UDP interface may be opened. networking.firewall.allowedUDPPorts = mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ]; - # 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); + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; }; }; }; diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index a4c94d9..0000000 --- a/src/config.rs +++ /dev/null @@ -1,332 +0,0 @@ -//! 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 7bcbbbe..a8959a3 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, UNKNOWN_USER, + 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, }; use crate::ratelimit::RateLimiter; use crate::server::handle_connection; @@ -70,7 +70,13 @@ 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", + ) })); }); } @@ -86,8 +92,7 @@ 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"); } } @@ -138,9 +143,7 @@ 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(); } @@ -223,10 +226,7 @@ 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,10 +299,7 @@ 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(); } @@ -417,11 +414,7 @@ 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']); @@ -430,6 +423,7 @@ 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 @@ -621,108 +615,3 @@ 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 dda8f6c..5ec7d66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod bind; mod channel; -mod config; mod crypto; #[cfg(test)] mod harness; @@ -43,11 +42,6 @@ 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")] @@ -138,7 +132,6 @@ fn main() -> anyhow::Result<()> { Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force), #[cfg(not(feature = "rns"))] Command::Serve { - config, key, db, host, @@ -153,30 +146,24 @@ fn main() -> anyhow::Result<()> { 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, - }) - } + } => 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, @@ -193,9 +180,6 @@ 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 b0bd510..50389ca 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,12 +1,11 @@ //! TCP accept loop, per-connection handling, and the background purge loop. -use std::net::{TcpListener, TcpStream}; +use std::net::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; @@ -30,97 +29,41 @@ pub struct ServeArgs { pub rate_tokens: u32, } -/// The single-domain CLI path: one anonymous domain. pub fn run(args: ServeArgs) -> anyhow::Result<()> { - serve_domains(vec![DomainConfig { - name: "default".to_string(), - key_path: args.key_path, - db_path: args.db_path, - host: args.host, - port: args.port, + 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 { max_envelope: args.max_envelope, - quota: args.quota, + fetch_budget: FETCH_BUDGET, + main_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), - rate_connections: args.rate_connections, - rate_sends: args.rate_sends, - rate_tokens: args.rate_tokens, - }]) -} + conn_limiter: RateLimiter::new(args.rate_connections), + send_limiter: RateLimiter::new(args.rate_sends), + token_limiter: RateLimiter::new(args.rate_tokens), + }); -/// 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 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) + }); - 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 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)); - 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, @@ -140,13 +83,15 @@ fn accept_loop( } let config = Arc::clone(&config); - let db_path = db_path.clone(); + let static_key = key_array; + let db_path = args.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