gsmol/nix/module.nix
randogoth 6f82a84b69 fix: use stdenv.hostPlatform.system, not the removed pkgs.system
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.
2026-10-06 09:59:12 +03:00

234 lines
8.2 KiB
Nix

# NixOS module for running the gsmol bridge as a service. Curried on `self` so
# the systemd unit's default package is exactly what `nix build .#default`
# produces for the target system, with one definition to keep in sync.
self:
{ config, lib, pkgs, ... }:
let
cfg = config.services.gsmol-bridge;
in
{
options.services.gsmol-bridge = {
enable = lib.mkEnableOption "the gsmol web client and Smol Mail relay bridge";
package = lib.mkOption {
type = lib.types.package;
default =
if cfg.presetServer == null
then self.packages.${pkgs.stdenv.hostPlatform.system}.default
else self.packages.${pkgs.stdenv.hostPlatform.system}.default.override {
serverHost = cfg.presetServer.host;
serverPublicKey = cfg.presetServer.publicKey;
};
defaultText = lib.literalExpression
"gsmol.packages.\${system}.default, built with presetServer baked in when set";
description = ''
The gsmol-bridge package to run. Overriding this yourself bypasses
`presetServer` — bake it in via the same `.override` if you need both.
'';
};
presetServer = lib.mkOption {
type = lib.types.nullOr (lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
example = "example.org";
description = "Hostname of the smolmaild server to pre-pin.";
};
publicKey = lib.mkOption {
type = lib.types.str;
example = "mfrggzdfmztwq2lknnwg23tpobxxk4tznb2xg5btmvwgy3zmn5xg4===";
description = ''
Its static key, base32-encoded exactly as gsmol's own settings
dialog and `smolmaild --keygen` display it.
'';
};
};
});
default = null;
description = ''
Bake a server key into the pages this deployment serves, so a
first-time visitor is pre-pinned rather than asked to copy a base32
key into settings by hand (SPEC.md §4).
This only makes sense when whoever runs this deployment and whoever
runs that smolmaild are the same trusted party: the value reaches the
browser over this deployment's own TLS, which stands in for the "get
it from the operator through a trusted channel" step. Do not set this
for a key you do not operate or otherwise vouch for — it pins it for
every visitor, unasked.
It only seeds the first run: the browser stores it as an ordinary
pin from then on, which the user can still replace or remove in
settings like any other.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Address the bridge listens on. The bridge speaks plain HTTP/WS with no
TLS of its own, and browsers refuse a `ws://` connection from an
`https://` page — so leave this at loopback and put a reverse proxy
(`services.gsmol-bridge.nginx` or `.caddy`) in front for TLS and
public exposure, rather than binding this directly to a public
interface.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 8096;
description = "Port the bridge listens on.";
};
allowPorts = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ 1961 ];
description = ''
TCP ports the bridge is allowed to relay WebSocket bytes to
(smolmaild's port on each server your users register with).
'';
};
allowOrigins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "https://mail.example.org" ];
description = ''
Extra browser origins permitted to open a relay, beyond the bridge's
own. The bridge always refuses a WebSocket upgrade whose `Origin` is
neither absent nor its own page, so a reverse-proxied public domain
that differs from what the bridge itself sees needs listing here.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open the firewall for `port`. Leave this off when
`services.gsmol-bridge.nginx.enable` (or another reverse proxy) is
doing the public exposure instead.
'';
};
nginx = {
enable = lib.mkEnableOption "an nginx virtual host in front of the bridge, with TLS via ACME";
domain = lib.mkOption {
type = lib.types.str;
example = "mail.example.org";
description = "Public hostname to serve gsmol from.";
};
};
caddy = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Put a Caddy virtual host in front of the bridge, with automatic
HTTPS — no `enableACME`/`forceSSL` to set, Caddy does this itself
for any site address that isn't `http://`-prefixed.
This wires into the standard `services.caddy.virtualHosts` option
and has no effect if your own configuration replaces
`services.caddy.configFile` outright with a hand-written Caddyfile
— that style of deployment needs the domain added to that file
instead (e.g. an imported drop-in), proxying to
`services.gsmol-bridge.host`:`.port`.
'';
};
domain = lib.mkOption {
type = lib.types.str;
example = "mail.example.org";
description = "Public hostname to serve gsmol from.";
};
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
assertions = [
{
assertion = !(cfg.nginx.enable && cfg.caddy.enable);
message = "services.gsmol-bridge: enable only one of .nginx or .caddy";
}
];
systemd.services.gsmol-bridge = {
description = "gsmol web client and Smol Mail bridge";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = lib.escapeShellArgs ([
(lib.getExe cfg.package)
"--host" cfg.host
"--port" (toString cfg.port)
]
++ lib.concatMap (p: [ "--allow-port" (toString p) ]) cfg.allowPorts
++ lib.concatMap (o: [ "--allow-origin" o ]) cfg.allowOrigins);
DynamicUser = true;
Restart = "on-failure";
RestartSec = "2s";
# No key material, no writes: bridge.py holds no keys and touches
# no filesystem state beyond serving web/ read-only (README, "why
# there is a bridge").
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RemoveIPC = true;
UMask = "0077";
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
SystemCallArchitectures = "native";
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
}
(lib.mkIf cfg.nginx.enable {
services.nginx.enable = true;
services.nginx.virtualHosts.${cfg.nginx.domain} = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://${cfg.host}:${toString cfg.port}";
proxyWebsockets = true;
};
};
})
(lib.mkIf cfg.caddy.enable {
services.caddy.enable = true;
# No proxyWebsockets-style flag needed: Caddy's reverse_proxy upgrades a
# WebSocket connection transparently, and it self-provisions TLS for a
# non-http:// site address, so neither forceSSL nor enableACME has a
# caddy-side equivalent to set here.
services.caddy.virtualHosts.${cfg.caddy.domain}.extraConfig = ''
reverse_proxy ${cfg.host}:${toString cfg.port}
'';
})
]);
}