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",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
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.
|
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
242
flake.nix
|
|
@ -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
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::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
48
src/main.rs
48
src/main.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
117
src/server.rs
117
src/server.rs
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue