diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..6302119f41 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,62 @@ +name: Nix + +on: + push: + branches: [main, release] + paths: + - ".github/workflows/nix.yml" + - "Cargo.lock" + - "Cargo.toml" + - "crates/**" + - "desktop/**" + - "flake.lock" + - "flake.nix" + - "nix/**" + - "package.json" + - "patches/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "rust-toolchain.toml" + pull_request: + paths: + - ".github/workflows/nix.yml" + - "Cargo.lock" + - "Cargo.toml" + - "crates/**" + - "desktop/**" + - "flake.lock" + - "flake.nix" + - "nix/**" + - "package.json" + - "patches/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "rust-toolchain.toml" + +concurrency: + group: nix-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + build: + name: Build packages + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + + - name: Check Nix formatting + run: nix fmt -- --check flake.nix nix/buzz.nix nix/versions.nix nix/modules/buzz-relay.nix + + - name: Evaluate all supported systems + run: nix flake show --all-systems + + - name: Build relay and NixOS module check + run: nix flake check --print-build-logs + + - name: Build desktop and sidecars + run: nix build --print-build-logs .#buzz-desktop .#buzz-sidecars diff --git a/README.md b/README.md index 72af92ce13..76bb51cce3 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,17 @@ Relay on `ws://localhost:3000`. Desktop app pops up. You're in. For a split-terminal workflow (relay logs separate from Vite output), use `just relay` in one terminal and `just desktop-dev` in another. +Using Nix, the flake exposes the desktop app, relay server, administration CLI, +agent sidecars, and a NixOS relay module: + +```bash +nix run .#buzz-desktop +nix run .#buzz-relay +``` + +See the [NixOS deployment guide](deploy/nixos/README.md) for package names, +module options, secrets, and reverse-proxy configuration. + Want a single-node / VPS relay instead of the local-dev stack? Use the production Compose bundle in [`deploy/compose/`](deploy/compose/README.md) (`docker compose` + Postgres, Redis, MinIO, optional Caddy/TLS). The root [`docker-compose.yml`](docker-compose.yml) is for day-to-day development only. For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls. @@ -223,6 +234,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 bb0e63fe15..41b3d9b015 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..211859fdd5 --- /dev/null +++ b/deploy/nixos/README.md @@ -0,0 +1,213 @@ +# Buzz on NixOS + +The repository flake builds the Buzz desktop app, relay server, administration +CLI, and agent sidecars. It also exports a NixOS module for running the relay as +a hardened systemd service. + +## Build and run + +From a Buzz checkout: + +```bash +nix build .#buzz-desktop +nix build .#buzz-relay +nix build .#buzz-sidecars + +nix run .#buzz-desktop +nix run .#buzz-relay +nix run .#buzz-admin -- --help +``` + +The `buzz-relay` package contains both `buzz-relay` and `buzz-admin`. The named +apps select the appropriate executable. + +### Codex ACP adapter + +The desktop package includes Buzz's own agent sidecars, but external managed +agent runtimes and their ACP adapters must also be available on `PATH`. + +Despite having the same `codex-acp` executable name, `pkgs.codex-acp` at this +flake's pinned nixpkgs revision is version 0.13.0 from the legacy +`@zed-industries/codex-acp` project. Buzz requires the official +`@agentclientprotocol/codex-acp` adapter at or above the minimum version +enforced by the desktop app (currently 1.1.7). NixOS configurations should +package that upstream adapter explicitly rather than install the nixpkgs +package with the matching name. The Codex CLI must also be on `PATH`, or its +executable can be selected with `CODEX_PATH`. + +## Import the NixOS module + +Pin Buzz as a flake input and import its relay module: + +```nix +{ + inputs.buzz.url = "github:block/buzz"; + + outputs = + { + nixpkgs, + buzz, + ... + }: + { + nixosConfigurations.relay = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + buzz.nixosModules.buzz-relay + ./configuration.nix + ]; + }; + }; +} +``` + +The module supports x86-64 and ARM64 Linux. + +## Configure the relay + +The module runs the relay but does not provision PostgreSQL, Redis, object +storage, DNS, or TLS. Those services may run on the same host or be provided +externally. + +```nix +{ + services.buzz-relay = { + enable = true; + + relayUrl = "wss://buzz.example.com"; + host = "127.0.0.1"; + port = 3000; + + databaseUrl = "postgres://buzz@localhost:5432/buzz"; + redisUrl = "redis://localhost:6379"; + s3Endpoint = "https://s3.example.com"; + s3Bucket = "buzz-media"; + s3Region = "eu-west-2"; + s3AddressingStyle = "path"; + mediaBaseUrl = "https://buzz.example.com/media"; + + environmentFile = "/run/secrets/buzz-relay.env"; + autoMigrate = true; + }; +} +``` + +URLs containing credentials can be supplied through `environmentFile` instead +of normal Nix options, which are copied into the Nix store. + +Example runtime 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_AT_LEAST_32_CHARACTERS +``` + +Keep `BUZZ_RELAY_PRIVATE_KEY` stable across rebuilds and restores. A changed key +gives the relay a new identity. + +Unmodeled relay settings can be passed through `environment` when they are not +secret: + +```nix +{ + services.buzz-relay.environment = { + BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN = 120; + BUZZ_MEDIA_UPLOADS_PER_MINUTE = 20; + }; +} +``` + +Deployments with a Postgres read replica can size its pool independently and +opt into bounded-staleness routing: + +```nix +{ + services.buzz-relay = { + readDatabaseUrl = "postgres://buzz@reader.example.com:5432/buzz"; + readDatabasePoolSize = 25; + replicaReadMaxAgeMs = 1000; + }; +} +``` + +Replica routing remains disabled when `replicaReadMaxAgeMs` is zero. + +## Closed relays + +Membership enforcement requires a stable relay key and an owner pubkey: + +```nix +{ + services.buzz-relay = { + requireRelayMembership = true; + ownerPubkey = "64_character_lowercase_hex_nostr_pubkey"; + environmentFile = "/run/secrets/buzz-relay.env"; + }; +} +``` + +The environment file must provide `BUZZ_RELAY_PRIVATE_KEY`. + +## Reverse proxy + +The public HTTP and WebSocket traffic shares one port. A typical deployment +binds Buzz to localhost and terminates TLS 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; + defaults.email = "ops@example.com"; + }; +} +``` + +`openFirewall` opens only the public relay port. Health and metrics ports remain +private unless configured separately. + +## Operations + +The module creates a `buzz-relay` system user and persists git data beneath +`dataDir`, which defaults to `/var/lib/buzz`. + +```bash +systemctl status buzz-relay +journalctl -u buzz-relay -f +curl --fail http://127.0.0.1:8080/_liveness +curl --fail http://127.0.0.1:8080/_readiness +``` + +Prometheus metrics are exposed on port `9102` by default. + +For horizontally scaled relay deployments, disable in-process huddle audio: + +```nix +services.buzz-relay.huddleAudioAvailable = false; +``` + +Back up PostgreSQL, the S3 bucket, `dataDir`, and all secret files before +upgrades. If `autoMigrate` is disabled, run `buzz-admin migrate` from the same +pinned Buzz revision before starting the new relay: + +```bash +nix run github:block/buzz#buzz-admin -- migrate +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..d72f92d213 --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1785314864, + "narHash": "sha256-imz9J5iMNersDeQ1ymenFg35N6C2R3MCaGg2i0XCIrE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "594418b2c9ea0731bee6988236ef5bfd97e81dca", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784796856, + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1785261141, + "narHash": "sha256-sA+DHPejWD68mLA7gtTiHGd5T41F6W+NKf0g+ibFCW4=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "bec66814323579659ffd77c909b3d963af118ece", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..ed0a6d3f49 --- /dev/null +++ b/flake.nix @@ -0,0 +1,157 @@ +{ + description = "Buzz desktop app, relay server, and agent tools"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + fenix, + }: + flake-utils.lib.eachSystem + [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ] + ( + system: + let + pkgs = import nixpkgs { inherit system; }; + toolchain = fenix.packages.${system}.fromToolchainFile { + file = ./rust-toolchain.toml; + sha256 = "sha256-gh/xTkxKHL4eiRXzWv8KP7vfjSk61Iq48x47BEDFgfk="; + }; + rustPlatform = pkgs.makeRustPlatform { + cargo = toolchain; + rustc = toolchain; + }; + source = pkgs.lib.cleanSourceWith { + src = self; + filter = + path: type: + let + name = baseNameOf path; + in + !( + ( + type == "directory" + && builtins.elem name [ + ".git" + ".github" + "deploy" + "docs" + "mobile" + "nix" + "result" + ] + ) + || builtins.elem name [ + "flake.lock" + "flake.nix" + "README.md" + ] + ); + }; + buzzPackages = pkgs.callPackage ./nix/buzz.nix { + src = source; + inherit rustPlatform; + }; + in + { + packages = { + inherit (buzzPackages) buzz-desktop buzz-sidecars; + default = buzzPackages.buzz-desktop; + } + // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + inherit (buzzPackages) buzz-relay; + }; + + apps = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + buzz-relay = { + type = "app"; + program = "${buzzPackages.buzz-relay}/bin/buzz-relay"; + meta.description = "Run the Buzz relay server"; + }; + buzz-admin = { + type = "app"; + program = "${buzzPackages.buzz-relay}/bin/buzz-admin"; + meta.description = "Run the Buzz relay administration CLI"; + }; + }; + + checks = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + nixos-module = + let + relaySystem = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + self.nixosModules.buzz-relay + { + system.stateVersion = "25.05"; + services.buzz-relay = { + enable = true; + host = "::1"; + port = 3456; + healthPort = 3457; + metricsPort = 3458; + autoMigrate = false; + openFirewall = true; + readDatabasePoolSize = 24; + replicaReadMaxAgeMs = 1000; + s3AddressingStyle = "virtual"; + environmentFile = [ "-/run/secrets/buzz-relay" ]; + environment.BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN = 120; + }; + } + ]; + }; + config = relaySystem.config; + environment = config.systemd.services.buzz-relay.environment; + in + assert environment.BUZZ_BIND_ADDR == "[::1]:3456"; + assert environment.BUZZ_AUTO_MIGRATE == "false"; + assert environment.BUZZ_DB_READ_POOL_SIZE == "24"; + assert environment.BUZZ_REPLICA_READ_MAX_AGE_MS == "1000"; + assert environment.BUZZ_S3_ADDRESSING_STYLE == "virtual"; + assert environment.BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN == "120"; + assert + config.systemd.services.buzz-relay.serviceConfig.EnvironmentFile == [ + "-/run/secrets/buzz-relay" + ]; + assert builtins.elem 3456 config.networking.firewall.allowedTCPPorts; + pkgs.runCommand "buzz-relay-nixos-module-check" { } '' + test -x ${buzzPackages.buzz-relay}/bin/buzz-relay + test -x ${buzzPackages.buzz-relay}/bin/buzz-admin + touch "$out" + ''; + }; + + formatter = pkgs.nixfmt; + + devShells.default = pkgs.mkShell { + inputsFrom = [ buzzPackages.buzz-desktop ]; + packages = [ + toolchain + pkgs.just + ]; + SHERPA_ONNX_ARCHIVE_DIR = buzzPackages.buzz-desktop.passthru.sherpaOnnxArchiveDir; + }; + } + ) + // { + nixosModules = { + buzz-relay = import ./nix/modules/buzz-relay.nix { inherit self; }; + default = self.nixosModules.buzz-relay; + }; + }; +} diff --git a/nix/buzz.nix b/nix/buzz.nix new file mode 100644 index 0000000000..0be09460cf --- /dev/null +++ b/nix/buzz.nix @@ -0,0 +1,264 @@ +{ + lib, + rustPlatform, + fetchPnpmDeps, + fetchurl, + linkFarm, + jq, + moreutils, + cmake, + cargo-tauri, + nodejs, + pnpm, + pnpmConfigHook, + pkg-config, + autoPatchelfHook, + wrapGAppsHook3, + yq, + gst_all_1, + glib-networking, + bzip2, + openssl, + webkitgtk_4_1, + alsa-lib, + libopus, + dbus, + glib, + gtk3, + libayatana-appindicator, + libsoup_3, + librsvg, + libiconv, + stdenv, + src, +}: + +let + versions = import ./versions.nix; + desktopVersion = + (builtins.fromTOML (builtins.readFile (src + "/desktop/src-tauri/Cargo.toml"))).package.version; + relayVersion = + (builtins.fromTOML (builtins.readFile (src + "/crates/buzz-relay/Cargo.toml"))).package.version; + + sherpaOnnxSystemConfig = + versions.sherpaOnnx.systems.${stdenv.hostPlatform.system} + or (builtins.throw "Unsupported platform: ${stdenv.hostPlatform.system}. Supported platforms: ${builtins.toString (builtins.attrNames versions.sherpaOnnx.systems)}"); + + sherpaOnnxArchive = fetchurl { + name = "sherpa-onnx-v${versions.sherpaOnnx.version}-${sherpaOnnxSystemConfig.urlSuffix}.tar.bz2"; + url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/v${versions.sherpaOnnx.version}/sherpa-onnx-v${versions.sherpaOnnx.version}-${sherpaOnnxSystemConfig.urlSuffix}.tar.bz2"; + hash = sherpaOnnxSystemConfig.hash; + }; + + sherpaOnnxArchiveDir = linkFarm "sherpa-onnx-archive" [ + { + name = "sherpa-onnx-v${versions.sherpaOnnx.version}-${sherpaOnnxSystemConfig.urlSuffix}.tar.bz2"; + path = sherpaOnnxArchive; + } + ]; + + sidecarPackages = [ + "buzz-acp" + "buzz-agent" + "buzz-dev-mcp" + "git-credential-nostr" + "buzz-cli" + ]; + sidecarBinNames = [ + "buzz-acp" + "buzz-agent" + "buzz-dev-mcp" + "git-credential-nostr" + "buzz" + ]; + prunePnpmDevTools = '' + jq 'del(.devDependencies["@biomejs/biome"])' package.json \ + | sponge package.json + jq 'del(.devDependencies["@tauri-apps/cli"])' desktop/package.json \ + | sponge desktop/package.json + + yq -y -i \ + 'del(.importers["."].devDependencies["@biomejs/biome"]) + | del(.importers.desktop.devDependencies["@tauri-apps/cli"])' \ + pnpm-lock.yaml + ''; + sidecars = rustPlatform.buildRustPackage { + pname = "buzz-sidecars"; + version = desktopVersion; + + inherit src; + + cargoLock = { + lockFileContents = builtins.readFile (src + "/Cargo.lock"); + outputHashes = versions.workspaceCargoOutputHashes; + }; + + cargoBuildFlags = lib.concatLists ( + map (p: [ + "-p" + p + ]) sidecarPackages + ); + doCheck = false; + + nativeBuildInputs = [ + cmake + pkg-config + ]; + + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ + openssl + ]; + + meta = with lib; { + description = "Buzz sidecar binaries"; + license = licenses.asl20; + platforms = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + }; + }; + + relayRuntime = rustPlatform.buildRustPackage { + pname = "buzz-relay-runtime"; + version = relayVersion; + + inherit src; + + cargoLock = { + lockFileContents = builtins.readFile (src + "/Cargo.lock"); + outputHashes = versions.workspaceCargoOutputHashes; + }; + + cargoBuildFlags = [ + "-p" + "buzz-relay" + "-p" + "buzz-admin" + ]; + doCheck = false; + + nativeBuildInputs = [ + cmake + pkg-config + ]; + + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ + openssl + ]; + + meta = with lib; { + description = "Buzz relay server and administration CLI"; + homepage = "https://github.com/block/buzz"; + license = licenses.asl20; + platforms = platforms.linux; + mainProgram = "buzz-relay"; + }; + }; +in +{ + buzz-relay = relayRuntime; + buzz-sidecars = sidecars; + + buzz-desktop = rustPlatform.buildRustPackage (finalAttrs: { + pname = "buzz-desktop"; + version = desktopVersion; + inherit src; + + cargoRoot = "desktop/src-tauri"; + buildAndTestSubdir = "desktop/src-tauri"; + + cargoLock = { + lockFileContents = builtins.readFile (src + "/desktop/src-tauri/Cargo.lock"); + outputHashes = versions.desktopCargoOutputHashes; + }; + + doCheck = false; + + pnpmDeps = fetchPnpmDeps { + inherit (finalAttrs) pname version src; + inherit pnpm; + pnpmWorkspaces = [ "buzz" ]; + fetcherVersion = 4; + hash = versions.pnpmHash; + postPatch = prunePnpmDevTools; + prePnpmInstall = '' + pnpm config set network-concurrency 4 + pnpm config set fetch-retries 5 + pnpm config set fetch-retry-maxtimeout 120000 + pnpm config set fetch-timeout 600000 + ''; + }; + + postPatch = prunePnpmDevTools; + + pnpmWorkspaces = [ "buzz" ]; + + preBuild = '' + export SHERPA_ONNX_ARCHIVE_DIR=${sherpaOnnxArchiveDir} + ${lib.optionalString stdenv.hostPlatform.isLinux '' + export LD_LIBRARY_PATH=${lib.makeLibraryPath [ bzip2 ]} + ''} + + mkdir -p desktop/src-tauri/binaries + for bin in ${builtins.concatStringsSep " " sidecarBinNames}; do + cp ${sidecars}/bin/$bin desktop/src-tauri/binaries/$bin-${stdenv.hostPlatform.config} + done + ''; + + nativeBuildInputs = [ + cmake + cargo-tauri.hook + jq + moreutils + nodejs + pnpmConfigHook + pnpm + pkg-config + yq + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + autoPatchelfHook + wrapGAppsHook3 + ]; + + buildInputs = + lib.optionals stdenv.hostPlatform.isLinux [ + alsa-lib + bzip2 + libopus + dbus + glib + gtk3 + libayatana-appindicator + libsoup_3 + librsvg + glib-networking + openssl + webkitgtk_4_1 + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good + gst_all_1.gst-plugins-bad + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ libiconv ]; + + passthru = { + inherit sherpaOnnxArchiveDir sidecars; + }; + + meta = with lib; { + description = "Buzz desktop app"; + homepage = "https://buzz.ai"; + license = licenses.asl20; + platforms = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + mainProgram = "buzz-desktop"; + }; + }); +} diff --git a/nix/modules/buzz-relay.nix b/nix/modules/buzz-relay.nix new file mode 100644 index 0000000000..f58712180c --- /dev/null +++ b/nix/modules/buzz-relay.nix @@ -0,0 +1,495 @@ +{ 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; + + relayEnvironment = { + BUZZ_BIND_ADDR = "${bindHost}:${toString cfg.port}"; + BUZZ_HEALTH_PORT = cfg.healthPort; + BUZZ_METRICS_PORT = cfg.metricsPort; + BUZZ_REDIS_POOL_SIZE = cfg.redisPoolSize; + BUZZ_DB_POOL_SIZE = cfg.databasePoolSize; + BUZZ_REPLICA_READ_MAX_AGE_MS = cfg.replicaReadMaxAgeMs; + BUZZ_MAX_CONNECTIONS = cfg.maxConnections; + BUZZ_MAX_CONCURRENT_HANDLERS = cfg.maxConcurrentHandlers; + BUZZ_SEND_BUFFER = cfg.sendBuffer; + BUZZ_MAX_FRAME_BYTES = cfg.maxFrameBytes; + BUZZ_SLOW_CLIENT_GRACE_LIMIT = cfg.slowClientGraceLimit; + 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_REQUIRE_MEDIA_GET_AUTH = cfg.requireMediaGetAuth; + BUZZ_HUDDLE_AUDIO_AVAILABLE = cfg.huddleAudioAvailable; + BUZZ_AUDIT_ENABLED = cfg.auditEnabled; + BUZZ_AUTO_MIGRATE = cfg.autoMigrate; + BUZZ_GIT_REPO_PATH = cfg.gitRepoPath; + BUZZ_GIT_PACK_CACHE_PATH = cfg.git.packCachePath; + BUZZ_GIT_MAX_PACK_BYTES = cfg.git.maxPackBytes; + BUZZ_GIT_MAX_REPO_BYTES = cfg.git.maxRepoBytes; + BUZZ_GIT_PACK_CACHE_MAX_BYTES = cfg.git.packCacheMaxBytes; + BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS = cfg.git.packCacheMaxConcurrentPopulations; + 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 "READ_DATABASE_URL" cfg.readDatabaseUrl + // nullableEnv "BUZZ_DB_READ_POOL_SIZE" cfg.readDatabasePoolSize + // nullableEnv "REDIS_URL" cfg.redisUrl + // nullableEnv "BUZZ_S3_ENDPOINT" cfg.s3Endpoint + // nullableEnv "BUZZ_S3_BUCKET" cfg.s3Bucket + // nullableEnv "BUZZ_S3_REGION" cfg.s3Region + // nullableEnv "BUZZ_S3_ADDRESSING_STYLE" cfg.s3AddressingStyle + // nullableEnv "BUZZ_MEDIA_BASE_URL" cfg.mediaBaseUrl + // 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-relay; + defaultText = lib.literalExpression "inputs.buzz.packages.\${pkgs.system}.buzz-relay"; + description = "Package providing the buzz-relay and buzz-admin binaries."; + }; + + 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 for git repository state."; + }; + + host = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "Address on which the relay HTTP and WebSocket server listens."; + }; + + port = mkOption { + type = types.port; + default = 3000; + description = "Port for relay HTTP and WebSocket traffic."; + }; + + healthPort = mkOption { + type = types.port; + default = 8080; + description = "Port for relay liveness and readiness probes."; + }; + + metricsPort = mkOption { + type = types.port; + default = 9102; + description = "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."; + }; + + ownerPubkey = mkOption { + type = types.nullOr types.str; + default = null; + example = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + description = "Optional Nostr pubkey to bootstrap as relay owner."; + }; + + databaseUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "postgres://buzz@localhost:5432/buzz"; + description = "Postgres writer URL. Prefer environmentFile when it contains credentials."; + }; + + readDatabaseUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "postgres://buzz@reader.example.com:5432/buzz"; + description = "Optional Postgres read-replica URL."; + }; + + redisUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "redis://localhost:6379"; + description = "Redis URL used for pub/sub and ephemeral state."; + }; + + redisPoolSize = mkOption { + type = types.ints.positive; + default = 16; + description = "Maximum number of connections in the Redis pool."; + }; + + databasePoolSize = mkOption { + type = types.ints.positive; + default = 50; + description = "Maximum writer-pool size and default read-replica pool size."; + }; + + readDatabasePoolSize = mkOption { + type = types.nullOr types.ints.positive; + default = null; + description = "Optional independent Postgres read-replica pool size. Null inherits databasePoolSize."; + }; + + replicaReadMaxAgeMs = mkOption { + type = types.ints.unsigned; + default = 0; + description = "Maximum replica snapshot age in milliseconds. Zero disables replica routing."; + }; + + s3Endpoint = mkOption { + type = types.nullOr types.str; + default = null; + example = "https://s3.example.com"; + description = "S3-compatible object storage endpoint."; + }; + + s3Bucket = mkOption { + type = types.nullOr types.str; + default = null; + example = "buzz-media"; + description = "S3 bucket used for relay media."; + }; + + s3Region = mkOption { + type = types.nullOr types.str; + default = null; + example = "eu-west-2"; + description = "S3 region used for relay media."; + }; + + s3AddressingStyle = mkOption { + type = types.nullOr ( + types.enum [ + "path" + "virtual" + ] + ); + default = null; + example = "path"; + description = "S3 bucket addressing style. Null retains the relay default."; + }; + + mediaBaseUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "https://buzz.example.com/media"; + description = "Public base URL for relay-hosted 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 attestations may grant relay membership."; + }; + + pubkeyAllowlist = mkOption { + type = types.bool; + default = false; + description = "Whether pubkey-only NIP-42 authentication uses the allowlist."; + }; + + requireMediaGetAuth = mkOption { + type = types.bool; + default = false; + description = "Whether media downloads require authentication."; + }; + + huddleAudioAvailable = mkOption { + type = types.bool; + default = true; + description = "Whether this deployment can serve in-process huddle audio."; + }; + + auditEnabled = mkOption { + type = types.bool; + default = true; + description = "Whether the relay writes its hash-chain audit log."; + }; + + autoMigrate = mkOption { + type = types.bool; + default = true; + description = "Whether the relay applies embedded Postgres 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."; + }; + + maxFrameBytes = mkOption { + type = types.ints.positive; + default = 512 * 1024; + description = "Maximum inbound WebSocket frame size in bytes."; + }; + + slowClientGraceLimit = mkOption { + type = types.ints.positive; + default = 15; + description = "Consecutive full-buffer events tolerated before disconnecting a slow client."; + }; + + corsOrigins = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "tauri://localhost" + "https://buzz.example.com" + ]; + description = "Allowed CORS origins. An empty list retains the permissive development default."; + }; + + ephemeralTtlOverride = mkOption { + type = types.nullOr types.ints.positive; + default = null; + description = "Optional TTL override in seconds for ephemeral channels."; + }; + + git = { + packCachePath = mkOption { + type = types.path; + default = "${cfg.gitRepoPath}/.pack-cache"; + defaultText = lib.literalExpression ''"''${config.services.buzz-relay.gitRepoPath}/.pack-cache"''; + description = "Directory used for cached git pack files."; + }; + + maxPackBytes = mkOption { + type = types.ints.positive; + default = 500 * 1024 * 1024; + description = "Maximum accepted git pack size in bytes."; + }; + + maxRepoBytes = mkOption { + type = types.ints.positive; + default = cfg.git.maxPackBytes * 2; + defaultText = lib.literalExpression "config.services.buzz-relay.git.maxPackBytes * 2"; + description = "Maximum stored git repository size in bytes."; + }; + + packCacheMaxBytes = mkOption { + type = types.ints.positive; + default = cfg.git.maxRepoBytes * 5; + defaultText = lib.literalExpression "config.services.buzz-relay.git.maxRepoBytes * 5"; + description = "Maximum aggregate git pack cache size in bytes."; + }; + + packCacheMaxConcurrentPopulations = mkOption { + type = types.ints.positive; + default = 2; + description = "Maximum number of concurrent git pack cache populations."; + }; + + 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 subprocesses."; + }; + }; + + logFilter = mkOption { + type = types.str; + default = "buzz_relay=info"; + description = "RUST_LOG filter for the relay."; + }; + + 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 = '' + Additional relay environment variables. Values here are copied into the + Nix store; use environmentFile for private keys, passwords, and access + credentials. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr (types.either types.str (types.listOf types.str)); + default = null; + example = "/run/secrets/buzz-relay.env"; + description = "Runtime environment file or files containing secrets and deployment overrides."; + }; + + path = mkOption { + type = types.listOf types.package; + default = with pkgs; [ + curl + git + openssl + ]; + defaultText = lib.literalExpression "with pkgs; [ curl git openssl ]"; + description = "Packages available to 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 public 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} - -" + "d ${cfg.git.packCachePath} 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) (relayEnvironment // 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.git.packCachePath + ] + ++ cfg.extraReadWritePaths; + } + // optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + + networking.firewall.allowedTCPPorts = lib.optional cfg.openFirewall cfg.port; + }; +} diff --git a/nix/versions.nix b/nix/versions.nix new file mode 100644 index 0000000000..23e8e0a68c --- /dev/null +++ b/nix/versions.nix @@ -0,0 +1,41 @@ +{ + # sherpa-onnx static library archive for huddle audio. + # Per-platform archive URL and hash. + # Regenerate hashes after version bump: run + # nix-prefetch-url + # for each platform. + sherpaOnnx = { + version = "1.13.4"; + systems = { + "x86_64-linux" = { + urlSuffix = "linux-x64-static-lib"; + hash = "sha256-mLDjGZZCb254JE284ZVVSPLGTo8BxL51uFr3zaoujVw="; + }; + "aarch64-linux" = { + urlSuffix = "linux-aarch64-static-lib"; + hash = "sha256-I7M2Fnh8yUnVsUOOl5RVD4BeIIoBTFwiRUgyB8WLvA8="; + }; + "aarch64-darwin" = { + urlSuffix = "osx-arm64-static-lib"; + hash = "sha256-V4Adsru3hqXTQ/UVo4/yELQBhCM4vcgE+gdTEtHNJAQ="; + }; + }; + }; + + # Cargo.lock output hashes for workspace builds (root Cargo.lock). + # Regenerate after Cargo.lock changes: remove one, nix build will error with the correct hash. + workspaceCargoOutputHashes = { + "aws-creds-0.39.1" = "sha256-QAAm1phmeLFtDRgfDCoHijN1ce/rYzh18KziOUbL+hw="; + "mesh-llm-api-client-0.73.1" = "sha256-2ArkxK7Ze13mqkQB+JkuqVSCLeHpdxXHMZ0592VyEWw="; + }; + + # Cargo.lock output hashes for the desktop Tauri build (desktop/src-tauri/Cargo.lock). + # Same package may have a different hash vs. sidecars due to different dep trees. + desktopCargoOutputHashes = { + "mesh-llm-api-client-0.73.1" = "sha256-OItlWwacyTtdS6LQCQDPlLmB09l4bbTX27uI8AGDQpk="; + }; + + # Hash for pnpm dependencies (desktop frontend). + # Regenerate after package.json / pnpm-lock.yaml changes: remove and let nix build fetch. + pnpmHash = "sha256-k5bRDcNSNN9a/xeBtcZYmtiW5d0NN+uDHl2LM+94F4A="; +}