diff --git a/.env.example b/.env.example index 7ce191a..4ff2deb 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ PACTO__BOT_ID=sigstack # PACTO__DEFAULT_RECIPIENT=npub1... PACTO__TIMEOUT=15s +# Optional: co-host the Pacto Nostr relay itself: +# cd docker && docker compose --profile relay up -d --build +# See docker/relay-config.toml and "Hosting the Pacto Nostr Relay" in CLAUDE.md +# Host port for the relay websocket (macOS: 7000 clashes with AirPlay +# Receiver — use 7447 or disable AirPlay) +# RELAY_PORT=7000 + # Payment Configuration (x402) # Set to true to enable credit tracking and payment system PAYMENTS__ENABLED=false diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..ddac77c --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,26 @@ +name: commitlint + +on: + pull_request: + push: + branches: [main, master] + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - name: Lint commit messages + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + npx --no -- commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose + else + npx --no -- commitlint --from "${{ github.event.before }}" --to "${{ github.sha }}" --verbose + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e2ce34b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: test + +on: + pull_request: + push: + branches: [main, master] + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + # Keep in sync with scripts/ci.sh local prerequisites. + components: rustfmt, clippy, llvm-tools-preview + + - uses: Swatinem/rust-cache@v2 + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Test with coverage gate (≥90% lines) + run: | + cargo llvm-cov --workspace --lcov --output-path lcov.info \ + --fail-under-lines 90 \ + --ignore-filename-regex '(^|/)main\.rs$' + + - name: Upload LCOV + if: always() + uses: actions/upload-artifact@v4 + with: + name: lcov + path: lcov.info + if-no-files-found: ignore diff --git a/CLAUDE.md b/CLAUDE.md index 2d3855b..1e90542 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -566,6 +566,88 @@ from source and copies in your `pacto-bot-api.toml`). Push it as linux/amd64, set `PACTO_BOT_API_IMAGE` plus the `PACTO_BOT_NSEC` encrypted secret, and uncomment the `pacto-bot-api` service block in `docker/phala-compose.yaml`. +## Hosting the Pacto Nostr Relay + +The deployment can also host the Pacto network's Nostr relay itself +([nostr-rs-relay](https://github.com/scsibug/nostr-rs-relay), pinned to the +same version as +[pacto-dev-env](https://github.com/covenant-gov/pacto-dev-env)). Opt-in via +the `relay` compose profile. + +Running the relay inside the TEE extends the verifiability story from the bot +to the relay: the binary is built from a pinned upstream tag and the policy +(`docker/relay-config.toml`) is baked into the image, so both are +reproducible and auditable. They become covered by the attested +`compose_hash` once the image reference in `docker/phala-compose.yaml` is a +literal sha256 digest (like the existing `signal-api` service) — a tag or +env-var reference is not attested. On Phala the sqlite database lives on the +CVM's encrypted disk. + +What the relay stores is not plaintext — Pacto DMs and group messages arrive +as NIP-59 gift wraps and MLS ciphertexts. The relay still observes connection +metadata (client IPs, timing, event sizes), which is the standard Nostr trust +model; running it in the TEE removes the relay *operator* from that picture. + +### Local Setup + +```bash +cd docker +docker compose --profile relay up -d --build +# together with the Pacto daemon: +docker compose --profile relay --profile pacto up -d --build +# teardown (a plain `docker compose down` ignores profiled services): +docker compose --profile relay down +``` + +- Host endpoint: `ws://localhost:7000`; in-network: `ws://nostr-relay:8080` +- macOS: AirPlay Receiver listens on port 7000 — set `RELAY_PORT=7447` in + `docker/.env` (or disable AirPlay Receiver) if the port is taken +- Point the co-located daemon at it via `relays` in + `docker/pacto-bot-api.toml` (see the example file) +- Events persist in the `relay-data` volume +- Health check: `curl -H 'Accept: application/nostr+json' localhost:7000` + returns the NIP-11 relay information document + +### Phala Deployment + +1. Build and push the image (the config is baked in, so rebuild after any + `relay-config.toml` change): + ```bash + docker buildx build --platform linux/amd64 \ + -t YOUR_DOCKERHUB/pacto-nostr-relay:0.9.0 -f docker/Dockerfile.relay --push docker + ``` +2. Uncomment the `nostr-relay` service and `relay-data` volume in + `docker/phala-compose.yaml`, pin the image by its pushed sha256 digest + (`docker buildx imagetools inspect ...`), then redeploy +3. The relay is served at `wss://-7000..phala.network` + (the dstack gateway terminates TLS and proxies WebSocket upgrades; the + gateway domain depends on the cluster — check the Phala dashboard) +4. Serving it as `relay.pacto.chat` is more than a DNS record: for custom + domains the gateway does TLS *passthrough*, so TLS must terminate inside + the CVM — add a [dstack-ingress](https://github.com/Dstack-TEE/dstack-examples/tree/main/custom-domain) + service (Cloudflare DNS + certbot) with `TARGET_ENDPOINT` pointing at + `nostr-relay:8080`, and keep `relay_url` in `relay-config.toml` in sync + before building. Until the domain actually points here, the advertised + `relay_url` is aspirational + +### Event Kind Policy + +`event_kind_allowlist` in `relay-config.toml` ships commented out. The kinds +Pacto uses today (from pacto-app's nostr-sdk usage: the `stored_event.rs` +kinds, `Kind::Metadata`, `Kind::GiftWrap`, the kind-9 chat rumor, and +nostr-sdk 0.43's MLS kinds 443-445) are +`0, 7, 9, 14, 15, 16, 443, 444, 445, 1059, 30078`. Enable the +allowlist only after confirming the set with the Pacto team — an over-strict +allowlist silently breaks clients, and it is spam control, not a security +boundary. + +Note: pacto-dev-env's `relay-config.toml` places `event_kind_allowlist` under +an `[events]` section (and the sqlite path under `[database] path`), neither +of which exists in nostr-rs-relay — upstream silently ignores unknown keys, +so that allowlist has never actually been enforced in the dev environment. +This repo's config uses the real keys (`[limits] event_kind_allowlist`, +`[database] data_directory`). + ## Testing ```bash diff --git a/README.md b/README.md index 7c45203..e18aaa6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This project implements a Signal bot that runs inside a Dstack-powered TEE (Inte - In-memory conversation storage (no external persistence) - Group chat support with shared conversation context - OpenAI-compatible API integration +- Optional co-hosted Nostr relay for the [Pacto](https://github.com/covenant-gov/pacto-app) network (opt-in `relay` compose profile) ## Bot Commands diff --git a/docker/Dockerfile.relay b/docker/Dockerfile.relay new file mode 100644 index 0000000..04e4b07 --- /dev/null +++ b/docker/Dockerfile.relay @@ -0,0 +1,60 @@ +# Co-hosted Pacto Nostr relay (nostr-rs-relay). +# +# Pinned to the same upstream version as covenant-gov/pacto-dev-env and built +# from source (upstream publishes amd64-only images; this builds natively on +# any platform). relay-config.toml is baked in so the same image works on +# Phala, where CVMs cannot bind-mount local files, and so the relay policy is +# part of the attested image. +# +# Local use: cd docker && docker compose --profile relay up -d --build +# +# Phala build (from the repo root; note the digest it pushes — the service in +# phala-compose.yaml should pin it so the relay is covered by attestation): +# docker buildx build --platform linux/amd64 \ +# -t YOUR_DOCKERHUB/pacto-nostr-relay:0.9.0 \ +# -f docker/Dockerfile.relay --push docker + +FROM rust:1-bookworm AS builder + +ARG NOSTR_RELAY_VERSION=0.9.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends cmake git libssl-dev pkg-config protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +RUN git clone --depth 1 --branch "${NOSTR_RELAY_VERSION}" \ + https://github.com/scsibug/nostr-rs-relay.git . \ + && cargo build --release + +# --- +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl libssl3 \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system --gid 1000 relay \ + && useradd --system --uid 1000 --gid relay \ + --home-dir /var/lib/nostr-relay --shell /sbin/nologin relay + +RUN mkdir -p /usr/src/app /var/lib/nostr-relay \ + && chown -R relay:relay /usr/src/app /var/lib/nostr-relay + +# nostr-rs-relay reads config.toml from its working directory +WORKDIR /usr/src/app +COPY --from=builder /src/target/release/nostr-rs-relay ./nostr-rs-relay +COPY --chown=relay:relay relay-config.toml ./config.toml + +USER relay + +# Without this the relay logs errors only (upstream's Dockerfile sets the same) +ENV RUST_LOG=info,nostr_rs_relay=info + +EXPOSE 8080 +VOLUME ["/var/lib/nostr-relay"] + +# Pass --config explicitly: with the implicit cwd lookup, a broken config +# makes 0.9.0 silently start on upstream defaults (no Pacto policy, sqlite +# outside the volume) instead of failing fast. +CMD ["./nostr-rs-relay", "--config", "./config.toml"] diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 8efd099..43da832 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -106,6 +106,36 @@ services: - internal restart: unless-stopped + # Co-hosted Nostr relay for the Pacto network (nostr-rs-relay, pinned to + # the same version as covenant-gov/pacto-dev-env; config baked in from + # relay-config.toml). Opt-in: `docker compose --profile relay up -d --build`. + # Reachable at ws://localhost:7000 on the host and ws://nostr-relay:8080 + # inside the compose network (add it to `relays` in pacto-bot-api.toml). + # macOS: AirPlay Receiver squats on port 7000 — set RELAY_PORT=7447 (or + # disable AirPlay Receiver) if startup fails with "address already in use". + nostr-relay: + build: + context: . + dockerfile: Dockerfile.relay + container_name: nostr-relay + profiles: ["relay"] + environment: + - RUST_LOG=${LOG_LEVEL:-info} + ports: + - "${RELAY_PORT:-7000}:8080" + volumes: + - relay-data:/var/lib/nostr-relay + networks: + - internal + healthcheck: + # NIP-11 relay information document + test: ["CMD", "curl", "-f", "-H", "Accept: application/nostr+json", "http://localhost:8080"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + restart: unless-stopped + signal-registration-proxy: build: context: .. @@ -150,6 +180,8 @@ volumes: driver: local pacto-data: driver: local + relay-data: + driver: local networks: internal: diff --git a/docker/pacto-bot-api.toml.example b/docker/pacto-bot-api.toml.example index d444719..7b5149d 100644 --- a/docker/pacto-bot-api.toml.example +++ b/docker/pacto-bot-api.toml.example @@ -21,4 +21,7 @@ id = "sigstack" npub = "npub1REPLACE_WITH_YOUR_BOT_NPUB" signing = { backend = "nsec", nsec = "${PACTO_BOT_NSEC}" } relays = ["wss://relay.pacto.chat"] +# Co-hosting the relay (docker compose --profile relay)? Use the in-network +# URL, optionally alongside public relays: +# relays = ["ws://nostr-relay:8080", "wss://relay.pacto.chat"] capabilities = ["ReadMessages", "SendMessages"] diff --git a/docker/phala-compose.yaml b/docker/phala-compose.yaml index 0879126..8c1794b 100644 --- a/docker/phala-compose.yaml +++ b/docker/phala-compose.yaml @@ -100,6 +100,32 @@ services: # - pacto-data:/var/lib/pacto-bot-api # restart: unless-stopped + # Co-hosted Pacto Nostr relay (nostr-rs-relay; docker/Dockerfile.relay bakes + # in docker/relay-config.toml, so policy changes require an image rebuild). + # Build & push (linux/amd64), then uncomment this service and the + # relay-data volume below: + # docker buildx build --platform linux/amd64 \ + # -t YOUR_DOCKERHUB/pacto-nostr-relay:0.9.0 -f docker/Dockerfile.relay --push docker + # Pin the image by the pushed sha256 digest (docker buildx imagetools + # inspect ...), like signal-api above — only a literal digest makes the + # relay binary + policy part of the attested compose_hash; a tag or env-var + # reference is not attested. + # Public endpoint (TLS terminated by the dstack gateway; the gateway domain + # depends on the cluster the CVM runs in — see the Phala dashboard): + # wss://-7000..phala.network + # The sqlite database lives in relay-data, on the CVM's encrypted disk. + # + # nostr-relay: + # image: YOUR_DOCKERHUB/pacto-nostr-relay@sha256:REPLACE_WITH_PUSHED_DIGEST + # container_name: nostr-relay + # environment: + # - RUST_LOG=${LOG_LEVEL:-info} + # ports: + # - "7000:8080" + # volumes: + # - relay-data:/var/lib/nostr-relay + # restart: unless-stopped + signal-registration-proxy: image: ${SIGNAL_PROXY_IMAGE} container_name: signal-registration-proxy @@ -132,3 +158,6 @@ volumes: driver: local pacto-data: driver: local + # Uncomment together with the nostr-relay service above: + # relay-data: + # driver: local diff --git a/docker/relay-config.toml b/docker/relay-config.toml new file mode 100644 index 0000000..fc21fa6 --- /dev/null +++ b/docker/relay-config.toml @@ -0,0 +1,64 @@ +# nostr-rs-relay configuration for the co-hosted Pacto relay. +# +# Baked into the image by docker/Dockerfile.relay so the same artifact runs +# locally and on Phala (CVMs cannot bind-mount local files), and so the +# relay's policy is auditable as part of the image. It is covered by TEE +# attestation once phala-compose.yaml pins the image by sha256 digest (an +# env-var or tag reference is not attested). +# +# Full reference: +# https://github.com/scsibug/nostr-rs-relay/blob/0.9.0/config.toml + +[info] +relay_url = "wss://relay.pacto.chat" +name = "Pacto relay" +description = "Nostr relay for the Pacto network, hosted inside the sigstack Dstack TEE." +# Administrative contact pubkey (32-byte hex, not npub) +#pubkey = "" +#contact = "mailto:contact@example.com" + +[database] +engine = "sqlite" +# Matches the relay-data volume mount in docker-compose.yaml / phala-compose.yaml +data_directory = "/var/lib/nostr-relay" + +[network] +address = "0.0.0.0" +port = 8080 +# Enable if fronted by a proxy that sets this header, so rate limiting and +# logs see real client IPs instead of the proxy's. +#remote_ip_header = "x-forwarded-for" + +[options] +# Reject events timestamped more than 30 minutes in the future +reject_future_seconds = 1800 + +[limits] +# Server-wide (not per-connection) event-creation rate limit +messages_per_sec = 50 +subscriptions_per_min = 60 + +# Event kinds Pacto uses today, from pacto-app's nostr-sdk usage +# (stored_event.rs kinds plus Kind::Metadata, Kind::GiftWrap, the kind-9 chat +# rumor, and nostr-sdk 0.43's MLS kinds): metadata (0), reactions (7), +# chat (9), NIP-17 DMs / file attachments / edits (14-16), MLS key package / +# welcome / group message (443-445), gift wraps (1059), NIP-78 app data +# (30078). +# +# Left DISABLED until the full set is confirmed with the Pacto team — an +# over-strict allowlist silently breaks clients. (pacto-dev-env's +# relay-config.toml puts this key under [events], which nostr-rs-relay +# ignores, so it has never actually been enforced there.) +#event_kind_allowlist = [0, 7, 9, 14, 15, 16, 443, 444, 445, 1059, 30078] + +limit_scrapers = false + +[authorization] +# Optional hardening: NIP-42 auth so gift wraps (kind 1059) are delivered +# only to their authenticated recipients. Requires client support — verify +# pacto-app handles AUTH before enabling. Also note NIP-42 challenges use +# relay_url above: it must match the URL clients actually connect to, so +# leave this off until relay.pacto.chat really points at this deployment +# (until then the advertised relay_url is aspirational). +#nip42_auth = true +#nip42_dms = true