diff --git a/.gitignore b/.gitignore index 055b698..148de57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .devbox/ .venv/ +result +result-* +.claude/ diff --git a/README.md b/README.md index 1485e2b..522099b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,44 @@ Then open http://127.0.0.1:8096. First use: create an identity, back up the seed For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port `. +## Deploy + +`flake.nix` packages the bridge for a server, separately from `devbox`'s local dev loop: + +``` +nix build .#default # -> ./result/bin/gsmol-bridge +nix run . # runs it directly, default flags +``` + +The package wraps `bridge.py` with `python3` and `aiohttp` from nixpkgs, not `uv` — no network access at build or run time, and the script's `#!/usr/bin/env -S uv run --script` shebang stays relevant only to `devbox run serve`. + +For a NixOS host, `nixosModules.default` provides `services.gsmol-bridge`: + +```nix +{ + inputs.gsmol.url = "git+https://code.randogoth.com/randogoth/gsmol.git"; + + outputs = { self, nixpkgs, gsmol, ... }: { + nixosConfigurations.mail = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + gsmol.nixosModules.default + { + services.gsmol-bridge = { + enable = true; + allowPorts = [ 1961 ]; # smolmaild's port(s) + nginx.enable = true; # TLS via ACME, in front of the bridge + nginx.domain = "mail.example.org"; + }; + } + ]; + }; + }; +} +``` + +The service runs under `DynamicUser`, restarts on failure, and — like the standalone script — listens on `127.0.0.1` by default. `nginx.enable` puts a TLS-terminating reverse proxy in front rather than exposing the bridge directly: it has no TLS of its own, and a browser refuses a `ws://` connection from an `https://` page, so a real deployment needs the proxy regardless of `--allow-origin`. `openFirewall = true` is for exposing the bridge's port directly instead, which is not the recommended path (see "Notes and limits"). + ## What it implements - **Identity** (§2): one 32-byte seed; create or restore; the X25519 agreement keys are derived from the Ed25519 keypair. The seed lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary. @@ -62,4 +100,4 @@ What is not verified here: clicking through the actual browser UI. `devbox run s - The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications in v1 (SPEC.md §13); click fetch. - Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence. -- The bridge binds to 127.0.0.1. Serving it beyond localhost is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). +- The bridge binds to 127.0.0.1 by default and has no TLS of its own. Serving it beyond localhost directly is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). The NixOS module's `nginx.enable` puts a TLS-terminating reverse proxy in front instead, which is the deployment path "Deploy" above documents. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..388599b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790323409, + "narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..70e9195 --- /dev/null +++ b/flake.nix @@ -0,0 +1,27 @@ +{ + description = "gsmol: browser client for Smol Mail — bridge package and NixOS module"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + packages = forEachSystem (pkgs: { + default = pkgs.callPackage ./nix/bridge.nix { }; + }); + + apps = forEachSystem (pkgs: { + default = { + type = "app"; + program = pkgs.lib.getExe self.packages.${pkgs.system}.default; + }; + }); + + # Curried on `self` so the module's default package is drawn from the + # `packages` output above rather than a second, driftable copy. + nixosModules.default = import ./nix/module.nix self; + }; +} diff --git a/nix/bridge.nix b/nix/bridge.nix new file mode 100644 index 0000000..2c90567 --- /dev/null +++ b/nix/bridge.nix @@ -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; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..8313bd5 --- /dev/null +++ b/nix/module.nix @@ -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; + }; + }; + }) + ]); +}