nixpkgs renamed it, and the shim warns on every evaluation that imports this module, so the noise lands on consumers rebuilding their systems rather than here.
576 lines
25 KiB
Nix
576 lines
25 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.
|
|
# 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).
|
|
microReticulum = {
|
|
url = "git+file:///mnt/data/Projects/code/microReticulum?ref=fix/outbound-reentrancy-deadlock";
|
|
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; };
|
|
|
|
# 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 ];
|
|
};
|
|
|
|
apps.default = flake-utils.lib.mkApp {
|
|
drv = bunshin { };
|
|
name = "bunshin";
|
|
};
|
|
|
|
# Builds packages.static and publishes it as a Forgejo release on
|
|
# this repo, tagged by short commit hash (creating the release if
|
|
# it doesn't exist yet, replacing the asset if it does — safe to
|
|
# rerun for the same commit). Needs FORGEJO_TOKEN (a token with
|
|
# write:repository 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 pkgs.jq ];
|
|
text = ''
|
|
if [ -f .env ]; then
|
|
set -a
|
|
# shellcheck disable=SC1091
|
|
. ./.env
|
|
set +a
|
|
fi
|
|
: "''${FORGEJO_TOKEN:?set FORGEJO_TOKEN (env or .env) to a Forgejo token with write:repository scope}"
|
|
|
|
rev=$(git rev-parse --short HEAD)
|
|
sha=$(git rev-parse HEAD)
|
|
out=$(nix build .#static --no-link --print-out-paths)
|
|
bin="$out/bin/bunshin"
|
|
|
|
api="https://code.randogoth.com/api/v1/repos/randogoth/bunshin"
|
|
auth=(-H "Authorization: token ''${FORGEJO_TOKEN}")
|
|
|
|
release_id=$(curl -sS "''${auth[@]}" "$api/releases/tags/$rev" | jq -r '.id // empty')
|
|
if [ -z "$release_id" ]; then
|
|
release_id=$(curl -sSf "''${auth[@]}" -H "Content-Type: application/json" \
|
|
-d "$(jq -n --arg tag "$rev" --arg sha "$sha" \
|
|
'{tag_name:$tag, target_commitish:$sha, name:$tag, body:"Static musl build.", draft:false, prerelease:false}')" \
|
|
"$api/releases" | jq -r '.id')
|
|
fi
|
|
|
|
asset_id=$(curl -sSf "''${auth[@]}" "$api/releases/$release_id/assets" | jq -r '.[] | select(.name=="bunshin") | .id' | head -1)
|
|
if [ -n "$asset_id" ]; then
|
|
curl -sSf -X DELETE "''${auth[@]}" "$api/releases/$release_id/assets/$asset_id" >/dev/null
|
|
fi
|
|
curl -sSf "''${auth[@]}" -F "attachment=@$bin;filename=bunshin" "$api/releases/$release_id/assets?name=bunshin" >/dev/null
|
|
|
|
echo "released: https://code.randogoth.com/randogoth/bunshin/releases/tag/$rev"
|
|
'';
|
|
};
|
|
};
|
|
|
|
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.stdenv.hostPlatform.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.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. 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.
|
|
'';
|
|
};
|
|
|
|
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.";
|
|
};
|
|
|
|
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.
|
|
'';
|
|
};
|
|
|
|
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.";
|
|
};
|
|
|
|
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.
|
|
'';
|
|
};
|
|
|
|
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.";
|
|
}
|
|
{
|
|
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 =
|
|
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}
|
|
)
|
|
${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 ];
|
|
# 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);
|
|
};
|
|
};
|
|
};
|
|
}
|