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.
This commit is contained in:
2ro
2026-07-03 03:22:43 -04:00
parent bba1dd5cba
commit 3fdf4a230c
8 changed files with 491 additions and 0 deletions
+58
View File
@@ -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
+58
View File
@@ -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
+39
View File
@@ -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.<GP_DOMAIN>; 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
+23
View File
@@ -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
}
+67
View File
@@ -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 "<workspace parent containing GoblinPay, nip44, nym>"
# 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"]
+89
View File
@@ -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:
+80
View File
@@ -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
+77
View File
@@ -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 <<EOF
Done. Next steps:
1. Edit $ENV_FILE — set GP_PUBLIC_URL, GP_NODE_URL, GP_BUNDLED_RELAY_URL,
GP_API_TOKEN, GP_ADMIN_TOKEN (and GP_WEBHOOK_URL/GP_WEBHOOK_SECRET if used).
2. Write the wallet secrets (root-owned, mode 0400):
sudo install -m0400 /dev/stdin $SECRETS_DIR/mnemonic <<<'your 24 words'
sudo install -m0400 /dev/stdin $SECRETS_DIR/wallet_password <<<'your password'
3. Run the bundled relay (deploy/docker-compose.yml) or point
GP_BUNDLED_RELAY_URL at a relay you control, and put a TLS reverse proxy
in front (see deploy/Caddyfile).
4. Start it: $SUDO systemctl start gp-server
5. Check it: curl -s http://127.0.0.1:8080/health
EOF