feat: serve multiple domains with per-domain keys, ports and databases
This commit is contained in:
parent
931933c423
commit
dfb57b265c
8 changed files with 891 additions and 120 deletions
242
flake.nix
242
flake.nix
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue