feat: package the bridge as a nix flake with a NixOS deploy module
This commit is contained in:
parent
80133da100
commit
db83ec1289
6 changed files with 285 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
||||||
.devbox/
|
.devbox/
|
||||||
.venv/
|
.venv/
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
.claude/
|
||||||
|
|
|
||||||
40
README.md
40
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 <port>`.
|
For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port <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
|
## 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.
|
- **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.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
27
flake.nix
Normal file
27
flake.nix
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
46
nix/bridge.nix
Normal file
46
nix/bridge.nix
Normal 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
143
nix/module.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue