feat: serve multiple domains with per-domain keys, ports and databases

This commit is contained in:
randogoth 2026-09-30 12:57:44 +03:00
parent 931933c423
commit dfb57b265c
8 changed files with 891 additions and 120 deletions

85
Cargo.lock generated
View file

@ -163,8 +163,10 @@ dependencies = [
"log", "log",
"rand_core", "rand_core",
"rusqlite", "rusqlite",
"serde",
"sha2", "sha2",
"snow", "snow",
"toml",
"x25519-dalek", "x25519-dalek",
] ]
@ -432,6 +434,12 @@ dependencies = [
"log", "log",
] ]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@ -496,13 +504,19 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.9.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.14.5",
] ]
[[package]] [[package]]
@ -511,6 +525,16 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 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]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@ -779,6 +803,15 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -891,6 +924,47 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@ -952,6 +1026,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "x25519-dalek" name = "x25519-dalek"
version = "2.0.1" version = "2.0.1"

View file

@ -26,6 +26,8 @@ clap = { version = "4", features = ["derive"] }
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
anyhow = "1" anyhow = "1"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
[build-dependencies] [build-dependencies]
cc = { version = "1", optional = true } cc = { version = "1", optional = true }

View file

@ -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. 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.<domain>. SRV 0 1 <port> <host>`, 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 <file>` takes a TOML file: one `[defaults]` table inherited by every `[domains.<name>]` table (names are 1-63 bytes of `[a-z0-9._-]`). Per domain, `key` and `port` are required; `db` defaults to `mail-<name>.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`: 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`:
``` ```

242
flake.nix
View file

@ -132,11 +132,74 @@
}; };
keyFile = mkOption { keyFile = mkOption {
type = types.path; type = types.nullOr types.path;
default = null;
description = '' description = ''
Path to the server's static Noise X25519 private key Path to the server's static Noise X25519 private key
(32 raw bytes, generated with `bunshin keygen`). Provisioned (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.<name>.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
`<dataDir>/mail-<name>.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); assertion = !(cfg.inviteToken != null && cfg.inviteTokenFile != null);
message = "services.bunshin: set only one of inviteToken or inviteTokenFile."; 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.<name>.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 = { systemd.services.bunshin =
description = "bunshin Smol Mail server"; let
wantedBy = [ "multi-user.target" ]; tomlFormat = pkgs.formats.toml { };
after = [ "network.target" ]; # 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 = { serviceConfig = {
ExecStart = pkgs.writeShellScript "bunshin-serve" '' ExecStart = if cfg.domains == { } then pkgs.writeShellScript "bunshin-serve" ''
set -euo pipefail set -euo pipefail
args=( args=(
serve serve
--key ${cfg.keyFile} --key ${cfg.keyFile}
--db ${cfg.dataDir}/mail.db --db ${cfg.dataDir}/mail.db
--host ${cfg.host} --host ${cfg.host}
--port ${toString cfg.port} --port ${toString cfg.port}
--max-envelope ${toString cfg.maxEnvelope} --max-envelope ${toString cfg.maxEnvelope}
--quota ${toString cfg.quota} --quota ${toString cfg.quota}
--requests-quota ${toString cfg.requestsQuota} --requests-quota ${toString cfg.requestsQuota}
--retention-days ${toString cfg.retentionDays} --retention-days ${toString cfg.retentionDays}
--requests-retention-days ${toString cfg.requestsRetentionDays} --requests-retention-days ${toString cfg.requestsRetentionDays}
--max-tokens ${toString cfg.maxTokens} --max-tokens ${toString cfg.maxTokens}
--rate-connections ${toString cfg.rateConnections} --rate-connections ${toString cfg.rateConnections}
--rate-sends ${toString cfg.rateSends} --rate-sends ${toString cfg.rateSends}
--rate-tokens ${toString cfg.rateTokens} --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 ''
${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null) args+=(
''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''} --rns
${lib.optionalString cfg.verbose --rns-key ${cfg.rns.keyFile}
''args+=(--verbose)''} --rns-max-envelope ${toString cfg.rns.maxEnvelope}
${lib.optionalString (cfg.inviteToken != null) --rns-fetch-budget ${toString cfg.rns.fetchBudget}
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} --rns-max-links ${toString cfg.rns.maxLinks}
${lib.optionalString (cfg.inviteTokenFile != null) --rns-rate-link-requests ${toString cfg.rns.rateLinkRequests}
''args+=(--invite-token "$(cat "$CREDENTIALS_DIRECTORY/invite-token")")''} --rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes}
exec ${cfg.package}/bin/bunshin "''${args[@]}" --rns-link-idle ${toString cfg.rns.linkIdleSecs}
''; --rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort}
DynamicUser = true; )
StateDirectory = "bunshin"; ''}
StateDirectoryMode = "0700"; ${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null)
Restart = "on-failure"; ''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''}
} // lib.optionalAttrs (cfg.inviteTokenFile != null) { ${lib.optionalString cfg.verbose
LoadCredential = "invite-token:${cfg.inviteTokenFile}"; ''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. # RNS needs no TCP port; only its UDP interface may be opened.
networking.firewall.allowedUDPPorts = networking.firewall.allowedUDPPorts =
mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ]; 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);
}; };
}; };
}; };

