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.
234 lines
8.2 KiB
Nix
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}
|
|
'';
|
|
})
|
|
]);
|
|
}
|