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/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/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/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/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/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}"