332
src/config.rs Normal file
View file

@ -0,0 +1,332 @@
//! Multi-domain TOML configuration: one `[defaults]` table inherited by
//! every `[domains.<name>]` 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<Vec<u8>>,
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<String>,
port: Option<u16>,
host: Option<String>,
db: Option<String>,
max_envelope: Option<usize>,
quota: Option<i64>,
requests_quota: Option<i64>,
retention_days: Option<i64>,
requests_retention_days: Option<i64>,
max_tokens: Option<u16>,
invite_token: Option<String>,
invite_token_file: Option<String>,
rate_connections: Option<u32>,
rate_sends: Option<u32>,
rate_tokens: Option<u32>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
#[serde(default)]
defaults: Table,
domains: BTreeMap<String, Table>,
}
/// 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<Option<Vec<u8>>> {
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<Vec<DomainConfig>> {
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.<name>] 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())
);
}
}

View file

@ -20,9 +20,9 @@ use snow::{Builder, TransportState};
use crate::crypto::generate_static_key; use crate::crypto::generate_static_key;
use crate::proto::{ use crate::proto::{
AUTH_REQUIRED, ENVELOPE_MAGIC, ENVELOPE_VERSION, ID_LEN, LABEL_AUTH, LABEL_REGISTER, AUTH_REQUIRED, ENVELOPE_MAGIC, ENVELOPE_VERSION, ID_LEN, LABEL_AUTH, LABEL_REGISTER, MALFORMED,
MALFORMED, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, OP_AUTH, OP_FETCH, OP_REGISTER, OP_RESOLVE, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, OP_AUTH, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND,
OP_SEND, PROLOGUE, TOO_LARGE, PROLOGUE, TOO_LARGE, UNKNOWN_USER,
}; };
use crate::ratelimit::RateLimiter; use crate::ratelimit::RateLimiter;
use crate::server::handle_connection; use crate::server::handle_connection;
@ -70,13 +70,7 @@ impl Target {
// The harness deliberately produces hostile connections; // The harness deliberately produces hostile connections;
// a panic in one must not take the accept loop with it. // a panic in one must not take the accept loop with it.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
handle_connection( handle_connection(stream, config, &static_private, &db_path, "127.0.0.1")
stream,
config,
&static_private,
&db_path,
"127.0.0.1",
)
})); }));
}); });
} }
@ -92,7 +86,8 @@ impl Target {
fn assert_alive(&self) { fn assert_alive(&self) {
let mut client = Client::connect(self.port); let mut client = Client::connect(self.port);
client.handshake().expect("server survived"); 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"); 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 mut packet = vec![0u8; payload.len() + 16];
let n = transport.write_message(payload, &mut packet).unwrap(); let n = transport.write_message(payload, &mut packet).unwrap();
packet.truncate(n); 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(); 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(&(10u16).to_be_bytes());
client.write_raw(&[0xA5; 10]); client.write_raw(&[0xA5; 10]);
let mut sink = [0u8; 16]; 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(); target.assert_alive();
} }
@ -299,7 +299,10 @@ fn zero_length_frame_gets_malformed_then_close() {
assert_eq!(op, 0); assert_eq!(op, 0);
assert_eq!(body[0], MALFORMED); assert_eq!(body[0], MALFORMED);
let mut sink = [0u8; 16]; 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(); target.assert_alive();
} }
@ -414,7 +417,11 @@ fn registered_flow_survives_absurd_future_cursor() {
let (op, resp) = client.request(OP_FETCH, &body); let (op, resp) = client.request(OP_FETCH, &body);
assert_eq!(op, OP_FETCH); assert_eq!(op, OP_FETCH);
assert_eq!(resp[0], 0, "status OK"); 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. // 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']); 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(); target.assert_alive();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Long-lived multi-session driver: the last agreed stress-campaign phase. // Long-lived multi-session driver: the last agreed stress-campaign phase.
// A dozen authenticated sessions live on one server simultaneously, run // 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(); 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"
);
}

View file

