Complete private gateway and local website publishing UAT

This commit is contained in:
archipelago
2026-10-08 18:10:41 -04:00
parent 768e828246
commit 3ab4162a8b
38 changed files with 2232 additions and 86 deletions
@@ -198,6 +198,17 @@ async fn forward_models() -> Result<Response<Body>> {
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
/// offline) → price quote → pay → forward → redeem change → record net.
async fn forward_chat(req: Request<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())
.await
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
@@ -445,6 +456,41 @@ mod tests {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn stale_routstr_selection_cannot_pay_after_provider_change() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
crate::settings::model_provider::ModelProvider {
provider: crate::settings::model_provider::Provider::Claude,
openai_model: String::new(),
}
.save(data_dir.path())
.await
.unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
"{}",
);
let response = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
crate::assistant::AssistantBudget::load(data_dir.path())
.await
.spent_sats,
0
);
}
/// D-05: a fresh node (no budget file → zero allowance) refuses the paid
/// path BEFORE any pricing/network I/O — this test runs fully offline.
#[tokio::test]
@@ -11,6 +11,16 @@ impl RpcHandler {
session_token: &Option<String>,
) -> Result<serde_json::Value> {
match method {
"publishing.gateway-app-route" => {
self.handle_publishing_gateway_app_route(params).await
}
"publishing.gateway-configure" => {
self.handle_publishing_gateway_configure(params).await
}
"publishing.gateway-route" => self.handle_publishing_gateway_route(params).await,
"publishing.gateway-disconnect" => {
crate::publishing::gateway::disconnect(&self.config.data_dir).await
}
"publishing.status" => self.handle_publishing_status().await,
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
"publishing.update" => self.handle_publishing_update(params).await,
+127 -8
View File
@@ -5,6 +5,84 @@ use serde::Deserialize;
use serde_json::json;
impl RpcHandler {
pub(super) async fn handle_publishing_gateway_app_route(
&self,
params: Option<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(
&self,
params: Option<serde_json::Value>,
@@ -203,10 +281,19 @@ impl RpcHandler {
use base64::Engine;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NsiteFile {
html: String,
server: String,
acknowledge_public: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
id: String,
version: u64,
authorization: nostr_sdk::Event,
#[serde(default)]
nsite: Option<NsiteFile>,
}
let request: Request =
serde_json::from_value(params.context("Missing local archive authorization")?)?;
@@ -227,7 +314,20 @@ impl RpcHandler {
!project.draft.trim().is_empty(),
"Save a website draft first"
);
let digest = publishing::nsite::hash(project.draft.as_bytes());
let content = if let Some(nsite) = &request.nsite {
publishing::nsite::local_server(project, &nsite.server)?;
anyhow::ensure!(
nsite.acknowledge_public
&& nsite.html.len() <= 512 * 1024
&& !nsite.html.contains('\0')
&& nsite.html.starts_with(publishing::nsite::POLICY),
"Review and confirm the local nsite file before sharing it"
);
nsite.html.clone()
} else {
project.draft.clone()
};
let digest = publishing::nsite::hash(content.as_bytes());
let event = serde_json::to_value(&request.authorization)?;
let tags = event["tags"]
.as_array()
@@ -259,7 +359,7 @@ impl RpcHandler {
.put(format!("{base}/upload"))
.header("Authorization", format!("Nostr {auth}"))
.header("Content-Type", "text/html; charset=utf-8")
.body(project.draft.clone())
.body(content.clone())
.send()
.await
.context("Local Blossom is not responding. Start it from Apps")?;
@@ -278,7 +378,7 @@ impl RpcHandler {
}
let descriptor: serde_json::Value = serde_json::from_slice(&descriptor)?;
anyhow::ensure!(
descriptor["sha256"] == digest && descriptor["size"] == project.draft.len(),
descriptor["sha256"] == digest && descriptor["size"] == content.len(),
"Local Blossom returned another file receipt"
);
let mut response = client
@@ -286,7 +386,7 @@ impl RpcHandler {
.send()
.await?
.error_for_status()?;
let expected = project.draft.as_bytes();
let expected = content.as_bytes();
let mut offset = 0;
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
@@ -310,9 +410,19 @@ impl RpcHandler {
&self.config.data_dir,
publishing::Update {
version: request.version,
change: publishing::Change::RecordLocalArchive {
id: request.id,
receipt,
change: if let Some(nsite) = request.nsite {
publishing::Change::ShareNsiteAsset {
id: request.id,
server: nsite.server,
html: content,
receipt,
acknowledge_public: nsite.acknowledge_public,
}
} else {
publishing::Change::RecordLocalArchive {
id: request.id,
receipt,
}
},
},
)
@@ -350,6 +460,7 @@ impl RpcHandler {
"nostr_relays": self.config.nostr_relays,
"publication_enabled": true,
"public_archive_enabled": true,
"gateway": publishing::gateway::status(&self.config.data_dir).await.unwrap_or_else(|_| json!({"configured":false,"routes":[],"error":"Private gateway configuration needs repair","externally_verified":false})),
"listeners": publishing::serving::status().await,
"onions": publishing::tor::status().await,
"notice": "FIPS and Tor static publishing are available for testing. Existing public proxies can be configured manually. Nostr publishing requires an explicit identity, Blossom server and relay selection. Automated gateway setup is not enabled yet. Saving choices does not change app access; external verification is separate.",
@@ -381,6 +492,8 @@ impl RpcHandler {
version: u64,
server: String,
html: String,
#[serde(default)]
local: bool,
}
let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?;
let state = publishing::load(&self.config.data_dir).await?;
@@ -396,7 +509,13 @@ impl RpcHandler {
}
let mut prepared = project.clone();
prepared.draft = request.html;
publishing::nsite::prepare(&prepared, &request.server)
let mut result = publishing::nsite::prepare(&prepared, &request.server)?;
if request.local {
publishing::nsite::local_server(project, &request.server)?;
result["local"] = json!(true);
result["authorization"]["tags"][2] = json!(["server", "127.0.0.1"]);
}
Ok(result)
}
pub(super) async fn handle_publishing_dns(
+49
View File
@@ -172,7 +172,30 @@ impl AppGate {
// snapshot when the port momentarily leaves the map mid-refresh.
let live = self.port_map.read().await.gated(app.port).cloned();
let app = live.as_ref().unwrap_or(app);
// A managed public route must still refer to this guest-enabled app.
// Refuse stale routes before login actions or public-resource exceptions.
if let Some(expected) = req.headers().get("x-archipelago-app") {
if expected.to_str().ok() != Some(app.app_id.as_str())
|| live.is_none()
|| !app.declared
|| !app.guest_access
|| !app.auth_enabled
|| app.session_passthrough
{
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("App route is no longer available"))
.unwrap();
}
}
// Managed gateway routes are HTTPS-only. Mark cookies Secure even
// though the final in-node FIPS hop uses HTTP. A forged header can only
// strengthen this cookie attribute, never grant authorization.
let mut req = req;
if req.headers().contains_key("x-archipelago-app") {
req.extensions_mut().insert(SecureTransport(true));
}
let path = req.uri().path().to_string();
// A dashboard same-origin proxy strips `/app/<id>/` before this gate
// sees the URI. Carry that trusted proxy mount into the challenge's
@@ -1380,6 +1403,32 @@ fn totp_page(
#[cfg(test)]
mod tests {
#[tokio::test]
async fn managed_gateway_rejects_stale_identity_or_disabled_guest_policy_before_login() {
let gate = test_gate().await;
let mut app = app();
app.guest_access = true;
for (expected, enabled, declared) in [
("another-app", true, true),
("strfry", false, true),
("strfry", true, false),
] {
app.auth_enabled = enabled;
app.declared = declared;
*gate.port_map.write().await = identity::test_port_map(app.clone());
for path in ["/", "/manifest.json", "/__archipelago-gate/guest"] {
let request = Request::get(path)
.header("x-archipelago-app", expected)
.body(Body::empty())
.unwrap();
let response = gate
.handle(request, &app, "127.0.0.1".parse().unwrap())
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
}
#[tokio::test]
async fn guest_login_is_app_only_and_revocation_blocks_subsequent_requests() {
let gate = test_gate().await;
+398
View File
@@ -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"));
}
}
+103 -10
View File
@@ -7,6 +7,7 @@ use std::path::Path;
use tokio::sync::Mutex;
mod firewall;
pub mod gateway;
pub mod nsite;
pub mod serving;
pub mod tor;
@@ -61,9 +62,18 @@ pub struct LocalArchive {
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublicNsiteAsset {
pub html: String,
pub receipt: LocalArchive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Publication {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nsite_asset: Option<PublicNsiteAsset>,
/// Exact archived bytes explicitly approved for public hash-addressed reads.
#[serde(default)]
pub public_archive: Option<String>,
@@ -125,6 +135,17 @@ pub enum Change {
domain: Option<Domain>,
html: String,
},
#[serde(skip_deserializing)]
ShareNsiteAsset {
id: String,
server: String,
html: String,
receipt: LocalArchive,
acknowledge_public: bool,
},
UnshareNsiteAsset {
id: String,
},
ShareArchive {
id: String,
route: Route,
@@ -267,6 +288,45 @@ pub fn dns_records(domain: &Domain) -> Result<Vec<DnsRecord>> {
impl State {
pub fn apply(&mut self, change: Change) -> Result<Option<String>> {
match change {
Change::ShareNsiteAsset {
id,
server,
html,
receipt,
acknowledge_public,
} => {
let project = self
.projects
.get_mut(&id)
.context("Website project not found")?;
nsite::local_server(project, &server)?;
if !acknowledge_public
|| html.len() > MAX_HTML
|| html.contains('\0')
|| !html.starts_with(nsite::POLICY)
|| receipt.sha256 != nsite::hash(html.as_bytes())
|| receipt.size != html.len()
{
bail!("Review and confirm the exact local nsite file before sharing it");
}
project
.fips_publication
.as_mut()
.context("Publish the website connection first")?
.nsite_asset = Some(PublicNsiteAsset { html, receipt });
Ok(Some(id))
}
Change::UnshareNsiteAsset { id } => {
let project = self
.projects
.get_mut(&id)
.context("Website project not found")?;
if let Some(publication) = project.fips_publication.as_mut() {
publication.nsite_asset = None;
}
Ok(Some(id))
}
Change::RecordLocalArchive { id, receipt } => {
let p = self
.projects
@@ -280,28 +340,47 @@ impl State {
p.local_archive = Some(receipt);
Ok(Some(id))
}
Change::ShareArchive { id, route, acknowledge_public } => {
if !acknowledge_public { bail!("Confirm public access to the exact archived website bytes"); }
let p = self.projects.get_mut(&id).context("Website project not found")?;
let archive = p.local_archive.as_ref().context("Store this website in local Blossom first")?;
Change::ShareArchive {
id,
route,
acknowledge_public,
} => {
if !acknowledge_public {
bail!("Confirm public access to the exact archived website bytes");
}
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
let archive = p
.local_archive
.as_ref()
.context("Store this website in local Blossom first")?;
let publication = match route {
Route::Fips => p.fips_publication.as_mut(),
Route::Tor => p.tor_publication.as_mut(),
_ => bail!("Choose the FIPS/public-web or Tor publication"),
}.context("Publish this connection before sharing its archived file")?;
if archive.sha256 != nsite::hash(publication.html.as_bytes()) || archive.size != publication.html.len() {
}
.context("Publish this connection before sharing its archived file")?;
if archive.sha256 != nsite::hash(publication.html.as_bytes())
|| archive.size != publication.html.len()
{
bail!("The archive differs from this published version. Store and publish the same version first");
}
publication.public_archive = Some(archive.sha256.clone());
Ok(Some(id))
}
Change::UnshareArchive { id, route } => {
let p = self.projects.get_mut(&id).context("Website project not found")?;
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
let publication = match route {
Route::Fips => p.fips_publication.as_mut(),
Route::Tor => p.tor_publication.as_mut(),
_ => bail!("Choose the FIPS/public-web or Tor publication"),
}.context("This connection is not published")?;
}
.context("This connection is not published")?;
publication.public_archive = None;
Ok(Some(id))
}
@@ -416,6 +495,7 @@ impl State {
.context("No website ports available")?,
};
p.fips_publication = Some(Publication {
nsite_asset: None,
public_archive: None,
port,
html: p.draft.clone(),
@@ -449,6 +529,7 @@ impl State {
.context("No onion website ports available")?,
};
p.tor_publication = Some(Publication {
nsite_asset: None,
public_archive: None,
port,
html: p.draft.clone(),
@@ -520,8 +601,20 @@ pub async fn load(root: &Path) -> Result<State> {
(&project.tor_publication, 32100..32132),
] {
if let Some(p) = publication {
if !range.contains(&p.port) || !ports.insert(p.port) || p.html.len() > MAX_HTML
|| p.public_archive.as_ref().is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes())) {
if !range.contains(&p.port)
|| !ports.insert(p.port)
|| p.html.len() > MAX_HTML
|| p.nsite_asset.as_ref().is_some_and(|a| {
a.html.len() > MAX_HTML
|| !a.html.starts_with(nsite::POLICY)
|| a.html.contains('\0')
|| a.receipt.size != a.html.len()
|| a.receipt.sha256 != nsite::hash(a.html.as_bytes())
})
|| p.public_archive
.as_ref()
.is_some_and(|hash| *hash != nsite::hash(p.html.as_bytes()))
{
bail!("Invalid stored website publication; existing state has been preserved");
}
}
+26 -1
View File
@@ -6,6 +6,27 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
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 {
format!("{:x}", Sha256::digest(bytes))
}
@@ -23,7 +44,11 @@ pub fn prepare(project: &Project, blossom: &str) -> Result<Value> {
let server = server(blossom)?;
// The first policy remains restrictive even if generated HTML adds another
// CSP. Hosted nsites use a separate origin, without dashboard privileges.
let html = format!("<!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 identifier: String = project.id.chars().filter(|c| *c != '-').take(13).collect();
let aggregate = hash(format!("{digest} /index.html\n").as_bytes());
+279 -36
View File
@@ -53,11 +53,30 @@ pub(super) fn response_for(
else {
return simple(StatusCode::NOT_FOUND, "Website is not published");
};
// Bind managed gateway routes to the project, even if a freed listener port
// is later assigned to a different published website.
if req
.headers()
.get("x-archipelago-website")
.is_some_and(|v| v.to_str().ok() != Some(id))
{
return simple(StatusCode::NOT_FOUND, "Website route no longer matches");
}
// Only the selected immutable snapshot is exposed, never the Blossom backend.
// No listing, upload, arbitrary hash lookup, filesystem access or credentials.
let asset = publication.public_archive.as_ref().is_some_and(|hash| {
req.uri().path() == format!("/{hash}") && *hash == super::nsite::hash(publication.html.as_bytes())
let nsite_asset = publication.nsite_asset.as_ref().filter(|asset| {
req.uri().path() == format!("/{}", asset.receipt.sha256)
&& asset.receipt.sha256 == super::nsite::hash(asset.html.as_bytes())
&& asset.receipt.size == asset.html.len()
});
let html = nsite_asset
.map(|asset| asset.html.as_str())
.unwrap_or(&publication.html);
let asset = nsite_asset.is_some()
|| publication.public_archive.as_ref().is_some_and(|hash| {
req.uri().path() == format!("/{hash}")
&& *hash == super::nsite::hash(publication.html.as_bytes())
});
if asset && req.method() == Method::OPTIONS {
let mut response = simple(StatusCode::NO_CONTENT, "");
asset_headers(&mut response);
@@ -76,13 +95,14 @@ pub(super) fn response_for(
response
.headers_mut()
.insert("content-type", "text/html; charset=utf-8".parse().unwrap());
response.headers_mut().insert(
"content-length",
publication.html.len().to_string().parse().unwrap(),
);
if asset { asset_headers(&mut response); }
response
.headers_mut()
.insert("content-length", html.len().to_string().parse().unwrap());
if asset {
asset_headers(&mut response);
}
if req.method() == Method::GET {
*response.body_mut() = Body::from(publication.html.clone());
*response.body_mut() = Body::from(html.to_owned());
}
response
}
@@ -90,9 +110,14 @@ fn asset_headers(response: &mut Response<Body>) {
for (name, value) in [
("access-control-allow-origin", "*"),
("access-control-allow-methods", "GET, HEAD, OPTIONS"),
("access-control-expose-headers", "Content-Length, Content-Type"),
(
"access-control-expose-headers",
"Content-Length, Content-Type",
),
("content-disposition", "attachment; filename=\"index.html\""),
] { response.headers_mut().insert(name, value.parse().unwrap()); }
] {
response.headers_mut().insert(name, value.parse().unwrap());
}
}
fn simple(status: StatusCode, body: &str) -> Response<Body> {
let mut r = Response::new(Body::from(body.to_owned()));
@@ -260,48 +285,266 @@ pub(super) async fn listen(
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn local_nsite_shares_only_reviewed_bytes_and_revokes_independently() {
use crate::publishing::{Change, Domain, LocalArchive, Route};
let mut state = State::default();
let id = state
.apply(Change::Create {
name: "Nsite".into(),
})
.unwrap()
.unwrap();
state
.apply(Change::Save {
id: id.clone(),
name: "Nsite".into(),
routes: [Route::PublicWeb, Route::Nostr].into_iter().collect(),
domain: Some(Domain {
hostname: "site.example.org".into(),
destination: None,
}),
html: "<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]
async fn public_archive_is_exact_explicit_route_scoped_and_revocable() {
use crate::publishing::{Change, LocalArchive, Route};
let mut state = State::default();
let id = state.apply(Change::Create { name: "Archive".into() }).unwrap().unwrap();
state.apply(Change::Save { id: id.clone(), name: "Archive".into(), routes: [Route::Fips, Route::Tor].into_iter().collect(), domain: None, html: "public snapshot".into() }).unwrap();
state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap();
state.apply(Change::PublishTor { id: id.clone(), acknowledge_public: true }).unwrap();
let id = state
.apply(Change::Create {
name: "Archive".into(),
})
.unwrap()
.unwrap();
state
.apply(Change::Save {
id: id.clone(),
name: "Archive".into(),
routes: [Route::Fips, Route::Tor].into_iter().collect(),
domain: None,
html: "public snapshot".into(),
})
.unwrap();
state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: true,
})
.unwrap();
state
.apply(Change::PublishTor {
id: id.clone(),
acknowledge_public: true,
})
.unwrap();
let port = state.projects[&id].fips_publication.as_ref().unwrap().port;
let tor_port = state.projects[&id].tor_publication.as_ref().unwrap().port;
let hash = crate::publishing::nsite::hash(b"public snapshot");
let req = Request::builder().uri(format!("/{hash}")).body(Body::empty()).unwrap();
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).is_err());
state.apply(Change::RecordLocalArchive { id: id.clone(), receipt: LocalArchive { sha256: hash.clone(), size: 15, pubkey: "a".repeat(64), created_at: "now".into() } }).unwrap();
assert!(state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: false }).is_err());
state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap();
assert_eq!(response_for(&state, &id, tor_port, Route::Tor, &req).status(), StatusCode::NOT_FOUND);
let req = Request::builder()
.uri(format!("/{hash}"))
.body(Body::empty())
.unwrap();
assert_eq!(
response(&state, &id, port, &req).status(),
StatusCode::NOT_FOUND
);
assert!(state
.apply(Change::ShareArchive {
id: id.clone(),
route: Route::Fips,
acknowledge_public: true
})
.is_err());
state
.apply(Change::RecordLocalArchive {
id: id.clone(),
receipt: LocalArchive {
sha256: hash.clone(),
size: 15,
pubkey: "a".repeat(64),
created_at: "now".into(),
},
})
.unwrap();
assert!(state
.apply(Change::ShareArchive {
id: id.clone(),
route: Route::Fips,
acknowledge_public: false
})
.is_err());
state
.apply(Change::ShareArchive {
id: id.clone(),
route: Route::Fips,
acknowledge_public: true,
})
.unwrap();
assert_eq!(
response_for(&state, &id, tor_port, Route::Tor, &req).status(),
StatusCode::NOT_FOUND
);
let r = response(&state, &id, port, &req);
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(r.headers()["access-control-allow-origin"], "*");
assert!(r.headers()["content-disposition"].to_str().unwrap().starts_with("attachment"));
assert!(r.headers()["content-disposition"]
.to_str()
.unwrap()
.starts_with("attachment"));
assert_eq!(r.headers()["content-security-policy"], CSP);
assert_eq!(hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(), b"public snapshot");
for path in ["/upload", "/list", "/0000000000000000000000000000000000000000000000000000000000000000", "/../state.json"] {
assert_eq!(
hyper::body::to_bytes(r.into_body()).await.unwrap().as_ref(),
b"public snapshot"
);
for path in [
"/upload",
"/list",
"/0000000000000000000000000000000000000000000000000000000000000000",
"/../state.json",
] {
let r = Request::builder().uri(path).body(Body::empty()).unwrap();
assert_eq!(response(&state, &id, port, &r).status(), StatusCode::NOT_FOUND);
assert_eq!(
response(&state, &id, port, &r).status(),
StatusCode::NOT_FOUND
);
}
let head = Request::builder().method(Method::HEAD).uri(format!("/{hash}")).body(Body::empty()).unwrap();
let head = Request::builder()
.method(Method::HEAD)
.uri(format!("/{hash}"))
.body(Body::empty())
.unwrap();
let r = response(&state, &id, port, &head);
assert_eq!(r.headers()["content-length"], "15");
assert!(hyper::body::to_bytes(r.into_body()).await.unwrap().is_empty());
let post = Request::builder().method(Method::PUT).uri(format!("/{hash}")).body(Body::empty()).unwrap();
assert_eq!(response(&state, &id, port, &post).status(), StatusCode::METHOD_NOT_ALLOWED);
assert!(hyper::body::to_bytes(r.into_body())
.await
.unwrap()
.is_empty());
let post = Request::builder()
.method(Method::PUT)
.uri(format!("/{hash}"))
.body(Body::empty())
.unwrap();
assert_eq!(
response(&state, &id, port, &post).status(),
StatusCode::METHOD_NOT_ALLOWED
);
state.projects.get_mut(&id).unwrap().draft = "private later edits".into();
assert_eq!(hyper::body::to_bytes(response(&state, &id, port, &req).into_body()).await.unwrap().as_ref(), b"public snapshot");
state.apply(Change::UnshareArchive { id: id.clone(), route: Route::Fips }).unwrap();
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
state.apply(Change::ShareArchive { id: id.clone(), route: Route::Fips, acknowledge_public: true }).unwrap();
state.apply(Change::PublishFips { id: id.clone(), acknowledge_public: true }).unwrap();
assert_eq!(response(&state, &id, port, &req).status(), StatusCode::NOT_FOUND);
assert!(state.apply(Change::ShareArchive { id, route: Route::Fips, acknowledge_public: true }).is_err());
assert_eq!(
hyper::body::to_bytes(response(&state, &id, port, &req).into_body())
.await
.unwrap()
.as_ref(),
b"public snapshot"
);
state
.apply(Change::UnshareArchive {
id: id.clone(),
route: Route::Fips,
})
.unwrap();
assert_eq!(
response(&state, &id, port, &req).status(),
StatusCode::NOT_FOUND
);
state
.apply(Change::ShareArchive {
id: id.clone(),
route: Route::Fips,
acknowledge_public: true,
})
.unwrap();
state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: true,
})
.unwrap();
assert_eq!(
response(&state, &id, port, &req).status(),
StatusCode::NOT_FOUND
);
assert!(state
.apply(Change::ShareArchive {
id,
route: Route::Fips,
acknowledge_public: true
})
.is_err());
}
#[tokio::test]
+3
View File
@@ -95,6 +95,9 @@ impl EndpointRateLimiter {
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Explicit publishing actions can allocate credentials or perform
// bounded network I/O. Saving/previewing never invokes these actions.
limits.insert("publishing.gateway-configure".to_string(), (5, 60));
limits.insert("publishing.gateway-app-route".to_string(), (10, 60));
limits.insert("publishing.gateway-route".to_string(), (10, 60));
limits.insert("publishing.access-create".to_string(), (10, 60));
limits.insert("publishing.verify-https".to_string(), (10, 60));
limits.insert("publishing.blossom-store".to_string(), (10, 60));