From 3ab4162a8b5c7f7d06e995df4c7169655b1af1c2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 8 Oct 2026 18:10:41 -0400 Subject: [PATCH] Complete private gateway and local website publishing UAT --- app-catalog/catalog.json | 16 +- apps/blossom/manifest.yml | 4 +- apps/public-web-router/README.md | 52 +++ apps/public-web-router/manifest.yml | 49 +++ core/Cargo.lock | 1 + .../src/api/handler/routstr_proxy.rs | 46 ++ core/archipelago/src/api/rpc/dispatcher.rs | 10 + core/archipelago/src/api/rpc/publishing.rs | 135 +++++- core/archipelago/src/appgate/mod.rs | 49 +++ core/archipelago/src/publishing/gateway.rs | 398 ++++++++++++++++++ core/archipelago/src/publishing/mod.rs | 113 ++++- core/archipelago/src/publishing/nsite.rs | 27 +- core/archipelago/src/publishing/serving.rs | 315 ++++++++++++-- core/archipelago/src/rate_limit.rs | 3 + core/publishing-tests/Cargo.toml | 1 + docker/public-web-router/Dockerfile | 6 + docker/public-web-router/download.py | 28 ++ docker/public-web-router/router.py | 143 +++++++ docs/external-access-and-websites.md | 174 ++++++++ neode-ui/public/catalog.json | 16 +- .../__tests__/installPublishingApp.test.ts | 19 + .../__tests__/nsitePublishing.test.ts | 22 +- neode-ui/src/services/installPublishingApp.ts | 14 + neode-ui/src/services/nsitePublishing.ts | 33 +- neode-ui/src/services/publishing.ts | 5 +- neode-ui/src/views/Chat.vue | 1 + .../src/views/__tests__/chatAiuiEmbed.test.ts | 6 +- neode-ui/src/views/discover/curatedApps.ts | 4 +- .../src/views/publishing/PublicWebGateway.vue | 106 +++++ .../src/views/publishing/PublishingSetup.vue | 40 +- .../__tests__/PublicWebGateway.test.ts | 53 +++ .../__tests__/PublishingSetup.test.ts | 18 +- scripts/public-web-gateway/README.md | 72 ++++ scripts/public-web-gateway/enroll.py | 96 +++++ scripts/public-web-gateway/policy.py | 113 +++++ tests/public-web-gateway/test_enroll.py | 32 ++ tests/public-web-gateway/test_policy.py | 45 ++ tests/public-web-gateway/test_router.py | 53 +++ 38 files changed, 2232 insertions(+), 86 deletions(-) create mode 100644 apps/public-web-router/README.md create mode 100644 apps/public-web-router/manifest.yml create mode 100644 core/archipelago/src/publishing/gateway.rs create mode 100644 docker/public-web-router/Dockerfile create mode 100644 docker/public-web-router/download.py create mode 100644 docker/public-web-router/router.py create mode 100644 neode-ui/src/services/__tests__/installPublishingApp.test.ts create mode 100644 neode-ui/src/services/installPublishingApp.ts create mode 100644 neode-ui/src/views/publishing/PublicWebGateway.vue create mode 100644 neode-ui/src/views/publishing/__tests__/PublicWebGateway.test.ts create mode 100644 scripts/public-web-gateway/README.md create mode 100644 scripts/public-web-gateway/enroll.py create mode 100644 scripts/public-web-gateway/policy.py create mode 100644 tests/public-web-gateway/test_enroll.py create mode 100644 tests/public-web-gateway/test_policy.py create mode 100644 tests/public-web-gateway/test_router.py diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 348529ff..4ddca262 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -680,12 +680,24 @@ "requires": [], "tier": "optional", "title": "Blossom", - "version": "6.4.1-archy.1", + "version": "6.4.1-archy.2", "description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.", - "dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1", + "dockerImage": "localhost/archipelago-blossom:6.4.1-archy.2", "category": "data", "repoUrl": "https://github.com/hzrd149/blossom-server", "icon": "/assets/img/app-icons/blossom.svg" + }, + { + "id": "public-web-router", + "author": "Archipelago", + "requires": [], + "tier": "optional", + "title": "Public Web Router", + "version": "0.1.0", + "description": "Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.", + "dockerImage": "localhost/archipelago-public-web-router:0.1.0", + "category": "networking", + "icon": "/assets/img/app-icons/nginx.svg" } ] } diff --git a/apps/blossom/manifest.yml b/apps/blossom/manifest.yml index 30ff7127..d07ee744 100644 --- a/apps/blossom/manifest.yml +++ b/apps/blossom/manifest.yml @@ -1,7 +1,7 @@ app: id: blossom name: Blossom - version: 6.4.1-archy.1 + version: 6.4.1-archy.2 upstream: kind: github repo: hzrd149/blossom-server @@ -12,7 +12,7 @@ app: build: context: /opt/archipelago/docker/blossom dockerfile: Dockerfile - tag: localhost/archipelago-blossom:6.4.1-archy.1 + tag: localhost/archipelago-blossom:6.4.1-archy.2 derived_env: - key: ARCHY_BLOSSOM_PUBKEYS template: '{{NODE_IDENTITY_PUBKEYS}}' diff --git a/apps/public-web-router/README.md b/apps/public-web-router/README.md new file mode 100644 index 00000000..d6e29b75 --- /dev/null +++ b/apps/public-web-router/README.md @@ -0,0 +1,52 @@ +# Public Web Router + +Optional, manifest-first rootless app for node-terminated HTTPS through an +operator-owned frp gateway. Uses pinned frpc0.71.0 and Caddy2.11.7 binaries and a +pinned multi-architecture Python base. No host network, host port, capabilities, +privileged socket, or node signing keys are needed. FIPS connects the isolated +container to explicitly published website listeners. + +Setup stores the private enrollment and derived routes in +`/var/lib/archipelago/public-web-router/config/router.json` (0600). The app mounts +that directory read-only, watches for atomic replacement, validates input, and +supervises only its own Caddy and frpc processes. Removing or invalidating config +stops both. The gateway CA is pinned; HTTPS SNI passes through to Caddy. Caddy +keeps certificate keys under the persistent `/data` bind mount. Uninstall and +Disconnect must preserve that data unless the user explicitly requests removal. + +The automatic adapter accepts website IDs or guest-enabled app IDs and resolves +saved domains, FIPS addresses and listener ports on the backend. Arbitrary target +URLs/ports and management endpoints are not accepted. App routes require the +installed catalogue policy to enable guest sharing and retain authentication. +Each request carries the expected project/app identity. The app gate rechecks +its live policy before login actions or static exceptions; a stale route cannot +follow a reassigned port or a disabled gate. Existing manual proxies still work. + +No Nostr signer integration is requested: routing neither signs nor broadcasts +Nostr events. Blossom/nsite publication continues to use its explicit profile +signer and exact-byte review. Enrollment files contain private credentials and +must never enter that publishing flow. + +Public mode requests ACME using TLS-ALPN-01. A dedicated public443 path must reach +the node through the gateway; competing gateways/proxies must not claim it. +Explicit test mode uses a private Caddy CA and is not browser-trusted public TLS. +The process-health probe reports supervision, not external reachability or +certificate issuance. Setup's independent HTTPS exact-content check remains +required before claiming public reachability. + +Framework qualification passed the signed private catalogue, normal manifest +installer, owner-RPC enrollment, exact website bytes through isolated Yaya TLS, +and app guest-cookie issue/revocation. Public ACME on port 443 remains untested; +the isolated test uses a private CA. General publication still requires the +repository release gates. + +Distribution must include both `apps/public-web-router` and +`docker/public-web-router` in the runtime payload. Build-source manifests defer +to the shipped disk manifest; the catalogue alone cannot install the build +context. On nodes with `web-ui/archipelago-runtime`, update that payload too: +startup restores it into `/opt/archipelago`. Do not patch only the live copy. + +The manifest requests CPU/memory limits. Framework's rootless runtime currently +reports no enforced memory cgroup limit; do not present the requested 256 MiB as +an enforced limit on that host. Read-only root, dropped capabilities, slirp and +read-only configuration mounts were verified on the normally installed app. diff --git a/apps/public-web-router/manifest.yml b/apps/public-web-router/manifest.yml new file mode 100644 index 00000000..d1c211c1 --- /dev/null +++ b/apps/public-web-router/manifest.yml @@ -0,0 +1,49 @@ +app: + id: public-web-router + name: Public Web Router + version: 0.1.0 + description: Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup. + container: + network: slirp4netns + build: + context: /opt/archipelago/docker/public-web-router + dockerfile: Dockerfile + tag: localhost/archipelago-public-web-router:0.1.0 + dependencies: + - storage: 256Mi + resources: + cpu_limit: 1 + memory_limit: 256Mi + disk_limit: 512Mi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: isolated + seccomp_profile: default + volumes: + - type: bind + source: /var/lib/archipelago/public-web-router/data + target: /data + options: [rw] + - type: bind + source: /var/lib/archipelago/public-web-router/config + target: /config + options: [ro] + - type: tmpfs + target: /tmp + options: [rw, nosuid, nodev, size=16m] + health_check: + type: exec + endpoint: python3 -c "import pathlib,time; assert time.time()-pathlib.Path('/tmp/router/heartbeat').stat().st_mtime < 30" + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + metadata: + author: Archipelago + category: networking + tier: optional + license: Apache-2.0 / MIT + icon: /assets/img/app-icons/nginx.svg + tags: [networking, websites, privacy] diff --git a/core/Cargo.lock b/core/Cargo.lock index 0748933f..ccec556c 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -235,6 +235,7 @@ dependencies = [ "anyhow", "chrono", "hyper 0.14.32", + "reqwest 0.11.27", "serde", "serde_json", "sha2 0.10.9", diff --git a/core/archipelago/src/api/handler/routstr_proxy.rs b/core/archipelago/src/api/handler/routstr_proxy.rs index 7b0e1e98..a9f6d90f 100644 --- a/core/archipelago/src/api/handler/routstr_proxy.rs +++ b/core/archipelago/src/api/handler/routstr_proxy.rs @@ -198,6 +198,17 @@ async fn forward_models() -> Result> { /// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05, /// offline) → price quote → pay → forward → redeem change → record net. async fn forward_chat(req: Request, data_dir: &Path) -> Result> { + // An already-open iframe may still show its previous selection. The node's + // saved choice is authoritative before any pricing, token or network work. + let settings = crate::settings::model_provider::ModelProvider::load(data_dir).await?; + if settings.provider != crate::settings::model_provider::Provider::Routstr { + return Ok(json_response( + StatusCode::CONFLICT, + json!({"error": { + "code": "provider_changed", "message": "Your AI provider changed. Reopen AIUI before sending this request." + }}), + )); + } let payload = hyper::body::to_bytes(req.into_body()) .await .map_err(|e| anyhow::anyhow!("read request payload: {e}"))?; @@ -445,6 +456,41 @@ mod tests { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn stale_routstr_selection_cannot_pay_after_provider_change() { + let store = test_store().await; + let token = store.create().await; + let data_dir = tempfile::tempdir().unwrap(); + crate::settings::model_provider::ModelProvider { + provider: crate::settings::model_provider::Provider::Claude, + openai_model: String::new(), + } + .save(data_dir.path()) + .await + .unwrap(); + let r = req( + "POST", + "/aiui/api/routstr/chat/completions", + Some(&token), + "{}", + ); + let response = route_routstr_proxy( + &store, + data_dir.path(), + r, + "/aiui/api/routstr/chat/completions", + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!( + crate::assistant::AssistantBudget::load(data_dir.path()) + .await + .spent_sats, + 0 + ); + } + /// D-05: a fresh node (no budget file → zero allowance) refuses the paid /// path BEFORE any pricing/network I/O — this test runs fully offline. #[tokio::test] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 14b4da31..0e32b405 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -11,6 +11,16 @@ impl RpcHandler { session_token: &Option, ) -> Result { match method { + "publishing.gateway-app-route" => { + self.handle_publishing_gateway_app_route(params).await + } + "publishing.gateway-configure" => { + self.handle_publishing_gateway_configure(params).await + } + "publishing.gateway-route" => self.handle_publishing_gateway_route(params).await, + "publishing.gateway-disconnect" => { + crate::publishing::gateway::disconnect(&self.config.data_dir).await + } "publishing.status" => self.handle_publishing_status().await, "publishing.verify-https" => self.handle_publishing_verify_https(params).await, "publishing.update" => self.handle_publishing_update(params).await, diff --git a/core/archipelago/src/api/rpc/publishing.rs b/core/archipelago/src/api/rpc/publishing.rs index 1d3fe7f3..477ac146 100644 --- a/core/archipelago/src/api/rpc/publishing.rs +++ b/core/archipelago/src/api/rpc/publishing.rs @@ -5,6 +5,84 @@ use serde::Deserialize; use serde_json::json; impl RpcHandler { + pub(super) async fn handle_publishing_gateway_app_route( + &self, + params: Option, + ) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Request { + app_id: String, + domain: String, + enabled: bool, + } + let request: Request = serde_json::from_value(params.context("Missing app route")?)?; + let map = crate::appgate::identity::build_port_map(); + let port = map + .gated_ports() + .find(|p| { + p.app_id == request.app_id + && p.declared + && p.guest_access + && p.auth_enabled + && !p.session_passthrough + }) + .map(|p| p.port); + publishing::gateway::app_route( + &self.config.data_dir, + &request.app_id, + &request.domain, + request.enabled, + crate::fips::iface::fips0_ula(), + port, + ) + .await + } + + pub(super) async fn handle_publishing_gateway_configure( + &self, + params: Option, + ) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Request { + enrollment: publishing::gateway::Enrollment, + certificate_mode: String, + acknowledge: bool, + } + let request: Request = + serde_json::from_value(params.context("Missing gateway enrollment")?)?; + anyhow::ensure!( + request.acknowledge, + "Confirm connecting to this gateway first" + ); + publishing::gateway::configure( + &self.config.data_dir, + request.enrollment, + request.certificate_mode, + ) + .await + } + pub(super) async fn handle_publishing_gateway_route( + &self, + params: Option, + ) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Request { + id: String, + enabled: bool, + } + let request: Request = serde_json::from_value(params.context("Missing website route")?)?; + publishing::gateway::route( + &self.config.data_dir, + &request.id, + request.enabled, + crate::fips::iface::fips0_ula(), + ) + .await + } + pub(super) async fn handle_publishing_verify_https( &self, params: Option, @@ -203,10 +281,19 @@ impl RpcHandler { use base64::Engine; #[derive(Deserialize)] #[serde(deny_unknown_fields)] + struct NsiteFile { + html: String, + server: String, + acknowledge_public: bool, + } + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct Request { id: String, version: u64, authorization: nostr_sdk::Event, + #[serde(default)] + nsite: Option, } let request: Request = serde_json::from_value(params.context("Missing local archive authorization")?)?; @@ -227,7 +314,20 @@ impl RpcHandler { !project.draft.trim().is_empty(), "Save a website draft first" ); - let digest = publishing::nsite::hash(project.draft.as_bytes()); + let content = if let Some(nsite) = &request.nsite { + publishing::nsite::local_server(project, &nsite.server)?; + anyhow::ensure!( + nsite.acknowledge_public + && nsite.html.len() <= 512 * 1024 + && !nsite.html.contains('\0') + && nsite.html.starts_with(publishing::nsite::POLICY), + "Review and confirm the local nsite file before sharing it" + ); + nsite.html.clone() + } else { + project.draft.clone() + }; + let digest = publishing::nsite::hash(content.as_bytes()); let event = serde_json::to_value(&request.authorization)?; let tags = event["tags"] .as_array() @@ -259,7 +359,7 @@ impl RpcHandler { .put(format!("{base}/upload")) .header("Authorization", format!("Nostr {auth}")) .header("Content-Type", "text/html; charset=utf-8") - .body(project.draft.clone()) + .body(content.clone()) .send() .await .context("Local Blossom is not responding. Start it from Apps")?; @@ -278,7 +378,7 @@ impl RpcHandler { } let descriptor: serde_json::Value = serde_json::from_slice(&descriptor)?; anyhow::ensure!( - descriptor["sha256"] == digest && descriptor["size"] == project.draft.len(), + descriptor["sha256"] == digest && descriptor["size"] == content.len(), "Local Blossom returned another file receipt" ); let mut response = client @@ -286,7 +386,7 @@ impl RpcHandler { .send() .await? .error_for_status()?; - let expected = project.draft.as_bytes(); + let expected = content.as_bytes(); let mut offset = 0; while let Some(chunk) = response.chunk().await? { anyhow::ensure!( @@ -310,9 +410,19 @@ impl RpcHandler { &self.config.data_dir, publishing::Update { version: request.version, - change: publishing::Change::RecordLocalArchive { - id: request.id, - receipt, + change: if let Some(nsite) = request.nsite { + publishing::Change::ShareNsiteAsset { + id: request.id, + server: nsite.server, + html: content, + receipt, + acknowledge_public: nsite.acknowledge_public, + } + } else { + publishing::Change::RecordLocalArchive { + id: request.id, + receipt, + } }, }, ) @@ -350,6 +460,7 @@ impl RpcHandler { "nostr_relays": self.config.nostr_relays, "publication_enabled": true, "public_archive_enabled": true, + "gateway": publishing::gateway::status(&self.config.data_dir).await.unwrap_or_else(|_| json!({"configured":false,"routes":[],"error":"Private gateway configuration needs repair","externally_verified":false})), "listeners": publishing::serving::status().await, "onions": publishing::tor::status().await, "notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Nostr publishing requires an explicit identity, Blossom server and relay selection. Automated gateway setup is not enabled yet. Saving choices does not change app access; external verification is separate.", @@ -381,6 +492,8 @@ impl RpcHandler { version: u64, server: String, html: String, + #[serde(default)] + local: bool, } let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?; let state = publishing::load(&self.config.data_dir).await?; @@ -396,7 +509,13 @@ impl RpcHandler { } let mut prepared = project.clone(); prepared.draft = request.html; - publishing::nsite::prepare(&prepared, &request.server) + let mut result = publishing::nsite::prepare(&prepared, &request.server)?; + if request.local { + publishing::nsite::local_server(project, &request.server)?; + result["local"] = json!(true); + result["authorization"]["tags"][2] = json!(["server", "127.0.0.1"]); + } + Ok(result) } pub(super) async fn handle_publishing_dns( diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 6a57f5cd..44afd104 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -172,7 +172,30 @@ impl AppGate { // snapshot when the port momentarily leaves the map mid-refresh. let live = self.port_map.read().await.gated(app.port).cloned(); let app = live.as_ref().unwrap_or(app); + // A managed public route must still refer to this guest-enabled app. + // Refuse stale routes before login actions or public-resource exceptions. + if let Some(expected) = req.headers().get("x-archipelago-app") { + if expected.to_str().ok() != Some(app.app_id.as_str()) + || live.is_none() + || !app.declared + || !app.guest_access + || !app.auth_enabled + || app.session_passthrough + { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("App route is no longer available")) + .unwrap(); + } + } + // Managed gateway routes are HTTPS-only. Mark cookies Secure even + // though the final in-node FIPS hop uses HTTP. A forged header can only + // strengthen this cookie attribute, never grant authorization. + let mut req = req; + if req.headers().contains_key("x-archipelago-app") { + req.extensions_mut().insert(SecureTransport(true)); + } let path = req.uri().path().to_string(); // A dashboard same-origin proxy strips `/app//` before this gate // sees the URI. Carry that trusted proxy mount into the challenge's @@ -1380,6 +1403,32 @@ fn totp_page( #[cfg(test)] mod tests { + #[tokio::test] + async fn managed_gateway_rejects_stale_identity_or_disabled_guest_policy_before_login() { + let gate = test_gate().await; + let mut app = app(); + app.guest_access = true; + for (expected, enabled, declared) in [ + ("another-app", true, true), + ("strfry", false, true), + ("strfry", true, false), + ] { + app.auth_enabled = enabled; + app.declared = declared; + *gate.port_map.write().await = identity::test_port_map(app.clone()); + for path in ["/", "/manifest.json", "/__archipelago-gate/guest"] { + let request = Request::get(path) + .header("x-archipelago-app", expected) + .body(Body::empty()) + .unwrap(); + let response = gate + .handle(request, &app, "127.0.0.1".parse().unwrap()) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + } + } + #[tokio::test] async fn guest_login_is_app_only_and_revocation_blocks_subsequent_requests() { let gate = test_gate().await; diff --git a/core/archipelago/src/publishing/gateway.rs b/core/archipelago/src/publishing/gateway.rs new file mode 100644 index 00000000..eb8dd276 --- /dev/null +++ b/core/archipelago/src/publishing/gateway.rs @@ -0,0 +1,398 @@ +//! Private enrollment for the optional manifest-owned public-web router. +//! Secrets never enter website state, status responses, or generated content. +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::Path; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +static LOCK: Mutex<()> = Mutex::const_new(()); +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Enrollment { + pub host: String, + pub port: u16, + pub node_id: String, + pub transport_token: String, + pub enrollment_token: String, + pub ca_pem: String, + pub tls_server_name: String, + pub domains: Vec, +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Config { + schema: u32, + gateway: Enrollment, + certificate_mode: String, + routes: Vec, +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WebsiteRoute { + #[serde(default)] + app_id: Option, + id: String, + domain: String, + fips_address: String, + port: u16, +} +fn name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 48 + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && value.as_bytes()[0] != b'-' +} +impl Enrollment { + fn validate(&self) -> Result<()> { + for host in [&self.host, &self.tls_server_name] { + if host.parse::().is_err() { + anyhow::ensure!( + super::hostname(host)? == *host, + "Use a lowercase gateway hostname" + ); + } + } + anyhow::ensure!( + self.port >= 1024 && name(&self.node_id), + "Invalid gateway port or node enrollment name" + ); + for token in [&self.transport_token, &self.enrollment_token] { + anyhow::ensure!( + (32..=256).contains(&token.len()) && !token.chars().any(char::is_control), + "Invalid gateway credential" + ); + } + anyhow::ensure!( + self.ca_pem.len() <= 16384 + && self.ca_pem.starts_with("-----BEGIN CERTIFICATE-----") + && !self.ca_pem.contains("PRIVATE KEY"), + "Supply the gateway CA certificate, never a private key" + ); + reqwest::Certificate::from_pem(self.ca_pem.as_bytes()) + .context("Invalid gateway CA certificate")?; + anyhow::ensure!( + !self.domains.is_empty() && self.domains.len() <= 32, + "Gateway enrollment needs assigned domains" + ); + for domain in &self.domains { + anyhow::ensure!( + super::hostname(domain)? == *domain, + "Use lowercase assigned domains" + ); + } + Ok(()) + } +} +async fn load(root: &Path) -> Result> { + let path = root.join("public-web-router/config/router.json"); + match tokio::fs::read(path).await { + Ok(bytes) => { + anyhow::ensure!(bytes.len() <= 131072, "Gateway configuration exceeds limit"); + Ok(Some( + serde_json::from_slice(&bytes).context("Invalid private gateway configuration")?, + )) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} +async fn store(root: &Path, config: &Config) -> Result<()> { + anyhow::ensure!( + config.routes.len() <= 32, + "Gateway supports at most 32 routes" + ); + let dir = root.join("public-web-router/config"); + tokio::fs::create_dir_all(&dir).await?; + let bytes = serde_json::to_vec(config)?; + anyhow::ensure!(bytes.len() <= 131072, "Gateway configuration exceeds limit"); + let stage = dir.join(format!(".router-{}", uuid::Uuid::new_v4())); + let mut opts = tokio::fs::OpenOptions::new(); + opts.create_new(true).write(true); + #[cfg(unix)] + opts.mode(0o600); + let mut file = opts.open(&stage).await?; + file.write_all(&bytes).await?; + file.sync_all().await?; + tokio::fs::rename(&stage, dir.join("router.json")).await?; + tokio::fs::File::open(&dir).await?.sync_all().await?; + Ok(()) +} +fn public_status(config: Option<&Config>) -> Value { + match config { + None => json!({"configured":false,"routes":[],"externally_verified":false}), + Some(c) => { + json!({"configured":true,"host":c.gateway.host,"port":c.gateway.port,"domains":c.gateway.domains,"certificate_mode":c.certificate_mode,"routes":c.routes.iter().map(|r| json!({"id":r.id,"domain":r.domain})).collect::>(),"externally_verified":false}) + } + } +} +pub async fn status(root: &Path) -> Result { + Ok(public_status(load(root).await?.as_ref())) +} +pub async fn configure(root: &Path, enrollment: Enrollment, mode: String) -> Result { + let _guard = LOCK.lock().await; + enrollment.validate()?; + anyhow::ensure!( + matches!(mode.as_str(), "public" | "test"), + "Choose public or test certificates" + ); + // A changed enrollment never silently sends existing sites to a new gateway. + let config = Config { + schema: 1, + gateway: enrollment, + certificate_mode: mode, + routes: vec![], + }; + store(root, &config).await?; + Ok(public_status(Some(&config))) +} +pub async fn route( + root: &Path, + id: &str, + enabled: bool, + fips: Option, +) -> Result { + let _guard = LOCK.lock().await; + let mut config = load(root).await?.context("Connect your gateway first")?; + if enabled { + let state = super::load(root).await?; + let project = state + .projects + .get(id) + .context("Website project not found")?; + anyhow::ensure!( + project.routes.contains(&super::Route::PublicWeb), + "Select public web and save this website first" + ); + let domain = project + .domain + .as_ref() + .context("Save this website's domain first")? + .hostname + .clone(); + anyhow::ensure!( + config.gateway.domains.contains(&domain), + "This domain is not assigned by your gateway enrollment" + ); + let publication = project + .fips_publication + .as_ref() + .context("Publish the website upstream first")?; + anyhow::ensure!( + (32000..32032).contains(&publication.port), + "Invalid website listener" + ); + let address = fips.context("FIPS is unavailable; start the node connection first")?; + anyhow::ensure!(address.octets()[0] == 0xfd, "FIPS must use a ULA address"); + if config + .routes + .iter() + .any(|r| r.domain == domain && r.id != id) + { + bail!("This domain already routes another website"); + } + config.routes.retain(|r| r.id != id); + config.routes.push(WebsiteRoute { + app_id: None, + id: id.to_owned(), + domain, + fips_address: address.to_string(), + port: publication.port, + }); + } else { + config.routes.retain(|r| r.id != id); + } + store(root, &config).await?; + Ok(public_status(Some(&config))) +} +/// Caller resolves the port from the live, guest-enabled catalogue app gate. +pub async fn app_route( + root: &Path, + app_id: &str, + domain: &str, + enabled: bool, + address: Option, + port: Option, +) -> Result { + let _guard = LOCK.lock().await; + anyhow::ensure!(name(app_id), "Invalid app identity"); + let mut config = load(root).await?.context("Connect your gateway first")?; + let id = format!("app-{app_id}"); + anyhow::ensure!(name(&id), "App identity is too long for a gateway route"); + if enabled { + let domain = super::hostname(domain)?; + anyhow::ensure!( + config.gateway.domains.contains(&domain), + "This domain is not assigned by your gateway enrollment" + ); + anyhow::ensure!( + !config + .routes + .iter() + .any(|r| r.domain == domain && r.id != id), + "This domain already routes another service" + ); + let address = address.context("FIPS is unavailable")?; + anyhow::ensure!(address.octets()[0] == 0xfd, "FIPS must use a ULA address"); + let port = port.context("This app does not currently allow guest sharing")?; + anyhow::ensure!(port >= 1024, "Invalid gated app port"); + config.routes.retain(|r| r.id != id); + config.routes.push(WebsiteRoute { + id, + app_id: Some(app_id.to_owned()), + domain, + fips_address: address.to_string(), + port, + }); + } else { + config.routes.retain(|r| r.id != id); + } + anyhow::ensure!( + config.routes.len() <= 32, + "Gateway supports at most 32 routes" + ); + store(root, &config).await?; + Ok(public_status(Some(&config))) +} +pub async fn disconnect(root: &Path) -> Result { + let _guard = LOCK.lock().await; + let path = root.join("public-web-router/config/router.json"); + match tokio::fs::remove_file(path).await { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + Ok(public_status(None)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn config() -> Config { + Config { + schema: 1, + gateway: Enrollment { + host: "gateway.example".into(), + port: 7400, + node_id: "node-a".into(), + transport_token: "secret-transport-value".repeat(3), + enrollment_token: "secret-enrollment-value".repeat(3), + ca_pem: "test-certificate".into(), + tls_server_name: "gateway.example".into(), + domains: vec!["site.example".into()], + }, + certificate_mode: "test".into(), + routes: vec![], + } + } + #[tokio::test] + async fn private_enrollment_is_never_returned_and_disconnect_preserves_certificates() { + let dir = tempfile::tempdir().unwrap(); + let c = config(); + store(dir.path(), &c).await.unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + tokio::fs::metadata(dir.path().join("public-web-router/config/router.json")) + .await + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + let status = status(dir.path()).await.unwrap().to_string(); + assert!(!status.contains("secret")); + assert!(!status.contains("test-certificate")); + assert!(status.contains("gateway.example")); + let data = dir.path().join("public-web-router/data"); + tokio::fs::create_dir_all(&data).await.unwrap(); + tokio::fs::write(data.join("certificate-marker"), b"preserve") + .await + .unwrap(); + disconnect(dir.path()).await.unwrap(); + assert!(load(dir.path()).await.unwrap().is_none()); + assert_eq!( + tokio::fs::read(data.join("certificate-marker")) + .await + .unwrap(), + b"preserve" + ); + } + #[tokio::test] + async fn refuses_routing_unsaved_projects_and_never_accepts_raw_targets() { + let dir = tempfile::tempdir().unwrap(); + store(dir.path(), &config()).await.unwrap(); + assert!(route( + dir.path(), + "missing", + true, + Some("fd00::1".parse().unwrap()) + ) + .await + .is_err()); + assert!(load(dir.path()).await.unwrap().unwrap().routes.is_empty()); + } + #[tokio::test] + async fn app_routes_require_resolved_guest_port_and_assigned_domain() { + let dir = tempfile::tempdir().unwrap(); + store(dir.path(), &config()).await.unwrap(); + let address = Some("fd00::1".parse().unwrap()); + assert!(app_route( + dir.path(), + "photoprism", + "site.example", + true, + address, + None + ) + .await + .is_err()); + assert!(app_route( + dir.path(), + "photoprism", + "unassigned.example", + true, + address, + Some(2342) + ) + .await + .is_err()); + app_route( + dir.path(), + "photoprism", + "site.example", + true, + address, + Some(2342), + ) + .await + .unwrap(); + assert_eq!( + load(dir.path()).await.unwrap().unwrap().routes[0] + .app_id + .as_deref(), + Some("photoprism") + ); + app_route(dir.path(), "photoprism", "", false, None, None) + .await + .unwrap(); + assert!(load(dir.path()).await.unwrap().unwrap().routes.is_empty()); + } + + #[test] + fn enrollment_rejects_invalid_certificates_and_names() { + let mut c = config(); + assert!(c.gateway.validate().is_err()); + c.gateway.node_id = "../another-node".into(); + assert!(c.gateway.validate().is_err()); + assert!(!name("")); + assert!(!name("-node")); + assert!(name("node-a")); + } +} diff --git a/core/archipelago/src/publishing/mod.rs b/core/archipelago/src/publishing/mod.rs index fe11d173..5088bd97 100644 --- a/core/archipelago/src/publishing/mod.rs +++ b/core/archipelago/src/publishing/mod.rs @@ -7,6 +7,7 @@ use std::path::Path; use tokio::sync::Mutex; mod firewall; +pub mod gateway; pub mod nsite; pub mod serving; pub mod tor; @@ -61,9 +62,18 @@ pub struct LocalArchive { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicNsiteAsset { + pub html: String, + pub receipt: LocalArchive, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Publication { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nsite_asset: Option, /// Exact archived bytes explicitly approved for public hash-addressed reads. #[serde(default)] pub public_archive: Option, @@ -125,6 +135,17 @@ pub enum Change { domain: Option, html: String, }, + #[serde(skip_deserializing)] + ShareNsiteAsset { + id: String, + server: String, + html: String, + receipt: LocalArchive, + acknowledge_public: bool, + }, + UnshareNsiteAsset { + id: String, + }, ShareArchive { id: String, route: Route, @@ -267,6 +288,45 @@ pub fn dns_records(domain: &Domain) -> Result> { impl State { pub fn apply(&mut self, change: Change) -> Result> { match change { + Change::ShareNsiteAsset { + id, + server, + html, + receipt, + acknowledge_public, + } => { + let project = self + .projects + .get_mut(&id) + .context("Website project not found")?; + nsite::local_server(project, &server)?; + if !acknowledge_public + || html.len() > MAX_HTML + || html.contains('\0') + || !html.starts_with(nsite::POLICY) + || receipt.sha256 != nsite::hash(html.as_bytes()) + || receipt.size != html.len() + { + bail!("Review and confirm the exact local nsite file before sharing it"); + } + project + .fips_publication + .as_mut() + .context("Publish the website connection first")? + .nsite_asset = Some(PublicNsiteAsset { html, receipt }); + Ok(Some(id)) + } + Change::UnshareNsiteAsset { id } => { + let project = self + .projects + .get_mut(&id) + .context("Website project not found")?; + if let Some(publication) = project.fips_publication.as_mut() { + publication.nsite_asset = None; + } + Ok(Some(id)) + } + Change::RecordLocalArchive { id, receipt } => { let p = self .projects @@ -280,28 +340,47 @@ impl State { p.local_archive = Some(receipt); Ok(Some(id)) } - Change::ShareArchive { id, route, acknowledge_public } => { - if !acknowledge_public { bail!("Confirm public access to the exact archived website bytes"); } - let p = self.projects.get_mut(&id).context("Website project not found")?; - let archive = p.local_archive.as_ref().context("Store this website in local Blossom first")?; + Change::ShareArchive { + id, + route, + acknowledge_public, + } => { + if !acknowledge_public { + bail!("Confirm public access to the exact archived website bytes"); + } + let p = self + .projects + .get_mut(&id) + .context("Website project not found")?; + let archive = p + .local_archive + .as_ref() + .context("Store this website in local Blossom first")?; let publication = match route { Route::Fips => p.fips_publication.as_mut(), Route::Tor => p.tor_publication.as_mut(), _ => bail!("Choose the FIPS/public-web or Tor publication"), - }.context("Publish this connection before sharing its archived file")?; - if archive.sha256 != nsite::hash(publication.html.as_bytes()) || archive.size != publication.html.len() { + } + .context("Publish this connection before sharing its archived file")?; + if archive.sha256 != nsite::hash(publication.html.as_bytes()) + || archive.size != publication.html.len() + { bail!("The archive differs from this published version. Store and publish the same version first"); } publication.public_archive = Some(archive.sha256.clone()); Ok(Some(id)) } Change::UnshareArchive { id, route } => { - let p = self.projects.get_mut(&id).context("Website project not found")?; + let p = self + .projects + .get_mut(&id) + .context("Website project not found")?; let publication = match route { Route::Fips => p.fips_publication.as_mut(), Route::Tor => p.tor_publication.as_mut(), _ => bail!("Choose the FIPS/public-web or Tor publication"), - }.context("This connection is not published")?; + } + .context("This connection is not published")?; publication.public_archive = None; Ok(Some(id)) } @@ -416,6 +495,7 @@ impl State { .context("No website ports available")?, }; p.fips_publication = Some(Publication { + nsite_asset: None, public_archive: None, port, html: p.draft.clone(), @@ -449,6 +529,7 @@ impl State { .context("No onion website ports available")?, }; p.tor_publication = Some(Publication { + nsite_asset: None, public_archive: None, port, html: p.draft.clone(), @@ -520,8 +601,20 @@ pub async fn load(root: &Path) -> Result { (&project.tor_publication, 32100..32132), ] { if let Some(p) = publication { - if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML - || p.public_archive.as_ref().is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes())) { + if !range.contains(&p.port) + || !ports.insert(p.port) + || p.html.len() > MAX_HTML + || p.nsite_asset.as_ref().is_some_and(|a| { + a.html.len() > MAX_HTML + || !a.html.starts_with(nsite::POLICY) + || a.html.contains('\0') + || a.receipt.size != a.html.len() + || a.receipt.sha256 != nsite::hash(a.html.as_bytes()) + }) + || p.public_archive + .as_ref() + .is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes())) + { bail!("Invalid stored website publication; existing state has been preserved"); } } diff --git a/core/archipelago/src/publishing/nsite.rs b/core/archipelago/src/publishing/nsite.rs index 9f572b5d..fc5df567 100644 --- a/core/archipelago/src/publishing/nsite.rs +++ b/core/archipelago/src/publishing/nsite.rs @@ -6,6 +6,27 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +pub const POLICY: &str = ""; + +pub fn local_server(project: &Project, raw: &str) -> Result { + let server = server(raw)?; + anyhow::ensure!( + project.routes.contains(&Route::Nostr) + && project.routes.contains(&Route::PublicWeb) + && project.fips_publication.is_some(), + "Publish this website over public HTTPS before using local Blossom for an nsite" + ); + let domain = project + .domain + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Set the website domain first"))?; + anyhow::ensure!( + server == format!("https://{}", domain.hostname), + "Local nsite assets must use this website’s HTTPS origin" + ); + Ok(server) +} + pub fn hash(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } @@ -23,7 +44,11 @@ pub fn prepare(project: &Project, blossom: &str) -> Result { let server = server(blossom)?; // The first policy remains restrictive even if generated HTML adds another // CSP. Hosted nsites use a separate origin, without dashboard privileges. - let html = format!("{}", project.draft); + let html = format!("{POLICY}{}", project.draft); + anyhow::ensure!( + html.len() <= super::MAX_HTML, + "Prepared website exceeds 512 KiB" + ); let digest = hash(html.as_bytes()); let identifier: String = project.id.chars().filter(|c| *c != '-').take(13).collect(); let aggregate = hash(format!("{digest} /index.html\n").as_bytes()); diff --git a/core/archipelago/src/publishing/serving.rs b/core/archipelago/src/publishing/serving.rs index 72bdd640..3a2316f0 100644 --- a/core/archipelago/src/publishing/serving.rs +++ b/core/archipelago/src/publishing/serving.rs @@ -53,11 +53,30 @@ pub(super) fn response_for( else { return simple(StatusCode::NOT_FOUND, "Website is not published"); }; + // Bind managed gateway routes to the project, even if a freed listener port + // is later assigned to a different published website. + if req + .headers() + .get("x-archipelago-website") + .is_some_and(|v| v.to_str().ok() != Some(id)) + { + return simple(StatusCode::NOT_FOUND, "Website route no longer matches"); + } // Only the selected immutable snapshot is exposed, never the Blossom backend. // No listing, upload, arbitrary hash lookup, filesystem access or credentials. - let asset = publication.public_archive.as_ref().is_some_and(|hash| { - req.uri().path() == format!("/{hash}") && *hash == super::nsite::hash(publication.html.as_bytes()) + let nsite_asset = publication.nsite_asset.as_ref().filter(|asset| { + req.uri().path() == format!("/{}", asset.receipt.sha256) + && asset.receipt.sha256 == super::nsite::hash(asset.html.as_bytes()) + && asset.receipt.size == asset.html.len() }); + let html = nsite_asset + .map(|asset| asset.html.as_str()) + .unwrap_or(&publication.html); + let asset = nsite_asset.is_some() + || publication.public_archive.as_ref().is_some_and(|hash| { + req.uri().path() == format!("/{hash}") + && *hash == super::nsite::hash(publication.html.as_bytes()) + }); if asset && req.method() == Method::OPTIONS { let mut response = simple(StatusCode::NO_CONTENT, ""); asset_headers(&mut response); @@ -76,13 +95,14 @@ pub(super) fn response_for( response .headers_mut() .insert("content-type", "text/html; charset=utf-8".parse().unwrap()); - response.headers_mut().insert( - "content-length", - publication.html.len().to_string().parse().unwrap(), - ); - if asset { asset_headers(&mut response); } + response + .headers_mut() + .insert("content-length", html.len().to_string().parse().unwrap()); + if asset { + asset_headers(&mut response); + } if req.method() == Method::GET { - *response.body_mut() = Body::from(publication.html.clone()); + *response.body_mut() = Body::from(html.to_owned()); } response } @@ -90,9 +110,14 @@ fn asset_headers(response: &mut Response) { for (name, value) in [ ("access-control-allow-origin", "*"), ("access-control-allow-methods", "GET, HEAD, OPTIONS"), - ("access-control-expose-headers", "Content-Length, Content-Type"), + ( + "access-control-expose-headers", + "Content-Length, Content-Type", + ), ("content-disposition", "attachment; filename=\"index.html\""), - ] { response.headers_mut().insert(name, value.parse().unwrap()); } + ] { + response.headers_mut().insert(name, value.parse().unwrap()); + } } fn simple(status: StatusCode, body: &str) -> Response { let mut r = Response::new(Body::from(body.to_owned())); @@ -260,48 +285,266 @@ pub(super) async fn listen( #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn local_nsite_shares_only_reviewed_bytes_and_revokes_independently() { + use crate::publishing::{Change, Domain, LocalArchive, Route}; + let mut state = State::default(); + let id = state + .apply(Change::Create { + name: "Nsite".into(), + }) + .unwrap() + .unwrap(); + state + .apply(Change::Save { + id: id.clone(), + name: "Nsite".into(), + routes: [Route::PublicWeb, Route::Nostr].into_iter().collect(), + domain: Some(Domain { + hostname: "site.example.org".into(), + destination: None, + }), + html: "