@ -2,6 +2,7 @@
mod bind; mod bind;
mod channel; mod channel;
mod config;
mod crypto; mod crypto;
#[cfg(test)] #[cfg(test)]
mod harness; mod harness;
@ -42,6 +43,11 @@ enum Command {
}, },
/// Run the mailbox server /// Run the mailbox server
Serve { Serve {
/// Serve one domain per [domains.<name>] 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<String>,
#[arg(long, default_value = "server.key")] #[arg(long, default_value = "server.key")]
key: String, key: String,
#[arg(long, default_value = "mail.db")] #[arg(long, default_value = "mail.db")]
@ -132,6 +138,7 @@ fn main() -> anyhow::Result<()> {
Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force), Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force),
#[cfg(not(feature = "rns"))] #[cfg(not(feature = "rns"))]
Command::Serve { Command::Serve {
config,
key, key,
db, db,
host, host,
@ -146,24 +153,30 @@ fn main() -> anyhow::Result<()> {
rate_connections, rate_connections,
rate_sends, rate_sends,
rate_tokens, rate_tokens,
} => server::run(server::ServeArgs { } => {
key_path: key, if let Some(path) = config {
db_path: db, return server::serve_domains(config::load(&path)?);
host, }
port, server::run(server::ServeArgs {
max_envelope, key_path: key,
quota, db_path: db,
requests_quota, host,
retention_days, port,
requests_retention_days, max_envelope,
max_tokens, quota,
invite_token, requests_quota,
rate_connections, retention_days,
rate_sends, requests_retention_days,
rate_tokens, max_tokens,
}), invite_token,
rate_connections,
rate_sends,
rate_tokens,
})
}
#[cfg(feature = "rns")] #[cfg(feature = "rns")]
Command::Serve { Command::Serve {
config,
key, key,
db, db,
host, host,
@ -180,6 +193,9 @@ fn main() -> anyhow::Result<()> {
rate_tokens, rate_tokens,
rns, rns,
} => { } => {
if let Some(path) = config {
return server::serve_domains(config::load(&path)?);
}
// One process serves both carriers (RNS.md sec 7.4): shared // One process serves both carriers (RNS.md sec 7.4): shared
// Store, shared config, single purge loop in server::run. // Store, shared config, single purge loop in server::run.
if rns.enabled { if rns.enabled {

View file

@ -1,11 +1,12 @@
//! TCP accept loop, per-connection handling, and the background purge loop. //! 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::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::bind::TransportBindValues; use crate::bind::TransportBindValues;
use crate::channel::{handshake, Channel}; use crate::channel::{handshake, Channel};
use crate::config::DomainConfig;
use crate::crypto::{b32, derive_public}; use crate::crypto::{b32, derive_public};
use crate::proto::{FETCH_BUDGET, HEADER_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS}; use crate::proto::{FETCH_BUDGET, HEADER_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
use crate::ratelimit::RateLimiter; use crate::ratelimit::RateLimiter;
@ -29,41 +30,97 @@ pub struct ServeArgs {
pub rate_tokens: u32, pub rate_tokens: u32,
} }
/// The single-domain CLI path: one anonymous domain.
pub fn run(args: ServeArgs) -> anyhow::Result<()> { pub fn run(args: ServeArgs) -> anyhow::Result<()> {
let static_key = std::fs::read(&args.key_path) serve_domains(vec![DomainConfig {
.map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?; name: "default".to_string(),
anyhow::ensure!( key_path: args.key_path,
static_key.len() == KEY_LEN, db_path: args.db_path,
"server key must be {KEY_LEN} raw bytes, got {}", host: args.host,
static_key.len() port: args.port,
);
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, max_envelope: args.max_envelope,
fetch_budget: FETCH_BUDGET, quota: args.quota,
main_quota: args.quota,
requests_quota: args.requests_quota, requests_quota: args.requests_quota,
retention_days: args.retention_days,
requests_retention_days: args.requests_retention_days,
max_tokens: args.max_tokens, max_tokens: args.max_tokens,
invite_token: args.invite_token.map(String::into_bytes), invite_token: args.invite_token.map(String::into_bytes),
conn_limiter: RateLimiter::new(args.rate_connections), rate_connections: args.rate_connections,
send_limiter: RateLimiter::new(args.rate_sends), rate_sends: args.rate_sends,
token_limiter: RateLimiter::new(args.rate_tokens), rate_tokens: args.rate_tokens,
}); }])
}
let main_retention_secs = args.retention_days * 86400; /// Serves every domain: one listener, key, mailbox database, config and
let requests_retention_secs = args.requests_retention_days * 86400; /// purge loop per domain, nothing shared between them.
let purge_db_path = args.db_path.clone(); pub fn serve_domains(domains: Vec<DomainConfig>) -> anyhow::Result<()> {
std::thread::spawn(move || { // Read every key and bind every listener first, so a missing key or a
purge_loop(purge_db_path, main_retention_secs, requests_retention_secs) // 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))?; for (domain, static_key, listener, config) in bound {
log::info!("listening on {}:{}", args.host, args.port); log::info!(
log::info!("server public key: {}", b32(&server_static)); "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<ServerConfig>,
static_key: [u8; KEY_LEN],
db_path: String,
) {
for incoming in listener.incoming() { for incoming in listener.incoming() {
let stream = match incoming { let stream = match incoming {
Ok(s) => s, Ok(s) => s,
@ -83,15 +140,13 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
} }
let config = Arc::clone(&config); let config = Arc::clone(&config);
let static_key = key_array; let db_path = db_path.clone();
let db_path = args.db_path.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) { if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
log::info!("connection error from {peer_ip}: {e}"); log::info!("connection error from {peer_ip}: {e}");
} }
}); });
} }
Ok(())
} }
/// Also the hostile harness's entry point: it drives real connections /// Also the hostile harness's entry point: it drives real connections