# 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 = self.packages.${pkgs.system}.default; defaultText = lib.literalExpression "gsmol.packages.\${system}.default"; description = "The gsmol-bridge package to run."; }; 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 (see `services.gsmol-bridge.nginx`) 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."; }; }; }; config = lib.mkIf cfg.enable (lib.mkMerge [ { 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; }; }; }) ]); }