2026-09-26 11:52:22 +03:00
|
|
|
{
|
|
|
|
|
description = "bunshin - Smol Mail server (Rust)";
|
|
|
|
|
|
|
|
|
|
inputs = {
|
|
|
|
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
|
|
|
flake-utils.url = "github:numtide/flake-utils";
|
2026-09-28 15:52:37 +03:00
|
|
|
|
|
|
|
|
# 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.
|
2026-09-29 10:27:44 +03:00
|
|
|
# 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).
|
2026-09-28 15:52:37 +03:00
|
|
|
microReticulum = {
|
2026-09-29 10:27:44 +03:00
|
|
|
url = "git+file:///mnt/data/Projects/code/microReticulum?ref=fix/outbound-reentrancy-deadlock";
|
2026-09-28 15:52:37 +03:00
|
|
|
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;
|
|
|
|
|
};
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
|
2026-09-28 15:52:37 +03:00
|
|
|
outputs = { self, nixpkgs, flake-utils, microReticulum, rns-arduinojson
|
|
|
|
|
, rns-msgpack, rns-arxcontainer, rns-arxtypetraits, rns-debuglog
|
|
|
|
|
, rns-crypto, rns-microstore }:
|
2026-09-26 11:52:22 +03:00
|
|
|
flake-utils.lib.eachDefaultSystem (system:
|
|
|
|
|
let
|
|
|
|
|
pkgs = import nixpkgs { inherit system; };
|
2026-09-28 15:52:37 +03:00
|
|
|
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}";
|
|
|
|
|
};
|
2026-09-26 11:52:22 +03:00
|
|
|
|
2026-09-28 15:52:37 +03:00
|
|
|
bunshin = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
|
2026-09-26 11:52:22 +03:00
|
|
|
pname = "bunshin";
|
|
|
|
|
version = "0.1.0";
|
|
|
|
|
src = ./.;
|
|
|
|
|
cargoLock.lockFile = ./Cargo.lock;
|
2026-09-28 15:52:37 +03:00
|
|
|
nativeBuildInputs = [ pkgs.pkg-config ]
|
|
|
|
|
++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ];
|
2026-09-26 11:52:22 +03:00
|
|
|
buildInputs = [ pkgs.sqlite ];
|
2026-09-28 15:52:37 +03:00
|
|
|
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;
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
in
|
|
|
|
|
{
|
2026-09-28 15:52:37 +03:00
|
|
|
packages.default = bunshin { };
|
|
|
|
|
packages.rns = bunshin { rns = true; };
|
2026-09-26 11:52:22 +03:00
|
|
|
|
2026-09-30 14:49:17 +03:00
|
|
|
# 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 ];
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 11:52:22 +03:00
|
|
|
apps.default = flake-utils.lib.mkApp {
|
2026-09-28 15:52:37 +03:00
|
|
|
drv = bunshin { };
|
2026-09-26 11:52:22 +03:00
|
|
|
name = "bunshin";
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-30 14:49:17 +03:00
|
|
|
# Builds packages.static and uploads it to this repo owner's
|
|
|
|
|
# Forgejo generic package registry, tagged by short commit hash.
|
|
|
|
|
# Needs FORGEJO_TOKEN (a token with write:package 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 ];
|
|
|
|
|
text = ''
|
|
|
|
|
if [ -f .env ]; then
|
|
|
|
|
set -a
|
|
|
|
|
# shellcheck disable=SC1091
|
|
|
|
|
. ./.env
|
|
|
|
|
set +a
|
|
|
|
|
fi
|
|
|
|
|
rev=$(git rev-parse --short HEAD)
|
|
|
|
|
out=$(nix build .#static --no-link --print-out-paths)
|
|
|
|
|
: "''${FORGEJO_TOKEN:?set FORGEJO_TOKEN (env or .env) to a Forgejo token with write:package scope}"
|
|
|
|
|
url="https://code.randogoth.com/api/packages/randogoth/generic/bunshin/$rev/bunshin"
|
|
|
|
|
curl -sSf -H "Authorization: token ''${FORGEJO_TOKEN}" --upload-file "$out/bin/bunshin" "$url"
|
|
|
|
|
echo "uploaded: $url"
|
|
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 11:52:22 +03:00
|
|
|
devShells.default = pkgs.mkShell {
|
2026-09-28 15:52:37 +03:00
|
|
|
packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config gcc sqlite cmake ninja ];
|
|
|
|
|
env = rnsSourceEnv;
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
}) // {
|
|
|
|
|
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;
|
2026-09-28 15:52:37 +03:00
|
|
|
description = ''
|
|
|
|
|
bunshin package to run. Use `packages.rns` when the RNS
|
|
|
|
|
carrier is enabled: the default package is built without it.
|
|
|
|
|
'';
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-09-30 12:57:44 +03:00
|
|
|
type = types.nullOr types.path;
|
|
|
|
|
default = null;
|
2026-09-26 11:52:22 +03:00
|
|
|
description = ''
|
|
|
|
|
Path to the server's static Noise X25519 private key
|
|
|
|
|
(32 raw bytes, generated with `bunshin keygen`). Provisioned
|
2026-09-30 12:57:44 +03:00
|
|
|
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.
|
2026-09-26 11:52:22 +03:00
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
dataDir = mkOption {
|
|
|
|
|
type = types.path;
|
|
|
|
|
default = "/var/lib/bunshin";
|
|
|
|
|
description = "Directory holding mail.db.";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
maxEnvelope = mkOption {
|
|
|
|
|
type = types.ints.positive;
|
2026-09-27 09:39:01 +03:00
|
|
|
default = 786432;
|
2026-09-26 11:52:22 +03:00
|
|
|
description = "Maximum accepted envelope size, in bytes.";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
quota = mkOption {
|
|
|
|
|
type = types.ints.positive;
|
|
|
|
|
default = 67108864;
|
2026-09-27 09:39:01 +03:00
|
|
|
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.";
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
retentionDays = mkOption {
|
|
|
|
|
type = types.ints.positive;
|
|
|
|
|
default = 30;
|
2026-09-27 09:39:01 +03:00
|
|
|
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.";
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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.";
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-27 09:39:01 +03:00
|
|
|
rateTokens = mkOption {
|
|
|
|
|
type = types.ints.positive;
|
|
|
|
|
default = 30;
|
|
|
|
|
description = "Max SEND operations per minute, per accept token.";
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 11:52:22 +03:00
|
|
|
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.";
|
|
|
|
|
};
|
2026-09-28 15:52:37 +03:00
|
|
|
|
2026-09-29 09:43:28 +03:00
|
|
|
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.
|
|
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-28 15:52:37 +03:00
|
|
|
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.";
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-29 10:27:44 +03:00
|
|
|
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.
|
|
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-28 15:52:37 +03:00
|
|
|
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.
|
|
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
};
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
config = mkIf cfg.enable {
|
|
|
|
|
assertions = [
|
|
|
|
|
{
|
|
|
|
|
assertion = !(cfg.inviteToken != null && cfg.inviteTokenFile != null);
|
|
|
|
|
message = "services.bunshin: set only one of inviteToken or inviteTokenFile.";
|
|
|
|
|
}
|
2026-09-30 12:57:44 +03:00
|
|
|
{
|
|
|
|
|
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.";
|
|
|
|
|
}
|
2026-09-26 11:52:22 +03:00
|
|
|
];
|
|
|
|
|
|
2026-09-30 12:57:44 +03:00
|
|
|
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}
|
2026-09-28 15:52:37 +03:00
|
|
|
)
|
2026-09-30 12:57:44 +03:00
|
|
|
${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;
|
|
|
|
|
};
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
|
2026-09-28 15:52:37 +03:00
|
|
|
# RNS needs no TCP port; only its UDP interface may be opened.
|
|
|
|
|
networking.firewall.allowedUDPPorts =
|
|
|
|
|
mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ];
|
2026-09-30 12:57:44 +03:00
|
|
|
# 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);
|
2026-09-26 11:52:22 +03:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|