From edcce5a3081ce4ebadd024085b1f8084a467a3b4 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 1 Oct 2026 00:29:51 +0000 Subject: [PATCH] feat(nostr-vpn): package paid-exit seller + web control panel as manifest apps Phase 1 of docs/nostr-vpn-integration-plan.md's Phase 0->4 plan (seller-side rootless feasibility already confirmed there). Two apps, one image: - apps/nostr-vpn: the daemon. Own network namespace (container.network: pasta), NET_ADMIN+NET_RAW scoped to that netns, /dev/net/tun, and the net.ipv4.ip_forward sysctl via the primitive added in e42bd26. UDP 51822 (not upstream's default 51820, which collides with archipelago-wg on fleet nodes per the Phase 0 log). Seller mode stays off until an operator explicitly enables it (paid_exit.enabled defaults to false upstream). - apps/nostr-vpn-web: the control panel, gated behind 127.0.0.1:38080, talking to the daemon only through the shared /data volume (state-file status + shelling out to the nvpn CLI) -- no network link between the two containers, matching upstream's own umbrel/docker-compose.yml. - docker/nostr-vpn: upstream's umbrel/Dockerfile, unchanged except for how the pinned commit arrives (shallow git fetch of a verified SHA, since codeload.github.com archive tarballs 404 from this environment and GitHub won't fetch an arbitrary SHA directly). Entrypoint seeds a minimal config.toml with the chosen listen_port on first boot only -- every AppConfig field is `serde(default = ...)`, confirmed by reading nostr-vpn-core directly, so this merges with nvpn's own identity/wallet bootstrap instead of needing a generated_secrets entry or full config template, and never touches a config that already exists. Both volumes point at /var/lib/archipelago/nostr-vpn, adopting state from the old root-mode install. Build and the seed-config path were verified against the real `nvpn daemon` binary, not just read -- see the plan doc's Phase 1 log for what that caught (a fabricated commit SHA, the codeload 404, wrong default branch name, and confirming identity/wallet persistence actually survives container recreation). Not done here, flagged in the plan doc instead: removing the old root-mode path (rpc/vpn.rs, rpc/auth.rs's auto-enable-on-login) touches live onboarding on every node, not just this app -- needs explicit sign-off. Also missing: a stop-hook/uninstall-guard manifest primitive (doesn't exist yet -- LifecycleHooks only has post_install/pre_start) for the collect-due-on-stop and non-zero-wallet uninstall guard, and registry mirroring + catalog signing (need credentials this pass doesn't have). Co-Authored-By: Claude Sonnet 5 --- apps/nostr-vpn-web/manifest.yml | 89 +++++++++++++++++++ apps/nostr-vpn/manifest.yml | 119 ++++++++++++++++++++++++++ docker/nostr-vpn/Dockerfile | 98 +++++++++++++++++++++ docker/nostr-vpn/docker-entrypoint.sh | 37 ++++++++ 4 files changed, 343 insertions(+) create mode 100644 apps/nostr-vpn-web/manifest.yml create mode 100644 apps/nostr-vpn/manifest.yml create mode 100644 docker/nostr-vpn/Dockerfile create mode 100644 docker/nostr-vpn/docker-entrypoint.sh diff --git a/apps/nostr-vpn-web/manifest.yml b/apps/nostr-vpn-web/manifest.yml new file mode 100644 index 00000000..0157803c --- /dev/null +++ b/apps/nostr-vpn-web/manifest.yml @@ -0,0 +1,89 @@ +app: + id: nostr-vpn-web + name: Nostr VPN Control Panel + version: 1.0.0 + upstream: + kind: github + repo: mmalmi/nostr-vpn + description: | + Web control panel for the nostr-vpn paid-exit seller (apps/nostr-vpn). + Talks to the daemon only through the shared /data volume (state-file + status + shelling out to the nvpn CLI) -- no network link between the + two containers, mirroring upstream's own umbrel/docker-compose.yml + exactly (read directly, not assumed). Same image as apps/nostr-vpn, + different entrypoint args. + category: money + + container: + build: + context: /opt/archipelago/docker/nostr-vpn + dockerfile: Dockerfile + tag: localhost/nostr-vpn:local + entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"] + custom_args: + - /usr/local/bin/nvpn-web + - --listen + - 0.0.0.0:38080 + - --behind-trusted-proxy + - --config + - /data/config/nvpn/config.toml + + dependencies: + - app_id: nostr-vpn + + resources: + memory_limit: 128Mi + + security: + capabilities: [] + readonly_root: false + no_new_privileges: true + network_policy: bridge + + ports: + - host: 38080 + container: 38080 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + # Same volume as apps/nostr-vpn, read-write: the panel's wallet/seller + # actions (wallet send, paid-exit run) shell out to the nvpn CLI + # against this same config.toml and data dir, per + # NVPN_EXTERNAL_DAEMON/NVPN_DAEMON_STATUS_MODE below. + - type: bind + source: /var/lib/archipelago/nostr-vpn + target: /data + options: [rw] + + environment: + - NVPN_CLI_PATH=/usr/local/bin/nvpn + - NVPN_DAEMON_STATUS_MODE=state-file + - NVPN_EXTERNAL_DAEMON=true + + health_check: + type: http + endpoint: http://127.0.0.1:38080 + path: / + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Control Panel + description: nostr-vpn paid-exit status, wallet, and seller settings + type: ui + port: 38080 + protocol: http + path: / + + metadata: + category: money + tier: optional + author: mmalmi + repo: https://github.com/mmalmi/nostr-vpn + features: + - Paid-exit seller status, wallet, and offer controls + - Shares state with apps/nostr-vpn via one data volume, no RPC link diff --git a/apps/nostr-vpn/manifest.yml b/apps/nostr-vpn/manifest.yml new file mode 100644 index 00000000..c4124cdb --- /dev/null +++ b/apps/nostr-vpn/manifest.yml @@ -0,0 +1,119 @@ +app: + id: nostr-vpn + name: Nostr VPN (paid exit) + version: 1.0.0 + # Pinned commit, not a tag -- upstream has no release tags yet. Re-pin + # deliberately in docker/nostr-vpn/Dockerfile's NVPN_COMMIT build arg; see + # docs/nostr-vpn-integration-plan.md for the Phase 0 feasibility log this + # pin was verified against. + upstream: + kind: github + repo: mmalmi/nostr-vpn + description: | + Sells spare bandwidth as a Nostr-discovered, Cashu-metered paid exit + (github.com/mmalmi/nostr-vpn). Runs rootless in its own network + namespace (pasta) -- NET_ADMIN/NET_RAW are scoped to that netns, never + the host. Seller mode defaults OFF (upstream's own `paid_exit.enabled` + default); turning it on is a separate step (Phase 3 UI, not yet built). + + This replaces the old root-mode integration (image-recipe's + nostr-vpn.service running `nvpn daemon` as root, auto-enabled on first + login via rpc/auth.rs) that broke the rootless/no-OS-reliance + invariant. That old path and its RPC TOML-rewriting code + (rpc/vpn.rs::handle_vpn_add_participant) are a separate, higher-risk + removal -- not done here, since it's wired into every node's login + flow today, not just this app. + category: money + + container: + build: + context: /opt/archipelago/docker/nostr-vpn + dockerfile: Dockerfile + tag: localhost/nostr-vpn:local + network: pasta + # Image has no image-level ENTRYPOINT/CMD (see Dockerfile) -- both this + # app and nostr-vpn-web point the shared seed-config entrypoint at + # different binaries/args. + entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"] + custom_args: + - /usr/local/bin/nvpn + - daemon + - --config + - /data/config/nvpn/config.toml + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + + security: + # NET_ADMIN/NET_RAW: TUN device + the exit forwarding/NAT nvpn installs + # itself inside its own netns (nvpn-exit-forward-in/out, nvpn-exit-masq, + # the MSS clamp) -- confirmed working rootless in Phase 0 testing, with + # no capabilities beyond these two plus the sysctl below. Host iptables + # and routes were confirmed untouched. + capabilities: [NET_ADMIN, NET_RAW] + # false: not verified read-only-root-compatible in Phase 0 testing (the + # working run flags there didn't include --read-only). nvpn's own state + # (config/identity/wallet) lives on the /data volume either way. + readonly_root: false + no_new_privileges: true + network_policy: isolated + + # Rootless /proc/sys is read-only, so forwarding can only be set at + # container-create time via this primitive (added for exactly this app -- + # see commit e42bd26). nvpn only *reads* ip_forward and writes it when 0, + # so setting it here once at create is enough; nvpn's own cleanup path + # leaves it alone. + sysctls: + net.ipv4.ip_forward: "1" + + devices: + - /dev/net/tun + + ports: + # Paid-exit buyers dial this directly from the open internet to pay for + # bandwidth -- it's the whole point of the app, not an admin surface, + # and it speaks nvpn's own FIPS UDP wire protocol, not HTTP, so the app + # gate cannot front it. 51822, not upstream's default 51820: that + # collides with archipelago-wg (kernel WireGuard) on fleet nodes -- + # found running both side by side in Phase 0 testing. + - host: 51822 + container: 51822 + protocol: udp + auth: none + auth_rationale: >- + FIPS UDP transport for paid-exit buyers. Anonymous by design (not + HTTP), and the seller is off by default (paid_exit.enabled=false) + until an operator explicitly turns on selling, so exposure here + alone grants no access to anything. + + volumes: + # Adopts whatever a node already has under the old root-mode path + # (nostr-vpn.service wrote here too) -- an identity, wallet balance, or + # pending Cashu credit must survive this migration, not reset. + - type: bind + source: /var/lib/archipelago/nostr-vpn + target: /data + options: [rw] + + environment: + - NVPN_LISTEN_PORT=51822 + + health_check: + type: exec + endpoint: nvpn status + interval: 30s + timeout: 10s + retries: 3 + + metadata: + category: money + tier: optional + author: mmalmi + repo: https://github.com/mmalmi/nostr-vpn + features: + - Sell spare bandwidth as a Cashu-metered Nostr paid exit + - Rootless: own network namespace, no host network access + - Seller mode off by default diff --git a/docker/nostr-vpn/Dockerfile b/docker/nostr-vpn/Dockerfile new file mode 100644 index 00000000..d25c00c2 --- /dev/null +++ b/docker/nostr-vpn/Dockerfile @@ -0,0 +1,98 @@ +# syntax=docker/dockerfile:1.7 +# +# Packages nostr-vpn (github.com/mmalmi/nostr-vpn) as the paid-exit seller +# daemon + its web control panel. Both apps/nostr-vpn and apps/nostr-vpn-web +# build from this one image (same binaries, different entrypoint/command), +# mirroring upstream's own umbrel/docker-compose.yml, which runs `daemon` +# and `web` as two containers sharing one /data volume with no network link +# between them — reviewed directly, not assumed. +# +# This is upstream's own umbrel/Dockerfile, unchanged except for how the +# source arrives (a pinned commit tarball here, instead of a local checkout +# in their build context) — see docs/nostr-vpn-integration-plan.md for why +# the pin exists and what was verified against this exact commit. +ARG NVPN_COMMIT=87f19447741998ab5a06aadc701abc7ae021004b + +FROM debian:bookworm-slim AS source +ARG NVPN_COMMIT +# git clone, not a codeload.github.com/archive/.tar.gz tarball: the +# latter 404s from this environment even for refs/heads/main HEAD (network +# policy on that specific endpoint, not a real upstream 404 — plain +# `git clone https://github.com/...` works fine). +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +# GitHub's anonymous smart-HTTP upload-pack refuses to fetch an arbitrary +# SHA directly (only advertised refs) — fetch main by name and verify the +# pinned commit is actually what we land on, so a force-push to main can't +# silently swap out the reviewed code. +RUN git init -q . \ + && git remote add origin https://github.com/mmalmi/nostr-vpn.git \ + && git fetch -q --depth 1 origin master \ + && git checkout -q FETCH_HEAD \ + && test "$(git rev-parse HEAD)" = "${NVPN_COMMIT}" \ + && rm -rf .git + +FROM node:24-bookworm AS web-builder +WORKDIR /work/web/control-panel +COPY --from=source /src/web/control-panel/package.json /src/web/control-panel/pnpm-lock.yaml ./ +RUN --mount=type=cache,id=nostr-vpn-pnpm-store,target=/pnpm/store \ + corepack enable \ + && corepack prepare pnpm@10.28.2 --activate \ + && pnpm install --frozen-lockfile --store-dir /pnpm/store +COPY --from=source /src/web/control-panel ./ +RUN pnpm run build + +FROM rust:1.94-bookworm AS rust-builder +ARG TARGETPLATFORM +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + clang \ + libclang-dev \ + libdbus-1-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* +COPY --from=source /src/Cargo.toml /src/Cargo.lock ./ +COPY --from=source /src/crates ./crates +COPY --from=source /src/vendor ./vendor +RUN --mount=type=cache,id=nostr-vpn-cargo-registry-${TARGETPLATFORM},target=/usr/local/cargo/registry \ + --mount=type=cache,id=nostr-vpn-cargo-git-${TARGETPLATFORM},target=/usr/local/cargo/git \ + --mount=type=cache,id=nostr-vpn-cargo-target-${TARGETPLATFORM},target=/work/target \ + cargo build --release -p nvpn -p nostr-vpn-web \ + && mkdir -p /out \ + && cp /work/target/release/nvpn /out/nvpn \ + && cp /work/target/release/nostr-vpn-web /out/nvpn-web + +FROM debian:bookworm-slim AS runtime +LABEL org.opencontainers.image.source="https://github.com/mmalmi/nostr-vpn" \ + org.opencontainers.image.description="nostr-vpn, packaged as an Archipelago paid-exit seller app" \ + org.opencontainers.image.licenses="MIT" +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + iproute2 \ + iptables \ + iputils-ping \ + libdbus-1-3 \ + procps \ + wireguard-tools \ + && rm -rf /var/lib/apt/lists/* +COPY --from=rust-builder /out/nvpn /usr/local/bin/nvpn +COPY --from=rust-builder /out/nvpn-web /usr/local/bin/nvpn-web +COPY --from=web-builder /work/web/control-panel/dist /usr/share/nostr-vpn/web +COPY docker-entrypoint.sh /usr/local/bin/archy-nvpn-entrypoint.sh +RUN chmod +x /usr/local/bin/archy-nvpn-entrypoint.sh + +ENV HOME=/data/home \ + XDG_CONFIG_HOME=/data/config \ + NVPN_CLI_PATH=/usr/local/bin/nvpn \ + RUST_LOG=info + +EXPOSE 38080 +VOLUME ["/data"] + +# No image-level ENTRYPOINT/CMD: apps/nostr-vpn and apps/nostr-vpn-web set +# their own entrypoint/custom_args in their manifests (daemon vs. web), +# both pointing at archy-nvpn-entrypoint.sh — see that script for why the +# seed-config step has to run before either binary starts. diff --git a/docker/nostr-vpn/docker-entrypoint.sh b/docker/nostr-vpn/docker-entrypoint.sh new file mode 100644 index 00000000..6f8e8063 --- /dev/null +++ b/docker/nostr-vpn/docker-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Shared entrypoint for both apps/nostr-vpn (daemon) and apps/nostr-vpn-web +# (control panel) -- they're the same image, differing only in the args +# this script execs into (see each manifest's container.entrypoint/custom_args). +# +# Seeds a minimal config.toml with our chosen listen_port BEFORE nvpn's own +# bootstrap (config_bootstrap.rs::load_or_default_config) ever runs, so the +# very first boot never has to self-heal off upstream's default 51820 -- +# archy-x250 fleet nodes already run archipelago-wg on that port (found in +# Phase 0 testing, see docs/nostr-vpn-integration-plan.md). Every AppConfig +# field has #[serde(default = ...)], confirmed by reading +# crates/nostr-vpn-core/src/config/types.rs directly, so a partial TOML here +# merges cleanly with nvpn's own defaults (including the self-generated +# Nostr seller identity) instead of needing a full config. +# +# Never overwrites an existing config.toml: this volume may already hold a +# seller's identity, wallet, and pending Cashu credit adopted from the old +# root-mode install (/var/lib/archipelago/nostr-vpn) -- clobbering it would +# be a real funds-safety bug, not just a config reset. +set -eu + +NVPN_LISTEN_PORT="${NVPN_LISTEN_PORT:-51822}" +CONFIG_DIR=/data/config/nvpn +CONFIG_PATH="$CONFIG_DIR/config.toml" + +mkdir -p "$CONFIG_DIR" /data/home + +if [ ! -f "$CONFIG_PATH" ]; then + cat > "$CONFIG_PATH" <