chore: flatten repository history
This commit is contained in:
commit
3a03fab876
30 changed files with 7223 additions and 0 deletions
62
nix/bridge.nix
Normal file
62
nix/bridge.nix
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# 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
|
||||
# A server this deployment pre-pins for every first-time visitor (the NixOS
|
||||
# module's `presetServer`). Both or neither — see web/js/config.js. Baking a
|
||||
# config value into the served JS is a build-time concern, not something
|
||||
# bridge.py — "one file, no keys" — should gain runtime logic to do.
|
||||
, serverHost ? null
|
||||
, serverPublicKey ? null
|
||||
}:
|
||||
|
||||
let
|
||||
pythonEnv = python3.withPackages (ps: [ ps.aiohttp ]);
|
||||
hasPreset = serverHost != null && serverPublicKey != null;
|
||||
presetLine = "export const PRESET_SERVER = "
|
||||
+ builtins.toJSON { host = serverHost; publicKey = serverPublicKey; } + ";";
|
||||
in
|
||||
assert lib.assertMsg ((serverHost == null) == (serverPublicKey == null))
|
||||
"gsmol-bridge: serverHost and serverPublicKey must be set together, or not at all";
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "gsmol-bridge";
|
||||
version = "0.2.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
|
||||
|
||||
${lib.optionalString hasPreset ''
|
||||
printf '%s\n' ${lib.escapeShellArg presetLine} > $out/share/gsmol/web/js/config.js
|
||||
''}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
234
nix/module.nix
Normal file
234
nix/module.nix
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# 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}
|
||||
'';
|
||||
})
|
||||
]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue