From 3fdf4a230c4e568ce0da6ff0f074d1b0e3c519b5 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:22:43 -0400 Subject: [PATCH] M11: reproducible deploy pipeline Multi-stage non-root Dockerfile (builds -p gp-server against the nip44/nym siblings; excludes the goblin-tree dev crate), a full docker-compose (server + bundled nostr-rs-relay + auto-HTTPS Caddy), a hardened systemd unit (DynamicUser, ProtectSystem=strict, NoNewPrivileges, seed via LoadCredential), an install.sh bare-metal bootstrap, .env.example, and an fmt+clippy+test CI workflow for Gitea and GitHub. --- .gitea/workflows/ci.yml | 58 +++++++++++++++++++++++++ .github/workflows/ci.yml | 58 +++++++++++++++++++++++++ deploy/.env.example | 39 +++++++++++++++++ deploy/Caddyfile | 23 ++++++++++ deploy/Dockerfile | 67 +++++++++++++++++++++++++++++ deploy/docker-compose.yml | 89 +++++++++++++++++++++++++++++++++++++++ deploy/gp-server.service | 80 +++++++++++++++++++++++++++++++++++ deploy/install.sh | 77 +++++++++++++++++++++++++++++++++ 8 files changed, 491 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .github/workflows/ci.yml create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/gp-server.service create mode 100755 deploy/install.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..12666cd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,58 @@ +# CI gate for GoblinPay. Mirror of .github/workflows/ci.yml. +# +# What runs where: +# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is +# self-contained (no out-of-repo deps), and it holds the domain logic +# (config, invoices, matching, webhooks, rates, the connector seam). +# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the +# sibling checkouts next to the repo: nip44/ and nym/ (the Nostr/Nym path) +# and goblin/ (the gp-goblin-sender round-trip gate). Where the workspace is +# laid out like the deploy host, it runs too; otherwise it is skipped with a +# note. `-p` scoping always keeps the goblin-tree dev crate off the money +# path build. +name: ci +on: + push: + branches: [main, master] + pull_request: +env: + CARGO_TERM_COLOR: always +jobs: + rust: + name: fmt / clippy / test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Format check (whole workspace) + run: cargo fmt --all -- --check + + - name: Clippy (gp-core, deny warnings) + run: cargo clippy -p gp-core --all-targets -- -D warnings + + - name: Test (gp-core) + run: cargo test -p gp-core --locked + + - name: Full gate (when sibling checkouts are present) + run: | + if [ -d ../nip44 ] && [ -d ../nym/smolmix/core ] && [ -d ../goblin ]; then + echo "Workspace siblings present — running the full ./ci.sh gate." + ./ci.sh + else + echo "nip44/nym/goblin siblings absent on this runner;" + echo "the full gp-server gate runs via ./ci.sh on the deploy host." + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf16a2c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +# CI gate for GoblinPay. Mirror of .gitea/workflows/ci.yml. +# +# What runs where: +# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is +# self-contained (no out-of-repo deps), and it holds the domain logic +# (config, invoices, matching, webhooks, rates, the connector seam). +# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the +# sibling checkouts next to the repo: nip44/ and nym/ (the Nostr/Nym path) +# and goblin/ (the gp-goblin-sender round-trip gate). Where the workspace is +# laid out like the deploy host, it runs too; otherwise it is skipped with a +# note. `-p` scoping always keeps the goblin-tree dev crate off the money +# path build. +name: ci +on: + push: + branches: [main, master] + pull_request: +env: + CARGO_TERM_COLOR: always +jobs: + rust: + name: fmt / clippy / test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Format check (whole workspace) + run: cargo fmt --all -- --check + + - name: Clippy (gp-core, deny warnings) + run: cargo clippy -p gp-core --all-targets -- -D warnings + + - name: Test (gp-core) + run: cargo test -p gp-core --locked + + - name: Full gate (when sibling checkouts are present) + run: | + if [ -d ../nip44 ] && [ -d ../nym/smolmix/core ] && [ -d ../goblin ]; then + echo "Workspace siblings present — running the full ./ci.sh gate." + ./ci.sh + else + echo "nip44/nym/goblin siblings absent on this runner;" + echo "the full gp-server gate runs via ./ci.sh on the deploy host." + fi diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..77b91c0 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,39 @@ +# GoblinPay environment. Copy to /etc/goblinpay.env (bare metal) or deploy/.env +# (docker compose), then edit. NON-SECRET config only: the Grin seed and the +# wallet password live as mode-0400 files (systemd LoadCredential / the compose +# ./secrets mount), never in this file. + +# --- domain / URLs --- +# docker-compose serves GoblinPay on GP_DOMAIN and the bundled relay on +# relay.; point BOTH DNS records at this host before `compose up`. +GP_DOMAIN=pay.example +GP_PUBLIC_URL=https://pay.example + +# --- relay (bundled is the default: GoblinPay runs its own relay) --- +GP_RELAY_MODE=bundled +# The bundled relay's PUBLIC url: it is BOTH dialed by the server AND advertised +# to payers in the checkout nprofile, so it must be reachable from the internet. +GP_BUNDLED_RELAY_URL=wss://relay.pay.example +# For GP_RELAY_MODE=external instead, drop the bundled relay and set: +#GP_RELAY_MODE=external +#GP_RELAYS=wss://relay.damus.io,wss://nos.lol + +# --- Grin node (read-only: confirmations + balance) --- +GP_NODE_URL=https://main.gri.mw + +# --- mixnet --- +# on (default) routes THIS server's relay traffic over the Nym mixnet. off is a +# supported production posture (server-side clearnet): the payer's Goblin Wallet +# still provides sender privacy and the payload stays gift-wrapped end to end. +GP_NYM=on + +# --- API / admin tokens (bearer capabilities; use strong random values) --- +GP_API_TOKEN=change-me-api-token +GP_ADMIN_TOKEN=change-me-admin-token + +# --- webhook to your store (optional; the URL requires the secret) --- +#GP_WEBHOOK_URL=https://your-store/hook +#GP_WEBHOOK_SECRET=change-me-webhook-secret + +# --- default payment-matching mode: memo | derived | amount --- +GP_MATCH_MODE=derived diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..d9ebe5b --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,23 @@ +# Caddy reverse proxy for a GoblinPay till, with automatic HTTPS. +# +# Two names on one host (point both A/AAAA records at this server before +# `docker compose up`, so Caddy can obtain certificates): +# {$GP_DOMAIN} -> the GoblinPay checkout pages + REST API (gp-server) +# relay.{$GP_DOMAIN} -> the bundled nostr-rs-relay (payers connect here; it +# is what the checkout nprofile advertises) +# +# The relay gets its OWN subdomain rather than a path on the main domain so +# there is no path rewriting: nostr-rs-relay serves both the WebSocket relay +# protocol and the NIP-11 relay-info document at the root. +# +# GP_DOMAIN is injected from the environment by docker-compose. + +{$GP_DOMAIN} { + encode gzip + reverse_proxy gp-server:8080 +} + +relay.{$GP_DOMAIN} { + # WebSocket upgrades and the NIP-11 document both go straight through. + reverse_proxy relay:7777 +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..1fe2557 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,67 @@ +# Multi-stage build for the GoblinPay server, run as a non-root user. +# +# IMPORTANT — build context is the WORKSPACE PARENT, not the repo. +# The Nostr/Nym money path depends on two crates that live next to this repo, +# not inside it (see crates/gp-nostr/Cargo.toml): +# nip44 -> ../nip44 (the NIP-44 v3 companion crate) +# smolmix-> ../nym/smolmix/core (the in-process Nym mixnet) +# So the image must be built from the directory that contains GoblinPay/, +# nip44/, and nym/ side by side. docker-compose.yml already sets +# `build.context: ../..` for this; to build by hand: +# +# cd "" +# docker build -f GoblinPay/deploy/Dockerfile -t goblinpay:latest . +# +# Only `-p gp-server` is built, which EXCLUDES the gp-goblin-sender dev crate +# (it needs the goblin wallet tree, absent on servers). gp-wallet's grin_wallet +# crates are fetched from git during the build. + +# ---- builder ---- +FROM rust:1-bookworm AS builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends clang cmake pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build + +# The three trees the gp-server dependency graph needs, in the same relative +# layout the path deps expect (nip44 and nym are siblings of GoblinPay). +COPY GoblinPay ./GoblinPay +COPY nip44 ./nip44 +COPY nym ./nym + +WORKDIR /build/GoblinPay +# Build ONLY gp-server (and its deps); never the goblin-tree dev crate. +RUN cargo build --release --locked -p gp-server + +# ---- runtime ---- +FROM debian:bookworm-slim AS runtime +# ca-certificates for outbound TLS (node reads, CoinGecko, relays); curl for the +# healthcheck. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user; wallet files, seed-at-rest, and the SQLite db live under /data. +RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin goblinpay \ + && mkdir -p /data \ + && chown -R goblinpay:goblinpay /data + +COPY --from=builder /build/GoblinPay/target/release/gp-server /usr/local/bin/gp-server + +USER goblinpay +WORKDIR /data +VOLUME ["/data"] + +# Bind on all interfaces inside the container (Caddy is the only thing in front); +# keep state under the /data volume. Money/identity secrets are injected at run +# time via the *_FILE mounted-secret variants, never baked into the image. +ENV GP_BIND=0.0.0.0:8080 \ + GP_DB_PATH=/data/goblinpay.db \ + GP_DATA_DIR=/data/gp-data + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8080/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/gp-server"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..a2c48a2 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,89 @@ +# A full, self-contained GoblinPay till: the server, its BUNDLED relay, and an +# auto-HTTPS reverse proxy. +# +# cd deploy +# cp .env.example .env # then edit it (domain, tokens) +# mkdir -p secrets # drop the mounted-secret files in here +# docker compose up -d +# +# gives you: +# - gp-server : the GoblinPay payment server (this repo) +# - relay : a stock nostr-rs-relay, the bundled relay GP_RELAY_MODE=bundled +# points at (so no third-party relay is needed) +# - caddy : auto-TLS reverse proxy terminating HTTPS for both +# +# Set GP_DOMAIN in .env to your own domain BEFORE bringing it up: Caddy obtains +# a certificate for it, so DNS must already point at this host. +# +# NOTE on the build context: gp-server's Nostr/Nym path depends on the sibling +# crates nip44/ and nym/ (see deploy/Dockerfile), so the build context is the +# workspace parent (`../..`) that holds GoblinPay, nip44, and nym. + +services: + gp-server: + build: + context: ../.. + dockerfile: GoblinPay/deploy/Dockerfile + image: goblinpay:latest + restart: unless-stopped + env_file: .env + environment: + # Bundled relay (default mode). GP_BUNDLED_RELAY_URL is BOTH dialed by the + # server and advertised to payers in the nprofile, so it must be the + # relay's PUBLIC url (payers connect here); the server reaches it back + # through Caddy. + GP_RELAY_MODE: bundled + GP_BUNDLED_RELAY_URL: ${GP_BUNDLED_RELAY_URL:-wss://relay.${GP_DOMAIN}} + GP_PUBLIC_URL: ${GP_PUBLIC_URL:-https://${GP_DOMAIN}} + GP_BIND: 0.0.0.0:8080 + GP_DB_PATH: /data/goblinpay.db + GP_DATA_DIR: /data/gp-data + # Money/identity secrets come from mounted files (never the image/env): + GP_MNEMONIC_FILE: /run/secrets/gp_mnemonic + GP_WALLET_PASSWORD_FILE: /run/secrets/gp_wallet_password + GP_NCRYPTSEC_FILE: /run/secrets/gp_ncryptsec + volumes: + - gp-data:/data + - ./secrets:/run/secrets:ro + expose: + - "8080" + depends_on: + - relay + + relay: + image: scsibug/nostr-rs-relay:latest + restart: unless-stopped + volumes: + - ./relay/nostr-rs-relay.toml:/usr/src/app/config.toml:ro + - relay-data:/usr/src/app/db + expose: + - "7777" + # Bound the relay's footprint so an unauthenticated flood cannot starve the + # till or proxy on the same host. + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + + caddy: + image: caddy:2 + restart: unless-stopped + depends_on: + - gp-server + - relay + environment: + GP_DOMAIN: ${GP_DOMAIN:-pay.example} + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + +volumes: + gp-data: + relay-data: + caddy-data: + caddy-config: diff --git a/deploy/gp-server.service b/deploy/gp-server.service new file mode 100644 index 0000000..08e8697 --- /dev/null +++ b/deploy/gp-server.service @@ -0,0 +1,80 @@ +# Hardened systemd unit for the GoblinPay server on bare metal. +# +# Install (or just run deploy/install.sh): +# sudo install -m0755 target/release/gp-server /usr/local/bin/ +# sudo install -m0640 deploy/.env.example /etc/goblinpay.env # then EDIT it +# sudo install -m0644 deploy/gp-server.service /etc/systemd/system/ +# sudo mkdir -p /etc/goblinpay/secrets # 0400 secret files +# sudo systemctl daemon-reload && sudo systemctl enable --now gp-server +# +# Unlike goblin-nip05d, this service holds MONEY secrets (the Grin seed and the +# wallet password) and a wallet data directory. The seed and password are passed +# as systemd credentials (read by PID1 as root, exposed read-only to the dynamic +# service user) rather than left world-readable, and the config supports the +# `*_FILE` mounted-secret variants for exactly this. + +[Unit] +Description=GoblinPay — self-hostable, receive-only Grin payment server +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +# Throwaway unprivileged user allocated at runtime. For a stable data owner, +# comment this out and set `User=goblinpay` (create the user first). +DynamicUser=yes + +# Non-secret config (domain, node, tokens, webhook, relay URL). Read by systemd +# as root, so a 0640 root:root file is fine even under DynamicUser. +EnvironmentFile=/etc/goblinpay.env + +# Money/identity secrets as credentials: the source files stay root-owned 0400; +# systemd exposes copies under $CREDENTIALS_DIRECTORY (%d), readable by the +# dynamic service user. Point the wallet at them via the *_FILE variants. +LoadCredential=gp_mnemonic:/etc/goblinpay/secrets/mnemonic +LoadCredential=gp_wallet_password:/etc/goblinpay/secrets/wallet_password +Environment=GP_MNEMONIC_FILE=%d/gp_mnemonic +Environment=GP_WALLET_PASSWORD_FILE=%d/gp_wallet_password +# Optional: a NIP-49 encrypted Nostr identity (else a random one is generated +# and persisted under the data dir on first start). Uncomment with its file: +#LoadCredential=gp_ncryptsec:/etc/goblinpay/secrets/ncryptsec +#Environment=GP_NCRYPTSEC_FILE=%d/gp_ncryptsec + +# Managed state at /var/lib/goblinpay: the SQLite db, the wallet files, and the +# encrypted seed at rest. 0700 — only the service user may read it. +StateDirectory=goblinpay +StateDirectoryMode=0700 +Environment=GP_DB_PATH=/var/lib/goblinpay/goblinpay.db +Environment=GP_DATA_DIR=/var/lib/goblinpay/gp-data + +ExecStart=/usr/local/bin/gp-server +Restart=on-failure +RestartSec=2 + +# --- hardening --- +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +# If the Nym mixnet stack ever fails to start with a W^X error, comment this out. +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +# Only the state directory is writable. +ReadWritePaths=/var/lib/goblinpay +# No raw sockets; only IP + unix. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..8e4666d --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# One-command bare-metal bootstrap for the GoblinPay server: +# - builds the release binary (gp-server only; never the goblin-tree dev crate) +# - installs it to /usr/local/bin +# - creates the managed state dir and the 0700 secrets dir +# - installs an env file from deploy/.env.example (if absent) +# - installs and enables the hardened systemd unit +# +# Re-runnable: it never overwrites an existing /etc/goblinpay.env. +# Requires: a Rust toolchain (cargo) and root (sudo) for the install steps. +# +# BUILD PREREQUISITE: gp-server's Nostr/Nym path depends on the sibling crates +# nip44/ and nym/ (see crates/gp-nostr/Cargo.toml). They must sit next to this +# repo, exactly as on the deploy host. `-p gp-server` deliberately excludes the +# gp-goblin-sender dev crate, which needs the (absent) goblin wallet tree. +# +# After it finishes, edit /etc/goblinpay.env and drop the secret files into +# /etc/goblinpay/secrets (mnemonic, wallet_password), then: +# sudo systemctl restart gp-server + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN=/usr/local/bin/gp-server +ENV_FILE=/etc/goblinpay.env +UNIT=/etc/systemd/system/gp-server.service +STATE_DIR=/var/lib/goblinpay +SECRETS_DIR=/etc/goblinpay/secrets + +say() { printf '\033[1;33m==>\033[0m %s\n' "$1"; } + +if [[ $EUID -ne 0 ]]; then + SUDO=sudo +else + SUDO="" +fi + +say "Building release binary (cargo build --release --locked -p gp-server)" +( cd "$REPO_DIR" && cargo build --release --locked -p gp-server ) + +say "Installing binary to $BIN" +$SUDO install -m0755 "$REPO_DIR/target/release/gp-server" "$BIN" + +say "Creating state directory $STATE_DIR (0700)" +$SUDO install -d -m0700 "$STATE_DIR" + +say "Creating secrets directory $SECRETS_DIR (0700)" +$SUDO install -d -m0700 "$SECRETS_DIR" + +if [[ -f "$ENV_FILE" ]]; then + say "Env file $ENV_FILE already exists — leaving it untouched" +else + say "Installing env file to $ENV_FILE (EDIT IT: domain, node, tokens)" + $SUDO install -m0640 "$REPO_DIR/deploy/.env.example" "$ENV_FILE" +fi + +say "Installing systemd unit to $UNIT" +$SUDO install -m0644 "$REPO_DIR/deploy/gp-server.service" "$UNIT" + +say "Reloading systemd and enabling the service" +$SUDO systemctl daemon-reload +$SUDO systemctl enable gp-server + +cat <