bunshin/flake.nix

358 lines
14 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.
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.";
};
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.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 ];
};
};
};
}