From 4503d8d36d1781d71bc4aa7aa16b4e7608aa77aa Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 22 Jun 2026 16:42:10 +0100 Subject: [PATCH] feat(nix): add Buzz relay NixOS module --- README.md | 1 + deploy/compose/README.md | 2 + deploy/nixos/README.md | 186 +++++++++++++++++++ flake.lock | 27 +++ flake.nix | 131 ++++++++++++++ nix/modules/buzz-relay.nix | 356 +++++++++++++++++++++++++++++++++++++ 6 files changed, 703 insertions(+) create mode 100644 deploy/nixos/README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/modules/buzz-relay.nix diff --git a/README.md b/README.md index 6aa3eeff0b..4464dd559b 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH - **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs - **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries - **[TESTING.md](TESTING.md)** — multi-agent E2E test suite +- **[deploy/nixos](deploy/nixos/README.md)** · **[deploy/compose](deploy/compose/README.md)** · **[deploy/charts/buzz](deploy/charts/buzz/README.md)** — relay deployment guides - **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**
diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 0de524fb5b..82f3e2b9f1 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -2,6 +2,8 @@ This is the single-node/VPS deployment bundle. It is intentionally separate from the root `docker-compose.yml`, which remains local development infrastructure. +For NixOS deployments, use the NixOS module guide in +[`../nixos/README.md`](../nixos/README.md). ## Quick start diff --git a/deploy/nixos/README.md b/deploy/nixos/README.md new file mode 100644 index 0000000000..fb32e3a597 --- /dev/null +++ b/deploy/nixos/README.md @@ -0,0 +1,186 @@ +# Buzz NixOS relay deployment + +This guide covers deploying the Buzz relay with the NixOS module exported by +the flake. The module runs the `buzz-relay` systemd service; it does not +provision the full backing stack for you. + +For a complete relay deployment, plan for: + +- PostgreSQL: canonical event store, full-text search, channels, tokens, + workflows, and audit log. +- Redis: pub/sub fan-out, presence, and typing indicators. +- S3-compatible object storage: media blobs, usually AWS S3, MinIO, or similar. +- Persistent local storage: git repository state under `services.buzz-relay.gitRepoPath`. +- Public DNS and TLS: clients should connect to a stable `wss://` relay URL. +- Stable secrets: relay identity, database credentials, S3 keys, and any git + hook secrets. + +## Import the module + +Add the Buzz flake input and import the NixOS module: + +```nix +{ + inputs.buzz.url = "github:block/buzz"; + + outputs = { self, nixpkgs, buzz, ... }: { + nixosConfigurations.relay = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + buzz.nixosModules.buzz-relay + ./configuration.nix + ]; + }; + }; +} +``` + +## Relay service + +Keep secrets out of normal Nix options when possible. Values in +`services.buzz-relay.environment` are written into the Nix store; use +`environmentFile` for passwords and private keys. + +```nix +{ ... }: + +{ + services.buzz-relay = { + enable = true; + + relayUrl = "wss://buzz.example.com"; + host = "127.0.0.1"; + port = 3000; + + redisUrl = "redis://localhost:6379"; + s3Endpoint = "https://s3.example.com"; + s3Bucket = "buzz-media"; + + environmentFile = "/run/secrets/buzz-relay.env"; + + # Leave this true unless migrations are handled by a separate job. + autoMigrate = true; + }; +} +``` + +When a value contains a secret, put the runtime environment variable in +`environmentFile` instead of setting the corresponding Nix option. + +Example secret file: + +```env +DATABASE_URL=postgres://buzz:CHANGE_ME@localhost:5432/buzz +BUZZ_RELAY_PRIVATE_KEY=CHANGE_ME_32_BYTE_HEX_PRIVATE_KEY +BUZZ_S3_ACCESS_KEY=CHANGE_ME +BUZZ_S3_SECRET_KEY=CHANGE_ME +BUZZ_GIT_HOOK_HMAC_SECRET=CHANGE_ME +``` + +`BUZZ_RELAY_PRIVATE_KEY` must stay stable across rebuilds and restores. Rotating +it gives the relay a new identity. + +## Backing services + +The NixOS module expects the backing services to exist. They can run on the same +host, in containers, or as managed external services. + +Minimum required environment: + +| Service | Required relay setting | Notes | +| --- | --- | --- | +| PostgreSQL | `DATABASE_URL` | Required. Run migrations before serving, or keep `autoMigrate = true`. | +| Redis | `REDIS_URL` | Required for cross-connection fan-out, presence, and typing. | +| S3-compatible storage | `BUZZ_S3_ENDPOINT`, `BUZZ_S3_BUCKET`, S3 keys | Required for media uploads. | +| Local filesystem | `gitRepoPath` | Persistent git repository state. Defaults under `/var/lib/buzz/git`. | + +For a single-node NixOS host, it is reasonable to run PostgreSQL and Redis with +native NixOS services, and run MinIO as a container or external service. For +production, managed PostgreSQL, Redis, and S3 are easier to back up and upgrade. + +## Reverse proxy and TLS + +The relay serves HTTP and WebSocket traffic on one port. Put it behind a TLS +reverse proxy and advertise the public WebSocket URL with `relayUrl`. + +Example with nginx: + +```nix +{ + services.nginx = { + enable = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + + virtualHosts."buzz.example.com" = { + forceSSL = true; + enableACME = true; + + locations."/" = { + proxyPass = "http://127.0.0.1:3000"; + proxyWebsockets = true; + }; + }; + }; + + security.acme.acceptTerms = true; + security.acme.defaults.email = "ops@example.com"; +} +``` + +If the relay binds directly to a public interface instead, set +`services.buzz-relay.openFirewall = true;`. For a reverse proxy on the same host, +keep the relay bound to `127.0.0.1` and open only ports 80/443. + +## Closed relay settings + +For a closed relay, configure an owner and require membership: + +```nix +{ + services.buzz-relay = { + requireRelayMembership = true; + ownerPubkey = "64_character_lowercase_hex_nostr_pubkey"; + }; +} +``` + +`RELAY_OWNER_PUBKEY` is intentionally not named with a `BUZZ_` prefix in the +runtime environment. The module exposes it as `ownerPubkey`. + +## Operations + +Useful checks: + +```bash +systemctl status buzz-relay +journalctl -u buzz-relay -f +curl -fsS http://127.0.0.1:8080/_liveness +curl -fsS http://127.0.0.1:8080/_readiness +``` + +The relay exposes: + +- Main HTTP/WebSocket traffic on `services.buzz-relay.port` (`3000` by default). +- Health probes on `services.buzz-relay.healthPort` (`8080` by default). +- Prometheus metrics on `services.buzz-relay.metricsPort` (`9102` by default). + +Back up these items before upgrades and before moving hosts: + +- PostgreSQL database. +- S3 bucket contents. +- `services.buzz-relay.dataDir`, especially the git repository path. +- `BUZZ_RELAY_PRIVATE_KEY`. +- Owner private key, which is held by the operator, not by the relay. +- Secret files used by `environmentFile`. + +If `autoMigrate = false`, run `buzz-admin migrate` from the configured package +against the database before starting a new relay version. For example, with +`DATABASE_URL` set: + +```bash +nix shell github:block/buzz#buzz-runtime --command buzz-admin migrate +``` + +Pin that flake reference to the same revision as the deployed module. The NixOS +module does not create a separate migration job. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..0ee7307546 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "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 0000000000..ff7e2d3be6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,131 @@ +{ + description = "Buzz relay NixOS module"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + nixosModules = { + buzz-relay = import ./nix/modules/buzz-relay.nix { inherit self; }; + default = self.nixosModules.buzz-relay; + }; + + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + lib = pkgs.lib; + source = lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + root = toString ./.; + rel = lib.removePrefix "${root}/" (toString path); + base = baseNameOf path; + in + !(base == ".git" + || base == ".jj" + || base == "target" + || base == "node_modules" + || lib.hasPrefix ".git/" rel + || lib.hasPrefix ".jj/" rel + || lib.hasPrefix "target/" rel + || lib.hasInfix "/target/" rel + || lib.hasInfix "/node_modules/" rel); + }; + relayRuntime = pkgs.rustPlatform.buildRustPackage { + pname = "buzz-relay-runtime"; + version = "0.1.0"; + + src = source; + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; + }; + + cargoBuildFlags = [ + "-p" + "buzz-relay" + "-p" + "buzz-admin" + ]; + doCheck = false; + + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + ]; + + buildInputs = with pkgs; [ openssl ]; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/bin" + for bin in buzz-relay buzz-admin; do + bin_path="$(find target -type f -path "*/release/$bin" -perm -0100 | head -n 1)" + if [ -z "$bin_path" ]; then + echo "Could not find built binary: $bin" >&2 + find target -type f -perm -0100 >&2 + exit 1 + fi + install -Dm755 "$bin_path" "$out/bin/$bin" + done + + runHook postInstall + ''; + + meta = { + description = "Buzz relay and migration binaries"; + license = lib.licenses.asl20; + mainProgram = "buzz-relay"; + }; + }; + in + { + default = relayRuntime; + buzz-runtime = relayRuntime; + relay-runtime = relayRuntime; + }); + + checks = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + relayModule = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + self.nixosModules.buzz-relay + { + system.stateVersion = "25.05"; + services.buzz-relay = { + enable = true; + host = "::1"; + port = 3456; + autoMigrate = false; + openFirewall = true; + }; + } + ]; + }; + config = relayModule.config; + environment = config.systemd.services.buzz-relay.environment; + moduleCheck = + assert environment.BUZZ_BIND_ADDR == "[::1]:3456"; + assert environment.BUZZ_AUTO_MIGRATE == "false"; + assert builtins.elem 3456 config.networking.firewall.allowedTCPPorts; + pkgs.runCommand "buzz-relay-nixos-module-check" { } '' + test -x ${self.packages.${system}.buzz-runtime}/bin/buzz-relay + touch "$out" + ''; + in + { + nixos-module = moduleCheck; + }); + }; +} diff --git a/nix/modules/buzz-relay.nix b/nix/modules/buzz-relay.nix new file mode 100644 index 0000000000..9c79ecbacb --- /dev/null +++ b/nix/modules/buzz-relay.nix @@ -0,0 +1,356 @@ +{ self }: +{ config, lib, pkgs, ... }: + +let + cfg = config.services.buzz-relay; + + inherit (lib) + boolToString + concatStringsSep + mkEnableOption + mkIf + mkOption + optionalAttrs + types; + + envValue = value: + if builtins.isBool value then boolToString value + else toString value; + + nullableEnv = name: value: + optionalAttrs (value != null) { ${name} = value; }; + + bindHost = + if lib.hasPrefix "[" cfg.host && lib.hasSuffix "]" cfg.host then cfg.host + else if lib.hasInfix ":" cfg.host then "[${cfg.host}]" + else cfg.host; + + relayEnv = + { + BUZZ_BIND_ADDR = "${bindHost}:${toString cfg.port}"; + BUZZ_HEALTH_PORT = cfg.healthPort; + BUZZ_METRICS_PORT = cfg.metricsPort; + BUZZ_MAX_CONNECTIONS = cfg.maxConnections; + BUZZ_MAX_CONCURRENT_HANDLERS = cfg.maxConcurrentHandlers; + BUZZ_SEND_BUFFER = cfg.sendBuffer; + BUZZ_REQUIRE_AUTH_TOKEN = cfg.requireAuthToken; + BUZZ_REQUIRE_RELAY_MEMBERSHIP = cfg.requireRelayMembership; + BUZZ_ALLOW_NIP_OA_AUTH = cfg.allowNipOaAuth; + BUZZ_PUBKEY_ALLOWLIST = cfg.pubkeyAllowlist; + BUZZ_AUTO_MIGRATE = cfg.autoMigrate; + BUZZ_GIT_REPO_PATH = cfg.gitRepoPath; + BUZZ_GIT_MAX_PACK_BYTES = cfg.git.maxPackBytes; + BUZZ_GIT_MAX_REPOS_PER_PUBKEY = cfg.git.maxReposPerPubkey; + BUZZ_GIT_MAX_CONCURRENT_OPS = cfg.git.maxConcurrentOps; + RUST_LOG = cfg.logFilter; + } + // nullableEnv "RELAY_URL" cfg.relayUrl + // nullableEnv "RELAY_OWNER_PUBKEY" cfg.ownerPubkey + // nullableEnv "DATABASE_URL" cfg.databaseUrl + // nullableEnv "REDIS_URL" cfg.redisUrl + // nullableEnv "BUZZ_S3_ENDPOINT" cfg.s3Endpoint + // nullableEnv "BUZZ_S3_BUCKET" cfg.s3Bucket + // nullableEnv "BUZZ_MEDIA_BASE_URL" cfg.mediaBaseUrl + // nullableEnv "BUZZ_MEDIA_SERVER_DOMAIN" cfg.mediaServerDomain + // nullableEnv "BUZZ_WEB_DIR" cfg.webDir + // optionalAttrs (cfg.corsOrigins != [ ]) { + BUZZ_CORS_ORIGINS = concatStringsSep "," cfg.corsOrigins; + } + // optionalAttrs (cfg.ephemeralTtlOverride != null) { + BUZZ_EPHEMERAL_TTL_OVERRIDE = cfg.ephemeralTtlOverride; + }; +in +{ + options.services.buzz-relay = { + enable = mkEnableOption "Buzz relay"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.buzz-runtime; + defaultText = lib.literalExpression "inputs.buzz.packages.\${pkgs.stdenv.hostPlatform.system}.buzz-runtime"; + description = "Package providing the buzz-relay binary."; + }; + + user = mkOption { + type = types.str; + default = "buzz-relay"; + description = "User account that runs the relay."; + }; + + group = mkOption { + type = types.str; + default = "buzz-relay"; + description = "Group account that runs the relay."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/buzz"; + description = "Persistent state directory for relay-managed local data."; + }; + + gitRepoPath = mkOption { + type = types.path; + default = "${cfg.dataDir}/git"; + defaultText = lib.literalExpression ''"${config.services.buzz-relay.dataDir}/git"''; + description = "Directory used by the relay for git repository state."; + }; + + host = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "Address the relay HTTP/WebSocket server binds to."; + }; + + port = mkOption { + type = types.port; + default = 3000; + description = "TCP port for the relay HTTP/WebSocket server."; + }; + + healthPort = mkOption { + type = types.port; + default = 8080; + description = "TCP port for relay liveness, readiness, and status probes."; + }; + + metricsPort = mkOption { + type = types.port; + default = 9102; + description = "TCP port for Prometheus metrics."; + }; + + relayUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "wss://buzz.example.com"; + description = "Public WebSocket URL advertised by the relay."; + }; + + mediaBaseUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "https://buzz.example.com/media"; + description = "Public base URL for media served by the relay."; + }; + + mediaServerDomain = mkOption { + type = types.nullOr types.str; + default = null; + example = "buzz.example.com"; + description = "Domain clients should associate with relay-hosted media."; + }; + + ownerPubkey = mkOption { + type = types.nullOr types.str; + default = null; + example = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + description = "Optional 64-character hex Nostr pubkey to bootstrap as relay owner."; + }; + + databaseUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "postgres://buzz:buzz@localhost:5432/buzz"; + description = "Postgres connection URL. Prefer environmentFile when it contains a password."; + }; + + redisUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "redis://localhost:6379"; + description = "Redis URL used for pub/sub fan-out."; + }; + + s3Endpoint = mkOption { + type = types.nullOr types.str; + default = null; + example = "http://localhost:9000"; + description = "S3-compatible object storage endpoint for media."; + }; + + s3Bucket = mkOption { + type = types.nullOr types.str; + default = null; + example = "buzz-media"; + description = "S3 bucket name for media."; + }; + + webDir = mkOption { + type = types.nullOr types.path; + default = null; + description = "Optional built web UI directory containing index.html."; + }; + + requireAuthToken = mkOption { + type = types.bool; + default = true; + description = "Whether REST API requests must present a valid token."; + }; + + requireRelayMembership = mkOption { + type = types.bool; + default = false; + description = "Whether authenticated requests must pass relay membership checks."; + }; + + allowNipOaAuth = mkOption { + type = types.bool; + default = false; + description = "Whether NIP-OA owner attestation can grant membership access on closed relays."; + }; + + pubkeyAllowlist = mkOption { + type = types.bool; + default = false; + description = "Whether NIP-42 pubkey-only auth is restricted to the pubkey allowlist."; + }; + + autoMigrate = mkOption { + type = types.bool; + default = true; + description = "Whether buzz-relay runs embedded SQL migrations at startup."; + }; + + maxConnections = mkOption { + type = types.ints.positive; + default = 10000; + description = "Maximum number of concurrent WebSocket connections."; + }; + + maxConcurrentHandlers = mkOption { + type = types.ints.positive; + default = 1024; + description = "Maximum number of concurrently executing message handlers."; + }; + + sendBuffer = mkOption { + type = types.ints.positive; + default = 1000; + description = "Per-connection outbound message buffer size."; + }; + + corsOrigins = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "https://buzz.example.com" "tauri://localhost" ]; + description = "Allowed CORS origins. Empty keeps the relay's permissive development default."; + }; + + ephemeralTtlOverride = mkOption { + type = types.nullOr types.ints.positive; + default = null; + description = "Optional TTL override, in seconds, for ephemeral channels."; + }; + + git = { + maxPackBytes = mkOption { + type = types.ints.positive; + default = 500 * 1024 * 1024; + description = "Maximum accepted git pack size in bytes."; + }; + + maxReposPerPubkey = mkOption { + type = types.ints.positive; + default = 100; + description = "Maximum number of git repositories per pubkey."; + }; + + maxConcurrentOps = mkOption { + type = types.ints.positive; + default = 20; + description = "Maximum number of concurrent git subprocess operations."; + }; + }; + + logFilter = mkOption { + type = types.str; + default = "info,buzz_relay=info"; + description = "RUST_LOG filter for the relay service."; + }; + + environment = mkOption { + type = types.attrsOf (types.oneOf [ types.str types.int types.bool types.path ]); + default = { }; + example = { + BUZZ_RELAY_PRIVATE_KEY = "32-byte-hex-private-key"; + }; + description = '' + Extra environment variables for buzz-relay. + + Values here are written into the Nix store. Prefer environmentFile for + secrets such as DATABASE_URL, BUZZ_RELAY_PRIVATE_KEY, + BUZZ_GIT_HOOK_HMAC_SECRET, BUZZ_S3_ACCESS_KEY, and BUZZ_S3_SECRET_KEY. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr (types.either types.path (types.listOf types.path)); + default = null; + example = "/run/secrets/buzz-relay.env"; + description = "Environment file or files read by systemd for secrets and deployment-specific overrides."; + }; + + path = mkOption { + type = types.listOf types.package; + default = with pkgs; [ git openssl curl ]; + defaultText = lib.literalExpression "with pkgs; [ git openssl curl ]"; + description = "Packages added to PATH for relay-managed git subprocesses and hooks."; + }; + + extraReadWritePaths = mkOption { + type = types.listOf types.path; + default = [ ]; + description = "Additional paths the hardened systemd unit may write to."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Whether to open the relay port in the NixOS firewall."; + }; + }; + + config = mkIf cfg.enable { + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.gitRepoPath} 0750 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.buzz-relay = { + description = "Buzz relay"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + path = cfg.path; + environment = builtins.mapAttrs (_: envValue) (relayEnv // cfg.environment); + + serviceConfig = { + ExecStart = "${cfg.package}/bin/buzz-relay"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.dataDir; + Restart = "on-failure"; + RestartSec = "5s"; + + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadWritePaths = [ cfg.dataDir cfg.gitRepoPath ] ++ cfg.extraReadWritePaths; + } // optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + + networking.firewall.allowedTCPPorts = lib.optional cfg.openFirewall cfg.port; + }; +}