Complete private gateway and local website publishing UAT
This commit is contained in:
@@ -680,12 +680,24 @@
|
|||||||
"requires": [],
|
"requires": [],
|
||||||
"tier": "optional",
|
"tier": "optional",
|
||||||
"title": "Blossom",
|
"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.",
|
"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",
|
"category": "data",
|
||||||
"repoUrl": "https://github.com/hzrd149/blossom-server",
|
"repoUrl": "https://github.com/hzrd149/blossom-server",
|
||||||
"icon": "/assets/img/app-icons/blossom.svg"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
app:
|
app:
|
||||||
id: blossom
|
id: blossom
|
||||||
name: Blossom
|
name: Blossom
|
||||||
version: 6.4.1-archy.1
|
version: 6.4.1-archy.2
|
||||||
upstream:
|
upstream:
|
||||||
kind: github
|
kind: github
|
||||||
repo: hzrd149/blossom-server
|
repo: hzrd149/blossom-server
|
||||||
@@ -12,7 +12,7 @@ app:
|
|||||||
build:
|
build:
|
||||||
context: /opt/archipelago/docker/blossom
|
context: /opt/archipelago/docker/blossom
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
tag: localhost/archipelago-blossom:6.4.1-archy.1
|
tag: localhost/archipelago-blossom:6.4.1-archy.2
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: ARCHY_BLOSSOM_PUBKEYS
|
- key: ARCHY_BLOSSOM_PUBKEYS
|
||||||
template: '{{NODE_IDENTITY_PUBKEYS}}'
|
template: '{{NODE_IDENTITY_PUBKEYS}}'
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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]
|
||||||
Generated
+1
@@ -235,6 +235,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hyper 0.14.32",
|
"hyper 0.14.32",
|
||||||
|
"reqwest 0.11.27",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
|||||||
@@ -198,6 +198,17 @@ async fn forward_models() -> Result<Response<Body>> {
|
|||||||
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
|
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
|
||||||
/// offline) → price quote → pay → forward → redeem change → record net.
|
/// offline) → price quote → pay → forward → redeem change → record net.
|
||||||
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
|
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
|
||||||
|
// 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())
|
let payload = hyper::body::to_bytes(req.into_body())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
|
||||||
@@ -445,6 +456,41 @@ mod tests {
|
|||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
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
|
/// 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.
|
/// path BEFORE any pricing/network I/O — this test runs fully offline.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ impl RpcHandler {
|
|||||||
session_token: &Option<String>,
|
session_token: &Option<String>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
match method {
|
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.status" => self.handle_publishing_status().await,
|
||||||
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
|
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
|
||||||
"publishing.update" => self.handle_publishing_update(params).await,
|
"publishing.update" => self.handle_publishing_update(params).await,
|
||||||
|
|||||||
@@ -5,6 +5,84 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_publishing_gateway_app_route(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
#[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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
#[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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
#[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(
|
pub(super) async fn handle_publishing_verify_https(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@@ -203,10 +281,19 @@ impl RpcHandler {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct NsiteFile {
|
||||||
|
html: String,
|
||||||
|
server: String,
|
||||||
|
acknowledge_public: bool,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
struct Request {
|
struct Request {
|
||||||
id: String,
|
id: String,
|
||||||
version: u64,
|
version: u64,
|
||||||
authorization: nostr_sdk::Event,
|
authorization: nostr_sdk::Event,
|
||||||
|
#[serde(default)]
|
||||||
|
nsite: Option<NsiteFile>,
|
||||||
}
|
}
|
||||||
let request: Request =
|
let request: Request =
|
||||||
serde_json::from_value(params.context("Missing local archive authorization")?)?;
|
serde_json::from_value(params.context("Missing local archive authorization")?)?;
|
||||||
@@ -227,7 +314,20 @@ impl RpcHandler {
|
|||||||
!project.draft.trim().is_empty(),
|
!project.draft.trim().is_empty(),
|
||||||
"Save a website draft first"
|
"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 event = serde_json::to_value(&request.authorization)?;
|
||||||
let tags = event["tags"]
|
let tags = event["tags"]
|
||||||
.as_array()
|
.as_array()
|
||||||
@@ -259,7 +359,7 @@ impl RpcHandler {
|
|||||||
.put(format!("{base}/upload"))
|
.put(format!("{base}/upload"))
|
||||||
.header("Authorization", format!("Nostr {auth}"))
|
.header("Authorization", format!("Nostr {auth}"))
|
||||||
.header("Content-Type", "text/html; charset=utf-8")
|
.header("Content-Type", "text/html; charset=utf-8")
|
||||||
.body(project.draft.clone())
|
.body(content.clone())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Local Blossom is not responding. Start it from Apps")?;
|
.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)?;
|
let descriptor: serde_json::Value = serde_json::from_slice(&descriptor)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
descriptor["sha256"] == digest && descriptor["size"] == project.draft.len(),
|
descriptor["sha256"] == digest && descriptor["size"] == content.len(),
|
||||||
"Local Blossom returned another file receipt"
|
"Local Blossom returned another file receipt"
|
||||||
);
|
);
|
||||||
let mut response = client
|
let mut response = client
|
||||||
@@ -286,7 +386,7 @@ impl RpcHandler {
|
|||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
let expected = project.draft.as_bytes();
|
let expected = content.as_bytes();
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
while let Some(chunk) = response.chunk().await? {
|
while let Some(chunk) = response.chunk().await? {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -310,9 +410,19 @@ impl RpcHandler {
|
|||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
publishing::Update {
|
publishing::Update {
|
||||||
version: request.version,
|
version: request.version,
|
||||||
change: publishing::Change::RecordLocalArchive {
|
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,
|
id: request.id,
|
||||||
receipt,
|
receipt,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -350,6 +460,7 @@ impl RpcHandler {
|
|||||||
"nostr_relays": self.config.nostr_relays,
|
"nostr_relays": self.config.nostr_relays,
|
||||||
"publication_enabled": true,
|
"publication_enabled": true,
|
||||||
"public_archive_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,
|
"listeners": publishing::serving::status().await,
|
||||||
"onions": publishing::tor::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.",
|
"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,
|
version: u64,
|
||||||
server: String,
|
server: String,
|
||||||
html: String,
|
html: String,
|
||||||
|
#[serde(default)]
|
||||||
|
local: bool,
|
||||||
}
|
}
|
||||||
let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?;
|
let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?;
|
||||||
let state = publishing::load(&self.config.data_dir).await?;
|
let state = publishing::load(&self.config.data_dir).await?;
|
||||||
@@ -396,7 +509,13 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
let mut prepared = project.clone();
|
let mut prepared = project.clone();
|
||||||
prepared.draft = request.html;
|
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(
|
pub(super) async fn handle_publishing_dns(
|
||||||
|
|||||||
@@ -172,7 +172,30 @@ impl AppGate {
|
|||||||
// snapshot when the port momentarily leaves the map mid-refresh.
|
// snapshot when the port momentarily leaves the map mid-refresh.
|
||||||
let live = self.port_map.read().await.gated(app.port).cloned();
|
let live = self.port_map.read().await.gated(app.port).cloned();
|
||||||
let app = live.as_ref().unwrap_or(app);
|
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();
|
let path = req.uri().path().to_string();
|
||||||
// A dashboard same-origin proxy strips `/app/<id>/` before this gate
|
// A dashboard same-origin proxy strips `/app/<id>/` before this gate
|
||||||
// sees the URI. Carry that trusted proxy mount into the challenge's
|
// sees the URI. Carry that trusted proxy mount into the challenge's
|
||||||
@@ -1380,6 +1403,32 @@ fn totp_page(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[tokio::test]
|
||||||
async fn guest_login_is_app_only_and_revocation_blocks_subsequent_requests() {
|
async fn guest_login_is_app_only_and_revocation_blocks_subsequent_requests() {
|
||||||
let gate = test_gate().await;
|
let gate = test_gate().await;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Config {
|
||||||
|
schema: u32,
|
||||||
|
gateway: Enrollment,
|
||||||
|
certificate_mode: String,
|
||||||
|
routes: Vec<WebsiteRoute>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct WebsiteRoute {
|
||||||
|
#[serde(default)]
|
||||||
|
app_id: Option<String>,
|
||||||
|
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::<std::net::IpAddr>().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<Option<Config>> {
|
||||||
|
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::<Vec<_>>(),"externally_verified":false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn status(root: &Path) -> Result<Value> {
|
||||||
|
Ok(public_status(load(root).await?.as_ref()))
|
||||||
|
}
|
||||||
|
pub async fn configure(root: &Path, enrollment: Enrollment, mode: String) -> Result<Value> {
|
||||||
|
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<std::net::Ipv6Addr>,
|
||||||
|
) -> Result<Value> {
|
||||||
|
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<std::net::Ipv6Addr>,
|
||||||
|
port: Option<u16>,
|
||||||
|
) -> Result<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use std::path::Path;
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
mod firewall;
|
mod firewall;
|
||||||
|
pub mod gateway;
|
||||||
pub mod nsite;
|
pub mod nsite;
|
||||||
pub mod serving;
|
pub mod serving;
|
||||||
pub mod tor;
|
pub mod tor;
|
||||||
@@ -61,9 +62,18 @@ pub struct LocalArchive {
|
|||||||
pub created_at: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Publication {
|
pub struct Publication {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub nsite_asset: Option<PublicNsiteAsset>,
|
||||||
/// Exact archived bytes explicitly approved for public hash-addressed reads.
|
/// Exact archived bytes explicitly approved for public hash-addressed reads.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub public_archive: Option<String>,
|
pub public_archive: Option<String>,
|
||||||
@@ -125,6 +135,17 @@ pub enum Change {
|
|||||||
domain: Option<Domain>,
|
domain: Option<Domain>,
|
||||||
html: String,
|
html: String,
|
||||||
},
|
},
|
||||||
|
#[serde(skip_deserializing)]
|
||||||
|
ShareNsiteAsset {
|
||||||
|
id: String,
|
||||||
|
server: String,
|
||||||
|
html: String,
|
||||||
|
receipt: LocalArchive,
|
||||||
|
acknowledge_public: bool,
|
||||||
|
},
|
||||||
|
UnshareNsiteAsset {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
ShareArchive {
|
ShareArchive {
|
||||||
id: String,
|
id: String,
|
||||||
route: Route,
|
route: Route,
|
||||||
@@ -267,6 +288,45 @@ pub fn dns_records(domain: &Domain) -> Result<Vec<DnsRecord>> {
|
|||||||
impl State {
|
impl State {
|
||||||
pub fn apply(&mut self, change: Change) -> Result<Option<String>> {
|
pub fn apply(&mut self, change: Change) -> Result<Option<String>> {
|
||||||
match change {
|
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 } => {
|
Change::RecordLocalArchive { id, receipt } => {
|
||||||
let p = self
|
let p = self
|
||||||
.projects
|
.projects
|
||||||
@@ -280,28 +340,47 @@ impl State {
|
|||||||
p.local_archive = Some(receipt);
|
p.local_archive = Some(receipt);
|
||||||
Ok(Some(id))
|
Ok(Some(id))
|
||||||
}
|
}
|
||||||
Change::ShareArchive { id, route, acknowledge_public } => {
|
Change::ShareArchive {
|
||||||
if !acknowledge_public { bail!("Confirm public access to the exact archived website bytes"); }
|
id,
|
||||||
let p = self.projects.get_mut(&id).context("Website project not found")?;
|
route,
|
||||||
let archive = p.local_archive.as_ref().context("Store this website in local Blossom first")?;
|
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 {
|
let publication = match route {
|
||||||
Route::Fips => p.fips_publication.as_mut(),
|
Route::Fips => p.fips_publication.as_mut(),
|
||||||
Route::Tor => p.tor_publication.as_mut(),
|
Route::Tor => p.tor_publication.as_mut(),
|
||||||
_ => bail!("Choose the FIPS/public-web or Tor publication"),
|
_ => 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");
|
bail!("The archive differs from this published version. Store and publish the same version first");
|
||||||
}
|
}
|
||||||
publication.public_archive = Some(archive.sha256.clone());
|
publication.public_archive = Some(archive.sha256.clone());
|
||||||
Ok(Some(id))
|
Ok(Some(id))
|
||||||
}
|
}
|
||||||
Change::UnshareArchive { id, route } => {
|
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 {
|
let publication = match route {
|
||||||
Route::Fips => p.fips_publication.as_mut(),
|
Route::Fips => p.fips_publication.as_mut(),
|
||||||
Route::Tor => p.tor_publication.as_mut(),
|
Route::Tor => p.tor_publication.as_mut(),
|
||||||
_ => bail!("Choose the FIPS/public-web or Tor publication"),
|
_ => 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;
|
publication.public_archive = None;
|
||||||
Ok(Some(id))
|
Ok(Some(id))
|
||||||
}
|
}
|
||||||
@@ -416,6 +495,7 @@ impl State {
|
|||||||
.context("No website ports available")?,
|
.context("No website ports available")?,
|
||||||
};
|
};
|
||||||
p.fips_publication = Some(Publication {
|
p.fips_publication = Some(Publication {
|
||||||
|
nsite_asset: None,
|
||||||
public_archive: None,
|
public_archive: None,
|
||||||
port,
|
port,
|
||||||
html: p.draft.clone(),
|
html: p.draft.clone(),
|
||||||
@@ -449,6 +529,7 @@ impl State {
|
|||||||
.context("No onion website ports available")?,
|
.context("No onion website ports available")?,
|
||||||
};
|
};
|
||||||
p.tor_publication = Some(Publication {
|
p.tor_publication = Some(Publication {
|
||||||
|
nsite_asset: None,
|
||||||
public_archive: None,
|
public_archive: None,
|
||||||
port,
|
port,
|
||||||
html: p.draft.clone(),
|
html: p.draft.clone(),
|
||||||
@@ -520,8 +601,20 @@ pub async fn load(root: &Path) -> Result<State> {
|
|||||||
(&project.tor_publication, 32100..32132),
|
(&project.tor_publication, 32100..32132),
|
||||||
] {
|
] {
|
||||||
if let Some(p) = publication {
|
if let Some(p) = publication {
|
||||||
if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML
|
if !range.contains(&p.port)
|
||||||
|| p.public_archive.as_ref().is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes())) {
|
|| !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");
|
bail!("Invalid stored website publication; existing state has been preserved");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub const POLICY: &str = "<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\"><meta name=\"referrer\" content=\"no-referrer\">";
|
||||||
|
|
||||||
|
pub fn local_server(project: &Project, raw: &str) -> Result<String> {
|
||||||
|
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 {
|
pub fn hash(bytes: &[u8]) -> String {
|
||||||
format!("{:x}", Sha256::digest(bytes))
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
}
|
}
|
||||||
@@ -23,7 +44,11 @@ pub fn prepare(project: &Project, blossom: &str) -> Result<Value> {
|
|||||||
let server = server(blossom)?;
|
let server = server(blossom)?;
|
||||||
// The first policy remains restrictive even if generated HTML adds another
|
// The first policy remains restrictive even if generated HTML adds another
|
||||||
// CSP. Hosted nsites use a separate origin, without dashboard privileges.
|
// CSP. Hosted nsites use a separate origin, without dashboard privileges.
|
||||||
let html = format!("<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\"><meta name=\"referrer\" content=\"no-referrer\">{}", 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 digest = hash(html.as_bytes());
|
||||||
let identifier: String = project.id.chars().filter(|c| *c != '-').take(13).collect();
|
let identifier: String = project.id.chars().filter(|c| *c != '-').take(13).collect();
|
||||||
let aggregate = hash(format!("{digest} /index.html\n").as_bytes());
|
let aggregate = hash(format!("{digest} /index.html\n").as_bytes());
|
||||||
|
|||||||
@@ -53,10 +53,29 @@ pub(super) fn response_for(
|
|||||||
else {
|
else {
|
||||||
return simple(StatusCode::NOT_FOUND, "Website is not published");
|
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.
|
// Only the selected immutable snapshot is exposed, never the Blossom backend.
|
||||||
// No listing, upload, arbitrary hash lookup, filesystem access or credentials.
|
// No listing, upload, arbitrary hash lookup, filesystem access or credentials.
|
||||||
let asset = publication.public_archive.as_ref().is_some_and(|hash| {
|
let nsite_asset = publication.nsite_asset.as_ref().filter(|asset| {
|
||||||
req.uri().path() == format!("/{hash}") && *hash == super::nsite::hash(publication.html.as_bytes())
|
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 {
|
if asset && req.method() == Method::OPTIONS {
|
||||||
let mut response = simple(StatusCode::NO_CONTENT, "");
|
let mut response = simple(StatusCode::NO_CONTENT, "");
|
||||||
@@ -76,13 +95,14 @@ pub(super) fn response_for(
|
|||||||
response
|
response
|
||||||
.headers_mut()
|
.headers_mut()
|
||||||
.insert("content-type", "text/html; charset=utf-8".parse().unwrap());
|
.insert("content-type", "text/html; charset=utf-8".parse().unwrap());
|
||||||
response.headers_mut().insert(
|
response
|
||||||
"content-length",
|
.headers_mut()
|
||||||
publication.html.len().to_string().parse().unwrap(),
|
.insert("content-length", html.len().to_string().parse().unwrap());
|
||||||
);
|
if asset {
|
||||||
if asset { asset_headers(&mut response); }
|
asset_headers(&mut response);
|
||||||
|
}
|
||||||
if req.method() == Method::GET {
|
if req.method() == Method::GET {
|
||||||
*response.body_mut() = Body::from(publication.html.clone());
|
*response.body_mut() = Body::from(html.to_owned());
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
@@ -90,9 +110,14 @@ fn asset_headers(response: &mut Response<Body>) {
|
|||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("access-control-allow-origin", "*"),
|
("access-control-allow-origin", "*"),
|
||||||
("access-control-allow-methods", "GET, HEAD, OPTIONS"),
|
("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\""),
|
("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<Body> {
|
fn simple(status: StatusCode, body: &str) -> Response<Body> {
|
||||||
let mut r = Response::new(Body::from(body.to_owned()));
|
let mut r = Response::new(Body::from(body.to_owned()));
|
||||||
@@ -260,48 +285,266 @@ pub(super) async fn listen(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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: "<h1>Original</h1>".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!("{}<h1>Reviewed</h1>", 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]
|
#[tokio::test]
|
||||||
async fn public_archive_is_exact_explicit_route_scoped_and_revocable() {
|
async fn public_archive_is_exact_explicit_route_scoped_and_revocable() {
|
||||||
use crate::publishing::{Change, LocalArchive, Route};
|
use crate::publishing::{Change, LocalArchive, Route};
|
||||||
let mut state = State::default();
|
let mut state = State::default();
|
||||||
let id = state.apply(Change::Create { name: "Archive".into() }).unwrap().unwrap();
|
let id = state
|
||||||
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();
|
.apply(Change::Create {
|
||||||
state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap();
|
name: "Archive".into(),
|
||||||
state.apply(Change::PublishTor { id: id.clone(), acknowledge_public: true }).unwrap();
|
})
|
||||||
|
.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 port = state.projects[&id].fips_publication.as_ref().unwrap().port;
|
||||||
let tor_port = state.projects[&id].tor_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 hash = crate::publishing::nsite::hash(b"public snapshot");
|
||||||
let req = Request::builder().uri(format!("/{hash}")).body(Body::empty()).unwrap();
|
let req = Request::builder()
|
||||||
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
|
.uri(format!("/{hash}"))
|
||||||
assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).is_err());
|
.body(Body::empty())
|
||||||
state.apply(Change::RecordLocalArchive { id: id.clone(), receipt: LocalArchive { sha256: hash.clone(), size: 15, pubkey: "a".repeat(64), created_at: "now".into() } }).unwrap();
|
.unwrap();
|
||||||
assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: false }).is_err());
|
assert_eq!(
|
||||||
state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap();
|
response(&state, &id, port, &req).status(),
|
||||||
assert_eq!(response_for(&state, &id, tor_port, Route::Tor, &req).status(), StatusCode::NOT_FOUND);
|
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);
|
let r = response(&state, &id, port, &req);
|
||||||
assert_eq!(r.status(), StatusCode::OK);
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
assert_eq!(r.headers()["access-control-allow-origin"], "*");
|
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!(r.headers()["content-security-policy"], CSP);
|
||||||
assert_eq!(hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(), b"public snapshot");
|
assert_eq!(
|
||||||
for path in ["/upload", "/list", "/0000000000000000000000000000000000000000000000000000000000000000", "/../state.json"] {
|
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();
|
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);
|
let r = response(&state, &id, port, &head);
|
||||||
assert_eq!(r.headers()["content-length"], "15");
|
assert_eq!(r.headers()["content-length"], "15");
|
||||||
assert!(hyper::body::to_bytes(r.into_body()).await.unwrap().is_empty());
|
assert!(hyper::body::to_bytes(r.into_body())
|
||||||
let post = Request::builder().method(Method::PUT).uri(format!("/{hash}")).body(Body::empty()).unwrap();
|
.await
|
||||||
assert_eq!(response(&state, &id, port, &post).status(), StatusCode::METHOD_NOT_ALLOWED);
|
.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();
|
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");
|
assert_eq!(
|
||||||
state.apply(Change::UnshareArchive { id: id.clone(), route: Route::Fips }).unwrap();
|
hyper::body::to_bytes(response(&state, &id, port, &req).into_body())
|
||||||
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
|
.await
|
||||||
state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap();
|
.unwrap()
|
||||||
state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap();
|
.as_ref(),
|
||||||
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
|
b"public snapshot"
|
||||||
assert!(state.apply(Change::ShareArchive { id, route: Route::Fips, acknowledge_public: true }).is_err());
|
);
|
||||||
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ impl EndpointRateLimiter {
|
|||||||
limits.insert("identity.issue-credential".to_string(), (20, 300));
|
limits.insert("identity.issue-credential".to_string(), (20, 300));
|
||||||
// Explicit publishing actions can allocate credentials or perform
|
// Explicit publishing actions can allocate credentials or perform
|
||||||
// bounded network I/O. Saving/previewing never invokes these actions.
|
// 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.access-create".to_string(), (10, 60));
|
||||||
limits.insert("publishing.verify-https".to_string(), (10, 60));
|
limits.insert("publishing.verify-https".to_string(), (10, 60));
|
||||||
limits.insert("publishing.blossom-store".to_string(), (10, 60));
|
limits.insert("publishing.blossom-store".to_string(), (10, 60));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish = false
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
hyper = { version = "0.14", features = ["full", "http1"] }
|
hyper = { version = "0.14", features = ["full", "http1"] }
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -464,3 +464,177 @@ Setup is synchronized into an already-cached Chat iframe on return. Source
|
|||||||
inspection shows configuration synchronization on iframe readiness; reactivation
|
inspection shows configuration synchronization on iframe readiness; reactivation
|
||||||
currently arms listeners without explicitly refreshing the provider. This is a
|
currently arms listeners without explicitly refreshing the provider. This is a
|
||||||
follow-up acceptance concern, not a confirmed live inference result.
|
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.
|
||||||
|
|||||||
@@ -680,12 +680,24 @@
|
|||||||
"requires": [],
|
"requires": [],
|
||||||
"tier": "optional",
|
"tier": "optional",
|
||||||
"title": "Blossom",
|
"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.",
|
"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",
|
"category": "data",
|
||||||
"repoUrl": "https://github.com/hzrd149/blossom-server",
|
"repoUrl": "https://github.com/hzrd149/blossom-server",
|
||||||
"icon": "/assets/img/app-icons/blossom.svg"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -32,7 +32,7 @@ async function publishReviewed(html: string) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks(); accept = true
|
vi.clearAllMocks(); accept = true
|
||||||
vi.stubGlobal('WebSocket', Socket)
|
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(publishing.update).mockResolvedValue({} as never)
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async request => {
|
vi.mocked(rpcClient.call).mockImplementation(async request => {
|
||||||
if (request.method === 'publishing.nsite-prepare') return prepared as never
|
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)))
|
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', () => {
|
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 () => {
|
it('stores locally through the authenticated node adapter without external uploads or broadcasts', async () => {
|
||||||
const socket = vi.fn()
|
const socket = vi.fn()
|
||||||
vi.stubGlobal('WebSocket', socket)
|
vi.stubGlobal('WebSocket', socket)
|
||||||
@@ -135,6 +146,15 @@ describe('named nsite publishing', () => {
|
|||||||
expect(fetch).not.toHaveBeenCalled()
|
expect(fetch).not.toHaveBeenCalled()
|
||||||
expect(rpcClient.call).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 () => {
|
it('requests deletion with the publishing identity without deleting shared blobs', async () => {
|
||||||
await requestNsiteDeletion('project', receipt, identity, ['wss://relay.example'])
|
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`]]) }) } }))
|
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`]]) }) } }))
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 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 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 NsiteIdentity { id: string; name: string; nostr_pubkey: string; is_node: boolean }
|
||||||
export interface PreparedNsite { html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
|
export interface PreparedNsite { local?: boolean; html: string; sha256: string; server: string; identifier: string; authorization: Record<string, unknown>; manifest: Record<string, unknown> }
|
||||||
|
|
||||||
// Match the platform app signer and its derived Blossom upload allowlist,
|
// Match the platform app signer and its derived Blossom upload allowlist,
|
||||||
// including older node records that do not carry the explicit is_node flag.
|
// 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)))
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)))
|
||||||
} finally { await reader.cancel() }
|
} finally { await reader.cancel() }
|
||||||
}
|
}
|
||||||
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string): Promise<PreparedNsite> {
|
export async function prepareNsite(projectId: string, version: number, server: string, savedHtml: string, local = false): Promise<PreparedNsite> {
|
||||||
return await rpcClient.call<PreparedNsite>({ 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 })
|
return await rpcClient.call<PreparedNsite>({ 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<NsiteReceipt> {
|
export async function publishNsite(projectId: string, version: number, identity: NsiteIdentity, relays: string[], p: PreparedNsite): Promise<NsiteReceipt> {
|
||||||
if (identity.is_node) throw new Error('Use a profile identity, not the operational node identity')
|
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.')
|
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 authorization = await sign(identity, p.authorization)
|
||||||
const bytes = new TextEncoder().encode(p.html)
|
const bytes = new TextEncoder().encode(p.html)
|
||||||
|
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 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 })
|
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.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.`)
|
if (!uploaded.ok) throw new Error(`Blossom upload failed (${uploaded.status}). The server may retain an uploaded copy.`)
|
||||||
const descriptor = await uploadDescriptor(uploaded)
|
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 (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)
|
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 event = await sign(identity, p.manifest)
|
||||||
const receipt: NsiteReceipt = { identity_id: identity.id, server: p.server, event, accepted_relays: [], deletion_requested: false }
|
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<NsiteReceipt> {
|
export async function retryNsite(projectId: string, receipt: NsiteReceipt, relays: string[]): Promise<NsiteReceipt> {
|
||||||
if (receipt.deletion_requested) throw new Error('Publish explicitly to restore a site after a deletion request')
|
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 accepted = await broadcast(receipt.event, relays)
|
||||||
const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] }
|
const next = { ...receipt, accepted_relays: [...new Set([...receipt.accepted_relays, ...accepted])] }
|
||||||
await record(projectId, next)
|
await record(projectId, next)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export interface WebsiteRevision { id: string; created_at: string; html: string
|
|||||||
export interface WebsiteProject {
|
export interface WebsiteProject {
|
||||||
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
|
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
|
||||||
draft: string; revisions: WebsiteRevision[]
|
draft: string; revisions: WebsiteRevision[]
|
||||||
fips_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 } | 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
|
nsite_receipt?: NsiteReceipt | null
|
||||||
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | 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
|
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 }[]
|
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 }[]
|
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[]
|
nostr_relays?: string[]
|
||||||
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean; guest_access?: boolean }[]
|
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 }[]
|
grants?: { id: string; label: string; apps: string[]; expires_at: number | null }[]
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ function onAiuiMessage(event: MessageEvent) {
|
|||||||
// the iframe survives deactivation that message will not be re-sent on
|
// the iframe survives deactivation that message will not be re-sent on
|
||||||
// re-entry, so it must NOT be reset on deactivate.
|
// re-entry, so it must NOT be reset on deactivate.
|
||||||
function armChatLive() {
|
function armChatLive() {
|
||||||
|
if (!IS_DEMO && aiuiConnected.value) void connectionSetup.value?.syncSelection()
|
||||||
window.removeEventListener('message', onAiuiMessage)
|
window.removeEventListener('message', onAiuiMessage)
|
||||||
window.addEventListener('message', onAiuiMessage)
|
window.addEventListener('message', onAiuiMessage)
|
||||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import Chat from '../Chat.vue'
|
import Chat from '../Chat.vue'
|
||||||
|
|
||||||
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
|
const providerSync = vi.hoisted(() => vi.fn(async () => {}))
|
||||||
|
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: providerSync } } }))
|
||||||
|
|
||||||
const routerBackMock = vi.fn()
|
const routerBackMock = vi.fn()
|
||||||
const routerPushMock = vi.fn()
|
const routerPushMock = vi.fn()
|
||||||
@@ -66,6 +67,7 @@ function iframeSrc(wrapper: ReturnType<typeof mount>): string | undefined {
|
|||||||
|
|
||||||
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
providerSync.mockClear()
|
||||||
vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173')
|
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('[title="chat.aiuiConnected"]').exists()).toBe(true)
|
||||||
expect(wrapper.find('.chat-loading').exists()).toBe(false)
|
expect(wrapper.find('.chat-loading').exists()).toBe(false)
|
||||||
|
|
||||||
|
expect(providerSync).toHaveBeenCalledTimes(1)
|
||||||
show.value = false
|
show.value = false
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
show.value = true
|
show.value = true
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(providerSync).toHaveBeenCalledTimes(2)
|
||||||
// No second 'ready' message is sent on reactivation — aiuiConnected must
|
// No second 'ready' message is sent on reactivation — aiuiConnected must
|
||||||
// not have been reset to false by the deactivate/reactivate cycle.
|
// not have been reset to false by the deactivate/reactivate cycle.
|
||||||
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
|
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface SignedAppEntry {
|
|||||||
version?: string
|
version?: string
|
||||||
description?: string
|
description?: string
|
||||||
category?: string
|
category?: string
|
||||||
container?: { image?: string }
|
container?: { image?: string; build?: { tag?: string } }
|
||||||
metadata?: { icon?: string; author?: string; repo?: string }
|
metadata?: { icon?: string; author?: string; repo?: string }
|
||||||
ports?: { host?: number | string; container?: number | string; auth?: string }[]
|
ports?: { host?: number | string; container?: number | string; auth?: string }[]
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
|
|||||||
description: app?.description || '',
|
description: app?.description || '',
|
||||||
icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg',
|
icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg',
|
||||||
author: app?.metadata?.author,
|
author: app?.metadata?.author,
|
||||||
dockerImage: entry.image || app?.container?.image || '',
|
dockerImage: entry.image || app?.container?.image || app?.container?.build?.tag || '',
|
||||||
repoUrl: app?.metadata?.repo,
|
repoUrl: app?.metadata?.repo,
|
||||||
category: app?.category,
|
category: app?.category,
|
||||||
source: 'signed-catalog',
|
source: 'signed-catalog',
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { installPublishingApp } from '@/services/installPublishingApp'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import type { WebsiteProject } from '@/services/publishing'
|
||||||
|
interface GatewayStatus { configured: boolean; host?: string; port?: number; domains?: string[]; certificate_mode?: string; routes: { id: string; domain: string }[]; error?: string }
|
||||||
|
const props = defineProps<{ gateway: GatewayStatus; project?: WebsiteProject | null; app?: { id: string; name: string } | null }>()
|
||||||
|
const emit = defineEmits<{ refresh: [] }>()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const appDomain = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const enrollment = ref<Record<string, unknown> | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const acknowledge = ref(false)
|
||||||
|
const testCertificates = ref(false)
|
||||||
|
const installed = computed(() => !!appStore.data?.['package-data']?.['public-web-router'])
|
||||||
|
const routeId = computed(() => props.project?.id ?? (props.app ? `app-${props.app.id}` : ''))
|
||||||
|
const connected = computed(() => props.gateway.routes.some(r => r.id === routeId.value))
|
||||||
|
async function perform(fn: () => Promise<void>) {
|
||||||
|
busy.value = true; error.value = ''; message.value = ''
|
||||||
|
try { await fn() } catch (e) { error.value = e instanceof Error ? e.message : 'Gateway action failed' }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
async function readEnrollment(event: Event) {
|
||||||
|
enrollment.value = null; acknowledge.value = false; error.value = ''
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
if (file.size > 65536) throw new Error('Enrollment file is too large')
|
||||||
|
const data = JSON.parse(await file.text())
|
||||||
|
if (!data || typeof data !== 'object' || typeof data.host !== 'string' || !Array.isArray(data.domains) || !data.domains.every((d: unknown) => typeof d === 'string')) throw new Error('Choose the enrollment file supplied by your gateway operator')
|
||||||
|
enrollment.value = data
|
||||||
|
} catch { error.value = 'Choose a valid gateway enrollment JSON file. Its contents stay in this setup session until you connect.' }
|
||||||
|
}
|
||||||
|
async function configure() {
|
||||||
|
if (!enrollment.value || !acknowledge.value) return
|
||||||
|
await perform(async () => {
|
||||||
|
await rpcClient.call({ method: 'publishing.gateway-configure', params: { enrollment: enrollment.value, certificate_mode: testCertificates.value ? 'test' : 'public', acknowledge: true }, maxRetries: 0 })
|
||||||
|
enrollment.value = null; acknowledge.value = false
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
message.value = 'Gateway saved privately. Connect each published website when you are ready.'
|
||||||
|
emit('refresh')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function route(enabled: boolean) {
|
||||||
|
if (!props.project && !props.app) return
|
||||||
|
await perform(async () => {
|
||||||
|
if (props.app) await rpcClient.call({ method: 'publishing.gateway-app-route', params: { app_id: props.app.id, domain: appDomain.value.trim(), enabled }, maxRetries: 0 })
|
||||||
|
else await rpcClient.call({ method: 'publishing.gateway-route', params: { id: props.project!.id, enabled }, maxRetries: 0 })
|
||||||
|
message.value = enabled ? 'Route requested. Check HTTPS and guest access before sharing the address.' : 'Gateway route removed. Other connections remain available.'
|
||||||
|
emit('refresh')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function disconnect() {
|
||||||
|
await perform(async () => {
|
||||||
|
await rpcClient.call({ method: 'publishing.gateway-disconnect', maxRetries: 0 })
|
||||||
|
message.value = 'Gateway disconnected. Its local enrollment was removed; website drafts and certificates are retained.'
|
||||||
|
emit('refresh')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function install() {
|
||||||
|
await perform(async () => { await installPublishingApp('public-web-router'); message.value = 'Router installation requested through the app catalogue.' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="space-y-4" aria-label="Your public gateway">
|
||||||
|
<h3 class="font-medium">Keep HTTPS on this node</h3>
|
||||||
|
<p class="text-sm text-white/60">Connect to a gateway you control using the open-source Public Web Router. The gateway forwards encrypted traffic; website certificates and keys stay here. You can reuse this connection for websites and supported apps.</p>
|
||||||
|
<p v-if="gateway.error" role="alert" class="text-sm text-amber-200">{{ gateway.error }}</p>
|
||||||
|
<button v-if="!installed" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="install">Install Public Web Router</button>
|
||||||
|
<template v-if="gateway.configured">
|
||||||
|
<p class="text-sm break-all">Gateway: {{ gateway.host }} · control port {{ gateway.port }}</p>
|
||||||
|
<p class="text-sm break-all">Assigned domains: {{ gateway.domains?.join(', ') }}</p>
|
||||||
|
<p v-if="gateway.certificate_mode === 'test'" class="text-sm text-amber-200">Test certificates only. Ordinary browsers will not trust this route.</p>
|
||||||
|
<p v-else class="text-sm text-white/60">Point your domain at the gateway’s public IP. It must forward public port 443 to this node so a certificate can be issued.</p>
|
||||||
|
<div v-if="project" class="flex flex-wrap gap-3">
|
||||||
|
<button v-if="!connected" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !installed || !project.fips_publication || !project.domain || !gateway.domains?.includes(project.domain.hostname)" @click="route(true)">Connect this published website</button>
|
||||||
|
<button v-else class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="route(false)">Disconnect this website from gateway</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="app" class="space-y-3">
|
||||||
|
<p class="text-sm">Connect {{ app.name }} through its existing app gate. Guests still need app-only access; this does not grant dashboard access.</p>
|
||||||
|
<label v-if="!connected" class="block text-sm">Assigned domain for this app<input v-model="appDomain" maxlength="253" autocomplete="off" class="w-full mt-2 rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm" placeholder="app.yourdomain.com" :disabled="busy" /></label>
|
||||||
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || (!connected && (!installed || !gateway.domains?.includes(appDomain.trim())))" @click="route(!connected)">{{ connected ? 'Disconnect this app from gateway' : 'Connect this app through its gate' }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="project && !project.fips_publication" class="text-sm text-white/60">Publish the website upstream in Review and publish, then return here to connect it.</p>
|
||||||
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="disconnect">Disconnect all gateway routes</button>
|
||||||
|
</template>
|
||||||
|
<details class="space-y-4">
|
||||||
|
<summary class="cursor-pointer">{{ gateway.configured ? 'Replace gateway enrollment' : 'Connect your gateway' }}</summary>
|
||||||
|
<p class="text-sm text-white/60">Choose the private enrollment file supplied by your gateway operator. It contains connection credentials: keep it off Nostr and public file storage.</p>
|
||||||
|
<label class="block text-sm">Gateway enrollment file<input ref="fileInput" type="file" accept="application/json,.json" class="block w-full mt-2 text-sm" :disabled="busy" @change="readEnrollment" /></label>
|
||||||
|
<template v-if="enrollment">
|
||||||
|
<p class="text-sm break-all">Connect to {{ enrollment.host }} · assigned domains: {{ (enrollment.domains as string[]).join(', ') }}</p>
|
||||||
|
<label class="flex items-start gap-3 text-sm"><input v-model="acknowledge" type="checkbox" class="mt-1" :disabled="busy" /><span>I trust this gateway operator. Replacing enrollment disconnects existing gateway routes until I connect them again.</span></label>
|
||||||
|
<details><summary class="cursor-pointer text-sm">Testing options</summary><label class="flex items-start gap-3 mt-3 text-sm"><input v-model="testCertificates" type="checkbox" class="mt-1" :disabled="busy" /><span>Use private test certificates for isolated-port testing.</span></label></details>
|
||||||
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !acknowledge" @click="configure">Save private gateway connection</button>
|
||||||
|
</template>
|
||||||
|
</details>
|
||||||
|
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
|
||||||
|
<p v-if="message" role="status" class="text-sm text-white/70">{{ message }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -3,9 +3,11 @@ import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from
|
|||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { installPublishingApp } from '@/services/installPublishingApp'
|
||||||
import { pendingWebsiteHtml } from '@/services/websiteImport'
|
import { pendingWebsiteHtml } from '@/services/websiteImport'
|
||||||
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
|
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
|
||||||
import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProject } 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 { nsiteIdentities, prepareNsite, publishNsite, retryNsite, requestNsiteDeletion, namedNsiteUrl, relayAddresses, storeLocalWebsite } from '@/services/nsitePublishing'
|
||||||
import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
|
import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ const acknowledgeTor = ref(false)
|
|||||||
const identities = ref<NsiteIdentity[]>([])
|
const identities = ref<NsiteIdentity[]>([])
|
||||||
const identityId = ref('')
|
const identityId = ref('')
|
||||||
const blossom = 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 relays = ref('')
|
||||||
const gateway = ref('')
|
const gateway = ref('')
|
||||||
const nsiteUrl = ref('')
|
const nsiteUrl = ref('')
|
||||||
@@ -229,7 +233,7 @@ async function verifyHttps() {
|
|||||||
}
|
}
|
||||||
async function installBlossom() {
|
async function installBlossom() {
|
||||||
await perform(async () => {
|
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.'
|
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.'
|
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() {
|
async function reviewNsite() {
|
||||||
await perform(async () => {
|
await perform(async () => {
|
||||||
if (!status.value || !current.value) return
|
if (!status.value || !current.value) return
|
||||||
const identity = identities.value.find(i => i.id === identityId.value)
|
const identity = identities.value.find(i => i.id === identityId.value)
|
||||||
if (!identity) throw new Error('Choose a profile identity first')
|
if (!identity) throw new Error('Choose a profile identity first')
|
||||||
const targets = relayAddresses(relays.value)
|
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 }
|
nsiteReview.value = { projectId: projectId.value, version: status.value.state.version, identity: { ...identity }, relays: [...targets], prepared }
|
||||||
acknowledgeNostr.value = false; acknowledgeUpload.value = false
|
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.'
|
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() {
|
async function showNsiteAddress() {
|
||||||
await perform(async () => {
|
await perform(async () => {
|
||||||
if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value)
|
if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value)
|
||||||
@@ -339,6 +351,7 @@ onMounted(refresh)
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ 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.' }}</p>
|
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ 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.' }}</p>
|
||||||
|
<PublicWebGateway v-if="!websiteMode && selected.includes('public-web') && status.gateway" :gateway="status.gateway" @refresh="refresh" />
|
||||||
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
|
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -446,6 +459,7 @@ onMounted(refresh)
|
|||||||
<label class="block">Website hostname<input v-model="hostname" class="field mt-2" placeholder="www.yourdomain.com" /></label>
|
<label class="block">Website hostname<input v-model="hostname" class="field mt-2" placeholder="www.yourdomain.com" /></label>
|
||||||
<label class="block">Gateway hostname or public IP<input v-model="destination" class="field mt-2" placeholder="Use the destination supplied by your gateway" /></label>
|
<label class="block">Gateway hostname or public IP<input v-model="destination" class="field mt-2" placeholder="Use the destination supplied by your gateway" /></label>
|
||||||
<p class="text-sm text-white/60">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.</p>
|
<p class="text-sm text-white/60">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.</p>
|
||||||
|
<PublicWebGateway v-if="selected.includes('public-web') && status.gateway" :gateway="status.gateway" :project="current" @refresh="refresh" />
|
||||||
<div v-if="selected.includes('public-web') && current?.fips_publication && status.fips_address" class="space-y-2 rounded-lg border border-white/10 p-4">
|
<div v-if="selected.includes('public-web') && current?.fips_publication && status.fips_address" class="space-y-2 rounded-lg border border-white/10 p-4">
|
||||||
<h3 class="font-medium">Use an existing reverse proxy</h3>
|
<h3 class="font-medium">Use an existing reverse proxy</h3>
|
||||||
<p class="text-sm text-white/60">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:</p>
|
<p class="text-sm text-white/60">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:</p>
|
||||||
@@ -479,6 +493,7 @@ onMounted(refresh)
|
|||||||
<template v-if="shareableApps.length">
|
<template v-if="shareableApps.length">
|
||||||
<SearchableAppSelect v-model="guestApp" :options="shareableApps" :disabled="busy" />
|
<SearchableAppSelect v-model="guestApp" :options="shareableApps" :disabled="busy" />
|
||||||
<template v-if="guestTarget">
|
<template v-if="guestTarget">
|
||||||
|
<PublicWebGateway v-if="selected.includes('public-web') && status.gateway?.configured" :gateway="status.gateway" :app="guestTarget" @refresh="refresh" />
|
||||||
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
|
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
|
||||||
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
|
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
|
||||||
<div v-if="guestTarget" class="space-y-2 text-sm">
|
<div v-if="guestTarget" class="space-y-2 text-sm">
|
||||||
@@ -545,28 +560,35 @@ onMounted(refresh)
|
|||||||
</section>
|
</section>
|
||||||
<section v-if="websiteMode && current && (selected.includes('nostr') || current.nsite_receipt)" class="space-y-3">
|
<section v-if="websiteMode && current && (selected.includes('nostr') || current.nsite_receipt)" class="space-y-3">
|
||||||
<h2 class="text-lg font-semibold">Publish a named nsite</h2>
|
<h2 class="text-lg font-semibold">Publish a named nsite</h2>
|
||||||
<p class="text-sm text-white/60">Upload a public static copy to a Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p>
|
<p class="text-sm text-white/60">Serve a reviewed static copy from your node or another Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p>
|
||||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="loadIdentities">Load signing identities</button>
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="loadIdentities">Load signing identities</button>
|
||||||
<label class="block">Profile identity<select v-model="identityId" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
|
<label class="block">Profile identity<select v-model="identityId" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
|
||||||
<p class="text-xs text-white/50">The node's operational identity is excluded. Your private key stays in the existing signer.</p>
|
<p class="text-xs text-white/50">The node's operational identity is excluded. Your private key stays in the existing signer.</p>
|
||||||
<label class="block">Blossom server<input v-model="blossom" class="field mt-2" placeholder="https://your-blossom-server.example" /></label>
|
<label class="flex items-start gap-3"><input v-model="localNsite" type="checkbox" class="mt-1" /><span>Serve the reviewed file from this node’s Blossom storage</span></label>
|
||||||
|
<p v-if="localNsite" class="text-sm text-white/60">Publish and verify this website’s public HTTPS connection first. Only the reviewed file is shared; private Blossom files stay protected. Your node must stay online for nsite gateways to fetch it.</p>
|
||||||
|
<p v-if="localNsite" class="text-sm break-all">File address: {{ nsiteServer || 'Set this website’s domain first' }}</p>
|
||||||
|
<label v-else class="block">External Blossom server<input v-model="blossom" class="field mt-2" placeholder="https://your-blossom-server.example" /></label>
|
||||||
<label class="block">Relays<textarea v-model="relays" rows="3" class="field mt-2" placeholder="wss://your-relay.example" /></label>
|
<label class="block">Relays<textarea v-model="relays" rows="3" class="field mt-2" placeholder="wss://your-relay.example" /></label>
|
||||||
<p class="text-sm text-white/60">Choose servers you trust or host your own. The Blossom server must allow browser uploads and reads. Paid storage requires a separate arrangement; this flow never pays automatically.</p>
|
<p class="text-sm text-white/60">Choose servers you trust or host your own. The Blossom server must allow browser uploads and reads. Paid storage requires a separate arrangement; this flow never pays automatically.</p>
|
||||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!identityId || !blossom || !current.draft" @click="reviewNsite">Prepare publication review — stays on this node</button>
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!identityId || !nsiteServer || !current.draft" @click="reviewNsite">Prepare publication review — stays on this node</button>
|
||||||
<div v-if="nsiteReview" class="rounded-xl border border-amber-300/30 p-4 space-y-3">
|
<div v-if="nsiteReview" class="rounded-xl border border-amber-300/30 p-4 space-y-3">
|
||||||
<h3 class="font-semibold">Review exactly what will leave your node</h3>
|
<h3 class="font-semibold">Review exactly what will leave your node</h3>
|
||||||
<p class="text-sm">Signing identity: {{ nsiteReview.identity.name }} <span class="font-mono break-all">{{ nsiteReview.identity.nostr_pubkey }}</span></p>
|
<p class="text-sm">Signing identity: {{ nsiteReview.identity.name }} <span class="font-mono break-all">{{ nsiteReview.identity.nostr_pubkey }}</span></p>
|
||||||
<p class="text-sm break-all">Upload destination: {{ nsiteReview.prepared.server }}</p>
|
<p class="text-sm break-all">{{ nsiteReview.prepared.local ? 'Public file origin' : 'Upload destination' }}: {{ nsiteReview.prepared.server }}</p>
|
||||||
<p class="text-sm break-all">Announcement relays: {{ nsiteReview.relays.join(', ') }}</p>
|
<p class="text-sm break-all">Announcement relays: {{ nsiteReview.relays.join(', ') }}</p>
|
||||||
<p class="text-xs font-mono break-all">Content SHA-256: {{ nsiteReview.prepared.sha256 }}</p>
|
<p class="text-xs font-mono break-all">Content SHA-256: {{ nsiteReview.prepared.sha256 }}</p>
|
||||||
<iframe :srcdoc="websitePreview(nsiteReview.prepared.html)" sandbox="" referrerpolicy="no-referrer" title="Exact nsite publication preview" class="w-full h-64 rounded-xl bg-white" />
|
<iframe :srcdoc="websitePreview(nsiteReview.prepared.html)" sandbox="" referrerpolicy="no-referrer" title="Exact nsite publication preview" class="w-full h-64 rounded-xl bg-white" />
|
||||||
<details><summary>Inspect the exact uploaded HTML</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ nsiteReview.prepared.html }}</pre></details>
|
<details><summary>Inspect the exact uploaded HTML</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ nsiteReview.prepared.html }}</pre></details>
|
||||||
<details><summary>Inspect the public manifest</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(nsiteReview.prepared.manifest, null, 2) }}</pre></details>
|
<details><summary>Inspect the public manifest</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(nsiteReview.prepared.manifest, null, 2) }}</pre></details>
|
||||||
<p class="text-sm text-amber-200">Check for personal information, credentials, private addresses and anything you do not want copied. Sanitising HTML does not remove sensitive text. Public copies cannot be guaranteed erased.</p>
|
<p class="text-sm text-amber-200">Check for personal information, credentials, private addresses and anything you do not want copied. Sanitising HTML does not remove sensitive text. Public copies cannot be guaranteed erased.</p>
|
||||||
<label class="flex items-start gap-3"><input v-model="acknowledgeUpload" type="checkbox" class="mt-1" /><span>I approve sending these exact website bytes to this Blossom server.</span></label>
|
<label class="flex items-start gap-3"><input v-model="acknowledgeUpload" type="checkbox" class="mt-1" /><span>{{ localNsite ? 'I approve making these exact website bytes publicly readable from my node.' : 'I approve sending these exact website bytes to this Blossom server.' }}</span></label>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-start gap-3"><input v-model="acknowledgeNostr" type="checkbox" class="mt-1" /><span>I approve sending the displayed manifest or removal request to the listed relays when I press its action button. It identifies the author, and copies may remain after a deletion request.</span></label>
|
<label class="flex items-start gap-3"><input v-model="acknowledgeNostr" type="checkbox" class="mt-1" /><span>I approve sending the displayed manifest or removal request to the listed relays when I press its action button. It identifies the author, and copies may remain after a deletion request.</span></label>
|
||||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeNostr || !acknowledgeUpload || !nsiteReview || !current.routes.includes('nostr') || !current.draft || !identityId || !blossom" @click="handleNsite('publish')">Upload and publish saved website</button>
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!acknowledgeNostr || !acknowledgeUpload || !nsiteReview || !current.routes.includes('nostr') || !current.draft || !identityId || !nsiteServer" @click="handleNsite('publish')">{{ localNsite ? 'Share file and publish manifest' : 'Upload and publish saved website' }}</button>
|
||||||
|
<div v-if="current.fips_publication?.nsite_asset" class="space-y-2">
|
||||||
|
<p class="text-sm break-all">Local nsite file is publicly readable: {{ current.fips_publication.nsite_asset.receipt.sha256 }}</p>
|
||||||
|
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="unshareNsiteAsset">Stop sharing the local nsite file</button>
|
||||||
|
</div>
|
||||||
<template v-if="current.nsite_receipt">
|
<template v-if="current.nsite_receipt">
|
||||||
<p class="text-sm">{{ current.nsite_receipt.deletion_requested ? 'Deletion requested; copies may remain.' : current.nsite_receipt.accepted_relays.length ? 'Relay delivery recorded; gateway access not verified.' : 'Signed manifest retained; relay delivery is pending.' }}</p>
|
<p class="text-sm">{{ current.nsite_receipt.deletion_requested ? 'Deletion requested; copies may remain.' : current.nsite_receipt.accepted_relays.length ? 'Relay delivery recorded; gateway access not verified.' : 'Signed manifest retained; relay delivery is pending.' }}</p>
|
||||||
<details><summary>Review retained manifest for retry or removal</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(current.nsite_receipt.event, null, 2) }}</pre></details>
|
<details><summary>Review retained manifest for retry or removal</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(current.nsite_receipt.event, null, 2) }}</pre></details>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
vi.mock('@/services/installPublishingApp', () => ({ installPublishingApp: vi.fn() }))
|
||||||
|
import { installPublishingApp } from '@/services/installPublishingApp'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
const app = vi.hoisted(() => ({ installPackage: vi.fn(), data: { 'package-data': {} as Record<string, unknown> } }))
|
||||||
|
vi.mock('@/stores/app', () => ({ useAppStore: () => app }))
|
||||||
|
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import PublicWebGateway from '../PublicWebGateway.vue'
|
||||||
|
const gateway = { configured: false, routes: [] }
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); app.data['package-data'] = {}; vi.mocked(rpcClient.call).mockResolvedValue({}) })
|
||||||
|
describe('private gateway walkthrough', () => {
|
||||||
|
it('imports credentials privately and requires deliberate enrollment consent', async () => {
|
||||||
|
const wrapper = mount(PublicWebGateway, { props: { gateway } })
|
||||||
|
const enrollment = { host: 'gateway.example', domains: ['site.example'], enrollment_token: 'synthetic-private-value' }
|
||||||
|
const input = wrapper.get('input[type=file]')
|
||||||
|
Object.defineProperty(input.element, 'files', { value: [{ size: 100, text: async () => JSON.stringify(enrollment) }] })
|
||||||
|
await input.trigger('change'); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('gateway.example')
|
||||||
|
expect(wrapper.text()).not.toContain('synthetic-private-value')
|
||||||
|
const save = wrapper.findAll('button').find(b => b.text() === 'Save private gateway connection')!
|
||||||
|
expect(save.attributes('disabled')).toBeDefined()
|
||||||
|
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||||
|
await wrapper.findAll('input[type=checkbox]')[0]!.setValue(true)
|
||||||
|
await save.trigger('click'); await flushPromises()
|
||||||
|
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'publishing.gateway-configure', params: { enrollment, certificate_mode: 'public', acknowledge: true }, maxRetries: 0 }))
|
||||||
|
expect(wrapper.findAll('button').some(b => b.text() === 'Save private gateway connection')).toBe(false)
|
||||||
|
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('uses the normal app installer without enabling any route', async () => {
|
||||||
|
const wrapper = mount(PublicWebGateway, { props: { gateway } })
|
||||||
|
await wrapper.findAll('button').find(b => b.text() === 'Install Public Web Router')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(installPublishingApp).toHaveBeenCalledWith('public-web-router')
|
||||||
|
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('requests a selected app route without supplying a raw upstream or granting guest credentials', async () => {
|
||||||
|
app.data['package-data']['public-web-router'] = { state: 'installed' }
|
||||||
|
const wrapper = mount(PublicWebGateway, { props: { gateway: { ...gateway, configured: true, domains: ['app.example'] }, app: { id: 'photoprism', name: 'PhotoPrism' } } })
|
||||||
|
await wrapper.get('input[maxlength="253"]').setValue('app.example')
|
||||||
|
await wrapper.findAll('button').find(b => b.text() === 'Connect this app through its gate')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(rpcClient.call).toHaveBeenCalledTimes(1)
|
||||||
|
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.gateway-app-route', params: { app_id: 'photoprism', domain: 'app.example', enabled: true }, maxRetries: 0 })
|
||||||
|
})
|
||||||
|
it('reuses saved enrollment and clearly labels test certificates', () => {
|
||||||
|
app.data['package-data']['public-web-router'] = { status: 'running' }
|
||||||
|
const wrapper = mount(PublicWebGateway, { props: { gateway: { ...gateway, configured: true, host: 'gateway.example', domains: ['site.example'], certificate_mode: 'test' } } })
|
||||||
|
expect(wrapper.text()).toContain('Ordinary browsers will not trust this route')
|
||||||
|
expect(wrapper.text()).not.toContain('Install Public Web Router')
|
||||||
|
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
vi.mock('@/services/installPublishingApp', () => ({ installPublishingApp: vi.fn() }))
|
||||||
|
import { installPublishingApp } from '@/services/installPublishingApp'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn(), verifyHttps: vi.fn() }))
|
const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn(), verifyHttps: vi.fn() }))
|
||||||
@@ -32,6 +34,20 @@ describe('publishing setup', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('revokes a local nsite asset through the publishing action without announcing anything', async () => {
|
||||||
|
page.name = 'publish-website'
|
||||||
|
appStore.data['package-data'].blossom = { state: 'installed' }
|
||||||
|
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<h1>Public</h1>', routes: ['nostr', 'public-web'], domain: { hostname: 'site.example' }, revisions: [], fips_publication: { html: '<h1>Public</h1>', port: 32000, nsite_asset: { html: '<h1>Reviewed</h1>', receipt: { sha256: 'a'.repeat(64), size: 17 } } } } } }, apps: [], publication_enabled: true })
|
||||||
|
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||||
|
await wrapper.get('[aria-controls="setup-step-publish"]').trigger('click')
|
||||||
|
await wrapper.findAll('button').find(b => b.text() === 'Stop sharing the local nsite file')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.update).toHaveBeenCalledWith(2, { action: 'unshare-nsite-asset', id: 'site' })
|
||||||
|
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'identity.nostr-sign' }))
|
||||||
|
expect(wrapper.text()).toContain('Local nsite file sharing stopped')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps unpublish controls available when a published route is deselected', async () => {
|
it('keeps unpublish controls available when a published route is deselected', async () => {
|
||||||
page.name = 'publish-website'
|
page.name = 'publish-website'
|
||||||
appStore.data['package-data'].blossom = { state: 'installed' }
|
appStore.data['package-data'].blossom = { state: 'installed' }
|
||||||
@@ -114,7 +130,7 @@ describe('publishing setup', () => {
|
|||||||
const wrapper = mount(PublishingSetup); await flushPromises()
|
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||||
expect(wrapper.get('[data-testid="blossom-setup"]').text()).toContain('Install Blossom')
|
expect(wrapper.get('[data-testid="blossom-setup"]').text()).toContain('Install Blossom')
|
||||||
await wrapper.findAll('button').find(b => b.text() === 'Install Blossom')!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button').find(b => b.text() === 'Install Blossom')!.trigger('click'); await flushPromises()
|
||||||
expect(appStore.installPackage).toHaveBeenCalledWith('blossom', '', 'latest')
|
expect(installPublishingApp).toHaveBeenCalledWith('blossom')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
appStore.data['package-data'].blossom = { state: 'installed' }
|
appStore.data['package-data'].blossom = { state: 'installed' }
|
||||||
const installed = mount(PublishingSetup); await flushPromises()
|
const installed = mount(PublishingSetup); await flushPromises()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Owned public-web gateway admission
|
||||||
|
|
||||||
|
`policy.py` implements the frp server-plugin contract for an operator-owned
|
||||||
|
HTTPS passthrough gateway. The node-side app installs from the trusted catalogue; an operator provisions
|
||||||
|
the gateway separately and supplies the private enrollment file to Setup.
|
||||||
|
|
||||||
|
Run it bound to loopback alongside frps. Configure **all** operations `Login`,
|
||||||
|
`NewProxy`, `Ping`, `NewWorkConn`, and `NewUserConn`; omitting them weakens
|
||||||
|
revocation. Require TLS on frps and pin the gateway CA on every client. Retain
|
||||||
|
frp token authentication with `HeartBeats` and `NewWorkConns` additional scopes.
|
||||||
|
Do not publish its enrollment file, client config, or transport credentials.
|
||||||
|
|
||||||
|
The 0600 enrollment JSON maps a node name to `enabled`, the SHA-256 of a random
|
||||||
|
32-byte-or-longer `enrollment_token`, and exact lowercase `domains`. Set frpc
|
||||||
|
`user` to the node name and `metadatas.enrollment_token` to that token. Proxies
|
||||||
|
must be HTTPS with one assigned domain. TCP/UDP, wildcard subdomains, shared
|
||||||
|
proxy groups, and gateway-side content rewrites are refused. The node terminates
|
||||||
|
website TLS and owns its website keys. The gateway still observes SNI and traffic
|
||||||
|
metadata; passthrough is not an anonymity service.
|
||||||
|
|
||||||
|
Replace the policy file atomically to enroll, disable or rotate a node. Every
|
||||||
|
request reloads it; missing, malformed or nonprivate files fail closed. Disabling
|
||||||
|
an enrollment denies new connections and subsequent heartbeats. Already forwarded
|
||||||
|
bytes cannot be recalled; do not promise immediate termination of every stream.
|
||||||
|
The process never logs tokens or request bodies. Run behind a dedicated service
|
||||||
|
account with filesystem and process limits in the final deployment.
|
||||||
|
|
||||||
|
Tests: `python3 -m unittest discover -s tests/public-web-gateway -v`.
|
||||||
|
|
||||||
|
Contract references:
|
||||||
|
- https://gofrp.org/en/docs/features/common/server-plugin/
|
||||||
|
- https://gofrp.org/en/docs/features/common/network/network-tls/
|
||||||
|
- https://github.com/fatedier/frp/blob/v0.71.0/pkg/auth/token.go
|
||||||
|
|
||||||
|
The isolated Yaya qualification uses 17400 and14443, preserving existing public
|
||||||
|
sites. Its private certificate verifies TLS passthrough and ownership, not public
|
||||||
|
ACME issuance. Production ACME requires an appropriate public 443 route.
|
||||||
|
|
||||||
|
## Enroll a node
|
||||||
|
|
||||||
|
On the gateway, use the existing private frps JSON configuration and public CA
|
||||||
|
certificate. Keep the admission listener on loopback. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 enroll.py --frps-config /etc/archy-gateway/frps.json \
|
||||||
|
--policy /etc/archy-gateway/enrollments.json \
|
||||||
|
--ca /etc/archy-gateway/gateway.crt \
|
||||||
|
--host gateway.example.com --tls-server-name gateway.example.com \
|
||||||
|
--name my-node --domain www.example.com \
|
||||||
|
--output /secure/path/my-node-enrollment.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat `--domain` for separately assigned website/app names. Existing enrollments
|
||||||
|
require explicit `--rotate`; use a new output filename. Transfer the file privately
|
||||||
|
to the node owner. In Setup → Allow external connections → Public web, install
|
||||||
|
Public Web Router, choose the file, review the gateway/domains, and confirm.
|
||||||
|
Then connect a published website or a guest-enabled app to an assigned domain.
|
||||||
|
Neither importing the file nor connecting an app grants a guest token.
|
||||||
|
|
||||||
|
Set each public DNS A/AAAA record to the gateway's reachable public address.
|
||||||
|
The gateway needs its frps control port and a dedicated TCP 443 passthrough
|
||||||
|
listener. Public certificate issuance cannot be tested by pointing DNS at a
|
||||||
|
private LAN address or by using our isolated 14443 test port. If 443 already
|
||||||
|
serves other sites, retain that proxy and use a separately provisioned IP/path;
|
||||||
|
do not replace the existing listener blindly. Run frps and the policy as
|
||||||
|
persistent supervised services before production use. The Yaya qualification
|
||||||
|
services are intentionally isolated test services, not a production deployment.
|
||||||
|
|
||||||
|
For revocation, atomically replace the private policy with the node's `enabled`
|
||||||
|
set to false. For local disconnect, use Setup; it removes enrollment/routes while
|
||||||
|
preserving local certificates and drafts. Removing a route does not erase copies
|
||||||
|
of content that visitors previously downloaded.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a private node enrollment for an existing operator-owned frps gateway.
|
||||||
|
|
||||||
|
Reads frps token configuration without printing credentials. The admission policy
|
||||||
|
is replaced atomically; an existing node requires --rotate to replace its token.
|
||||||
|
The exported enrollment is for Setup's file picker, never Nostr or public storage.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
import fcntl
|
||||||
|
import tempfile
|
||||||
|
from policy import DOMAIN, NAME
|
||||||
|
|
||||||
|
|
||||||
|
def atomic(path, value):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, stage = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
os.fchmod(f.fileno(), 0o600)
|
||||||
|
json.dump(value, f); f.flush(); os.fsync(f.fileno())
|
||||||
|
os.replace(stage, path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try: os.fsync(directory)
|
||||||
|
finally: os.close(directory)
|
||||||
|
finally:
|
||||||
|
Path(stage).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.umask(0o077)
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument('--frps-config', type=Path, required=True)
|
||||||
|
p.add_argument('--policy', type=Path, required=True)
|
||||||
|
p.add_argument('--ca', type=Path, required=True)
|
||||||
|
p.add_argument('--host', required=True)
|
||||||
|
p.add_argument('--tls-server-name', required=True)
|
||||||
|
p.add_argument('--name', required=True)
|
||||||
|
p.add_argument('--domain', action='append', required=True)
|
||||||
|
p.add_argument('--output', type=Path, required=True)
|
||||||
|
p.add_argument('--rotate', action='store_true')
|
||||||
|
args = p.parse_args()
|
||||||
|
if not NAME.fullmatch(args.name) or len(args.domain) > 32 or any(not DOMAIN.fullmatch(d) for d in args.domain):
|
||||||
|
p.error('Use a lowercase node name and exact lowercase domains')
|
||||||
|
for host in (args.host, args.tls_server_name):
|
||||||
|
try: ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
if not DOMAIN.fullmatch(host): p.error('Invalid gateway host or TLS name')
|
||||||
|
if args.output.exists(): p.error('Enrollment output already exists; choose a new private file')
|
||||||
|
if args.frps_config.stat().st_mode & 0o077: p.error('frps configuration must be private (0600)')
|
||||||
|
server = json.loads(args.frps_config.read_text())
|
||||||
|
auth = server.get('auth', {})
|
||||||
|
if auth.get('method') != 'token' or not isinstance(auth.get('token'), str) or len(auth['token']) < 32:
|
||||||
|
p.error('Gateway requires a strong frps transport token')
|
||||||
|
if server.get('transport', {}).get('tls', {}).get('force') is not True:
|
||||||
|
p.error('Gateway must require TLS')
|
||||||
|
required = {'Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn'}
|
||||||
|
if not any(required.issubset(plugin.get('ops', [])) for plugin in server.get('httpPlugins', [])):
|
||||||
|
p.error('Configure the admission plugin for every required operation first')
|
||||||
|
args.policy.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
policy_lock = args.policy.with_suffix(args.policy.suffix + ".lock").open("a")
|
||||||
|
os.chmod(policy_lock.name, 0o600)
|
||||||
|
fcntl.flock(policy_lock, fcntl.LOCK_EX)
|
||||||
|
existing = {}
|
||||||
|
if args.policy.exists():
|
||||||
|
if args.policy.stat().st_mode & 0o077: p.error('Admission policy must be private (0600)')
|
||||||
|
existing = json.loads(args.policy.read_text())
|
||||||
|
if not isinstance(existing, dict): p.error('Invalid admission policy')
|
||||||
|
if args.name in existing and not args.rotate: p.error('Node already enrolled; use --rotate explicitly')
|
||||||
|
for name, entry in existing.items():
|
||||||
|
if name != args.name and set(entry.get('domains', [])) & set(args.domain):
|
||||||
|
p.error('A domain is already assigned to another enrollment')
|
||||||
|
ca = args.ca.read_text()
|
||||||
|
if len(ca) > 16384 or not ca.startswith('-----BEGIN CERTIFICATE-----') or 'PRIVATE KEY' in ca:
|
||||||
|
p.error('Supply only the public gateway CA certificate')
|
||||||
|
try: ssl.create_default_context().load_verify_locations(cadata=ca)
|
||||||
|
except ssl.SSLError: p.error("Invalid gateway CA certificate")
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
enrollment = {'host': args.host, 'port': server['bindPort'], 'node_id': args.name,
|
||||||
|
'tls_server_name': args.tls_server_name, 'transport_token': auth['token'],
|
||||||
|
'enrollment_token': token, 'ca_pem': ca, 'domains': args.domain}
|
||||||
|
# Save the recoverable private output before changing admission. A failed
|
||||||
|
# policy write leaves a file that is not yet enrolled, never a lost token.
|
||||||
|
atomic(args.output, enrollment)
|
||||||
|
existing[args.name] = {'enabled': True, 'token_sha256': hashlib.sha256(token.encode()).hexdigest(), 'domains': args.domain}
|
||||||
|
atomic(args.policy, existing)
|
||||||
|
print('Private enrollment written. Import it in Setup; do not publish it.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local frps admission plugin. Reload enrollments for every request.
|
||||||
|
|
||||||
|
The enrollment file is private operator configuration, never a public catalogue.
|
||||||
|
Transport must require TLS; the node pins the gateway CA. No bearer value is
|
||||||
|
logged. An unavailable/malformed policy rejects requests, including heartbeats.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
OPS = {'Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn'}
|
||||||
|
NAME = re.compile(r'[a-z0-9][a-z0-9-]{0,47}\Z')
|
||||||
|
DOMAIN = re.compile(r'(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\Z')
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(op, content, enrollments):
|
||||||
|
if op not in OPS or not isinstance(content, dict):
|
||||||
|
return False
|
||||||
|
user = content if op == 'Login' else content.get('user')
|
||||||
|
if not isinstance(user, dict):
|
||||||
|
return False
|
||||||
|
name = user.get('user')
|
||||||
|
if not isinstance(name, str) or not NAME.fullmatch(name):
|
||||||
|
return False
|
||||||
|
entry = enrollments.get(name)
|
||||||
|
if not isinstance(entry, dict) or entry.get('enabled') is not True:
|
||||||
|
return False
|
||||||
|
metas = user.get('metas', {})
|
||||||
|
token = metas.get('enrollment_token') if isinstance(metas, dict) else None
|
||||||
|
expected = entry.get('token_sha256')
|
||||||
|
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
||||||
|
return False
|
||||||
|
if not isinstance(expected, str) or not re.fullmatch('[a-f0-9]{64}', expected):
|
||||||
|
return False
|
||||||
|
if not hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), expected):
|
||||||
|
return False
|
||||||
|
domains = entry.get('domains')
|
||||||
|
if not isinstance(domains, list) or not domains or len(domains) > 32:
|
||||||
|
return False
|
||||||
|
if any(not isinstance(d, str) or not DOMAIN.fullmatch(d) for d in domains):
|
||||||
|
return False
|
||||||
|
if op in {'NewProxy', 'NewUserConn'}:
|
||||||
|
# frpc prefixes proxy names with its configured user.
|
||||||
|
proxy = content.get('proxy_name', '')
|
||||||
|
if not isinstance(proxy, str) or not proxy.startswith(name + '.'):
|
||||||
|
return False
|
||||||
|
if not NAME.fullmatch(proxy[len(name) + 1:]):
|
||||||
|
return False
|
||||||
|
if content.get('proxy_type') != 'https':
|
||||||
|
return False
|
||||||
|
if op == 'NewProxy':
|
||||||
|
requested = content.get('custom_domains')
|
||||||
|
if not isinstance(requested, list) or len(requested) != 1 or requested[0] not in domains:
|
||||||
|
return False
|
||||||
|
# No arbitrary TCP ports, wildcard subdomains, shared groups or routing
|
||||||
|
# rewrites. TLS terminates on the node; gateway only forwards SNI.
|
||||||
|
if any(content.get(k) for k in ('remote_port', 'subdomain', 'group', 'group_key', 'locations', 'host_header_rewrite', 'headers', 'http_user', 'http_pwd', 'multiplexer')):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
accepted = False
|
||||||
|
try:
|
||||||
|
self.connection.settimeout(3)
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
query = parse_qs(url.query, strict_parsing=True)
|
||||||
|
size = int(self.headers.get('Content-Length', '0'))
|
||||||
|
if url.path != '/handler' or query.get('version') != ['0.1.0'] or len(query.get('op', [])) != 1 or not 0 < size <= 65536:
|
||||||
|
raise ValueError('Invalid request')
|
||||||
|
if self.headers.get('Transfer-Encoding'):
|
||||||
|
raise ValueError('Streaming request unsupported')
|
||||||
|
config = self.server.policy_path
|
||||||
|
if config.stat().st_mode & 0o077:
|
||||||
|
raise ValueError('Enrollment file must be private')
|
||||||
|
raw = config.read_bytes()
|
||||||
|
if len(raw) > 1024 * 1024:
|
||||||
|
raise ValueError('Oversized policy')
|
||||||
|
enrollments = json.loads(raw)
|
||||||
|
request = json.loads(self.rfile.read(size))
|
||||||
|
accepted = authorize(query['op'][0], request['content'], enrollments)
|
||||||
|
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||||
|
pass
|
||||||
|
body = json.dumps({'reject': not accepted, 'unchange': True, 'reject_reason': '' if accepted else 'Enrollment or route is not authorized'}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--enrollments', type=Path, required=True)
|
||||||
|
parser.add_argument('--port', type=int, default=17700)
|
||||||
|
args = parser.parse_args()
|
||||||
|
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
|
||||||
|
server.policy_path = args.enrollments
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[2] / 'scripts/public-web-gateway/enroll.py'
|
||||||
|
class EnrollmentTests(unittest.TestCase):
|
||||||
|
def test_private_export_duplicate_domain_and_explicit_rotation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(root/'key'), '-out', str(root/'ca'), '-days', '1', '-subj', '/CN=gateway.example'], check=True, capture_output=True)
|
||||||
|
config = {'bindPort': 7400, 'auth': {'method': 'token', 'token': 't'*64}, 'transport': {'tls': {'force': True}}, 'httpPlugins': [{'ops': ['Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn']}]}
|
||||||
|
path = root/'frps.json'; path.write_text(json.dumps(config)); path.chmod(0o600)
|
||||||
|
base = [sys.executable, str(SCRIPT), '--frps-config', str(path), '--policy', str(root/'policy.json'), '--ca', str(root/'ca'), '--host', 'gateway.example', '--tls-server-name', 'gateway.example', '--domain', 'site.example']
|
||||||
|
result = subprocess.run(base + ['--name', 'node-a', '--output', str(root/'node-a.json')], capture_output=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||||
|
private = json.loads((root/'node-a.json').read_text())
|
||||||
|
self.assertNotIn(private['enrollment_token'].encode(), result.stdout + result.stderr)
|
||||||
|
self.assertEqual((root/'node-a.json').stat().st_mode & 0o777, 0o600)
|
||||||
|
first = (root/'policy.json').read_bytes()
|
||||||
|
result = subprocess.run(base + ['--name', 'node-b', '--output', str(root/'node-b.json')], capture_output=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual(first, (root/'policy.json').read_bytes())
|
||||||
|
self.assertFalse((root/'node-b.json').exists())
|
||||||
|
result = subprocess.run(base + ['--name', 'node-a', '--rotate', '--output', str(root/'rotated.json')], capture_output=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||||
|
self.assertNotEqual(private['enrollment_token'], json.loads((root/'rotated.json').read_text())['enrollment_token'])
|
||||||
|
self.assertNotEqual(first, (root/'policy.json').read_bytes())
|
||||||
|
|
||||||
|
if __name__ == '__main__': unittest.main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('gateway_policy', Path(__file__).resolve().parents[2] / 'scripts/public-web-gateway/policy.py')
|
||||||
|
policy = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(policy)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.token = 'synthetic-test-token-' * 3
|
||||||
|
self.user = {'user': 'framework', 'metas': {'enrollment_token': self.token}}
|
||||||
|
self.entries = {'framework': {'enabled': True, 'token_sha256': hashlib.sha256(self.token.encode()).hexdigest(), 'domains': ['free.archipelago.builders']}}
|
||||||
|
self.proxy = {'user': self.user, 'proxy_name': 'framework.website', 'proxy_type': 'https', 'custom_domains': ['free.archipelago.builders']}
|
||||||
|
|
||||||
|
def test_allow_assigned_https_only(self):
|
||||||
|
self.assertTrue(policy.authorize('Login', self.user, self.entries))
|
||||||
|
for op in ('NewProxy', 'NewUserConn', 'Ping', 'NewWorkConn'):
|
||||||
|
self.assertTrue(policy.authorize(op, self.proxy, self.entries))
|
||||||
|
|
||||||
|
def test_revoke_and_rotate_apply_to_all_operations(self):
|
||||||
|
for op in policy.OPS:
|
||||||
|
content = self.user if op == 'Login' else self.proxy
|
||||||
|
self.entries['framework']['enabled'] = False
|
||||||
|
self.assertFalse(policy.authorize(op, content, self.entries))
|
||||||
|
self.entries['framework']['enabled'] = True
|
||||||
|
self.entries['framework']['token_sha256'] = '0' * 64
|
||||||
|
self.assertFalse(policy.authorize(op, content, self.entries))
|
||||||
|
|
||||||
|
def test_no_other_domains_protocols_or_shared_groups(self):
|
||||||
|
for key, value in [('custom_domains', ['other.example']), ('custom_domains', ['free.archipelago.builders', 'other.example']), ('proxy_type', 'tcp'), ('proxy_type', 'http'), ('remote_port', 22), ('subdomain', 'admin'), ('group', 'shared'), ('locations', ['/']), ('proxy_name', 'another.website')]:
|
||||||
|
with self.subTest(key=key, value=value):
|
||||||
|
self.assertFalse(policy.authorize('NewProxy', {**self.proxy, key: value}, self.entries))
|
||||||
|
|
||||||
|
def test_missing_or_malformed_auth_is_denied(self):
|
||||||
|
for user in ({}, {'user': 'framework'}, {'user': 'framework', 'metas': []}, {'user': 'framework', 'metas': {'enrollment_token': 'wrong'}}):
|
||||||
|
self.assertFalse(policy.authorize('Login', user, self.entries))
|
||||||
|
self.assertFalse(policy.authorize('Unknown', self.proxy, self.entries))
|
||||||
|
self.assertFalse(policy.authorize('Login', self.user, {}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('node_router', Path(__file__).resolve().parents[2] / 'docker/public-web-router/router.py')
|
||||||
|
router = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(router)
|
||||||
|
|
||||||
|
class RouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = {'schema': 1, 'gateway': {'host': '192.0.2.1', 'port': 7400, 'node_id': 'node-a', 'transport_token': 'a'*64, 'enrollment_token': 'b'*64, 'ca_pem': '-----BEGIN CERTIFICATE-----\nexample\n-----END CERTIFICATE-----', 'tls_server_name': 'gateway.example', 'domains': ['site.example']}, 'routes': [{'id': 'site-a', 'domain': 'site.example', 'fips_address': 'fd00::1', 'port': 32000}]}
|
||||||
|
|
||||||
|
def test_tls_ends_on_node_with_pinned_control_channel(self):
|
||||||
|
caddy, frpc, pem = router.render(self.config)
|
||||||
|
self.assertIn('disable_http_challenge', caddy)
|
||||||
|
self.assertNotIn('tls internal', caddy)
|
||||||
|
self.assertIn('bind 127.0.0.1', caddy)
|
||||||
|
self.assertEqual(frpc['proxies'][0]['type'], 'https')
|
||||||
|
self.assertEqual(frpc['transport']['tls']['serverName'], 'gateway.example')
|
||||||
|
self.assertIn('trustedCaFile', frpc['transport']['tls'])
|
||||||
|
|
||||||
|
def test_refuses_management_and_arbitrary_upstreams(self):
|
||||||
|
for port in (22, 443, 7474, 8191, 31999, 32032, True):
|
||||||
|
self.config['routes'][0]['port'] = port
|
||||||
|
with self.assertRaises(ValueError): router.render(self.config)
|
||||||
|
self.config['routes'][0]['port'] = 32000
|
||||||
|
for address in ('127.0.0.1', '::1', '2001:db8::1'):
|
||||||
|
self.config['routes'][0]['fips_address'] = address
|
||||||
|
with self.assertRaises(ValueError): router.render(self.config)
|
||||||
|
|
||||||
|
def test_refuses_config_injection_and_duplicate_domains(self):
|
||||||
|
for key in ('domain', 'id'):
|
||||||
|
old = self.config['routes'][0][key]
|
||||||
|
self.config['routes'][0][key] = 'site.example\n import /secret'
|
||||||
|
with self.assertRaises(ValueError): router.render(self.config)
|
||||||
|
self.config['routes'][0][key] = old
|
||||||
|
self.config['routes'].append(dict(self.config['routes'][0]))
|
||||||
|
with self.assertRaises(ValueError): router.render(self.config)
|
||||||
|
|
||||||
|
def test_app_routes_keep_the_expected_app_gate_identity(self):
|
||||||
|
self.config['routes'][0].update(id='app-photoprism', app_id='photoprism', port=2342)
|
||||||
|
caddy, _, _ = router.render(self.config)
|
||||||
|
self.assertIn('header_up X-Archipelago-App photoprism', caddy)
|
||||||
|
self.config['routes'][0]['id'] = 'app-another'
|
||||||
|
with self.assertRaises(ValueError): router.render(self.config)
|
||||||
|
|
||||||
|
def test_test_certificates_require_explicit_mode(self):
|
||||||
|
self.config['certificate_mode'] = 'test'
|
||||||
|
self.assertIn('tls internal', router.render(self.config)[0])
|
||||||
|
self.config['certificate_mode'] = 'insecure'
|
||||||
|
with self.assertRaises(ValueError): router.render(self.config)
|
||||||
|
|
||||||
|
if __name__ == '__main__': unittest.main()
|
||||||
Reference in New Issue
Block a user