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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:29:51 +00:00
co-authored by Claude Sonnet 5
parent e42bd26ec7
commit edcce5a308
4 changed files with 343 additions and 0 deletions
+89
View File
@@ -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
+119
View File
@@ -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