{ 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__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" ''; }; }; # Builds the Containerfile image (same non-rns build the release # binary is, see its header comment) and pushes it to this repo's # Forgejo container registry, tagged by short commit hash and # `latest`. Needs FORGEJO_TOKEN (a token with write:package scope) # in the environment; run from a checkout so `git rev-parse` and # the Containerfile build context resolve to the right place. apps.release-container = flake-utils.lib.mkApp { drv = pkgs.writeShellApplication { name = "bunshin-release-container"; runtimeInputs = [ pkgs.podman pkgs.git ]; 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:package scope}" rev=$(git rev-parse --short HEAD) registry="code.randogoth.com/randogoth/bunshin" echo "''${FORGEJO_TOKEN}" | podman login code.randogoth.com -u randogoth --password-stdin podman build -f Containerfile -t "$registry:$rev" -t "$registry:latest" . podman push "$registry:$rev" podman push "$registry:latest" echo "pushed: https://code.randogoth.com/randogoth/-/packages/container/bunshin" ''; }; }; 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..keyFile` otherwise. ''; }; domains = mkOption { type = types.attrsOf (types.submodule { options = { keyFile = mkOption { type = types.path; description = '' Path to this domain's static Noise X25519 private key (32 raw bytes, `bunshin keygen` per domain). ''; }; port = mkOption { type = types.port; description = "TCP port this domain listens on (unique per domain)."; }; host = mkOption { type = types.nullOr types.str; default = null; description = "Address this domain listens on; null inherits the top-level host."; }; dbFile = mkOption { type = types.nullOr types.str; default = null; description = '' Mailbox database path, absolute or relative to `dataDir`; null defaults to `/mail-.db`. Databases are never shared between domains. ''; }; inviteToken = mkOption { type = types.nullOr types.str; default = null; description = "Registration invite token for this domain; null inherits the top-level token."; }; inviteTokenFile = mkOption { type = types.nullOr types.path; default = null; description = '' Path to a file (readable by the service via LoadCredential) holding this domain's registration invite token. Takes precedence over `inviteToken`. ''; }; }; }); default = { }; description = '' Extra domains served by one process, each with its own key, port and mailbox database. bunshin routes by port, so every domain needs a unique port; the top-level settings act as shared defaults. With domains configured, `keyFile` and `port` at the top level are unused. ''; }; 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."; }; maxConnections = mkOption { type = types.ints.unsigned; default = 100; description = "Max concurrently served TCP connections; past the cap connections are refused. 0 means unlimited."; }; 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..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; max_connections = cfg.maxConnections; } // 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} --max-connections ${toString cfg.maxConnections} ) ${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); }; }; }; }