diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6bd3581 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.direnv +.env +.relay-owner.nsec +data +result +result-* +target +work +worktrees +**/target diff --git a/.ngit/act/workflows/deployment_e2e.yaml b/.ngit/act/workflows/deployment_e2e.yaml new file mode 100644 index 0000000..7eff82d --- /dev/null +++ b/.ngit/act/workflows/deployment_e2e.yaml @@ -0,0 +1,38 @@ +# ngit-ci currently evaluates push path filters but not pull-request path +# filters, so PRs run this workflow unconditionally. +on: + push: + paths: + - ".dockerignore" + - "Cargo.lock" + - "Cargo.toml" + - "Dockerfile" + - "build.rs" + - "compose*.yaml" + - "deploy/**" + - "scripts/test-container-deployment.sh" + - "src/**" + pull_request: + +name: Container deployment e2e + +jobs: + container-deployment: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + - name: Require the disposable guest container daemon + run: | + if [[ ! -S /var/run/docker.sock ]]; then + echo "container daemon socket is not available" >&2 + echo "this workflow requires an ngit-ci operator opt-in" >&2 + exit 1 + fi + - name: Build, replace, and verify the container + run: | + nix shell nixpkgs#docker-client nixpkgs#curl nixpkgs#jq \ + --command scripts/test-container-deployment.sh diff --git a/AGENTS.md b/AGENTS.md index 6319ea5..159158f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ forces `ExecStart`, which coerces `src = ../.`; a standalone local path is not Git-filtered and may hash or copy ignored `target/` and worktree data. Use the Git-backed flake module, or a builder stub that ignores all build attributes and keeps `src` lazy. See -[`docs/how-to/deploy.md`](docs/how-to/deploy.md#resource-safe-module-validation) +[`docs/how-to/deploy-nixos.md`](docs/how-to/deploy-nixos.md#resource-safe-module-validation) for resource-safe validation and deployment. ### Testing ngit-grasp (Main Project) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbe1004 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +FROM rust:1.96-bookworm AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . . + +ARG NGIT_BUILD_REVISION=unknown +ENV NGIT_BUILD_REVISION=${NGIT_BUILD_REVISION} + +RUN cargo build --locked --release -p ngit-grasp + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gosu \ + libssl3 \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 ngit-grasp \ + && useradd --system --uid 10001 --gid ngit-grasp \ + --home-dir /data --no-create-home ngit-grasp \ + && install -d -m 0750 -o ngit-grasp -g ngit-grasp \ + /data /data/git /data/relay + +COPY --from=builder /src/target/release/ngit-grasp /usr/local/bin/ngit-grasp +COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +ARG NGIT_IMAGE_VERSION=dev +ARG NGIT_IMAGE_REVISION=unknown +LABEL org.opencontainers.image.title="ngit-grasp" \ + org.opencontainers.image.description="GRASP relay and Git Smart HTTP server" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git" \ + org.opencontainers.image.version="${NGIT_IMAGE_VERSION}" \ + org.opencontainers.image.revision="${NGIT_IMAGE_REVISION}" + +ENV HOME=/data \ + NGIT_GIT_DATA_PATH=/data/git \ + NGIT_RELAY_DATA_PATH=/data/relay + +WORKDIR /data +VOLUME ["/data"] +EXPOSE 7334 +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index d8af828..4020b20 100644 --- a/README.md +++ b/README.md @@ -406,9 +406,22 @@ This a useful feature of other git servers. ## Quick Start -Tagged releases provide a statically linked x86_64 Linux archive accompanied -by `SHA256SUMS`. The archive can be installed without a Rust or Nix toolchain; -on a Linux flake system, build the same output with `nix build .#static`. +For production, start with the [deployment chooser](docs/how-to/deploy.md). +The repository ships Docker and Compose configurations, a NixOS module, a +hardened systemd unit, and templates for selected managed hosts. All supported +paths preserve the same +[deployment contract](docs/reference/deployment-contract.md). + +The shortest fresh-VPS path uses Docker Compose and Caddy: + +```bash +cp deploy.env.example .env +# Set NGIT_DOMAIN in .env and point DNS at this server. +docker compose -f compose.yaml -f compose.caddy.yaml up --build -d +scripts/verify-deployment.sh https://ngit.example.com +``` + +For development from source: ```bash # install ngit @@ -443,7 +456,9 @@ nix develop -c cargo test --lib - Purgatory system activates, ready to hunt for missing git data - Prometheus metrics exposed at `/metrics` -**Don't have Nix?** See [Getting Started Tutorial](docs/tutorials/getting-started.md) for alternative setup methods. +**Don't have Nix?** Use the container path above or see the +[Getting Started Tutorial](docs/tutorials/getting-started.md) for development +alternatives. ## Configuration diff --git a/compose.caddy.yaml b/compose.caddy.yaml new file mode 100644 index 0000000..b7b05d8 --- /dev/null +++ b/compose.caddy.yaml @@ -0,0 +1,28 @@ +services: + ngit-grasp: + environment: + NGIT_TRUSTED_PROXY_CIDRS: "${NGIT_CADDY_IP:-172.30.73.2}/32" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + ngit-grasp: + condition: service_healthy + environment: + NGIT_DOMAIN: "${NGIT_DOMAIN:?copy deploy.env.example to .env and set NGIT_DOMAIN}" + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + backend: + ipv4_address: "${NGIT_CADDY_IP:-172.30.73.2}" + +volumes: + caddy-data: + caddy-config: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..faa3beb --- /dev/null +++ b/compose.yaml @@ -0,0 +1,46 @@ +name: ngit-grasp + +services: + ngit-grasp: + build: + context: . + args: + NGIT_BUILD_REVISION: "${NGIT_BUILD_REVISION:-unknown}" + NGIT_IMAGE_REVISION: "${NGIT_IMAGE_REVISION:-unknown}" + NGIT_IMAGE_VERSION: "${NGIT_IMAGE_VERSION:-dev}" + image: "${NGIT_IMAGE:-ngit-grasp:local}" + restart: unless-stopped + env_file: + - path: ./.env + required: false + environment: + NGIT_DOMAIN: "${NGIT_DOMAIN:?copy deploy.env.example to .env and set NGIT_DOMAIN}" + NGIT_BASE_PATH: "${NGIT_BASE_PATH:-/}" + NGIT_LOG_LEVEL: "${NGIT_LOG_LEVEL:-info}" + ports: + - "127.0.0.1:${NGIT_HOST_PORT:-7334}:7334" + volumes: + - ngit-grasp-data:/data + networks: + - backend + stop_grace_period: 5m + healthcheck: + test: + - CMD-SHELL + - >- + curl --fail --silent --show-error + --header 'Accept: application/nostr+json' + "http://127.0.0.1:7334$${NGIT_BASE_PATH:-/}" >/dev/null + interval: 30s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + ngit-grasp-data: + +networks: + backend: + ipam: + config: + - subnet: "${NGIT_COMPOSE_SUBNET:-172.30.73.0/24}" diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..ce6b34a --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,21 @@ +# Required: the public hostname, without a URL scheme or path. +NGIT_DOMAIN=ngit.example.com + +# Public mount path. The bundled Caddy stack expects a domain-root service. +NGIT_BASE_PATH=/ + +# Loopback port used by compose.yaml when an existing host proxy is used. +NGIT_HOST_PORT=7334 + +# Application logging filter. +NGIT_LOG_LEVEL=info + +# Optional initial relay. Sync discovers additional relays automatically. +# NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.ngit.dev + +# Compose network settings. Change both only if the default subnet conflicts. +NGIT_COMPOSE_SUBNET=172.30.73.0/24 +NGIT_CADDY_IP=172.30.73.2 + +# Do not put NGIT_RELAY_OWNER_NSEC here. By default the service generates +# /data/.relay-owner.nsec in the persistent volume. Back up that volume. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..92f7fa6 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,3 @@ +{$NGIT_DOMAIN} { + reverse_proxy ngit-grasp:7334 +} diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100755 index 0000000..950ace4 --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +data_root=/data +git_data_path=${NGIT_GIT_DATA_PATH:-${data_root}/git} +relay_data_path=${NGIT_RELAY_DATA_PATH:-${data_root}/relay} + +if [ -z "${NGIT_BIND_ADDRESS:-}" ]; then + public_port=${PORT:-7334} + case "${public_port}" in + ''|*[!0-9]*) + echo "PORT must be an integer" >&2 + exit 2 + ;; + esac + if [ "${public_port}" -lt 1 ] || [ "${public_port}" -gt 65535 ]; then + echo "PORT must be between 1 and 65535" >&2 + exit 2 + fi + export NGIT_BIND_ADDRESS="0.0.0.0:${public_port}" +fi + +export NGIT_GIT_DATA_PATH="${git_data_path}" +export NGIT_RELAY_DATA_PATH="${relay_data_path}" + +if [ "$(id -u)" -eq 0 ]; then + install -d -m 0750 -o ngit-grasp -g ngit-grasp \ + "${data_root}" "${git_data_path}" "${relay_data_path}" + + if [ -e "${data_root}/.relay-owner.nsec" ]; then + chown ngit-grasp:ngit-grasp "${data_root}/.relay-owner.nsec" + chmod 0600 "${data_root}/.relay-owner.nsec" + fi + + exec gosu ngit-grasp:ngit-grasp ngit-grasp "$@" +fi + +exec ngit-grasp "$@" diff --git a/deploy/systemd/ngit-grasp.env.example b/deploy/systemd/ngit-grasp.env.example new file mode 100644 index 0000000..03262ac --- /dev/null +++ b/deploy/systemd/ngit-grasp.env.example @@ -0,0 +1,15 @@ +# Required canonical public hostname, without a scheme or path. +NGIT_DOMAIN=ngit.example.com + +NGIT_BASE_PATH=/ +NGIT_BIND_ADDRESS=127.0.0.1:7334 +NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git +NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay +NGIT_DATABASE_BACKEND=lmdb +NGIT_LOG_LEVEL=info + +# Optional initial relay. Sync discovers additional relays automatically. +# NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.ngit.dev + +# Prefer /var/lib/ngit-grasp/.relay-owner.nsec or a systemd credential over an +# environment secret. Never put NGIT_RELAY_OWNER_NSEC in this example file. diff --git a/deploy/systemd/ngit-grasp.service b/deploy/systemd/ngit-grasp.service new file mode 100644 index 0000000..f0069cb --- /dev/null +++ b/deploy/systemd/ngit-grasp.service @@ -0,0 +1,38 @@ +[Unit] +Description=ngit-grasp GRASP relay +Documentation=https://gitworkshop.dev/danconwaydev.com/ngit-grasp +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=ngit-grasp +Group=ngit-grasp +WorkingDirectory=/var/lib/ngit-grasp +EnvironmentFile=/etc/ngit-grasp/ngit-grasp.env +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/local/bin/ngit-grasp +Restart=on-failure +RestartSec=10s +TimeoutStopSec=300s +UMask=0077 + +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectControlGroups=true +ProtectHome=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +ReadWritePaths=/var/lib/ngit-grasp +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/README.md b/docs/README.md index af2e51a..daa0330 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,7 +36,8 @@ WORKING │ How-To │ Reference │ **For:** Users with basic knowledge solving real problems **Style:** Practical recipes and solutions -- **[Deploy ngit-grasp](how-to/deploy.md)** - Production deployment guide +- **[Deploy ngit-grasp](how-to/deploy.md)** - Choose Docker, NixOS, Linux, Proxmox, or managed hosting +- **[Deployment contract](reference/deployment-contract.md)** - Shared runtime and persistence requirements - **[Configure Nix Flakes](how-to/nix-flakes.md)** - Nix development environment - **[Run Compliance Tests](how-to/test-compliance.md)** - GRASP compliance testing - **[Upgrade nostr-sdk](how-to/upgrade-nostr-sdk.md)** - Handling SDK upgrades @@ -74,8 +75,8 @@ WORKING │ How-To │ Reference │ ### I want to deploy ngit-grasp 1. Review [Configuration Reference](reference/configuration.md) -2. Follow [Deployment How-To](how-to/deploy.md) -3. Set up monitoring and backups +2. Choose an environment in [Deploy ngit-grasp](how-to/deploy.md) +3. Verify the deployment and test its backup ### I want to develop on ngit-grasp 1. Follow [Getting Started Tutorial](tutorials/getting-started.md) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index e227f79..c08ee9f 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -868,47 +868,20 @@ Relax the write policy to accept all repository announcements regardless of clon ## Deployment -### Single Binary +The runtime remains one binary plus Git, but persistence, identity, proxying, +and single-writer behavior are part of the production boundary. The normative +[deployment contract](../reference/deployment-contract.md) owns those shared +requirements. -```bash -cargo build --release -NGIT_RELAY_OWNER_NSEC=nsec1... \ - ./target/release/ngit-grasp --domain example.com -``` +Supported artifacts are maintained alongside their operating guides: -### Docker +- the root `Dockerfile` and Compose configurations; +- `deploy/systemd/ngit-grasp.service` for conventional Linux; +- `nix/module.nix` for declarative NixOS instances; and +- managed-host templates for single-instance deployments. -```dockerfile -FROM rust:1.75 as builder -WORKDIR /app -COPY . . -RUN cargo build --release - -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/ngit-grasp /usr/local/bin/ -EXPOSE 7334 -CMD ["ngit-grasp"] -``` - -### Systemd - -```ini -[Unit] -Description=ngit-grasp GRASP server -After=network.target - -[Service] -Type=simple -User=git -WorkingDirectory=/opt/ngit-grasp -EnvironmentFile=/opt/ngit-grasp/.env -ExecStart=/usr/local/bin/ngit-grasp -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` +See the [deployment chooser](../how-to/deploy.md) rather than copying an +illustrative container or service definition from this architecture document. ## Security Considerations diff --git a/docs/how-to/README.md b/docs/how-to/README.md index ef59732..ca15a9d 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -24,6 +24,18 @@ How-to guides are **recipes** that show you how to solve specific problems or ac ## Available How-To Guides +### [Deploy ngit-grasp](deploy.md) + +**Problem:** Run a durable production relay in a supported hosting environment + +**You'll learn:** + +- Choose Docker, NixOS, systemd Linux, Proxmox, or managed hosting +- Preserve the relay identity, Git repositories, and LMDB state +- Configure TLS, verify protocols, back up, and upgrade safely + +--- + ### [Upgrade from v2 to v3 Git family storage](upgrade-git-family-storage.md) **Problem:** Perform the one-way identifier-family storage migration safely @@ -64,18 +76,6 @@ How-to guides are **recipes** that show you how to solve specific problems or ac ## Planned How-To Guides -### Deploy ngit-grasp -**Status:** 🔜 Planned (waiting for main server) - -**Problem:** Deploy to production -**You'll learn:** -- Server requirements -- Reverse proxy setup (nginx/Caddy) -- SSL/TLS configuration -- Monitoring and logging - ---- - ### Run Compliance Tests **Status:** 🔜 Planned diff --git a/docs/how-to/deploy-docker.md b/docs/how-to/deploy-docker.md new file mode 100644 index 0000000..a1ab3e6 --- /dev/null +++ b/docs/how-to/deploy-docker.md @@ -0,0 +1,136 @@ +# Deploy with Docker or Podman + +This is the portable default for a Linux host. The repository ships a +multi-stage `Dockerfile`, a loopback-only Compose service, and an optional +Caddy overlay for automatic HTTPS. + +The image contains ngit-grasp, Git, CA certificates, and a small init process. +It prepares `/data` and then runs ngit-grasp as UID/GID `10001`. + +## Prerequisites + +- Docker Engine with Compose v2, or a compatible Podman Compose setup +- durable local storage for the named volume +- a domain whose DNS points to the host +- ports 80 and 443 available when using bundled Caddy + +Review the [deployment contract](../reference/deployment-contract.md) before +placing the state volume on remote or managed storage. + +## Fresh VPS with automatic HTTPS + +Create the small deployment environment file: + +```bash +cp deploy.env.example .env +``` + +Set `NGIT_DOMAIN` in `.env`, then start the relay and Caddy: + +```bash +docker compose -f compose.yaml -f compose.caddy.yaml up --build -d +docker compose -f compose.yaml -f compose.caddy.yaml ps +scripts/verify-deployment.sh https://ngit.example.com +``` + +Caddy obtains and renews the certificate. The relay is also published on +`127.0.0.1:7334` for local diagnostics, but it is not directly reachable from +the network. + +The bundled Caddy configuration serves ngit-grasp at the domain root. To share +a hostname using `NGIT_BASE_PATH`, use an existing reverse proxy and configure +its path routing explicitly. + +## Existing reverse proxy + +Start only the relay service: + +```bash +docker compose up --build -d +curl -H 'Accept: application/nostr+json' http://127.0.0.1:7334 +``` + +Proxy the public HTTPS hostname to `http://127.0.0.1:7334`. Preserve WebSocket +upgrades, request methods, bodies, and query strings. Forwarded client IP +headers are ignored by default; configure `NGIT_TRUSTED_PROXY_CIDRS` only after +identifying the exact container-visible proxy address and keeping the backend +private. + +## State and identity + +The `ngit-grasp-data` named volume is mounted at `/data` and contains all +durable state. The first start creates `/data/.relay-owner.nsec`; retaining the +volume retains the public identity advertised in NIP-11. + +Inspect the resolved volume without guessing its Compose prefix: + +```bash +docker volume inspect ngit-grasp_ngit-grasp-data +``` + +Do not use `docker compose down --volumes` in production. It deletes the relay +identity, events, and Git repositories. + +## Operations + +```bash +# Logs +docker compose logs --follow ngit-grasp + +# Controlled restart +docker compose restart ngit-grasp + +# Stop while retaining state +docker compose stop + +# Start again +docker compose start +``` + +When the Caddy overlay is active, include both `-f` arguments for commands that +must operate on the complete project. + +## Backup + +Stop the writer, archive the complete volume, and start it again: + +```bash +docker compose stop ngit-grasp +docker run --rm \ + --volume ngit-grasp_ngit-grasp-data:/data:ro \ + --volume "$PWD:/backup" \ + alpine:3.22 \ + tar -czf /backup/ngit-grasp-data.tar.gz -C /data . +docker compose start ngit-grasp +``` + +Store the archive away from the Docker host. Test restoration into a separate, +non-public deployment before relying on it. + +## Upgrade + +Pin or check out the intended tag, read `CHANGELOG.md`, take a backup, and +rebuild without overlapping the old and new writers: + +```bash +docker compose stop ngit-grasp +docker compose build --pull ngit-grasp +docker compose up -d ngit-grasp +scripts/verify-deployment.sh https://ngit.example.com +``` + +For storage-changing releases, follow the linked migration guide and restore +the pre-upgrade volume snapshot before attempting a binary rollback. + +## Validate the image locally + +The repository includes a destructive-to-test-resources-only integration +check. It builds the image, creates uniquely named temporary container and +volume resources, replaces the container, and confirms the NIP-11 pubkey did +not change: + +```bash +scripts/test-container-deployment.sh +``` + +Set `CONTAINER_ENGINE=podman` to exercise a compatible Podman CLI. diff --git a/docs/how-to/deploy-linux.md b/docs/how-to/deploy-linux.md new file mode 100644 index 0000000..193205a --- /dev/null +++ b/docs/how-to/deploy-linux.md @@ -0,0 +1,102 @@ +# Deploy a static binary with systemd + +Use this path for a conventional Debian, Ubuntu, Fedora, or other systemd Linux +server when containers are unnecessary. Build the portable binary on a Nix +build machine, then copy only the binary and service files to the server. + +## Build a pinned binary + +From a clean checkout of the reviewed tag or revision: + +```bash +nix build .#static +file result/bin/ngit-grasp +``` + +Copy `result/bin/ngit-grasp`, `deploy/systemd/ngit-grasp.service`, and +`deploy/systemd/ngit-grasp.env.example` to the server through your normal +authenticated deployment channel. + +The deployment host does not need Nix or Rust. It does need Git and trusted CA +certificates: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates git +``` + +Use the equivalent packages on non-Debian distributions. + +## Install + +```bash +getent group ngit-grasp >/dev/null || sudo groupadd --system ngit-grasp +id -u ngit-grasp >/dev/null 2>&1 || \ + sudo useradd --system --gid ngit-grasp --home-dir /var/lib/ngit-grasp \ + --create-home --shell /usr/sbin/nologin ngit-grasp +sudo install -Dm755 ngit-grasp /usr/local/bin/ngit-grasp +sudo install -Dm644 ngit-grasp.service \ + /etc/systemd/system/ngit-grasp.service +sudo install -Dm640 -o root -g ngit-grasp ngit-grasp.env.example \ + /etc/ngit-grasp/ngit-grasp.env +sudo install -d -m 0750 -o ngit-grasp -g ngit-grasp \ + /var/lib/ngit-grasp \ + /var/lib/ngit-grasp/git \ + /var/lib/ngit-grasp/relay +``` + +Edit `/etc/ngit-grasp/ngit-grasp.env` and set `NGIT_DOMAIN`. + +If restoring an existing identity, install `.relay-owner.nsec` as mode `0600` +owned by `ngit-grasp` under `/var/lib/ngit-grasp`. Otherwise the first start +generates it there. + +## Reverse proxy and TLS + +Keep the service bound to `127.0.0.1:7334`. For Caddy, a domain-root virtual +host is: + +```caddyfile +ngit.example.com { + reverse_proxy 127.0.0.1:7334 +} +``` + +Point DNS at the server and replace `ngit.example.com` in both Caddy and the +ngit-grasp environment file. Caddy preserves WebSocket upgrades automatically. + +## Start and verify + +On the server: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ngit-grasp +sudo systemctl status ngit-grasp --no-pager +sudo journalctl -u ngit-grasp -n 50 --no-pager +``` + +From the repository checkout on the operator workstation: + +```bash +scripts/verify-deployment.sh https://ngit.example.com +``` + +The service unit applies a restrictive umask, filesystem protection, private +temporary directory, empty capability set, and bounded five-minute shutdown. + +## Upgrade and rollback + +Build the new pinned binary before touching the server. Read `CHANGELOG.md`, +stop the service, snapshot `/var/lib/ngit-grasp`, install the new binary, and +start the service: + +```bash +sudo systemctl stop ngit-grasp +sudo install -Dm755 ngit-grasp /usr/local/bin/ngit-grasp +sudo systemctl start ngit-grasp +``` + +Run the verifier and inspect startup integrity summaries. If the release +changed storage, restoring the old binary also requires restoring its matching +state snapshot. diff --git a/docs/how-to/deploy-nixos.md b/docs/how-to/deploy-nixos.md new file mode 100644 index 0000000..4a14d32 --- /dev/null +++ b/docs/how-to/deploy-nixos.md @@ -0,0 +1,570 @@ +# Deploy ngit-grasp on NixOS + +**Purpose:** Deploy ngit-grasp to a production NixOS server +**Difficulty:** Intermediate +**Time:** 30-60 minutes + +This guide implements the shared +[deployment contract](../reference/deployment-contract.md) with the repository's +NixOS module. For another environment, return to the +[deployment chooser](deploy.md). + +--- + +## Problem + +You want to: +- Deploy ngit-grasp to a NixOS server +- Configure it as a systemd service +- Set up reverse proxy (Caddy) +- Ensure proper security and monitoring + +--- + +## Prerequisites + +- NixOS server with SSH access +- Flakes enabled on server and local machine +- Domain name configured (DNS pointing to server) +- Basic knowledge of NixOS configuration + +--- + +## Solution + +### Step 1: Add ngit-grasp to Your Server's Flake + +In your server's `flake.nix`, add ngit-grasp as an input: + +```nix +{ + inputs = { + # Keep the nixpkgs input already used by this server configuration. + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + ngit-grasp.url = + "git+https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git"; + }; + + outputs = { self, nixpkgs, ngit-grasp, ... }@inputs: { + nixosConfigurations.your-hostname = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { inherit inputs; }; + modules = [ + ./configuration.nix + # ... other modules + ]; + }; + }; +} +``` + +--- + +### Step 2: Create Service Configuration + +Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): + +```nix +{ inputs, ... }: + +{ + imports = [ inputs.ngit-grasp.nixosModules.default ]; + + services.ngit-grasp.production = { + enable = true; + domain = "ngit.example.com"; + + # Network + bindAddress = "127.0.0.1"; + port = 8082; + # Only Caddy can reach the loopback backend, so its forwarded client IP is trusted. + trustedProxyCidrs = [ "127.0.0.1/32" ]; + + # Storage + dataDir = "/persistent/ngit-grasp"; + + # Identity + relayName = "My GRASP Relay"; + relayDescription = "A Rust GRASP implementation with proactive sync"; + relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; + + # Sync - bootstrap from relay.ngit.dev + syncBootstrapRelayUrl = "wss://relay.ngit.dev"; + + # Metrics + metricsEnabled = true; + + # Logging + logLevel = "info"; + }; + + # Caddy reverse proxy + services.caddy.virtualHosts."ngit.example.com" = { + extraConfig = '' + reverse_proxy 127.0.0.1:8082 { + # Caddy manages X-Forwarded-For automatically. + header_up X-Real-IP {remote_host} + } + ''; + }; +} +``` + +**Key configuration options:** + +- **Instance name** (`production`): Can be any name. Used for systemd service (`ngit-grasp-production`) +- **domain**: Your relay's domain (used in GRASP validation) +- **port**: Local port (use reverse proxy for HTTPS) +- **trustedProxyCidrs**: Proxy source ranges allowed to supply the client IP + - Keep empty for a directly exposed listener + - Keep the backend private; trusting a public-facing source range permits spoofed headers + - Caddy automatically maintains `X-Forwarded-For`; `header_up`, not `header_down`, + changes headers sent to the backend +- **dataDir**: Where git repos and database are stored +- **relayOwnerNsecFile**: Path to file containing relay owner's nsec + - Passed to ngit-grasp as a protected systemd credential, not a process argument + - The runtime secret file must already exist (for example through agenix or sops-nix) + - Permissions on that external source file remain the operator or secret manager's responsibility + - Alternative: `relayOwnerNsec = "nsec1..."` (less secure, in nix store) + - If neither option is set, ngit-grasp loads or creates `.relay-owner.nsec` in `dataDir` +- **syncBootstrapRelayUrl**: Bootstrap relay to sync from on startup + +See [nix/example-configuration.nix](../../nix/example-configuration.nix) for more examples. + +--- + +### Step 3: Import the Service + +Import your service configuration in your main configuration file: + +```nix +# In configuration.nix or services/default.nix +{ + imports = [ + ./services/ngit-grasp.nix + # ... other services + ]; +} +``` + +--- + +### Step 4: Update Flake Lock + +```bash +cd /path/to/server/config +nix flake update ngit-grasp +git add flake.lock +git commit -m "Add ngit-grasp and update flake.lock" +``` + +--- + +### Step 5: Validate Configuration + +Before deploying, validate that the flake evaluates without starting its builds: + +```bash +nix flake check --no-build +``` + +#### Resource-safe module validation + +Nix copies path-valued build inputs into the store when they are forced. A Git +flake is materialized from its tracked files first, but a standalone path into a +working tree does not inherit that Git filtering. + +This matters when testing ngit-grasp's NixOS module locally. Importing +`nix/module.nix` is lazy by itself, but rendering an enabled service forces the +module-built package through `ExecStart`. The package's `src = ../.` then +resolves relative to that module. If the module was imported directly from a +working tree, Nix may recursively hash or copy ignored `target/`, `.git`, and +linked-worktree data while it appears to be evaluating the configuration. + +Use `inputs.ngit-grasp.nixosModules.default` from a Git-backed flake input, as +shown above. For local module changes, commit them to a temporary Git branch and +use that Git source, or replace `buildRustPackage` with a test stub that ignores +all build attributes so `src` remains unforced. Do not use a direct +working-tree module import for a test that enables an instance. + +Inspect the derivation plan before starting a build: + +```bash +nixos-rebuild dry-build --flake .#your-hostname +``` + +Multiple ngit-grasp instances should normally share one ngit-grasp package +derivation. Avoid service-level `ExecStart` overrides that force another flake +package or Rust toolchain. If distinct versions are intentional, build them +sequentially or on appropriately sized remote builders. For an initial local +build, constrain Nix while confirming the plan behaves as expected: + +```bash +nixos-rebuild build --flake .#your-hostname --max-jobs 1 --cores 2 +``` + +--- + +### Step 6: Deploy to Server + +Deploy the new configuration to your server: + +```bash +# Build and switch in one command (builds on server) +nixos-rebuild switch --flake .#your-hostname \ + --target-host user@server.example.com \ + --use-remote-sudo \ + --build-host user@server.example.com +``` + +**Alternative:** Build locally, then deploy: + +```bash +# Build locally +nixos-rebuild build --flake .#your-hostname + +# Deploy to server +nixos-rebuild switch --flake .#your-hostname \ + --target-host user@server.example.com \ + --use-remote-sudo +``` + +**Note:** Building locally requires your machine to trust the server's nix signing key. + +--- + +### Step 7: Verify Deployment + +SSH to the server and check the service: + +```bash +ssh user@server.example.com + +# Check service status +systemctl status ngit-grasp-production + +# View recent logs +journalctl -u ngit-grasp-production -n 50 --no-pager + +# Check if listening on port +ss -tlnp | grep 8082 +``` + +--- + +### Step 8: Test Functionality + +From your local machine, test the relay: + +```bash +# Test NIP-11 relay info +curl https://ngit.example.com -H "Accept: application/nostr+json" | jq + +# Test WebSocket connection +websocat wss://ngit.example.com +# Then type: ["REQ","test",{}] +# Should receive events + +# Test git clone (if you have repos) +git ls-remote https://ngit.example.com//.git +``` + +--- + +## Configuration Options + +### Required +- `enable` - Enable this instance +- `domain` - Domain where relay is hosted + +### Network +- `basePath` - Public URL mount path (default: `/`) +- `bindAddress` - IP to bind to (default: "127.0.0.1") +- `port` - Port to listen on (default: 7334) +- `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client + IP (default: empty; forwarded headers ignored) + +### Storage +- `dataDir` - Base directory for data (default: /var/lib/ngit-grasp-{name}) +- `databaseBackend` - "lmdb" | "memory" (default: "lmdb") + +See [Upgrade Git family storage](upgrade-git-family-storage.md) before updating +an existing instance to a release that enables identifier-family storage. + +### Identity +- `relayName` - Relay name for NIP-11 (default: "{domain} grasp relay") +- `relayDescription` - Relay description +- `relayOwnerNsecFile` - Runtime secret file loaded as a systemd credential (recommended) +- `relayOwnerNsec` - Inline nsec (less secure) + +### Sync +- `syncBootstrapRelayUrl` - Bootstrap relay URL (optional) +- `syncDisableNegentropy` - Disable NIP-77 negentropy (default: false) +- `syncMaxBackoffSecs` - Max backoff for reconnection (default: 3600) +- `syncDisconnectCheckIntervalSecs` - Check interval (default: 60) +- `syncBaseBackoffSecs` - Base backoff time (default: 5) + +### Metrics +- `metricsEnabled` - Enable `/metrics` below the configured base path (default: true) +- `metricsConnectionPerIpAbuseThreshold` - Abuse threshold (default: 10) +- `metricsTopNRepos` - Number of top repos to track (default: 10) + +### Logging +- `logLevel` - "trace" | "debug" | "info" | "warn" | "error" (default: "info") + +### Security +- `user` - User to run as (default: "ngit-grasp-{name}") +- `group` - Group to run as (default: "ngit-grasp") + +See [nix/module.nix](../../nix/module.nix) for complete option definitions. + +--- + +## Systemd Service + +The NixOS module creates a systemd service: `ngit-grasp-{instance-name}` + +```bash +# Start/stop/restart +systemctl start ngit-grasp-production +systemctl stop ngit-grasp-production +systemctl restart ngit-grasp-production + +# Enable/disable autostart +systemctl enable ngit-grasp-production +systemctl disable ngit-grasp-production + +# View logs +journalctl -u ngit-grasp-production -f +journalctl -u ngit-grasp-production --since "1 hour ago" + +# Check status +systemctl status ngit-grasp-production +``` + +--- + +## Multiple Instances + +You can run multiple instances on the same server: + +```nix +services.ngit-grasp = { + production = { + enable = true; + domain = "ngit.example.com"; + port = 8082; + dataDir = "/persistent/ngit-production"; + }; + + staging = { + enable = true; + domain = "ngit-staging.example.com"; + port = 8083; + dataDir = "/persistent/ngit-staging"; + logLevel = "debug"; + }; +}; +``` + +Each instance: +- Runs as separate systemd service: `ngit-grasp-production`, `ngit-grasp-staging` +- Has its own user: `ngit-grasp-production`, `ngit-grasp-staging` +- Stores data in separate directory +- Can have different configuration + +--- + +## Troubleshooting + +### Service won't start + +**Check logs:** +```bash +journalctl -u ngit-grasp-production -n 50 +``` + +**Common issues:** +- Port already in use: Check with `ss -tlnp | grep 8082` +- Data directory permissions: Should be owned by service user +- Invalid nsec file: Check file exists and contains valid nsec + +### Can't connect via WebSocket + +**Check:** +- Service is running: `systemctl status ngit-grasp-production` +- Firewall allows connections: `nix run nixpkgs#nmap -- -p 443 ngit.example.com` +- Caddy is configured correctly: `systemctl status caddy` +- DNS resolves: `dig ngit.example.com` + +### Sync not working + +**Check logs for sync errors:** +```bash +journalctl -u ngit-grasp-production | grep -i sync +``` + +**Common issues:** +- Bootstrap relay URL incorrect or unreachable +- Network connectivity issues +- Bootstrap relay doesn't support negentropy (disable with `syncDisableNegentropy = true`) + +### High memory/CPU usage + +**Monitor metrics:** +```bash +curl http://localhost:8082/metrics +``` + +**Tune configuration:** +- Reduce `metricsTopNRepos` +- Increase `syncMaxBackoffSecs` +- Tune `syncMaxBackoffSecs` for your network conditions + +--- + +## Rollback + +If deployment fails, rollback to previous configuration: + +```bash +# On the server +nixos-rebuild switch --rollback + +# Or remotely +nixos-rebuild switch --rollback \ + --target-host user@server.example.com \ + --use-remote-sudo +``` + +If the release changed on-disk storage, a NixOS generation rollback is not +enough. Restore the matching pre-upgrade snapshot of the complete `dataDir` +before starting the older service. See the +[deployment contract](../reference/deployment-contract.md#upgrade-and-rollback). + +--- + +## Upgrading + +To upgrade ngit-grasp: + +```bash +# Update flake input +nix flake update ngit-grasp + +# Review changes +git diff flake.lock + +# Commit +git add flake.lock +git commit -m "Update ngit-grasp" + +# Deploy +nixos-rebuild switch --flake .#your-hostname \ + --target-host user@server.example.com \ + --use-remote-sudo \ + --build-host user@server.example.com +``` + +--- + +## Security Hardening + +The NixOS module includes systemd hardening: + +- `NoNewPrivileges = true` - Prevents privilege escalation +- `ProtectSystem = "strict"` - Read-only filesystem except dataDir +- `ProtectHome = true` - No access to home directories +- `PrivateTmp = true` - Private /tmp +- `RestrictAddressFamilies` - Only allow needed network families + +Additional recommendations: + +1. **Use a runtime secret file instead of an inline key:** + ```nix + relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; + # NOT: relayOwnerNsec = "nsec1..."; # Ends up in nix store! + ``` + + The module exposes the file to ngit-grasp as the `relay_owner_nsec` + systemd credential. The key does not appear in `ExecStart` or the process + command line. ngit-grasp does not modify the external source file; keep its + ownership and permissions restricted through your secret manager. + +2. **Restrict data directory permissions:** + ```bash + chmod 750 /persistent/ngit-grasp + chown ngit-grasp-production:ngit-grasp /persistent/ngit-grasp + ``` + +3. **Use HTTPS (reverse proxy required):** + - ngit-grasp binds to localhost by default + - Use Caddy/nginx for TLS termination + - Caddy handles certificates automatically + +4. **Monitor logs regularly:** + ```bash + journalctl -u ngit-grasp-production --since today | grep -i error + ``` + +--- + +## Monitoring + +### Prometheus Metrics + +ngit-grasp exposes Prometheus metrics at `/metrics`: + +```bash +curl http://localhost:8082/metrics +``` + +See [Prometheus Setup](./prometheus-setup.md) for complete monitoring guide. + +### Basic Health Checks + +```bash +# Check if service is running +systemctl is-active ngit-grasp-production + +# Check if port is listening +nc -zv localhost 8082 + +# Check relay info +curl https://ngit.example.com -H "Accept: application/nostr+json" + +# Check disk usage +du -sh /persistent/ngit-grasp/* +``` + +## Backup + +Back up the complete `dataDir`, including `.relay-owner.nsec`, `git/`, and +`relay/`, from one point in time. For a portable consistent backup, stop the +instance before taking the snapshot: + +```bash +systemctl stop ngit-grasp-production +# Snapshot or back up /persistent/ngit-grasp with the host's storage tooling. +systemctl start ngit-grasp-production +``` + +Keep an off-host copy and test restoration into an isolated, non-public +instance. Never start the restored copy alongside production with the same +domain and relay identity. + +--- + +## Related Documentation + +- [Configuration Reference](../reference/configuration.md) - All configuration options +- [NixOS Module](../../nix/module.nix) - Module source code +- [Example Configuration](../../nix/example-configuration.nix) - More examples +- [Prometheus Setup](./prometheus-setup.md) - Monitoring guide +- [Nix Flakes How-To](./nix-flakes.md) - Nix development environment +- [Deployment Contract](../reference/deployment-contract.md) - Shared runtime and persistence rules +- [Deployment Chooser](deploy.md) - Other supported environments + +--- + +*Part of the [ngit-grasp how-to guides](./)* diff --git a/docs/how-to/deploy-paas.md b/docs/how-to/deploy-paas.md new file mode 100644 index 0000000..157568c --- /dev/null +++ b/docs/how-to/deploy-paas.md @@ -0,0 +1,162 @@ +# Deploy on managed hosting + +The supplied container can run on Railway, Render, and Fly.io when each service +has a custom domain, one persistent `/data` volume, and exactly one running +instance. These platforms terminate TLS and proxy WebSockets to ngit-grasp. + +Managed hosting is best for a small or moderate relay whose operator accepts +brief upgrade downtime. For a large existing relay, use NixOS, systemd, or +Compose so startup migrations and storage snapshots remain under direct +operator control. + +Read the [deployment contract](../reference/deployment-contract.md) first. In +particular, provider replicas do not make ngit-grasp highly available: their +local volumes do not replicate application state. + +## Common requirements + +For every provider: + +- set `NGIT_DOMAIN` to the final custom hostname, not the provider hostname; +- mount durable storage at `/data` before the first successful start; +- keep the instance count at one and disable scale-to-zero; +- keep `NGIT_BASE_PATH=/` unless path routing has been designed explicitly; +- allow at least five minutes between `SIGTERM` and forced termination; and +- take an external backup of `/data`, including `.relay-owner.nsec`. + +The image maps a platform-provided `PORT` to `0.0.0.0:${PORT}`. Do not set +`NGIT_BIND_ADDRESS` unless the provider template below does so explicitly. + +## Railway + +`railway.toml` selects the Dockerfile, uses `/` as the health check, disables +old/new deployment overlap, and gives shutdown five minutes. Railway storage is +configured separately from config-as-code. See Railway's +[config-as-code reference](https://docs.railway.com/config-as-code/reference) +and [volume guide](https://docs.railway.com/volumes) for the provider-side +details. + +From the repository root with the Railway CLI installed: + +```bash +railway login +railway init +railway add --service ngit-grasp +railway service ngit-grasp +railway variable set NGIT_DOMAIN=ngit.example.com NGIT_BASE_PATH=/ +railway volume add --mount-path /data +railway up +railway domain ngit.example.com +``` + +Add the DNS records returned by `railway domain`, then inspect status and verify +the public relay: + +```bash +railway domain status ngit.example.com +railway deployment list --json +scripts/verify-deployment.sh https://ngit.example.com +``` + +Do not add replicas or multi-region configuration. Railway mounts a new volume +as root; the image entrypoint prepares it and drops privileges before starting +ngit-grasp. + +Railway volume backups are useful recovery points, but also export or snapshot +state outside the platform. During a large storage migration, increase the +health-check timeout or move the upgrade to a directly operated host. + +## Render + +`render.yaml` describes a paid `starter` web service with a 10 GB disk mounted +at `/data`, a root health check, a five-minute shutdown delay, and automatic +deploys disabled. Render prompts for `NGIT_DOMAIN` when the Blueprint is +created. The fields follow Render's +[Blueprint specification](https://render.com/docs/blueprint-spec); its +[persistent disk guide](https://render.com/docs/disks) describes the storage +and scaling constraints. + +Render Blueprints require a repository connected through one of Render's +supported Git providers. Until ngit-grasp has an authorized mirror or published +OCI image, use Render's **Public Git Repository** flow with the canonical URL: + +```text +https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git +``` + +Choose Docker and apply the values from `render.yaml` in the service form: + +- environment variable `NGIT_DOMAIN=ngit.example.com`; +- persistent disk mounted at `/data`; +- health check path `/`; +- maximum shutdown delay 300 seconds; and +- one instance with automatic deploys disabled. + +Add and verify the custom domain before publishing it in repository +announcements. Render services backed by a persistent disk cannot run multiple +instances and have brief downtime during deploys, which matches the relay's +single-writer requirement. + +After a manual deploy, run: + +```bash +scripts/verify-deployment.sh https://ngit.example.com +``` + +Use Render disk snapshots plus an independent backup. Only `/data` persists; +all other container filesystem changes are ephemeral. + +## Fly.io + +Fly volumes are local to one Machine and are not automatically replicated. The +template therefore disables autostop, uses an immediate replacement strategy, +and must be deployed with high-availability seeding disabled. See Fly's +[configuration reference](https://fly.io/docs/reference/configuration/) and +[volume overview](https://fly.io/docs/volumes/overview/) for the underlying +platform behavior. + +Copy and edit the template: + +```bash +cp fly.toml.example fly.toml +# Set a unique app name, primary region, and NGIT_DOMAIN in fly.toml. +fly config validate --strict --config fly.toml +``` + +Create the app and one volume in the same region, then deploy one Machine: + +```bash +fly apps create replace-with-a-unique-ngit-grasp-name +fly volumes create ngit_grasp_data --region ord --size 10 +fly deploy --ha=false +fly scale count 1 +fly certs add ngit.example.com +``` + +Use the app name and region selected in `fly.toml`, add the DNS records shown by +`fly certs`, then verify: + +```bash +fly checks list +fly scale show +scripts/verify-deployment.sh https://ngit.example.com +``` + +Never scale above one: Fly creates a separate empty volume for each additional +Machine and does not copy ngit-grasp state. A single Machine and volume can be +unavailable after host failure, so maintain an external backup rather than +relying only on Fly snapshots. + +## Upgrades + +Managed volume deployments cannot provide a safe zero-downtime writer handoff. +Expect brief downtime: + +1. disable automatic deploys; +2. take a complete provider snapshot and an external backup; +3. deploy the pinned revision with one instance; +4. inspect startup and integrity logs; and +5. run the public verifier. + +For a storage-changing release, restore both the prior executable and its +matching pre-upgrade `/data` snapshot when rolling back. diff --git a/docs/how-to/deploy-proxmox-lxc.md b/docs/how-to/deploy-proxmox-lxc.md new file mode 100644 index 0000000..b50a6f3 --- /dev/null +++ b/docs/how-to/deploy-proxmox-lxc.md @@ -0,0 +1,58 @@ +# Deploy in a Proxmox LXC container or VM + +ngit-grasp has no kernel-virtualization or nested-container requirement. The +preferred Proxmox layout is an unprivileged Debian or Ubuntu LXC running the +static binary as a systemd service. + +## Container requirements + +- an unprivileged LXC or ordinary VM with systemd +- network access to public HTTPS and WebSocket relays +- inbound HTTP/HTTPS through the Proxmox network or an external proxy +- durable storage sized for Git repositories, LMDB, holding data, and backups + +Docker nesting is not required for the direct binary path. Leave it disabled +unless using the Compose alternative below. + +## Direct systemd path + +Follow [Deploy a static binary with systemd](deploy-linux.md) inside the guest. +Keep `/var/lib/ngit-grasp` on storage included in the guest's snapshot and +backup policy. + +When the Proxmox host bind-mounts a dataset into an unprivileged LXC, map its +ownership to the container's `ngit-grasp` UID/GID before starting the service. +Verify this from inside the container: + +```bash +sudo -u ngit-grasp test -w /var/lib/ngit-grasp +sudo -u ngit-grasp git --version +``` + +Terminate TLS either inside the guest with Caddy or at an upstream proxy. If +the upstream proxy connects directly to ngit-grasp, add only that private +source address to `NGIT_TRUSTED_PROXY_CIDRS` and prevent other clients from +reaching port 7334. + +## Compose alternative + +If the guest already operates Docker or Podman, follow the +[Docker guide](deploy-docker.md). Docker inside LXC generally requires the +Proxmox nesting feature; the static binary path avoids that extra layer. + +Do not mount the host Docker socket into the relay container. ngit-grasp needs +Git, not a container daemon. + +## Backup and upgrade + +For a simple consistent backup: + +1. stop `ngit-grasp` inside the guest; +2. snapshot or back up the guest and its attached state storage; +3. start the service; and +4. verify the public endpoint. + +Proxmox snapshots are not a substitute for an off-host backup. Before a +storage-changing upgrade, confirm that the state volume participates in the +snapshot and that the snapshot can be restored without starting a second +writer against the production domain. diff --git a/docs/how-to/deploy.md b/docs/how-to/deploy.md index d0749fe..ed864c6 100644 --- a/docs/how-to/deploy.md +++ b/docs/how-to/deploy.md @@ -1,543 +1,37 @@ -# How-To: Deploy ngit-grasp to Production +# Deploy ngit-grasp -**Purpose:** Deploy ngit-grasp to a production NixOS server -**Difficulty:** Intermediate -**Time:** 30-60 minutes +ngit-grasp supports several single-instance production layouts. Choose the +guide matching the host you already operate; every guide implements the same +[deployment contract](../reference/deployment-contract.md). ---- +| Environment | Start here | Supplied artifact | +| --- | --- | --- | +| Docker or Podman host | [Docker and Compose](deploy-docker.md) | `Dockerfile`, `compose.yaml`, optional Caddy overlay | +| NixOS | [NixOS module](deploy-nixos.md) | `nixosModules.default` | +| Debian, Ubuntu, or another systemd Linux | [Static binary and systemd](deploy-linux.md) | Static flake package and service unit | +| Proxmox LXC or VM | [Proxmox](deploy-proxmox-lxc.md) | Direct systemd or Compose path | +| Railway, Render, or Fly.io | [Managed hosting](deploy-paas.md) | Provider configuration templates | -## Problem - -You want to: -- Deploy ngit-grasp to a NixOS server -- Configure it as a systemd service -- Set up reverse proxy (Caddy) -- Ensure proper security and monitoring - ---- - -## Prerequisites - -- NixOS server with SSH access -- Flakes enabled on server and local machine -- Domain name configured (DNS pointing to server) -- Basic knowledge of NixOS configuration - ---- - -## Solution - -### Step 1: Add ngit-grasp to Your Server's Flake - -In your server's `flake.nix`, add ngit-grasp as an input: - -```nix -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; - ngit-grasp.url = "github:DanConwayDev/ngit-grasp"; - # or use a specific git repository: - # ngit-grasp.url = "git+https://git.shakespeare.diy/npub.../ngit-grasp.git"; - }; - - outputs = { self, nixpkgs, ngit-grasp, ... }@inputs: { - nixosConfigurations.your-hostname = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - specialArgs = { inherit inputs; }; - modules = [ - ./configuration.nix - # ... other modules - ]; - }; - }; -} -``` - ---- - -### Step 2: Create Service Configuration - -Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): - -```nix -{ inputs, ... }: - -{ - imports = [ inputs.ngit-grasp.nixosModules.default ]; - - services.ngit-grasp.production = { - enable = true; - domain = "ngit.example.com"; - - # Network - bindAddress = "127.0.0.1"; - port = 8082; - # Only Caddy can reach the loopback backend, so its forwarded client IP is trusted. - trustedProxyCidrs = [ "127.0.0.1/32" ]; - - # Storage - dataDir = "/persistent/ngit-grasp"; - - # Identity - relayName = "My GRASP Relay"; - relayDescription = "A Rust GRASP implementation with proactive sync"; - relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; - - # Sync - bootstrap from relay.ngit.dev - syncBootstrapRelayUrl = "wss://relay.ngit.dev"; - - # Metrics - metricsEnabled = true; - - # Logging - logLevel = "info"; - }; - - # Caddy reverse proxy - services.caddy.virtualHosts."ngit.example.com" = { - extraConfig = '' - reverse_proxy 127.0.0.1:8082 { - # Caddy manages X-Forwarded-For automatically. - header_up X-Real-IP {remote_host} - } - ''; - }; -} -``` - -**Key configuration options:** - -- **Instance name** (`production`): Can be any name. Used for systemd service (`ngit-grasp-production`) -- **domain**: Your relay's domain (used in GRASP validation) -- **port**: Local port (use reverse proxy for HTTPS) -- **trustedProxyCidrs**: Proxy source ranges allowed to supply the client IP - - Keep empty for a directly exposed listener - - Keep the backend private; trusting a public-facing source range permits spoofed headers - - Caddy automatically maintains `X-Forwarded-For`; `header_up`, not `header_down`, - changes headers sent to the backend -- **dataDir**: Where git repos and database are stored -- **relayOwnerNsecFile**: Path to file containing relay owner's nsec - - Passed to ngit-grasp as a protected systemd credential, not a process argument - - The runtime secret file must already exist (for example through agenix or sops-nix) - - Permissions on that external source file remain the operator or secret manager's responsibility - - Alternative: `relayOwnerNsec = "nsec1..."` (less secure, in nix store) - - If neither option is set, ngit-grasp loads or creates `.relay-owner.nsec` in `dataDir` -- **syncBootstrapRelayUrl**: Bootstrap relay to sync from on startup - -See [nix/example-configuration.nix](../../nix/example-configuration.nix) for more examples. - ---- - -### Step 3: Import the Service - -Import your service configuration in your main configuration file: - -```nix -# In configuration.nix or services/default.nix -{ - imports = [ - ./services/ngit-grasp.nix - # ... other services - ]; -} -``` - ---- - -### Step 4: Update Flake Lock +For a fresh internet-facing VPS, the shortest supported path is Docker Compose +with the Caddy overlay: ```bash -cd /path/to/server/config -nix flake update ngit-grasp -git add flake.lock -git commit -m "Add ngit-grasp and update flake.lock" +cp deploy.env.example .env +# Set NGIT_DOMAIN in .env and point its DNS records at this server. +docker compose -f compose.yaml -f compose.caddy.yaml up --build -d +scripts/verify-deployment.sh https://ngit.example.com ``` ---- +The Caddy path requires ports 80 and 443. If the host already has a reverse +proxy, follow the loopback-only path in the Docker guide instead. -### Step 5: Validate Configuration +## Unsupported layouts -Before deploying, validate that the flake evaluates without starting its builds: +Do not deploy ngit-grasp to serverless functions, an ephemeral filesystem, or +multiple replicas. It owns long-lived WebSockets, background synchronization, +local Git repositories, LMDB state, and a durable relay identity. -```bash -nix flake check --no-build -``` - -#### Resource-safe module validation - -Nix copies path-valued build inputs into the store when they are forced. A Git -flake is materialized from its tracked files first, but a standalone path into a -working tree does not inherit that Git filtering. - -This matters when testing ngit-grasp's NixOS module locally. Importing -`nix/module.nix` is lazy by itself, but rendering an enabled service forces the -module-built package through `ExecStart`. The package's `src = ../.` then -resolves relative to that module. If the module was imported directly from a -working tree, Nix may recursively hash or copy ignored `target/`, `.git`, and -linked-worktree data while it appears to be evaluating the configuration. - -Use `inputs.ngit-grasp.nixosModules.default` from a Git-backed flake input, as -shown above. For local module changes, commit them to a temporary Git branch and -use that Git source, or replace `buildRustPackage` with a test stub that ignores -all build attributes so `src` remains unforced. Do not use a direct -working-tree module import for a test that enables an instance. - -Inspect the derivation plan before starting a build: - -```bash -nixos-rebuild dry-build --flake .#your-hostname -``` - -Multiple ngit-grasp instances should normally share one ngit-grasp package -derivation. Avoid service-level `ExecStart` overrides that force another flake -package or Rust toolchain. If distinct versions are intentional, build them -sequentially or on appropriately sized remote builders. For an initial local -build, constrain Nix while confirming the plan behaves as expected: - -```bash -nixos-rebuild build --flake .#your-hostname --max-jobs 1 --cores 2 -``` - ---- - -### Step 6: Deploy to Server - -Deploy the new configuration to your server: - -```bash -# Build and switch in one command (builds on server) -nixos-rebuild switch --flake .#your-hostname \ - --target-host user@server.example.com \ - --use-remote-sudo \ - --build-host user@server.example.com -``` - -**Alternative:** Build locally, then deploy: - -```bash -# Build locally -nixos-rebuild build --flake .#your-hostname - -# Deploy to server -nixos-rebuild switch --flake .#your-hostname \ - --target-host user@server.example.com \ - --use-remote-sudo -``` - -**Note:** Building locally requires your machine to trust the server's nix signing key. - ---- - -### Step 7: Verify Deployment - -SSH to the server and check the service: - -```bash -ssh user@server.example.com - -# Check service status -systemctl status ngit-grasp-production - -# View logs -journalctl -u ngit-grasp-production -f - -# Check if listening on port -ss -tlnp | grep 8082 -``` - ---- - -### Step 8: Test Functionality - -From your local machine, test the relay: - -```bash -# Test NIP-11 relay info -curl https://ngit.example.com -H "Accept: application/nostr+json" | jq - -# Test WebSocket connection -websocat wss://ngit.example.com -# Then type: ["REQ","test",{}] -# Should receive events - -# Test git clone (if you have repos) -git ls-remote https://ngit.example.com//.git -``` - ---- - -## Configuration Options - -### Required -- `enable` - Enable this instance -- `domain` - Domain where relay is hosted - -### Network -- `basePath` - Public URL mount path (default: `/`) -- `bindAddress` - IP to bind to (default: "127.0.0.1") -- `port` - Port to listen on (default: 7334) -- `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client - IP (default: empty; forwarded headers ignored) - -### Storage -- `dataDir` - Base directory for data (default: /var/lib/ngit-grasp-{name}) -- `databaseBackend` - "lmdb" | "memory" (default: "lmdb") - -See [Upgrade Git family storage](upgrade-git-family-storage.md) before updating -an existing instance to a release that enables identifier-family storage. - -### Identity -- `relayName` - Relay name for NIP-11 (default: "{domain} grasp relay") -- `relayDescription` - Relay description -- `relayOwnerNsecFile` - Runtime secret file loaded as a systemd credential (recommended) -- `relayOwnerNsec` - Inline nsec (less secure) - -### Sync -- `syncBootstrapRelayUrl` - Bootstrap relay URL (optional) -- `syncDisableNegentropy` - Disable NIP-77 negentropy (default: false) -- `syncMaxBackoffSecs` - Max backoff for reconnection (default: 3600) -- `syncDisconnectCheckIntervalSecs` - Check interval (default: 60) -- `syncBaseBackoffSecs` - Base backoff time (default: 5) - -### Metrics -- `metricsEnabled` - Enable `/metrics` below the configured base path (default: true) -- `metricsConnectionPerIpAbuseThreshold` - Abuse threshold (default: 10) -- `metricsTopNRepos` - Number of top repos to track (default: 10) - -### Logging -- `logLevel` - "trace" | "debug" | "info" | "warn" | "error" (default: "info") - -### Security -- `user` - User to run as (default: "ngit-grasp-{name}") -- `group` - Group to run as (default: "ngit-grasp") - -See [nix/module.nix](../../nix/module.nix) for complete option definitions. - ---- - -## Systemd Service - -The NixOS module creates a systemd service: `ngit-grasp-{instance-name}` - -```bash -# Start/stop/restart -systemctl start ngit-grasp-production -systemctl stop ngit-grasp-production -systemctl restart ngit-grasp-production - -# Enable/disable autostart -systemctl enable ngit-grasp-production -systemctl disable ngit-grasp-production - -# View logs -journalctl -u ngit-grasp-production -f -journalctl -u ngit-grasp-production --since "1 hour ago" - -# Check status -systemctl status ngit-grasp-production -``` - ---- - -## Multiple Instances - -You can run multiple instances on the same server: - -```nix -services.ngit-grasp = { - production = { - enable = true; - domain = "ngit.example.com"; - port = 8082; - dataDir = "/persistent/ngit-production"; - }; - - staging = { - enable = true; - domain = "ngit-staging.example.com"; - port = 8083; - dataDir = "/persistent/ngit-staging"; - logLevel = "debug"; - }; -}; -``` - -Each instance: -- Runs as separate systemd service: `ngit-grasp-production`, `ngit-grasp-staging` -- Has its own user: `ngit-grasp-production`, `ngit-grasp-staging` -- Stores data in separate directory -- Can have different configuration - ---- - -## Troubleshooting - -### Service won't start - -**Check logs:** -```bash -journalctl -u ngit-grasp-production -n 50 -``` - -**Common issues:** -- Port already in use: Check with `ss -tlnp | grep 8082` -- Data directory permissions: Should be owned by service user -- Invalid nsec file: Check file exists and contains valid nsec - -### Can't connect via WebSocket - -**Check:** -- Service is running: `systemctl status ngit-grasp-production` -- Firewall allows connections: `nix-shell -p nmap --run "nmap -p 443 ngit.example.com"` -- Caddy is configured correctly: `systemctl status caddy` -- DNS resolves: `dig ngit.example.com` - -### Sync not working - -**Check logs for sync errors:** -```bash -journalctl -u ngit-grasp-production | grep -i sync -``` - -**Common issues:** -- Bootstrap relay URL incorrect or unreachable -- Network connectivity issues -- Bootstrap relay doesn't support negentropy (disable with `syncDisableNegentropy = true`) - -### High memory/CPU usage - -**Monitor metrics:** -```bash -curl http://localhost:8082/metrics -``` - -**Tune configuration:** -- Reduce `metricsTopNRepos` -- Increase `syncMaxBackoffSecs` -- Tune `syncMaxBackoffSecs` for your network conditions - ---- - -## Rollback - -If deployment fails, rollback to previous configuration: - -```bash -# On the server -nixos-rebuild switch --rollback - -# Or remotely -nixos-rebuild switch --rollback \ - --target-host user@server.example.com \ - --use-remote-sudo -``` - ---- - -## Upgrading - -To upgrade ngit-grasp: - -```bash -# Update flake input -nix flake update ngit-grasp - -# Review changes -git diff flake.lock - -# Commit -git add flake.lock -git commit -m "Update ngit-grasp" - -# Deploy -nixos-rebuild switch --flake .#your-hostname \ - --target-host user@server.example.com \ - --use-remote-sudo \ - --build-host user@server.example.com -``` - ---- - -## Security Hardening - -The NixOS module includes systemd hardening: - -- `NoNewPrivileges = true` - Prevents privilege escalation -- `ProtectSystem = "strict"` - Read-only filesystem except dataDir -- `ProtectHome = true` - No access to home directories -- `PrivateTmp = true` - Private /tmp -- `RestrictAddressFamilies` - Only allow needed network families -- `SystemCallFilter` - Restrict system calls - -Additional recommendations: - -1. **Use a runtime secret file instead of an inline key:** - ```nix - relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; - # NOT: relayOwnerNsec = "nsec1..."; # Ends up in nix store! - ``` - - The module exposes the file to ngit-grasp as the `relay_owner_nsec` - systemd credential. The key does not appear in `ExecStart` or the process - command line. ngit-grasp does not modify the external source file; keep its - ownership and permissions restricted through your secret manager. - -2. **Restrict data directory permissions:** - ```bash - chmod 750 /persistent/ngit-grasp - chown ngit-grasp-production:ngit-grasp /persistent/ngit-grasp - ``` - -3. **Use HTTPS (reverse proxy required):** - - ngit-grasp binds to localhost by default - - Use Caddy/nginx for TLS termination - - Caddy handles certificates automatically - -4. **Monitor logs regularly:** - ```bash - journalctl -u ngit-grasp-production --since today | grep -i error - ``` - ---- - -## Monitoring - -### Prometheus Metrics - -ngit-grasp exposes Prometheus metrics at `/metrics`: - -```bash -curl http://localhost:8082/metrics -``` - -See [Prometheus Setup](./prometheus-setup.md) for complete monitoring guide. - -### Basic Health Checks - -```bash -# Check if service is running -systemctl is-active ngit-grasp-production - -# Check if port is listening -nc -zv localhost 8082 - -# Check relay info -curl https://ngit.example.com -H "Accept: application/nostr+json" - -# Check disk usage -du -sh /persistent/ngit-grasp/* -``` - ---- - -## Related Documentation - -- [Configuration Reference](../reference/configuration.md) - All configuration options -- [NixOS Module](../../nix/module.nix) - Module source code -- [Example Configuration](../../nix/example-configuration.nix) - More examples -- [Prometheus Setup](./prometheus-setup.md) - Monitoring guide -- [Nix Flakes How-To](./nix-flakes.md) - Nix development environment - ---- - -*Part of the [ngit-grasp how-to guides](./)* +Kubernetes can run the container as a one-replica StatefulSet with a +ReadWriteOnce volume, but the repository does not yet ship or promise a Helm +chart. A container host or systemd service is simpler unless Kubernetes is an +existing operational requirement. diff --git a/docs/reference/README.md b/docs/reference/README.md index 96fc5ed..1be2ace 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -24,6 +24,13 @@ Reference documentation provides **factual, technical information** that you loo ## Available Reference Documentation +### [Deployment Contract](deployment-contract.md) + +Shared process, endpoint, persistence, identity, backup, single-writer, health, +and upgrade requirements for every supported hosting environment. + +--- + ### [Configuration](configuration.md) **Complete reference for all configuration options** diff --git a/docs/reference/deployment-contract.md b/docs/reference/deployment-contract.md new file mode 100644 index 0000000..f703082 --- /dev/null +++ b/docs/reference/deployment-contract.md @@ -0,0 +1,139 @@ +# Deployment contract + +This reference defines the runtime assumptions shared by every supported +ngit-grasp deployment. Environment-specific guides should provide these +properties rather than inventing a different layout. + +## Process + +ngit-grasp is one long-running process. It serves Git Smart HTTP, Nostr +WebSockets, NIP-11, landing pages, and Prometheus metrics from one HTTP port. +The process must: + +- have `git` and trusted CA certificates available at runtime; +- receive `SIGTERM` during a controlled stop; +- have enough shutdown time to persist state and clean up temporary refs; and +- run as a non-root user after writable storage has been prepared. + +## Required configuration + +`NGIT_DOMAIN` is the only required application setting. It is the canonical +public hostname, without a URL scheme or path: + +```text +NGIT_DOMAIN=ngit.example.com +``` + +When the relay is mounted below a shared hostname, set `NGIT_BASE_PATH` to the +public path. Otherwise keep the default `/`. + +The listener defaults to `127.0.0.1:7334`. A container or managed platform must +override it to an externally reachable container address. The supplied image +does this automatically, using `0.0.0.0:${PORT}` when the platform provides +`PORT` and `0.0.0.0:7334` otherwise. + +See the [configuration reference](configuration.md) for optional policy, +sync, curation, resource-limit, and private-service settings. + +## Public endpoint + +The public endpoint must preserve HTTP methods, request bodies, query strings, +and WebSocket upgrades. TLS normally terminates at Caddy, nginx, an ingress, +or the hosting platform. + +Configure `NGIT_TRUSTED_PROXY_CIDRS` only when all of the following are true: + +- the backend is unreachable except through the named proxies; +- the proxy overwrites or safely appends forwarding headers; and +- every trusted hop is represented by an exact address or narrow CIDR. + +Leaving the setting empty is safe. The relay then ignores forwarding headers +and records the proxy address instead of the original client address. + +## Durable state + +Every production deployment must persist these three items together: + +| Path | Contents | +| --- | --- | +| `.relay-owner.nsec` | Generated relay identity, unless an external credential supplies it | +| `git/` | Git object families, repository views, holding data, and maintenance queues | +| `relay/` | LMDB relay events, lifecycle metadata, cursors, and rejected-event state | + +The container contract places them below `/data`: + +```text +/data/.relay-owner.nsec +/data/git/ +/data/relay/ +``` + +The NixOS and generic systemd deployments use the same relative layout below +their configured state directory. + +Back up the entire state directory from one point in time. A simple portable +backup should stop the service first; a storage-level snapshot may instead +quiesce or atomically snapshot the filesystem. A backup that omits the owner +key changes the relay identity on restore. A backup that captures Git and LMDB +at unrelated times can restore an inconsistent authorization view. + +## One writer + +An ngit-grasp state directory has exactly one writer. Do not: + +- run multiple replicas against one filesystem or network volume; +- attach independent local volumes to replicas and load-balance between them; +- use rolling or blue/green replacement that overlaps two writers; or +- scale the service to zero when that can discard or detach its durable volume. + +Run one instance and scale it vertically. Separate instances are supported +only when each has its own domain, relay identity, and state directory. + +## Identity and secrets + +The default is intentionally low ceremony: on first start, ngit-grasp creates +`.relay-owner.nsec` with mode `0600` in its working directory and reuses it on +later starts. Protect and back up that file. + +To supply an existing identity: + +- use the `relay_owner_nsec` systemd credential where available; +- mount `.relay-owner.nsec` into the persistent state directory; or +- as a less preferred container fallback, use the + `NGIT_RELAY_OWNER_NSEC` secret environment variable. + +Never put an nsec in a command-line argument, image, Compose file, Nix store, +checked-in environment file, or hosting template. + +## Health and verification + +The HTTP listener starts only after configuration validation, storage +initialization, migrations, and startup integrity work. Until a dedicated +health route exists, platform health checks should request the configured base +path and accept any `2xx` response. + +After deployment, run: + +```bash +scripts/verify-deployment.sh https://ngit.example.com +``` + +The verifier checks NIP-11, an HTTP/1.1 WebSocket upgrade, and Prometheus +metrics with bounded network deadlines. Set `VERIFY_METRICS=false` only when +metrics were deliberately disabled. + +## Upgrade and rollback + +Pin deployments to a reviewed tag or revision. Before changing versions: + +1. read `CHANGELOG.md` and any linked migration guide; +2. take a restorable snapshot of the complete state directory; +3. stop the old writer before starting the new one; +4. wait for startup migrations and integrity summaries; and +5. run the deployment verifier. + +A binary or image rollback is not necessarily a data rollback. When a release +changes storage, restore the matching pre-upgrade state snapshot before +starting the older version. The +[Git family storage upgrade guide](../how-to/upgrade-git-family-storage.md) +documents the current one-way migration boundary. diff --git a/fly.toml.example b/fly.toml.example new file mode 100644 index 0000000..26cb2d3 --- /dev/null +++ b/fly.toml.example @@ -0,0 +1,42 @@ +# Copy this file to fly.toml, then change the app, primary_region, and +# NGIT_DOMAIN values before creating the Fly app. +app = "replace-with-a-unique-ngit-grasp-name" +primary_region = "ord" +kill_signal = "SIGTERM" +kill_timeout = 300 + +[build] +dockerfile = "Dockerfile" + +[env] +NGIT_DOMAIN = "ngit.example.com" +NGIT_BASE_PATH = "/" +NGIT_BIND_ADDRESS = "0.0.0.0:7334" +NGIT_GIT_DATA_PATH = "/data/git" +NGIT_RELAY_DATA_PATH = "/data/relay" +NGIT_LOG_LEVEL = "info" + +[deploy] +strategy = "immediate" + +[http_service] +internal_port = 7334 +force_https = true +auto_stop_machines = "off" +auto_start_machines = true +min_machines_running = 1 + +[[http_service.checks]] +grace_period = "30s" +interval = "30s" +method = "GET" +timeout = "5s" +path = "/" + +[[mounts]] +source = "ngit_grasp_data" +destination = "/data" + +[[vm]] +size = "shared-cpu-1x" +memory = "1gb" diff --git a/grasp-audit/src/specs/grasp06/prs_endpoint.rs b/grasp-audit/src/specs/grasp06/prs_endpoint.rs index f75b02d..46d77fd 100644 --- a/grasp-audit/src/specs/grasp06/prs_endpoint.rs +++ b/grasp-audit/src/specs/grasp06/prs_endpoint.rs @@ -699,7 +699,7 @@ fn git_push(cwd: &Path, url: &str, refname: &str) -> std::io::Result Result<( } let stderr = String::from_utf8_lossy(&out.stderr); - // Match git's two common 404 phrasings. `to_lowercase` keeps it robust to - // future git releases tweaking capitalisation; the substrings are stable. - let lower = stderr.to_lowercase(); - if lower.contains("not found") || lower.contains("404") { + if stderr_indicates_missing_prs_endpoint(&stderr) { return Err(format!( "Push to {} (ref={}) failed with a 404 — the /prs/ endpoint is not implemented. \ GRASP-06 06.md line 11 requires the endpoint to be reachable as an empty bare \ @@ -734,3 +731,39 @@ fn check_push_rejected_not_404(cwd: &Path, url: &str, refname: &str) -> Result<( Ok(()) } + +/// Recognize Git's missing-smart-HTTP-endpoint diagnostics without treating +/// arbitrary `404` digits in an echoed URL as an HTTP status. +fn stderr_indicates_missing_prs_endpoint(stderr: &str) -> bool { + stderr.to_lowercase().lines().any(|line| { + let line = line.trim(); + line.contains("requested url returned error: 404") + || line == "remote: repository not found." + || (line.starts_with("fatal: repository ") && line.ends_with(" not found")) + }) +} + +#[cfg(test)] +mod tests { + use super::stderr_indicates_missing_prs_endpoint; + + #[test] + fn missing_endpoint_detection_accepts_git_transport_diagnostics() { + for stderr in [ + "fatal: unable to access 'https://example.test/repo.git/': The requested URL returned error: 404", + "fatal: repository 'https://example.test/repo.git/' not found", + "remote: Repository not found.", + ] { + assert!(stderr_indicates_missing_prs_endpoint(stderr), "{stderr}"); + } + } + + #[test] + fn missing_endpoint_detection_ignores_404_digits_in_rejection_url() { + let stderr = "remote: ERR GRASP-06: only pushes to refs/nostr/ are accepted\n\ +fatal: the remote end hung up unexpectedly\n\ +error: failed to push some refs to 'http://127.0.0.1/prs/npub/audit-probe-6218-404b.git'"; + + assert!(!stderr_indicates_missing_prs_endpoint(stderr)); + } +} diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix index 8905cf2..dd29e77 100644 --- a/nix/example-configuration.nix +++ b/nix/example-configuration.nix @@ -2,7 +2,8 @@ # # Usage: # 1. Add to your server's flake.nix inputs: -# inputs.ngit-grasp.url = "github:DanConwayDev/ngit-grasp"; +# inputs.ngit-grasp.url = +# "git+https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git"; # # 2. Import the module in your configuration: # imports = [ inputs.ngit-grasp.nixosModules.default ]; diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..d668c25 --- /dev/null +++ b/railway.toml @@ -0,0 +1,12 @@ +"$schema" = "https://railway.com/railway.schema.json" + +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/" +healthcheckTimeout = 300 +restartPolicyType = "ALWAYS" +overlapSeconds = 0 +drainingSeconds = 300 diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..c5705fa --- /dev/null +++ b/render.yaml @@ -0,0 +1,19 @@ +services: + - type: web + name: ngit-grasp + runtime: docker + plan: starter + autoDeployTrigger: off + healthCheckPath: / + maxShutdownDelaySeconds: 300 + disk: + name: ngit-grasp-data + mountPath: /data + sizeGB: 10 + envVars: + - key: NGIT_DOMAIN + sync: false + - key: NGIT_BASE_PATH + value: / + - key: NGIT_LOG_LEVEL + value: info diff --git a/scripts/test-container-deployment.sh b/scripts/test-container-deployment.sh new file mode 100755 index 0000000..8ec0bbb --- /dev/null +++ b/scripts/test-container-deployment.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -eu + +container_engine=${CONTAINER_ENGINE:-docker} +test_suffix=$$ +container_name=ngit-grasp-deployment-test-${test_suffix} +volume_name=ngit-grasp-deployment-test-${test_suffix} +if [ -n "${NGIT_TEST_IMAGE:-}" ]; then + image_name=${NGIT_TEST_IMAGE} + remove_test_image=false +else + image_name=ngit-grasp:deployment-test-${test_suffix} + remove_test_image=true +fi + +case "${container_name}" in + ngit-grasp-deployment-test-[0-9]*) ;; + *) + echo "refusing to use unexpected test resource name" >&2 + exit 2 + ;; +esac + +if ! command -v "${container_engine}" >/dev/null 2>&1; then + echo "missing container engine: ${container_engine}" >&2 + exit 2 +fi +for required_command in curl jq; do + if ! command -v "${required_command}" >/dev/null 2>&1; then + echo "missing required command: ${required_command}" >&2 + exit 2 + fi +done + +cleanup() { + "${container_engine}" rm --force "${container_name}" >/dev/null 2>&1 || true + "${container_engine}" volume rm "${volume_name}" >/dev/null 2>&1 || true + if [ "${remove_test_image}" = true ]; then + "${container_engine}" image rm "${image_name}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT HUP INT TERM + +start_container() { + "${container_engine}" run \ + --detach \ + --name "${container_name}" \ + --publish 127.0.0.1::7334 \ + --volume "${volume_name}:/data" \ + --env NGIT_DOMAIN=localhost \ + "${image_name}" >/dev/null +} + +relay_url() { + published_port=$("${container_engine}" port "${container_name}" 7334/tcp) + published_port=${published_port##*:} + printf 'http://127.0.0.1:%s' "${published_port}" +} + +relay_pubkey() { + curl \ + --fail \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 15 \ + --retry 30 \ + --retry-all-errors \ + --retry-max-time 180 \ + --header 'Accept: application/nostr+json' \ + "$1" | jq -er '.pubkey' +} + +"${container_engine}" build --tag "${image_name}" . +"${container_engine}" volume create "${volume_name}" >/dev/null + +start_container +first_url=$(relay_url) +first_pubkey=$(relay_pubkey "${first_url}") +"${container_engine}" exec "${container_name}" \ + test -s /data/.relay-owner.nsec + +"${container_engine}" stop --time 300 "${container_name}" >/dev/null +"${container_engine}" rm "${container_name}" >/dev/null + +start_container +second_url=$(relay_url) +second_pubkey=$(relay_pubkey "${second_url}") + +if [ "${first_pubkey}" != "${second_pubkey}" ]; then + echo "relay identity changed after container replacement" >&2 + exit 1 +fi + +echo "container deployment verified; relay identity persisted" diff --git a/scripts/verify-deployment.sh b/scripts/verify-deployment.sh new file mode 100755 index 0000000..144ae36 --- /dev/null +++ b/scripts/verify-deployment.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +usage() { + echo "Usage: $0 " >&2 + echo "Example: $0 https://ngit.example.com" >&2 +} + +if [ "$#" -ne 1 ]; then + usage + exit 2 +fi + +base_url=${1%/} +case "${base_url}" in + http://*|https://*) ;; + *) + echo "public base URL must start with http:// or https://" >&2 + exit 2 + ;; +esac + +for required_command in curl grep mktemp; do + if ! command -v "${required_command}" >/dev/null 2>&1; then + echo "missing required command: ${required_command}" >&2 + exit 2 + fi +done + +temporary_directory=$(mktemp -d) +cleanup() { + rm -rf -- "${temporary_directory}" +} +trap cleanup EXIT HUP INT TERM + +nip11_body=${temporary_directory}/nip11.json +websocket_headers=${temporary_directory}/websocket.headers + +curl \ + --fail \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 15 \ + --retry 20 \ + --retry-all-errors \ + --retry-max-time 120 \ + --header 'Accept: application/nostr+json' \ + --output "${nip11_body}" \ + "${base_url}" + +if command -v jq >/dev/null 2>&1; then + jq -e ' + (.pubkey | type == "string" and length == 64) and + (.supported_nips | type == "array") + ' "${nip11_body}" >/dev/null +else + grep -q '"pubkey"' "${nip11_body}" + grep -q '"supported_nips"' "${nip11_body}" +fi +echo "ok: NIP-11 relay information" + +set +e +curl \ + --http1.1 \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 5 \ + --dump-header "${websocket_headers}" \ + --output /dev/null \ + --header 'Connection: Upgrade' \ + --header 'Upgrade: websocket' \ + --header 'Sec-WebSocket-Version: 13' \ + --header 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + "${base_url}" +websocket_curl_status=$? +set -e + +if ! grep -Eq '^HTTP/[0-9.]+ 101([[:space:]]|$)' "${websocket_headers}"; then + echo "WebSocket upgrade failed (curl status ${websocket_curl_status})" >&2 + cat "${websocket_headers}" >&2 + exit 1 +fi +echo "ok: WebSocket upgrade" + +if [ "${VERIFY_METRICS:-true}" = true ]; then + curl \ + --fail \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 15 \ + --output /dev/null \ + "${base_url}/metrics" + echo "ok: Prometheus metrics" +fi + +echo "deployment verified: ${base_url}"