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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<sha>.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.
|
||||
@@ -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" <<EOF
|
||||
[node]
|
||||
listen_port = ${NVPN_LISTEN_PORT}
|
||||
EOF
|
||||
chmod 600 "$CONFIG_PATH"
|
||||
echo "nostr-vpn: seeded $CONFIG_PATH with listen_port=${NVPN_LISTEN_PORT} (first boot)"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Reference in New Issue
Block a user