Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edcce5a308 | ||
|
|
e42bd26ec7 |
@@ -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
|
||||||
@@ -176,6 +176,8 @@ pub struct QuadletUnit {
|
|||||||
/// for rotation-drift detection.
|
/// for rotation-drift detection.
|
||||||
pub labels: Vec<(String, String)>,
|
pub labels: Vec<(String, String)>,
|
||||||
pub devices: Vec<String>,
|
pub devices: Vec<String>,
|
||||||
|
/// Namespaced sysctls (`Sysctl=k=v`), already allow-listed by the manifest.
|
||||||
|
pub sysctls: Vec<(String, String)>,
|
||||||
pub add_hosts: Vec<(String, String)>,
|
pub add_hosts: Vec<(String, String)>,
|
||||||
pub network_aliases: Vec<String>,
|
pub network_aliases: Vec<String>,
|
||||||
pub entrypoint: Option<Vec<String>>,
|
pub entrypoint: Option<Vec<String>>,
|
||||||
@@ -307,6 +309,9 @@ impl QuadletUnit {
|
|||||||
for dev in &self.devices {
|
for dev in &self.devices {
|
||||||
let _ = writeln!(s, "AddDevice={dev}");
|
let _ = writeln!(s, "AddDevice={dev}");
|
||||||
}
|
}
|
||||||
|
for (k, v) in &self.sysctls {
|
||||||
|
let _ = writeln!(s, "Sysctl={k}={v}");
|
||||||
|
}
|
||||||
for (name, ip) in &self.add_hosts {
|
for (name, ip) in &self.add_hosts {
|
||||||
let _ = writeln!(s, "AddHost={name}:{ip}");
|
let _ = writeln!(s, "AddHost={name}:{ip}");
|
||||||
}
|
}
|
||||||
@@ -521,6 +526,11 @@ impl QuadletUnit {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
devices: app.devices.clone(),
|
devices: app.devices.clone(),
|
||||||
|
sysctls: app
|
||||||
|
.sysctls
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||||
// Container always answers to its own name; manifest extras add the
|
// Container always answers to its own name; manifest extras add the
|
||||||
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
|
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
|
||||||
@@ -1487,6 +1497,7 @@ app:
|
|||||||
"RELAY_NAME=Archipelago Nostr Relay".into(),
|
"RELAY_NAME=Archipelago Nostr Relay".into(),
|
||||||
],
|
],
|
||||||
devices: vec!["/dev/kvm".into()],
|
devices: vec!["/dev/kvm".into()],
|
||||||
|
sysctls: vec![("net.ipv4.ip_forward".into(), "1".into())],
|
||||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||||
entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]),
|
entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]),
|
||||||
command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()],
|
command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()],
|
||||||
@@ -1503,6 +1514,7 @@ app:
|
|||||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||||
assert!(s.contains("AddDevice=/dev/kvm"));
|
assert!(s.contains("AddDevice=/dev/kvm"));
|
||||||
|
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
|
||||||
assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
|
assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
|
||||||
assert!(s.contains("ReadOnly=true"));
|
assert!(s.contains("ReadOnly=true"));
|
||||||
assert!(s.contains("NoNewPrivileges=true"));
|
assert!(s.contains("NoNewPrivileges=true"));
|
||||||
@@ -1524,6 +1536,7 @@ app:
|
|||||||
assert!(!s.contains("PublishPort="));
|
assert!(!s.contains("PublishPort="));
|
||||||
assert!(!s.contains("Environment="));
|
assert!(!s.contains("Environment="));
|
||||||
assert!(!s.contains("AddDevice="));
|
assert!(!s.contains("AddDevice="));
|
||||||
|
assert!(!s.contains("Sysctl="));
|
||||||
assert!(!s.contains("AddHost="));
|
assert!(!s.contains("AddHost="));
|
||||||
assert!(!s.contains("ReadOnly="));
|
assert!(!s.contains("ReadOnly="));
|
||||||
assert!(!s.contains("NoNewPrivileges="));
|
assert!(!s.contains("NoNewPrivileges="));
|
||||||
@@ -1621,6 +1634,31 @@ app:
|
|||||||
assert!(!s.contains("Network=host"));
|
assert!(!s.contains("Network=host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_manifest_renders_namespaced_sysctls() {
|
||||||
|
let yaml = r#"
|
||||||
|
app:
|
||||||
|
id: vpn-exit
|
||||||
|
name: VPN Exit
|
||||||
|
version: 1.0.0
|
||||||
|
container:
|
||||||
|
image: test/vpn:1.0.0
|
||||||
|
network: pasta
|
||||||
|
devices: [/dev/net/tun]
|
||||||
|
sysctls:
|
||||||
|
net.ipv4.ip_forward: "1"
|
||||||
|
security:
|
||||||
|
capabilities: [NET_ADMIN, NET_RAW]
|
||||||
|
"#;
|
||||||
|
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||||
|
let s = QuadletUnit::from_manifest(&m, "vpn-exit").render();
|
||||||
|
|
||||||
|
assert!(s.contains("Network=pasta"));
|
||||||
|
assert!(s.contains("AddDevice=/dev/net/tun"));
|
||||||
|
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
|
||||||
|
assert!(s.contains("AddCapability=NET_ADMIN"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||||
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
|
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -54,6 +54,12 @@ pub struct AppDefinition {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub devices: Vec<String>,
|
pub devices: Vec<String>,
|
||||||
|
|
||||||
|
/// Namespaced kernel parameters for the app's OWN network namespace
|
||||||
|
/// (podman `--sysctl`). Allow-listed to [`ALLOWED_SYSCTLS`] and rejected
|
||||||
|
/// under host networking, where they would change the host itself.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub sysctls: BTreeMap<String, String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub interfaces: HashMap<String, AppInterface>,
|
pub interfaces: HashMap<String, AppInterface>,
|
||||||
|
|
||||||
@@ -1009,6 +1015,11 @@ impl AppManifest {
|
|||||||
}
|
}
|
||||||
validate_environment(&self.app.environment)?;
|
validate_environment(&self.app.environment)?;
|
||||||
validate_devices(&self.app.devices)?;
|
validate_devices(&self.app.devices)?;
|
||||||
|
validate_sysctls(
|
||||||
|
&self.app.sysctls,
|
||||||
|
self.app.container.network.as_deref(),
|
||||||
|
&self.app.security.network_policy,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Volume tmpfs_options: only meaningful for type: tmpfs.
|
// Volume tmpfs_options: only meaningful for type: tmpfs.
|
||||||
for (i, v) in self.app.volumes.iter().enumerate() {
|
for (i, v) in self.app.volumes.iter().enumerate() {
|
||||||
@@ -1342,6 +1353,45 @@ fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sysctls an app may set. Each is scoped to the container's own network
|
||||||
|
/// namespace, so it cannot reach the host. Packet forwarding is what a
|
||||||
|
/// routing app (a VPN exit) needs, and rootless `/proc/sys` is read-only
|
||||||
|
/// inside the container, so it can only be set at create time.
|
||||||
|
pub const ALLOWED_SYSCTLS: &[&str] = &["net.ipv4.ip_forward", "net.ipv6.conf.all.forwarding"];
|
||||||
|
|
||||||
|
fn validate_sysctls(
|
||||||
|
sysctls: &BTreeMap<String, String>,
|
||||||
|
network: Option<&str>,
|
||||||
|
network_policy: &str,
|
||||||
|
) -> Result<(), ManifestError> {
|
||||||
|
if sysctls.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let host_network = match network {
|
||||||
|
Some(n) => n == "host",
|
||||||
|
None => network_policy == "host",
|
||||||
|
};
|
||||||
|
if host_network {
|
||||||
|
return Err(ManifestError::Invalid(
|
||||||
|
"sysctls require the app's own network namespace, not host networking".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (key, value) in sysctls {
|
||||||
|
if !ALLOWED_SYSCTLS.contains(&key.as_str()) {
|
||||||
|
return Err(ManifestError::Invalid(format!(
|
||||||
|
"sysctls.{key} is not allowed (allowed: {})",
|
||||||
|
ALLOWED_SYSCTLS.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if value != "0" && value != "1" {
|
||||||
|
return Err(ManifestError::Invalid(format!(
|
||||||
|
"sysctls.{key} must be \"0\" or \"1\""
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
|
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
|
||||||
let path = std::path::Path::new(source);
|
let path = std::path::Path::new(source);
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
@@ -2784,6 +2834,72 @@ app:
|
|||||||
assert_eq!(m.app.ports[2].bind, "");
|
assert_eq!(m.app.ports[2].bind, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sysctl_manifest(network: &str, sysctls: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
app:
|
||||||
|
id: sysctl-app
|
||||||
|
name: Sysctl App
|
||||||
|
version: 1.0.0
|
||||||
|
container:
|
||||||
|
image: test/image:1.0.0
|
||||||
|
network: {network}
|
||||||
|
sysctls:
|
||||||
|
{sysctls}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwarding_sysctls_parse_in_own_netns() {
|
||||||
|
let m = AppManifest::parse(&sysctl_manifest(
|
||||||
|
"pasta",
|
||||||
|
" net.ipv4.ip_forward: \"1\"\n net.ipv6.conf.all.forwarding: \"0\"",
|
||||||
|
))
|
||||||
|
.expect("allow-listed forwarding sysctls must validate");
|
||||||
|
assert_eq!(m.app.sysctls["net.ipv4.ip_forward"], "1");
|
||||||
|
assert_eq!(m.app.sysctls["net.ipv6.conf.all.forwarding"], "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysctls_absent_by_default_and_not_serialized() {
|
||||||
|
let m = AppManifest::parse(
|
||||||
|
"app:\n id: plain\n name: Plain\n version: 1.0.0\n container:\n image: test/image:1.0.0\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(m.app.sysctls.is_empty());
|
||||||
|
assert!(!serde_yaml::to_string(&m).unwrap().contains("sysctls"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_sysctls_are_rejected() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
sysctl_manifest("pasta", " kernel.core_pattern: \"|/bin/sh\""),
|
||||||
|
"not allowed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"2\""),
|
||||||
|
"must be \"0\" or \"1\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
sysctl_manifest("host", " net.ipv4.ip_forward: \"1\""),
|
||||||
|
"own network namespace",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
// No explicit network: the host policy still means the host netns.
|
||||||
|
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"1\"")
|
||||||
|
.replace(" network: pasta\n", "")
|
||||||
|
.replace(" sysctls:", " security:\n network_policy: host\n sysctls:"),
|
||||||
|
"own network namespace",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (yaml, expected) in cases {
|
||||||
|
let msg = AppManifest::parse(&yaml).unwrap_err().to_string();
|
||||||
|
assert!(msg.contains(expected), "expected '{expected}', got: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reviewed_host_bind_exceptions_parse() {
|
fn reviewed_host_bind_exceptions_parse() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ impl PodmanClient {
|
|||||||
"devices": manifest.app.devices.iter().map(|d| {
|
"devices": manifest.app.devices.iter().map(|d| {
|
||||||
serde_json::json!({"path": d})
|
serde_json::json!({"path": d})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
|
"sysctl": manifest.app.sysctls,
|
||||||
"resource_limits": resource_limits,
|
"resource_limits": resource_limits,
|
||||||
"cap_add": cap_add,
|
"cap_add": cap_add,
|
||||||
"cap_drop": cap_drop,
|
"cap_drop": cap_drop,
|
||||||
|
|||||||
@@ -712,6 +712,9 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
for device in &manifest.app.devices {
|
for device in &manifest.app.devices {
|
||||||
cmd.arg("--device").arg(device);
|
cmd.arg("--device").arg(device);
|
||||||
}
|
}
|
||||||
|
for (key, value) in &manifest.app.sysctls {
|
||||||
|
cmd.arg("--sysctl").arg(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
|
||||||
// Environment variables
|
// Environment variables
|
||||||
for env in &manifest.app.environment {
|
for env in &manifest.app.environment {
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -35,6 +35,7 @@ As of the current `1.8-alpha` workstream:
|
|||||||
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
|
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
|
||||||
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
|
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
|
||||||
- Data ownership repair is represented with `container.data_uid`.
|
- Data ownership repair is represented with `container.data_uid`.
|
||||||
|
- Per-app network-namespace kernel parameters are represented with `app.sysctls`, allow-listed to packet forwarding (added for rootless VPN exits such as nostr-vpn).
|
||||||
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
||||||
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
|
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
|
||||||
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
|
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ app:
|
|||||||
| `app.environment` | Static `KEY=value` environment entries |
|
| `app.environment` | Static `KEY=value` environment entries |
|
||||||
| `app.health_check` | HTTP or TCP health check settings |
|
| `app.health_check` | HTTP or TCP health check settings |
|
||||||
| `app.devices` | Explicit device paths |
|
| `app.devices` | Explicit device paths |
|
||||||
|
| `app.sysctls` | Namespaced packet-forwarding sysctls for the app's own network namespace (allow-listed; not with host networking) |
|
||||||
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
|
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
|
||||||
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ because a wrong source produces a confident wrong verdict.
|
|||||||
| `environment` | list of string | — | `- KEY=value` pairs (static). |
|
| `environment` | list of string | — | `- KEY=value` pairs (static). |
|
||||||
| `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. |
|
| `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. |
|
||||||
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
|
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
|
||||||
|
| `sysctls` | map | — | Kernel parameters for the app's **own** network namespace (podman `--sysctl`, Quadlet `Sysctl=`). Allow-list: `net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`; values `"0"`/`"1"`. Rejected under host networking. Needed by routing apps because rootless `/proc/sys` is read-only inside the container. |
|
||||||
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
|
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
|
||||||
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
|
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
|
||||||
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
|
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
|
||||||
@@ -116,6 +117,9 @@ Validation (enforced at `AppManifest::validate()`):
|
|||||||
FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN).
|
FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN).
|
||||||
- `network_policy` must be exactly `isolated`, `bridge`, or `host`.
|
- `network_policy` must be exactly `isolated`, `bridge`, or `host`.
|
||||||
- No `container:`/`ns:` network modes; devices must be `/dev/*`.
|
- No `container:`/`ns:` network modes; devices must be `/dev/*`.
|
||||||
|
- `sysctls` keys must be on `ALLOWED_SYSCTLS` (packet forwarding only) and
|
||||||
|
need the app's own network namespace — never host networking, where they
|
||||||
|
would change the host.
|
||||||
- Bind-mount sources are confined to `/var/lib/archipelago` (reviewed
|
- Bind-mount sources are confined to `/var/lib/archipelago` (reviewed
|
||||||
exceptions: the rootless podman socket and dbus).
|
exceptions: the rootless podman socket and dbus).
|
||||||
- `derived_env` templates may only use the placeholder allow-list;
|
- `derived_env` templates may only use the placeholder allow-list;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ PublishPort=<bind>:<host>:<container>/<proto>
|
|||||||
Environment=<KEY>=<value> # non-secret env only
|
Environment=<KEY>=<value> # non-secret env only
|
||||||
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
|
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
|
||||||
Volume=<source>:<target><opts>
|
Volume=<source>:<target><opts>
|
||||||
|
AddDevice=<path> # manifest devices
|
||||||
|
Sysctl=<key>=<value> # manifest sysctls (own netns, allow-listed)
|
||||||
ReadOnly=true # when security.readonly_root
|
ReadOnly=true # when security.readonly_root
|
||||||
NoNewPrivileges=true # when security.no_new_privileges
|
NoNewPrivileges=true # when security.no_new_privileges
|
||||||
HealthCmd=<cmd> # from the health_check block
|
HealthCmd=<cmd> # from the health_check block
|
||||||
|
|||||||
Reference in New Issue
Block a user