# 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} ''; }) ]); }