feat: serve multiple domains with per-domain keys, ports and databases
This commit is contained in:
parent
931933c423
commit
dfb57b265c
8 changed files with 891 additions and 120 deletions
85
Cargo.lock
generated
85
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
42
README.md
42
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.<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`:
|
||||
|
||||
```
|
||||
|
|
|
|||
146
flake.nix
146
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.<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,15 +379,74 @@
|
|||
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.<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 =
|
||||
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" ''
|
||||
ExecStart = if cfg.domains == { } then pkgs.writeShellScript "bunshin-serve" ''
|
||||
set -euo pipefail
|
||||
args=(
|
||||
serve
|
||||
|
|
@ -364,20 +486,30 @@
|
|||
${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) {
|
||||
LoadCredential = "invite-token:${cfg.inviteTokenFile}";
|
||||
} // 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);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
332
src/config.rs
Normal file
332
src/config.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
143
src/harness.rs
143
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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
20
src/main.rs
20
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.<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")]
|
||||
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,7 +153,11 @@ fn main() -> anyhow::Result<()> {
|
|||
rate_connections,
|
||||
rate_sends,
|
||||
rate_tokens,
|
||||
} => server::run(server::ServeArgs {
|
||||
} => {
|
||||
if let Some(path) = config {
|
||||
return server::serve_domains(config::load(&path)?);
|
||||
}
|
||||
server::run(server::ServeArgs {
|
||||
key_path: key,
|
||||
db_path: db,
|
||||
host,
|
||||
|
|
@ -161,9 +172,11 @@ fn main() -> anyhow::Result<()> {
|
|||
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 {
|
||||
|
|
|
|||
111
src/server.rs
111
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();
|
||||
/// Serves every domain: one listener, key, mailbox database, config and
|
||||
/// purge loop per domain, nothing shared between them.
|
||||
pub fn serve_domains(domains: Vec<DomainConfig>) -> 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));
|
||||
}
|
||||
|
||||
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 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 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() {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue