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";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
outputs = { self, nixpkgs, flake-utils }:
|
|
|
|
|
flake-utils.lib.eachDefaultSystem (system:
|
|
|
|
|
let
|
|
|
|
|
pkgs = import nixpkgs { inherit system; };
|
|
|
|
|
|
|
|
|
|
bunshin = pkgs.rustPlatform.buildRustPackage {
|
|
|
|
|
pname = "bunshin";
|
|
|
|
|
version = "0.1.0";
|
|
|
|
|
src = ./.;
|
|
|
|
|
cargoLock.lockFile = ./Cargo.lock;
|
|
|
|
|
nativeBuildInputs = [ pkgs.pkg-config ];
|
|
|
|
|
buildInputs = [ pkgs.sqlite ];
|
|
|
|
|
};
|
|
|
|
|
in
|
|
|
|
|
{
|
|
|
|
|
packages.default = bunshin;
|
|
|
|
|
|
|
|
|
|
apps.default = flake-utils.lib.mkApp {
|
|
|
|
|
drv = bunshin;
|
|
|
|
|
name = "bunshin";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
devShells.default = pkgs.mkShell {
|
|
|
|
|
packages = [ pkgs.cargo pkgs.rustc pkgs.rustfmt pkgs.clippy pkgs.pkg-config pkgs.gcc pkgs.sqlite ];
|
|
|
|
|
};
|
|
|
|
|
}) // {
|
|
|
|
|
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.";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
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.";
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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}
|
2026-09-27 09:39:01 +03:00
|
|
|
--requests-quota ${toString cfg.requestsQuota}
|
2026-09-26 11:52:22 +03:00
|
|
|
--retention-days ${toString cfg.retentionDays}
|
2026-09-27 09:39:01 +03:00
|
|
|
--requests-retention-days ${toString cfg.requestsRetentionDays}
|
|
|
|
|
--max-tokens ${toString cfg.maxTokens}
|
2026-09-26 11:52:22 +03:00
|
|
|
--rate-connections ${toString cfg.rateConnections}
|
|
|
|
|
--rate-sends ${toString cfg.rateSends}
|
2026-09-27 09:39:01 +03:00
|
|
|
--rate-tokens ${toString cfg.rateTokens}
|
2026-09-26 11:52:22 +03:00
|
|
|
)
|
|
|
|
|
${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}";
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|