feat: package the bridge as a nix flake with a NixOS deploy module

This commit is contained in:
randogoth 2026-09-26 19:10:59 +03:00
parent 80133da100
commit db83ec1289
6 changed files with 285 additions and 1 deletions

46
nix/bridge.nix Normal file
View file

@ -0,0 +1,46 @@
# Packages bridge.py + web/ as a standalone executable. bridge.py's own
# shebang (`uv run --script`) is only for `devbox run serve`; here it runs
# under a plain interpreter with aiohttp from nixpkgs, so no network access
# or uv is needed at build or run time — required for the Nix sandbox anyway.
{ lib, stdenvNoCC, makeWrapper, python3, nix-gitignore }:
let
pythonEnv = python3.withPackages (ps: [ ps.aiohttp ]);
in
stdenvNoCC.mkDerivation {
pname = "gsmol-bridge";
version = "0.1.0";
# Respects the repo's own .gitignore, so .venv/.devbox/.jj never enter the
# store path even when building from an unclean local checkout.
src = nix-gitignore.gitignoreSource [ ] ../.;
nativeBuildInputs = [ makeWrapper ];
dontBuild = true;
# The copied bridge.py is run as an argument to python3 (below), never via
# its own shebang — but fixupPhase mangles that shebang anyway, silently
# replacing "uv run --script" with a broken command. Harmless as shipped,
# but a landmine for anyone who later runs the store copy directly.
dontPatchShebangs = true;
installPhase = ''
runHook preInstall
mkdir -p $out/share/gsmol
cp bridge.py $out/share/gsmol/bridge.py
cp -r web $out/share/gsmol/web
makeWrapper ${pythonEnv}/bin/python3 $out/bin/gsmol-bridge \
--add-flags "$out/share/gsmol/bridge.py" \
--add-flags "--dir $out/share/gsmol/web"
runHook postInstall
'';
meta = {
description = "Static file server and WebSocket-to-TCP relay for the gsmol Smol Mail client";
homepage = "https://code.randogoth.com/randogoth/gsmol";
mainProgram = "gsmol-bridge";
platforms = lib.platforms.unix;
};
}

143
nix/module.nix Normal file
View file

@ -0,0 +1,143 @@
# 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;
};
};
})
]);
}