feat: serve multiple domains with per-domain keys, ports and databases

This commit is contained in:
randogoth 2026-09-30 12:57:44 +03:00
parent 931933c423
commit dfb57b265c
8 changed files with 891 additions and 120 deletions

242
flake.nix
View file

@ -132,11 +132,74 @@
};
keyFile = mkOption {
type = types.path;
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.
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.
'';
};
@ -316,68 +379,137 @@
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.<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.";
}
];
systemd.services.bunshin = {
description = "bunshin Smol Mail server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
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 = 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-link-idle ${toString cfg.rns.linkIdleSecs}
--rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort}
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}
)
''}
${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}";
${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 ];
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
# 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);
};
};
};