Original

".into(), + }) + .unwrap(); + state + .apply(Change::PublishFips { + id: id.clone(), + acknowledge_public: true, + }) + .unwrap(); + let port = state.projects[&id].fips_publication.as_ref().unwrap().port; + let html = format!("{}

Reviewed

", crate::publishing::nsite::POLICY); + let hash = crate::publishing::nsite::hash(html.as_bytes()); + let receipt = LocalArchive { + sha256: hash.clone(), + size: html.len(), + pubkey: "a".repeat(64), + created_at: "now".into(), + }; + for (server, ack) in [ + ("https://site.example.org", false), + ("https://other.example.org", true), + ] { + assert!(state + .apply(Change::ShareNsiteAsset { + id: id.clone(), + server: server.into(), + html: html.clone(), + receipt: receipt.clone(), + acknowledge_public: ack + }) + .is_err()); + } + state + .apply(Change::ShareNsiteAsset { + id: id.clone(), + server: "https://site.example.org".into(), + html: html.clone(), + receipt, + acknowledge_public: true, + }) + .unwrap(); + // Persisted snapshots keep the exact selection; a later draft cannot alter it. + let mut state: State = + serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap(); + state.projects.get_mut(&id).unwrap().draft = "private later draft".into(); + let req = Request::builder() + .uri(format!("/{hash}")) + .body(Body::empty()) + .unwrap(); + let response = response_for(&state, &id, port, Route::Fips, &req); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!( + hyper::body::to_bytes(response.into_body()).await.unwrap(), + html + ); + for path in ["/list", "/upload", "/rpc", "/other-hash"] { + let req = Request::builder().uri(path).body(Body::empty()).unwrap(); + assert_eq!( + response_for(&state, &id, port, Route::Fips, &req).status(), + StatusCode::NOT_FOUND + ); + } + state + .apply(Change::UnshareNsiteAsset { id: id.clone() }) + .unwrap(); + assert_eq!( + response_for(&state, &id, port, Route::Fips, &req).status(), + StatusCode::NOT_FOUND + ); + let root = Request::builder().uri("/").body(Body::empty()).unwrap(); + assert_eq!( + response_for(&state, &id, port, Route::Fips, &root).status(), + StatusCode::OK + ); + } + #[tokio::test] async fn public_archive_is_exact_explicit_route_scoped_and_revocable() { use crate::publishing::{Change, LocalArchive, Route}; let mut state = State::default(); - let id = state.apply(Change::Create { name: "Archive".into() }).unwrap().unwrap(); - state.apply(Change::Save { id: id.clone(), name: "Archive".into(), routes: [Route::Fips, Route::Tor].into_iter().collect(), domain: None, html: "public snapshot".into() }).unwrap(); - state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap(); - state.apply(Change::PublishTor { id: id.clone(), acknowledge_public: true }).unwrap(); + let id = state + .apply(Change::Create { + name: "Archive".into(), + }) + .unwrap() + .unwrap(); + state + .apply(Change::Save { + id: id.clone(), + name: "Archive".into(), + routes: [Route::Fips, Route::Tor].into_iter().collect(), + domain: None, + html: "public snapshot".into(), + }) + .unwrap(); + state + .apply(Change::PublishFips { + id: id.clone(), + acknowledge_public: true, + }) + .unwrap(); + state + .apply(Change::PublishTor { + id: id.clone(), + acknowledge_public: true, + }) + .unwrap(); let port = state.projects[&id].fips_publication.as_ref().unwrap().port; let tor_port = state.projects[&id].tor_publication.as_ref().unwrap().port; let hash = crate::publishing::nsite::hash(b"public snapshot"); - let req = Request::builder().uri(format!("/{hash}")).body(Body::empty()).unwrap(); - assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND); - assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).is_err()); - state.apply(Change::RecordLocalArchive { id: id.clone(), receipt: LocalArchive { sha256: hash.clone(), size: 15, pubkey: "a".repeat(64), created_at: "now".into() } }).unwrap(); - assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: false }).is_err()); - state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap(); - assert_eq!(response_for(&state, &id, tor_port, Route::Tor, &req).status(), StatusCode::NOT_FOUND); + let req = Request::builder() + .uri(format!("/{hash}")) + .body(Body::empty()) + .unwrap(); + assert_eq!( + response(&state, &id, port, &req).status(), + StatusCode::NOT_FOUND + ); + assert!(state + .apply(Change::ShareArchive { + id: id.clone(), + route: Route::Fips, + acknowledge_public: true + }) + .is_err()); + state + .apply(Change::RecordLocalArchive { + id: id.clone(), + receipt: LocalArchive { + sha256: hash.clone(), + size: 15, + pubkey: "a".repeat(64), + created_at: "now".into(), + }, + }) + .unwrap(); + assert!(state + .apply(Change::ShareArchive { + id: id.clone(), + route: Route::Fips, + acknowledge_public: false + }) + .is_err()); + state + .apply(Change::ShareArchive { + id: id.clone(), + route: Route::Fips, + acknowledge_public: true, + }) + .unwrap(); + assert_eq!( + response_for(&state, &id, tor_port, Route::Tor, &req).status(), + StatusCode::NOT_FOUND + ); let r = response(&state, &id, port, &req); assert_eq!(r.status(), StatusCode::OK); assert_eq!(r.headers()["access-control-allow-origin"], "*"); - assert!(r.headers()["content-disposition"].to_str().unwrap().starts_with("attachment")); + assert!(r.headers()["content-disposition"] + .to_str() + .unwrap() + .starts_with("attachment")); assert_eq!(r.headers()["content-security-policy"], CSP); - assert_eq!(hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(), b"public snapshot"); - for path in ["/upload", "/list", "/0000000000000000000000000000000000000000000000000000000000000000", "/../state.json"] { + assert_eq!( + hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(), + b"public snapshot" + ); + for path in [ + "/upload", + "/list", + "/0000000000000000000000000000000000000000000000000000000000000000", + "/../state.json", + ] { let r = Request::builder().uri(path).body(Body::empty()).unwrap(); - assert_eq!(response(&state, &id, port, &r).status(), StatusCode::NOT_FOUND); + assert_eq!( + response(&state, &id, port, &r).status(), + StatusCode::NOT_FOUND + ); } - let head = Request::builder().method(Method::HEAD).uri(format!("/{hash}")).body(Body::empty()).unwrap(); + let head = Request::builder() + .method(Method::HEAD) + .uri(format!("/{hash}")) + .body(Body::empty()) + .unwrap(); let r = response(&state, &id, port, &head); assert_eq!(r.headers()["content-length"], "15"); - assert!(hyper::body::to_bytes(r.into_body()).await.unwrap().is_empty()); - let post = Request::builder().method(Method::PUT).uri(format!("/{hash}")).body(Body::empty()).unwrap(); - assert_eq!(response(&state, &id, port, &post).status(), StatusCode::METHOD_NOT_ALLOWED); + assert!(hyper::body::to_bytes(r.into_body()) + .await + .unwrap() + .is_empty()); + let post = Request::builder() + .method(Method::PUT) + .uri(format!("/{hash}")) + .body(Body::empty()) + .unwrap(); + assert_eq!( + response(&state, &id, port, &post).status(), + StatusCode::METHOD_NOT_ALLOWED + ); state.projects.get_mut(&id).unwrap().draft = "private later edits".into(); - assert_eq!(hyper::body::to_bytes(response(&state, &id, port, &req).into_body()).await.unwrap().as_ref(), b"public snapshot"); - state.apply(Change::UnshareArchive { id: id.clone(), route: Route::Fips }).unwrap(); - assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND); - state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap(); - state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap(); - assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND); - assert!(state.apply(Change::ShareArchive { id, route: Route::Fips, acknowledge_public: true }).is_err()); + assert_eq!( + hyper::body::to_bytes(response(&state, &id, port, &req).into_body()) + .await + .unwrap() + .as_ref(), + b"public snapshot" + ); + state + .apply(Change::UnshareArchive { + id: id.clone(), + route: Route::Fips, + }) + .unwrap(); + assert_eq!( + response(&state, &id, port, &req).status(), + StatusCode::NOT_FOUND + ); + state + .apply(Change::ShareArchive { + id: id.clone(), + route: Route::Fips, + acknowledge_public: true, + }) + .unwrap(); + state + .apply(Change::PublishFips { + id: id.clone(), + acknowledge_public: true, + }) + .unwrap(); + assert_eq!( + response(&state, &id, port, &req).status(), + StatusCode::NOT_FOUND + ); + assert!(state + .apply(Change::ShareArchive { + id, + route: Route::Fips, + acknowledge_public: true + }) + .is_err()); } #[tokio::test] diff --git a/core/archipelago/src/rate_limit.rs b/core/archipelago/src/rate_limit.rs index f28fbff3..c54c3a1e 100644 --- a/core/archipelago/src/rate_limit.rs +++ b/core/archipelago/src/rate_limit.rs @@ -95,6 +95,9 @@ impl EndpointRateLimiter { limits.insert("identity.issue-credential".to_string(), (20, 300)); // Explicit publishing actions can allocate credentials or perform // bounded network I/O. Saving/previewing never invokes these actions. + limits.insert("publishing.gateway-configure".to_string(), (5, 60)); + limits.insert("publishing.gateway-app-route".to_string(), (10, 60)); + limits.insert("publishing.gateway-route".to_string(), (10, 60)); limits.insert("publishing.access-create".to_string(), (10, 60)); limits.insert("publishing.verify-https".to_string(), (10, 60)); limits.insert("publishing.blossom-store".to_string(), (10, 60)); diff --git a/core/publishing-tests/Cargo.toml b/core/publishing-tests/Cargo.toml index 04289bd1..c8a84766 100644 --- a/core/publishing-tests/Cargo.toml +++ b/core/publishing-tests/Cargo.toml @@ -6,6 +6,7 @@ publish = false license.workspace = true [dependencies] +reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] } anyhow = "1.0" chrono = "0.4" hyper = { version = "0.14", features = ["full", "http1"] } diff --git a/docker/public-web-router/Dockerfile b/docker/public-web-router/Dockerfile new file mode 100644 index 00000000..7b3ad9cf --- /dev/null +++ b/docker/public-web-router/Dockerfile @@ -0,0 +1,6 @@ +FROM docker.io/library/python:3.13-slim-bookworm@sha256:a1165e272e578941b84abc79e4ab38a0305cd12803a5c4247979ac7655f4d641 +COPY download.py /build/download.py +RUN python3 /build/download.py && rm -rf /build +COPY router.py /app/router.py +ENV XDG_DATA_HOME=/data XDG_CONFIG_HOME=/data/config PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +ENTRYPOINT ["python3", "/app/router.py"] diff --git a/docker/public-web-router/download.py b/docker/public-web-router/download.py new file mode 100644 index 00000000..ac8c6900 --- /dev/null +++ b/docker/public-web-router/download.py @@ -0,0 +1,28 @@ +import hashlib +import io +import platform +import tarfile +import urllib.request +from pathlib import Path + +arch = {'x86_64': 'amd64', 'aarch64': 'arm64'}[platform.machine()] +pins = { + 'frp': ('0.71.0', {'amd64': '84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716', 'arm64': 'f33c293c275d8fc68c654b6fba8f10b2551d6463d09a9fc9cffb7227eae82266'}), + 'caddy': ('2.11.7', {'amd64': '727b91701a392de6ebc5027509f548bf39979e5216340d0faed8fa5e69c84f8b', 'arm64': 'd8fc6d179a5d283028a472a5618564f6ad8a86fed513e64f032b3b0b7cc45e42'}), +} +for name, (version, digests) in pins.items(): + repo = 'fatedier/frp' if name == 'frp' else 'caddyserver/caddy' + url = f'https://github.com/{repo}/releases/download/v{version}/{name}_{version}_linux_{arch}.tar.gz' + with urllib.request.urlopen(url, timeout=120) as response: + data = response.read(64 * 1024 * 1024 + 1) + if hashlib.sha256(data).hexdigest() != digests[arch]: + raise ValueError(f'{name} archive checksum mismatch') + binary = 'frpc' if name == 'frp' else 'caddy' + member = f'frp_{version}_linux_{arch}/frpc' if name == 'frp' else 'caddy' + with tarfile.open(fileobj=io.BytesIO(data)) as archive: + info = archive.getmember(member) + if not info.isfile() or info.size > 128 * 1024 * 1024: + raise ValueError('Invalid binary archive member') + output = Path('/usr/local/bin') / binary + output.write_bytes(archive.extractfile(info).read()) + output.chmod(0o755) diff --git a/docker/public-web-router/router.py b/docker/public-web-router/router.py new file mode 100644 index 00000000..90cdc35f --- /dev/null +++ b/docker/public-web-router/router.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Supervise node-owned frpc and Caddy. Configuration is supplied by Setup. + +No local management listener or arbitrary TCP forwarding. Invalid or removed +configuration stops the owned children. Certificates persist in /data. +""" +import ipaddress +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import time + +DOMAIN = re.compile(r'(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\Z') +NAME = re.compile(r'[a-z0-9][a-z0-9-]{0,47}\Z') + + +def render(config): + if config.get('schema') != 1: + raise ValueError('Unsupported configuration') + gateway = config['gateway'] + host = gateway['host'] + try: + ipaddress.ip_address(host) + except ValueError: + if not DOMAIN.fullmatch(host): + raise ValueError('Invalid gateway hostname') + port = gateway['port'] + if type(port) is not int or not 1024 <= port <= 65535: + raise ValueError('Invalid gateway control port') + node = gateway['node_id'] + if not NAME.fullmatch(node): + raise ValueError('Invalid enrollment name') + for key in ('transport_token', 'enrollment_token'): + if not isinstance(gateway[key], str) or not 32 <= len(gateway[key]) <= 256: + raise ValueError('Invalid enrollment credential') + pem = gateway['ca_pem'] + if len(pem) > 16384 or not pem.startswith('-----BEGIN CERTIFICATE-----') or 'PRIVATE KEY' in pem: + raise ValueError('A gateway CA certificate is required') + server_name = gateway['tls_server_name'] + try: + ipaddress.ip_address(server_name) + except ValueError: + if not DOMAIN.fullmatch(server_name): + raise ValueError('Invalid gateway TLS name') + mode = config.get('certificate_mode', 'public') + if mode not in ('public', 'test'): + raise ValueError('Invalid certificate mode') + routes = config['routes'] + if not isinstance(routes, list) or len(routes) > 32: + raise ValueError('Too many routes') + caddy = '{\n admin off\n auto_https disable_redirects\n skip_install_trust\n}\n' + proxies = [] + domains, names = set(), set() + for route in routes: + name, domain = route['id'], route['domain'] + if not NAME.fullmatch(name) or not DOMAIN.fullmatch(domain) or name in names or domain in domains: + raise ValueError('Invalid or duplicate route') + if domain not in gateway.get('domains', []): + raise ValueError('Domain is not assigned by enrollment') + names.add(name); domains.add(domain) + address = ipaddress.IPv6Address(route['fips_address']) + if address not in ipaddress.IPv6Network('fd00::/8'): + raise ValueError('A FIPS ULA address is required') + upstream = route['port'] + app_id = route.get('app_id') + if app_id is not None: + if not isinstance(app_id, str) or not NAME.fullmatch(app_id) or name != 'app-' + app_id or type(upstream) is not int or not 1024 <= upstream <= 65535: + raise ValueError('Invalid catalogue app route') + identity_header = f'X-Archipelago-App {app_id}' + else: + if type(upstream) is not int or not 32000 <= upstream < 32032: + raise ValueError('Only published website listeners are supported') + identity_header = f'X-Archipelago-Website {name}' + tls = 'tls internal' if mode == 'test' else 'tls {\n issuer acme {\n disable_http_challenge\n }\n }' + caddy += f'https://{domain}:8443 {{\n bind 127.0.0.1\n {tls}\n reverse_proxy http://[{address}]:{upstream} {{\n header_up {identity_header}\n }}\n}}\n' + proxies.append({'name': name, 'type': 'https', 'localIP': '127.0.0.1', 'localPort': 8443, 'customDomains': [domain]}) + frpc = {'serverAddr': host, 'serverPort': port, 'user': node, + 'metadatas': {'enrollment_token': gateway['enrollment_token']}, + 'auth': {'method': 'token', 'token': gateway['transport_token'], 'additionalScopes': ['HeartBeats', 'NewWorkConns']}, + 'transport': {'tls': {'enable': True, 'trustedCaFile': '/tmp/router/gateway.crt', 'serverName': server_name}}, + 'loginFailExit': False, 'proxies': proxies, 'log': {'to': 'console', 'level': 'error'}} + return caddy, frpc, pem + + +def main(): + os.umask(0o077) + root = Path('/tmp/router'); root.mkdir(exist_ok=True) + source = Path('/config/router.json') + children = [] + stopping = False + previous = None + + def stop_children(): + for child in children: + if child.poll() is None: + child.terminate() + for child in children: + try: child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill(); child.wait() + children.clear() + + def shutdown(*_): + nonlocal stopping + stopping = True + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + try: + while not stopping: + try: + if source.stat().st_size > 131072: + raise ValueError('Oversized config') + raw = source.read_bytes() + caddy, frpc, pem = render(json.loads(raw)) + if previous != raw or any(child.poll() is not None for child in children): + stop_children() + (root/'gateway.crt').write_text(pem) + (root/'frpc.json').write_text(json.dumps(frpc)) + (root/'Caddyfile').write_text(caddy) + if frpc['proxies']: + for command in [ ['/usr/local/bin/caddy', 'validate', '--config', str(root/'Caddyfile'), '--adapter', 'caddyfile'], ['/usr/local/bin/frpc', 'verify', '-c', str(root/'frpc.json')] ]: + subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15) + for command in [['/usr/local/bin/caddy', 'run', '--config', str(root/'Caddyfile'), '--adapter', 'caddyfile'], ['/usr/local/bin/frpc', '-c', str(root/'frpc.json')]]: + children.append(subprocess.Popen(command)) + previous = raw + (root/'status.json').write_text(json.dumps({'configured': True, 'routes': len(frpc['proxies']), 'certificate_mode': json.loads(raw).get('certificate_mode', 'public'), 'externally_verified': False})) + except (OSError, ValueError, KeyError, TypeError, AttributeError, subprocess.SubprocessError): + stop_children(); previous = None + for name in ('frpc.json', 'Caddyfile', 'gateway.crt'): + (root/name).unlink(missing_ok=True) + (root/'status.json').write_text(json.dumps({'configured': False, 'externally_verified': False})) + (root/'heartbeat').touch() + time.sleep(2) + finally: + stop_children() + + +if __name__ == '__main__': + main() diff --git a/docs/external-access-and-websites.md b/docs/external-access-and-websites.md index e3fea7f2..d1327cf1 100644 --- a/docs/external-access-and-websites.md +++ b/docs/external-access-and-websites.md @@ -464,3 +464,177 @@ Setup is synchronized into an already-cached Chat iframe on return. Source inspection shows configuration synchronization on iframe readiness; reactivation currently arms listeners without explicitly refreshing the provider. This is a follow-up acceptance concern, not a confirmed live inference result. + +### Remaining qualification decisions — 2026-10-08 + +Operator confirmed all three physical companion checks pass: the app dropdown, +Blossom identity selection, and AI top-up screen. This closes those manual checks. +The operator authorized isolated Yaya test ports for the new tunnel. Preserve +existing ingress on ports 80/443 and the working free.archipelago.builders route. +Isolated-port TLS qualification must not be described as public ACME issuance. +Local nsite asset integration and cached Chat provider synchronization are in +source qualification; they are not yet deployed on Framework. + +### Isolated Yaya tunnel qualification — 2026-10-08 + +Pinned frp0.71.0 and Caddy2.11.7 archives were SHA-256 verified against the +upstream release digests before use. Separate user services under +`~/external-access-uat/tunnel` run frps and the enrollment admission plugin on +Yaya (192.168.63.169:17400/control, :14443/HTTPS, loopback:17700/policy), and +frpc/Caddy on Framework (Caddy loopback:33443). Existing ports80/443 and NPM +configuration were not changed. These transient qualification units are not yet +the finished app installer or reboot-persistent product implementation. + +The gateway forwards SNI TLS to Framework. Caddy's test CA and leaf private keys +were generated on Framework and stayed there; only its public root certificate +was retrieved for verification. The frpc control connection pins Yaya's test +certificate and requires TLS plus token authentication. A separate enrollment +policy restricts Framework to free.archipelago.builders and HTTPS proxies; +policy checks also apply to new connections and heartbeats. Enrollment values +remain in private0600 files, outside publishing state and the catalogue. + +Actual-node tests passed exact synthetic website bytes (SHA-256 +`6618540be22ec1a7fbdb89ef329ac851d7ddd0391cec8aa847ac8976f9b8598d`), +404 for management/upload/list paths, rejection of unassigned SNI, revocation of +new connections to an existing route, restored-enrollment recovery, gateway +restart/reconnect, and fail-closed admission-plugin outage/recovery. Both the +isolated route and the existing public HTTPS route returned the exact same +synthetic bytes. Evidence: `.build/isolated-tunnel-live.log`. Four focused Python +admission-policy tests pass. The first outage-test cleanup attempted to restart +a removed transient unit; recreated that owned unit and reran the full live +sequence successfully. Public ACME issuance on443 remains unqualified by these +private-certificate tests. No public Nostr events were sent. + +The manifest-based router image now builds on Framework and has passed the same +live isolated-port sequence inside a rootless slirp4netns container with read-only +root, no capabilities, no published host ports and a256MiB memory limit. Evidence: +`.build/isolated-container-tunnel-live.log`. The old transient Framework frpc/Caddy +units were stopped; the owned test container is `archy-uat-public-web-router`. +Yaya's frps/admission units remain separate from existing public ingress. Actual +frpc clients were denied for an unassigned domain and a wrong enrollment token; +a wrong TLS server name also failed login (frpc reported session shutdown). + +The new source includes a private enrollment adapter, normal-catalogue installer +button, shared Setup connection and per-website connection controls. Three gateway +UI tests,27publishing UI tests, eight gateway/router Python tests and16manifest +checks pass. Final full backend tests/build, matched UI deployment, trusted +catalogue signing/install, automatic app-gate routes, public ACME443 acceptance, +final reboot and release gates remain pending. The current installed management +backend is unchanged. No paid AI call or public Nostr event was made here. + +Container lifecycle qualification also passed removal of configuration, restored +configuration, and container restart, with the same certificate and exact bytes +after recovery (`.build/router-lifecycle-live.log`). The first full isolated +backend run passed1,721tests, zero failed, four ignored; that run predates the new +gateway integration, so it is not final candidate acceptance. The subsequent +full build/test pipeline remains in progress. Automatic catalogue-app routes +and live app-identity/policy enforcement have now been added in source; their +backend and live qualification remain pending. + +### Saved Claude credential: actual inference — 2026-10-08 + +The authenticated Framework AIUI Claude proxy returned its model list, then +successfully handled one synthetic HTML request using the existing saved key. +The selected available model was `claude-haiku-4-5-20251001`, maximum64output +tokens; actual usage was44input and33output tokens. Returned HTML SHA-256: +`0c61e55d9f4c80f36d0db9dce2834677ae02a3d1cefa587d60426e6b14b8d6b1`. +The private result is `~/external-access-uat/claude-live-generated.html` on +Framework. No tools, private files, prior conversation history, provider-setting +writes, allowance changes or publications were involved. The key was injected by +the node proxy and never read back. This verifies real saved-key inference, not +completion of the separate browser AIUI-to-publishing handoff. This request may +incur the provider's normal API charge; no top-up or payment transaction was made. +The first curl-cookie attempt was unauthorized; using the existing qualification +helper's authenticated cookie handling succeeded without disabling authentication. + +### Final candidate deployment and live installer checks — 2026-10-08 + +Backend SHA-256 `683a02e1cf00d291ee82bcc2e95d159c8cf7f922b9da7e1c72187de5d8595b66` +is deployed on Framework with the matched dashboard and signed private gateway +catalogue. Final isolated backend suite: 1,726 passed, zero failed, four ignored; +focused publishing suite: 21 passed. The dashboard build initially caught a null +store access; optional chaining fixed it and the production build passes. + +Live normal installation exposed the Setup helper's missing `dockerImage`. +Both Setup install buttons now resolve the image/build tag and version from the +backend-verified catalogue and use the normal package installer. Sixteen Setup +component tests and two installation-contract tests pass. The signed catalogue +listing also resolves build tags. No catalogue signature changed. + +Framework restores runtime assets from `web-ui/archipelago-runtime` at startup. +Staging only `/opt/archipelago/apps` was therefore insufficient: startup restored +the old manifests. Updated the owned runtime payload for Blossom and Public Web +Router, then repeated normal installation successfully through the orchestrator. +The initially bare test installs were stopped/removed; their data was empty. +The owned manual qualification container was removed after the normal app was +ready; its existing certificate storage was preserved in the manifest data bind. + +Normal Router enrollment through owner RPC, private 0600 configuration, credential +redaction and exact selected website HTTPS bytes pass. Blossom updated normally +to 6.4.1-archy.2 and is healthy. Its automatic identity chooser, signing denial and +approved local upload passed again. Guest app routing through isolated Yaya TLS +passed anonymous challenge, app-only token login, Secure/HttpOnly/SameSite cookie +and revocation. Removed the temporary grant/app route and restored the website. +Existing Yaya public80/443 remains unchanged. Native wallet processes retained +PID/start time throughout management restarts. + +Local nsite live acceptance passed signer-authorized BUD-02 upload into Blossom, +exact selected hash over public HTTPS, CORS and sandboxed attachment headers, +denial of upload/list/unknown-hash endpoints, and asset revocation. Restored the +original synthetic project's routes and left its website available. No manifest +was signed or sent to relays. Evidence: `.build/local-nsite-live.log`, +`.build/gateway-app-live.log`, `.build/blossom-archy2-live.log`. + +The normal rootless router has read-only root/config, dropped capabilities and +slirp networking. Framework reports memory cgroup limit zero despite the manifest +request: resource-limit enforcement is a retained host-runtime limitation, not a +passed 256MiB boundary. Public ACME443 and general publication remain outside this +isolated-port acceptance. Final AIUI handoff and reboot checks follow below. + +The full browser AIUI path subsequently passed with the saved Claude key: actual +synthetic HTML generation, Continue to website setup, and explicit import into a +new private project. The first attempt hit the test's short navigation timeout; +the rerun with the normal page-load allowance passed. Original AI provider settings +were restored. No generated site was published. Evidence: +`.build/aiui-handoff-live.log`. Claude's normal inference charges may apply; no +Routstr top-up, wallet payment, or allowance change was performed. + +### Final controlled Framework reboot — PASS, 2026-10-08 + +The operator-authorized reboot changed boot ID from +`1eb5205a-ba5e-46de-a519-89a066bd8aac` to +`b30e5001-5ca0-4738-9e82-0a29cef0e7a6`. Preflight saved the native LND snapshot +and static channel backup privately and verified no pending HTLCs. LND initially +reported locked/not-ready during normal startup; the dashboard RPC correctly +returned unavailable rather than a false zero. It unlocked automatically without +manual restart or unlock. Native identity, channel set, on-chain/channel balances, +and chain sync then passed the saved-snapshot comparison. + +The complete installed app set returned. Blossom is healthy; the normally +installed router started without intervention and retained its certificate. +Publishing state, gateway settings, onion identity and the absence of temporary +guest grants matched the pre-reboot snapshot exactly. Both the existing public443 +route and the isolated14443 tunnel returned the original synthetic website hash +`6618540be22ec1a7fbdb89ef329ac851d7ddd0391cec8aa847ac8976f9b8598d`. +Backend and dashboard bytes and the shipped router manifest survived restart. +Dashboard index SHA-256: +`dbcff02ed9bf8cc6bab4765e1b6f81155a938145f75b3f588bc2154dbb5476a9`. + +Repeated the normal router's negative/lifecycle sequence after reboot: management, +upload/list paths denied; unassigned SNI denied; enrollment revocation denied new +connections; restore recovered; isolated gateway restart reconnected; policy +outage failed closed and recovered. Initial attempt could not authenticate to +Yaya because the old SSH control session had expired; no policy mutation occurred. +Reauthenticated with the supplied account and the complete sequence passed. +Evidence: `.build/normal-router-after-reboot-live.log`. Existing public ingress +was unchanged. Final related UI regression group passed27tests and gateway Python +group passed10tests. No public Nostr events, source push, catalogue publication, +OTA or ISO publication occurred. + +Framework UAT candidate is ready. Retained boundaries: public ACME443 passthrough +needs a dedicated public ingress, external Nostr propagation is deliberately not +claimed, Framework's rootless memory cgroup limit is not enforced, and general +release remains gated by the separate release checklist and ngit/mirror review. +The operator was asked to restore the 2FA they temporarily disabled for testing. +Private catalogue pin and isolated Yaya services remain for UAT; remove/replace +them only during the reviewed release or explicit rollback. diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 348529ff..4ddca262 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -680,12 +680,24 @@ "requires": [], "tier": "optional", "title": "Blossom", - "version": "6.4.1-archy.1", + "version": "6.4.1-archy.2", "description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.", - "dockerImage": "localhost/archipelago-blossom:6.4.1-archy.1", + "dockerImage": "localhost/archipelago-blossom:6.4.1-archy.2", "category": "data", "repoUrl": "https://github.com/hzrd149/blossom-server", "icon": "/assets/img/app-icons/blossom.svg" + }, + { + "id": "public-web-router", + "author": "Archipelago", + "requires": [], + "tier": "optional", + "title": "Public Web Router", + "version": "0.1.0", + "description": "Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.", + "dockerImage": "localhost/archipelago-public-web-router:0.1.0", + "category": "networking", + "icon": "/assets/img/app-icons/nginx.svg" } ] } diff --git a/neode-ui/src/services/__tests__/installPublishingApp.test.ts b/neode-ui/src/services/__tests__/installPublishingApp.test.ts new file mode 100644 index 00000000..440af953 --- /dev/null +++ b/neode-ui/src/services/__tests__/installPublishingApp.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) +import { rpcClient } from '@/api/rpc-client' +import { installPublishingApp } from '../installPublishingApp' +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() }) +describe('Setup catalogue installation', () => { + it('supplies the signed build tag and version required by package.install', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ apps: { 'public-web-router': { version: '0.1.0', manifest: { app: { id: 'public-web-router', container: { build: { tag: 'localhost/archipelago-public-web-router:0.1.0' } } } } } } }) })) + await installPublishingApp('public-web-router') + expect(rpcClient.call).toHaveBeenCalledWith({ method: 'package.install', params: { id: 'public-web-router', dockerImage: 'localhost/archipelago-public-web-router:0.1.0', version: '0.1.0' }, timeout: 600000, maxRetries: 0 }) + }) + it('does not install from an unavailable or mismatched catalogue', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })) + await expect(installPublishingApp('blossom')).rejects.toThrow('unavailable') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ apps: { blossom: { version: '1', image: 'localhost/test:1', manifest: { app: { id: 'other' } } } } }) })) + await expect(installPublishingApp('blossom')).rejects.toThrow('not available') + expect(rpcClient.call).not.toHaveBeenCalled() + }) +}) diff --git a/neode-ui/src/services/__tests__/nsitePublishing.test.ts b/neode-ui/src/services/__tests__/nsitePublishing.test.ts index 33afdde6..6a268951 100644 --- a/neode-ui/src/services/__tests__/nsitePublishing.test.ts +++ b/neode-ui/src/services/__tests__/nsitePublishing.test.ts @@ -32,7 +32,7 @@ async function publishReviewed(html: string) { beforeEach(() => { vi.clearAllMocks(); accept = true vi.stubGlobal('WebSocket', Socket) - vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4 } } as never) + vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4, projects: {} } } as never) vi.mocked(publishing.update).mockResolvedValue({} as never) vi.mocked(rpcClient.call).mockImplementation(async request => { if (request.method === 'publishing.nsite-prepare') return prepared as never @@ -43,6 +43,17 @@ beforeEach(() => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ sha256: prepared.sha256, size: new TextEncoder().encode(prepared.html).length }), { status: 201 })).mockResolvedValueOnce(new Response(prepared.html))) }) describe('named nsite publishing', () => { + it('publishes local Blossom bytes through the node adapter and reads the public hash before announcing', async () => { + const original = vi.mocked(rpcClient.call).getMockImplementation()! + vi.mocked(rpcClient.call).mockImplementation(async request => request.method === 'publishing.blossom-store' ? {} as never : original(request)) + vi.mocked(fetch).mockReset().mockResolvedValue(new Response(prepared.html)) + await publishNsite('project', 4, identity, ['wss://relay.example'], { ...prepared, local: true }) + expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'publishing.blossom-store', params: expect.objectContaining({ nsite: { html: prepared.html, server: prepared.server, acknowledge_public: true } }) })) + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith(`${prepared.server}/${prepared.sha256}`, expect.objectContaining({ credentials: 'omit', redirect: 'error' })) + expect(publishing.update).toHaveBeenCalledTimes(2) + }) + it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => { const socket = vi.fn() vi.stubGlobal('WebSocket', socket) @@ -135,6 +146,15 @@ describe('named nsite publishing', () => { expect(fetch).not.toHaveBeenCalled() expect(rpcClient.call).not.toHaveBeenCalled() }) + it('does not retry announcements after local file sharing is revoked', async () => { + vi.mocked(publishing.status).mockResolvedValue({ state: { version: 4, projects: { project: { domain: { hostname: 'blossom.example' }, fips_publication: {} } } } } as never) + const socket = vi.fn() + vi.stubGlobal('WebSocket', socket) + await expect(retryNsite('project', receipt, ['wss://relay.example'])).rejects.toThrow('no longer shared') + expect(socket).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + expect(publishing.update).not.toHaveBeenCalled() + }) it('requests deletion with the publishing identity without deleting shared blobs', async () => { await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example']) expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { id: identity.id, event: expect.objectContaining({ kind: 5, tags: expect.arrayContaining([['e', event.id], ['a', `35128:${event.pubkey}:website123`]]) }) } })) diff --git a/neode-ui/src/services/installPublishingApp.ts b/neode-ui/src/services/installPublishingApp.ts new file mode 100644 index 00000000..d0bee770 --- /dev/null +++ b/neode-ui/src/services/installPublishingApp.ts @@ -0,0 +1,14 @@ +import { rpcClient } from '@/api/rpc-client' +import type { SignedAppCatalog } from '@/views/discover/curatedApps' + +/** Setup uses the same verified catalogue and package installer as Apps. */ +export async function installPublishingApp(id: 'blossom' | 'public-web-router') { + const response = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) }) + if (!response.ok) throw new Error('The trusted app catalogue is unavailable. Try again from Apps.') + const catalog = await response.json() as SignedAppCatalog + const entry = catalog.apps?.[id] + const app = entry?.manifest?.app + const dockerImage = entry?.image || app?.container?.image || app?.container?.build?.tag + if (!entry?.version || app?.id !== id || !dockerImage) throw new Error('This app is not available in the trusted catalogue yet.') + return rpcClient.call({ method: 'package.install', params: { id, dockerImage, version: entry.version }, timeout: 600000, maxRetries: 0 }) +} diff --git a/neode-ui/src/services/nsitePublishing.ts b/neode-ui/src/services/nsitePublishing.ts index b229a08c..4c6a1c1f 100644 --- a/neode-ui/src/services/nsitePublishing.ts +++ b/neode-ui/src/services/nsitePublishing.ts @@ -5,7 +5,7 @@ import { publishing } from './publishing' export interface SignedNsiteEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string } export interface NsiteReceipt { identity_id: string; server: string; event: SignedNsiteEvent; accepted_relays: string[]; deletion_requested: boolean } export interface NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean } -export interface PreparedNsite { html: string; sha256: string; server: string; identifier: string; authorization: Record; manifest: Record } +export interface PreparedNsite { local?: boolean; html: string; sha256: string; server: string; identifier: string; authorization: Record; manifest: Record } // Match the platform app signer and its derived Blossom upload allowlist, // including older node records that do not carry the explicit is_node flag. @@ -105,8 +105,8 @@ async function uploadDescriptor(response: Response): Promise<{ sha256: string; s return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size))) } finally { await reader.cancel() } } -export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string): Promise { - return await rpcClient.call({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 }) +export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string, local = false): Promise { + return await rpcClient.call({ method: 'publishing.nsite-prepare', params: { id: projectId, version, server, local, html: DOMPurify.sanitize(savedHtml, { WHOLE_DOCUMENT: true, FORBID_TAGS: ['meta', 'base', 'iframe', 'object', 'embed', 'form', 'script', 'link'] }) }, maxRetries: 0 }) } export async function publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise { if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity') @@ -116,12 +116,17 @@ export async function publishNsite(projectId: string, version: number, identity: if (!expiry || Number(expiry) <= Date.now() / 1000) throw new Error('The review expired. Prepare and review the publication again.') const authorization = await sign(identity, p.authorization) const bytes = new TextEncoder().encode(p.html) - const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization)))) - const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html }) - if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.') - if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`) - const descriptor = await uploadDescriptor(uploaded) - if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.') + if (p.local) { + await rpcClient.call({ method: 'publishing.blossom-store', params: { id: projectId, version, authorization, + nsite: { html: p.html, server: p.server, acknowledge_public: true } }, timeout: 70000, maxRetries: 0 }) + } else { + const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(authorization)))) + const uploaded = await fetch(`${p.server}/upload`, { method: 'PUT', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-SHA-256': p.sha256, Authorization: `Nostr ${encoded}` }, body: p.html }) + if (uploaded.status === 402) throw new Error('The Blossom server requires payment. No payment was made; choose another server or arrange storage yourself.') + if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`) + const descriptor = await uploadDescriptor(uploaded) + if (descriptor.sha256 !== p.sha256 || descriptor.size !== bytes.length) throw new Error('Blossom upload receipt does not match the website. The server may retain a copy.') + } await readback(await fetch(`${p.server}/${p.sha256}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), bytes) const event = await sign(identity, p.manifest) const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false } @@ -134,6 +139,16 @@ export async function publishNsite(projectId: string, version: number, identity: } export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise { if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request') + const status = await publishing.status() + const project = status.state.projects[projectId] + // For this node's origin, revoking the public asset must also stop retries. + // An external server is outside the node's control and retains its own copy. + if (project?.domain && receipt.server === `https://${project.domain.hostname}`) { + const asset = project.fips_publication?.nsite_asset + const digest = receipt.event.tags.find(t => t[0] === 'path' && t[1] === '/index.html')?.[2] + if (!asset || asset.receipt.sha256 !== digest) throw new Error('The local nsite file is no longer shared. Review and publish it again first.') + await readback(await fetch(`${receipt.server}/${digest}`, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(30000) }), new TextEncoder().encode(asset.html)) + } const accepted = await broadcast(receipt.event, relays) const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] } await record(projectId, next) diff --git a/neode-ui/src/services/publishing.ts b/neode-ui/src/services/publishing.ts index 57a87c6b..cbd8900a 100644 --- a/neode-ui/src/services/publishing.ts +++ b/neode-ui/src/services/publishing.ts @@ -7,8 +7,8 @@ export interface WebsiteRevision { id: string; created_at: string; html: string export interface WebsiteProject { id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null draft: string; revisions: WebsiteRevision[] - fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null - tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null + fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null; nsite_asset?: { html: string; receipt: { sha256: string; size: number } } | null } | null + tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null; nsite_asset?: { html: string; receipt: { sha256: string; size: number } } | null } | null nsite_receipt?: NsiteReceipt | null local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null } @@ -19,6 +19,7 @@ export interface PublishingStatus { state: PublishingState; fips_address: string | null; publication_enabled: boolean; public_archive_enabled?: boolean; notice: string listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[] onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[] + gateway?: { configured: boolean; host?: string; port?: number; domains?: string[]; certificate_mode?: string; routes: { id: string; domain: string }[]; error?: string } nostr_relays?: string[] apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[] grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[] diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 35be8fb8..34745609 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -325,6 +325,7 @@ function onAiuiMessage(event: MessageEvent) { // the iframe survives deactivation that message will not be re-sent on // re-entry, so it must NOT be reset on deactivate. function armChatLive() { + if (!IS_DEMO && aiuiConnected.value) void connectionSetup.value?.syncSelection() window.removeEventListener('message', onAiuiMessage) window.addEventListener('message', onAiuiMessage) window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) diff --git a/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts b/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts index 5932d8bb..2e6ac7d1 100644 --- a/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts +++ b/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts @@ -11,7 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Chat from '../Chat.vue' -vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '
', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } })) +const providerSync = vi.hoisted(() => vi.fn(async () => {})) +vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '
', methods: { checkNeeded: async () => false, syncSelection: providerSync } } })) const routerBackMock = vi.fn() const routerPushMock = vi.fn() @@ -66,6 +67,7 @@ function iframeSrc(wrapper: ReturnType): string | undefined { describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => { beforeEach(() => { + providerSync.mockClear() vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173') }) @@ -206,12 +208,14 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => { expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true) expect(wrapper.find('.chat-loading').exists()).toBe(false) + expect(providerSync).toHaveBeenCalledTimes(1) show.value = false await wrapper.vm.$nextTick() show.value = true await wrapper.vm.$nextTick() await flushPromises() + expect(providerSync).toHaveBeenCalledTimes(2) // No second 'ready' message is sent on reactivation — aiuiConnected must // not have been reset to false by the deactivate/reactivate cycle. expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true) diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 005c21e7..c8843a70 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -67,7 +67,7 @@ export interface SignedAppEntry { version?: string description?: string category?: string - container?: { image?: string } + container?: { image?: string; build?: { tag?: string } } metadata?: { icon?: string; author?: string; repo?: string } ports?: { host?: number | string; container?: number | string; auth?: string }[] } @@ -92,7 +92,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] description: app?.description || '', icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg', author: app?.metadata?.author, - dockerImage: entry.image || app?.container?.image || '', + dockerImage: entry.image || app?.container?.image || app?.container?.build?.tag || '', repoUrl: app?.metadata?.repo, category: app?.category, source: 'signed-catalog', diff --git a/neode-ui/src/views/publishing/PublicWebGateway.vue b/neode-ui/src/views/publishing/PublicWebGateway.vue new file mode 100644 index 00000000..6e6454cc --- /dev/null +++ b/neode-ui/src/views/publishing/PublicWebGateway.vue @@ -0,0 +1,106 @@ + + + diff --git a/neode-ui/src/views/publishing/PublishingSetup.vue b/neode-ui/src/views/publishing/PublishingSetup.vue index 7178e7fc..6ebd4bfb 100644 --- a/neode-ui/src/views/publishing/PublishingSetup.vue +++ b/neode-ui/src/views/publishing/PublishingSetup.vue @@ -3,9 +3,11 @@ import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from import { RouterLink, useRoute, useRouter } from 'vue-router' import { useAppStore } from '@/stores/app' import { rpcClient } from '@/api/rpc-client' +import { installPublishingApp } from '@/services/installPublishingApp' import { pendingWebsiteHtml } from '@/services/websiteImport' import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing' import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing' +import PublicWebGateway from './PublicWebGateway.vue' import { nsiteIdentities, prepareNsite, publishNsite, retryNsite, requestNsiteDeletion, namedNsiteUrl, relayAddresses, storeLocalWebsite } from '@/services/nsitePublishing' import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing' @@ -41,6 +43,8 @@ const acknowledgeTor = ref(false) const identities = ref([]) const identityId = ref('') const blossom = ref('') +const localNsite = ref(true) +const nsiteServer = computed(() => localNsite.value ? (current.value?.domain?.hostname ? `https://${current.value.domain.hostname}` : '') : blossom.value) const relays = ref('') const gateway = ref('') const nsiteUrl = ref('') @@ -229,7 +233,7 @@ async function verifyHttps() { } async function installBlossom() { await perform(async () => { - await appStore.installPackage('blossom', '', 'latest') + await installPublishingApp('blossom') message.value = 'Blossom installation requested through the app catalogue. This step will be skipped when installation is recorded.' }) } @@ -259,14 +263,14 @@ async function storeLocally() { message.value = 'Saved draft stored in local Blossom and fetched back to verify its bytes. Nothing was announced or replicated externally.' }) } -watch([projectId, blossom, relays, identityId, html, selected], () => { nsiteReview.value = null; acknowledgeNostr.value = false; acknowledgeUpload.value = false }, { deep: true }) +watch([projectId, blossom, localNsite, relays, identityId, html, selected], () => { nsiteReview.value = null; acknowledgeNostr.value = false; acknowledgeUpload.value = false }, { deep: true }) async function reviewNsite() { await perform(async () => { if (!status.value || !current.value) return const identity = identities.value.find(i => i.id === identityId.value) if (!identity) throw new Error('Choose a profile identity first') const targets = relayAddresses(relays.value) - const prepared = await prepareNsite(projectId.value, status.value.state.version, blossom.value, current.value.draft) + const prepared = await prepareNsite(projectId.value, status.value.state.version, nsiteServer.value, current.value.draft, localNsite.value) nsiteReview.value = { projectId: projectId.value, version: status.value.state.version, identity: { ...identity }, relays: [...targets], prepared } acknowledgeNostr.value = false; acknowledgeUpload.value = false }) @@ -297,6 +301,14 @@ async function handleNsite(action: 'publish' | 'retry' | 'delete') { message.value = action === 'delete' ? 'A relay accepted the deletion request. Other relays, Blossom servers and cached copies may retain the website.' : 'The uploaded bytes were checked and a relay accepted the named-site manifest. Gateway availability still needs checking.' }) } +async function unshareNsiteAsset() { + await perform(async () => { + if (!status.value || !current.value) return + await publishing.update(status.value.state.version, { action: 'unshare-nsite-asset', id: projectId.value }) + status.value = await publishing.status() + message.value = 'Local nsite file sharing stopped. Published manifests and downloaded copies may remain.' + }) +} async function showNsiteAddress() { await perform(async () => { if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value) @@ -339,6 +351,7 @@ onMounted(refresh)

{{ status.fips_address ? 'Your node has a FIPS address. Check visitor access after publishing or sharing an app.' : 'Your node does not have a FIPS address yet. Connect FIPS in Network settings before publishing here.' }}

+ Manage shared connections @@ -446,6 +459,7 @@ onMounted(refresh)

For a tunnel, point DNS at the public gateway. For a direct connection, use the node’s public IP. Do not use a home-network, FIPS or onion address for public web DNS.

+

Use an existing reverse proxy

If your proxy can reach this node over FIPS, you can reuse that connection. In Nginx Proxy Manager, add a separate Proxy Host with these settings:

@@ -479,6 +493,7 @@ onMounted(refresh)