{ 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. microReticulum = { url = "github:attermann/microReticulum/40fa628809d57140180c1c833559ab96fec992c1"; 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; }; apps.default = flake-utils.lib.mkApp { drv = bunshin { }; name = "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.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.path; 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. ''; }; 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."; }; 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."; } ]; systemd.services.bunshin = { description = "bunshin Smol Mail server"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; serviceConfig = { ExecStart = 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-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[@]}" ''; DynamicUser = true; StateDirectory = "bunshin"; StateDirectoryMode = "0700"; Restart = "on-failure"; } // lib.optionalAttrs (cfg.inviteTokenFile != null) { LoadCredential = "invite-token:${cfg.inviteTokenFile}"; }; }; # RNS needs no TCP port; only its UDP interface may be opened. networking.firewall.allowedUDPPorts = mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ]; networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; }; }; }; }