bunshin/flake.nix
randogoth 0c7311b068 fix: use stdenv.hostPlatform.system, not the removed pkgs.system
nixpkgs renamed it, and the shim warns on every evaluation that imports this
module, so the noise lands on consumers rebuilding their systems rather than
here.
2026-10-06 09:59:16 +03:00

576 lines
25 KiB
Nix

{
description = "bunshin - Smol Mail server (Rust)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
# RNS carrier sources (RNS.md sec 8): the build sandbox has no network,
# so every microReticulum FetchContent dependency is vendored as its own
# flake input and handed over via RNS_<DEP>_SOURCE_DIR. microReticulum
# is pinned at the commit RNS.md sec 5 verified against.
# Local fork of attermann/microReticulum at rev 40fa628809d5 plus a
# local patch: Transport::outbound/inbound skip their jobs-cycle wait
# for same-thread reentrant calls, which otherwise deadlock the single
# transport loop when a stalled resource tick sends a RESOURCE_REQ
# from inside jobs() (see RNS.md sec 9).
microReticulum = {
url = "git+file:///mnt/data/Projects/code/microReticulum?ref=fix/outbound-reentrancy-deadlock";
flake = false;
};
rns-arduinojson = {
url = "github:bblanchon/ArduinoJson/ed69feadb95182dc53ac978975d310122060a767";
flake = false;
};
rns-msgpack = {
url = "github:hideakitai/MsgPack/1f552c31b940d6e9063ee17a4b3fa10c47b27169";
flake = false;
};
rns-arxcontainer = {
url = "github:hideakitai/ArxContainer/d6affcd0bc83219b863c20abf7c269214db8db2a";
flake = false;
};
rns-arxtypetraits = {
url = "github:hideakitai/ArxTypeTraits/702de9cc59c7e047cdc169ae3547718b289d2c02";
flake = false;
};
rns-debuglog = {
url = "github:hideakitai/DebugLog/b581f7dde6c276c5df684e2328f406d9754d2f46";
flake = false;
};
rns-crypto = {
url = "github:attermann/Crypto/984dc891330986c302a86c4e312d4f5abcc28359";
flake = false;
};
rns-microstore = {
url = "github:attermann/microStore/0f28567fe00ab8ab14624a34c2e9e0a000a44c46";
flake = false;
};
};
outputs = { self, nixpkgs, flake-utils, microReticulum, rns-arduinojson
, rns-msgpack, rns-arxcontainer, rns-arxtypetraits, rns-debuglog
, rns-crypto, rns-microstore }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
inherit (pkgs) lib;
# build.rs consumes these directly (RNS.md sec 7.1); with every
# dependency vendored, the cmake configure step never fetches.
rnsSourceEnv = {
MICRORETICULUM_SOURCE_DIR = "${microReticulum}";
RNS_ARDUINOJSON_SOURCE_DIR = "${rns-arduinojson}";
RNS_MSGPACK_SOURCE_DIR = "${rns-msgpack}";
RNS_ARXCONTAINER_SOURCE_DIR = "${rns-arxcontainer}";
RNS_ARXTYPETRAITS_SOURCE_DIR = "${rns-arxtypetraits}";
RNS_DEBUGLOG_SOURCE_DIR = "${rns-debuglog}";
RNS_CRYPTO_SOURCE_DIR = "${rns-crypto}";
RNS_MICROSTORE_SOURCE_DIR = "${rns-microstore}";
};
bunshin = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
pname = "bunshin";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkg-config ]
++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ];
buildInputs = [ pkgs.sqlite ];
buildFeatures = lib.optionals rns [ "rns" ];
env = lib.optionalAttrs rns rnsSourceEnv;
# ninja's setup hook claims buildPhase before the cargo hooks
# can, which would run ninja against no build.ninja instead of
# cargo; ninja here is only for build.rs to invoke through cmake.
dontUseNinjaBuild = rns;
dontUseNinjaCheck = rns;
dontUseNinjaInstall = rns;
};
in
{
packages.default = bunshin { };
packages.rns = bunshin { rns = true; };
# Static musl build (no rns feature: microReticulum's cmake/C++
# build isn't set up for static linking). Lets a low-power deploy
# target (bucur) fetch a prebuilt binary instead of compiling —
# see apps.release-static.
packages.static = pkgs.pkgsStatic.rustPlatform.buildRustPackage {
pname = "bunshin";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkgsStatic.pkg-config ];
buildInputs = [ pkgs.pkgsStatic.sqlite ];
};
apps.default = flake-utils.lib.mkApp {
drv = bunshin { };
name = "bunshin";
};
# Builds packages.static and publishes it as a Forgejo release on
# this repo, tagged by short commit hash (creating the release if
# it doesn't exist yet, replacing the asset if it does — safe to
# rerun for the same commit). Needs FORGEJO_TOKEN (a token with
# write:repository scope) in the environment; run from a checkout
# so `git rev-parse` and the `.` flake ref resolve to the right
# place.
apps.release-static = flake-utils.lib.mkApp {
drv = pkgs.writeShellApplication {
name = "bunshin-release-static";
runtimeInputs = [ pkgs.nix pkgs.curl pkgs.git pkgs.jq ];
text = ''
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
: "''${FORGEJO_TOKEN:?set FORGEJO_TOKEN (env or .env) to a Forgejo token with write:repository scope}"
rev=$(git rev-parse --short HEAD)
sha=$(git rev-parse HEAD)
out=$(nix build .#static --no-link --print-out-paths)
bin="$out/bin/bunshin"
api="https://code.randogoth.com/api/v1/repos/randogoth/bunshin"
auth=(-H "Authorization: token ''${FORGEJO_TOKEN}")
release_id=$(curl -sS "''${auth[@]}" "$api/releases/tags/$rev" | jq -r '.id // empty')
if [ -z "$release_id" ]; then
release_id=$(curl -sSf "''${auth[@]}" -H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$rev" --arg sha "$sha" \
'{tag_name:$tag, target_commitish:$sha, name:$tag, body:"Static musl build.", draft:false, prerelease:false}')" \
"$api/releases" | jq -r '.id')
fi
asset_id=$(curl -sSf "''${auth[@]}" "$api/releases/$release_id/assets" | jq -r '.[] | select(.name=="bunshin") | .id' | head -1)
if [ -n "$asset_id" ]; then
curl -sSf -X DELETE "''${auth[@]}" "$api/releases/$release_id/assets/$asset_id" >/dev/null
fi
curl -sSf "''${auth[@]}" -F "attachment=@$bin;filename=bunshin" "$api/releases/$release_id/assets?name=bunshin" >/dev/null
echo "released: https://code.randogoth.com/randogoth/bunshin/releases/tag/$rev"
'';
};
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config gcc sqlite cmake ninja ];
env = rnsSourceEnv;
};
}) // {
nixosModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.bunshin;
inherit (lib) mkEnableOption mkOption mkIf types;
in
{
options.services.bunshin = {
enable = mkEnableOption "the bunshin Smol Mail server";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
description = ''
bunshin package to run. Use `packages.rns` when the RNS
carrier is enabled: the default package is built without it.
'';
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Address to listen on.";
};
port = mkOption {
type = types.port;
default = 1961;
description = "TCP port to listen on.";
};
keyFile = mkOption {
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. 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.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/bunshin";
description = "Directory holding mail.db.";
};
maxEnvelope = mkOption {
type = types.ints.positive;
default = 786432;
description = "Maximum accepted envelope size, in bytes.";
};
quota = mkOption {
type = types.ints.positive;
default = 67108864;
description = "Per-mailbox main-tier storage quota, in bytes.";
};
requestsQuota = mkOption {
type = types.ints.positive;
default = 2097152;
description = "Per-mailbox requests-tier storage quota, in bytes.";
};
retentionDays = mkOption {
type = types.ints.positive;
default = 30;
description = "Days a main-tier message is retained before being purged.";
};
requestsRetentionDays = mkOption {
type = types.ints.positive;
default = 7;
description = "Days a requests-tier message is retained before being purged.";
};
maxTokens = mkOption {
type = types.ints.positive;
default = 1024;
description = "Maximum accept tokens a mailbox may hold.";
};
rateConnections = mkOption {
type = types.ints.positive;
default = 120;
description = "Max accepted connections per minute, per source IP.";
};
rateSends = mkOption {
type = types.ints.positive;
default = 60;
description = "Max SEND operations per minute, per source IP.";
};
rateTokens = mkOption {
type = types.ints.positive;
default = 30;
description = "Max SEND operations per minute, per accept token.";
};
inviteToken = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Registration invite token. Null means open registration.
Prefer `inviteTokenFile` to avoid storing the token in the
world-readable Nix store.
'';
};
inviteTokenFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file (readable by the service via LoadCredential)
containing the registration invite token.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open the configured TCP port in the firewall.";
};
verbose = mkOption {
type = types.bool;
default = false;
description = ''
Enable debug-level logging (--verbose): per-op timing,
session summaries and RNS link events on both carriers.
'';
};
rns = {
enable = mkEnableOption "the RNS carrier alongside TCP (needs an rns-built package)";
keyFile = mkOption {
type = types.path;
description = ''
Path to the server's Reticulum identity (64 raw bytes,
x25519 || ed25519 private halves, generated with
`bunshin rns-keygen`). RNS writes the private key
unencrypted, so protect the file like any long-term key.
'';
};
maxEnvelope = mkOption {
type = types.ints.positive;
default = 32768;
description = "Maximum accepted envelope size over RNS, in bytes (RNS.md sec 3).";
};
fetchBudget = mkOption {
type = types.ints.positive;
default = 32768;
description = "Bytes one FETCH response may total over RNS.";
};
maxLinks = mkOption {
type = types.ints.positive;
default = 100;
description = "Maximum concurrent Reticulum links; further links are refused.";
};
rateLinkRequests = mkOption {
type = types.ints.positive;
default = 60;
description = "Max requests per minute, per link.";
};
rateLinkBytes = mkOption {
type = types.ints.positive;
default = 1048576;
description = "Max request bytes per minute, per link.";
};
linkIdleSecs = mkOption {
type = types.ints.unsigned;
default = 300;
description = ''
Seconds after which an idle RNS link is reaped, freeing
its session and link slot; 0 disables the reaper.
'';
};
udpListenPort = mkOption {
type = types.port;
default = 4242;
description = ''
UDP port the Reticulum interface listens on. RNS reaches
the mesh through a configured interface rather than a
listening TCP port; only this UDP port needs a firewall
opening.
'';
};
udpForward = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Optional host[:port] the interface forwards every packet
to. Without it, replies go to the source address of the
last datagram received, which suits a listen-only
point-to-point setup.
'';
};
};
};
config = mkIf cfg.enable {
assertions = [
{
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 =
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 = if cfg.domains == { } then pkgs.writeShellScript "bunshin-serve" ''
set -euo pipefail
args=(
serve
--key ${cfg.keyFile}
--db ${cfg.dataDir}/mail.db
--host ${cfg.host}
--port ${toString cfg.port}
--max-envelope ${toString cfg.maxEnvelope}
--quota ${toString cfg.quota}
--requests-quota ${toString cfg.requestsQuota}
--retention-days ${toString cfg.retentionDays}
--requests-retention-days ${toString cfg.requestsRetentionDays}
--max-tokens ${toString cfg.maxTokens}
--rate-connections ${toString cfg.rateConnections}
--rate-sends ${toString cfg.rateSends}
--rate-tokens ${toString cfg.rateTokens}
)
${lib.optionalString cfg.rns.enable ''
args+=(
--rns
--rns-key ${cfg.rns.keyFile}
--rns-max-envelope ${toString cfg.rns.maxEnvelope}
--rns-fetch-budget ${toString cfg.rns.fetchBudget}
--rns-max-links ${toString cfg.rns.maxLinks}
--rns-rate-link-requests ${toString cfg.rns.rateLinkRequests}
--rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes}
--rns-link-idle ${toString cfg.rns.linkIdleSecs}
--rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort}
)
''}
${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null)
''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''}
${lib.optionalString cfg.verbose
''args+=(--verbose)''}
${lib.optionalString (cfg.inviteToken != null)
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
${lib.optionalString (cfg.inviteTokenFile != null)
''args+=(--invite-token "$(cat "$CREDENTIALS_DIRECTORY/invite-token")")''}
exec ${cfg.package}/bin/bunshin "''${args[@]}"
'' else pkgs.writeShellScript "bunshin-serve" ''
set -euo pipefail
args=(serve --config ${tomlFile})
${lib.optionalString cfg.verbose
''args+=(--verbose)''}
exec ${cfg.package}/bin/bunshin "''${args[@]}"
'';
DynamicUser = true;
StateDirectory = "bunshin";
StateDirectoryMode = "0700";
Restart = "on-failure";
} // lib.optionalAttrs (cfg.inviteTokenFile != null || domainCredentials != [ ]) {
LoadCredential = lib.optionals (cfg.inviteTokenFile != null)
[ "invite-token:${cfg.inviteTokenFile}" ] ++ domainCredentials;
};
};
# RNS needs no TCP port; only its UDP interface may be opened.
networking.firewall.allowedUDPPorts =
mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ];
# 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);
};
};
};
}