feat: integrate local Blossom, reviewed nsites and scoped app access
This commit is contained in:
@@ -12,9 +12,15 @@ impl RpcHandler {
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"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,
|
||||
"publishing.dns" => self.handle_publishing_dns(params).await,
|
||||
"publishing.generate" => self.handle_publishing_generate(params).await,
|
||||
"publishing.nsite-prepare" => self.handle_publishing_nsite_prepare(params).await,
|
||||
"publishing.blossom-prepare" => self.handle_publishing_blossom_prepare(params).await,
|
||||
"publishing.blossom-store" => self.handle_publishing_blossom_store(params).await,
|
||||
"publishing.access-create" => self.handle_publishing_access_create(params).await,
|
||||
"publishing.access-revoke" => self.handle_publishing_access_revoke(params).await,
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
|
||||
@@ -5,6 +5,322 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_publishing_verify_https(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
id: String,
|
||||
version: u64,
|
||||
}
|
||||
let request: Request =
|
||||
serde_json::from_value(params.context("Missing website to verify")?)?;
|
||||
let state = publishing::load(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
state.version == request.version,
|
||||
"Settings changed. Reload before checking"
|
||||
);
|
||||
let project = state
|
||||
.projects
|
||||
.get(&request.id)
|
||||
.context("Website project not found")?;
|
||||
anyhow::ensure!(
|
||||
project.routes.contains(&publishing::Route::PublicWeb),
|
||||
"Select public web and save first"
|
||||
);
|
||||
let host = publishing::hostname(
|
||||
&project
|
||||
.domain
|
||||
.as_ref()
|
||||
.context("Save a domain first")?
|
||||
.hostname,
|
||||
)?;
|
||||
let expected = project
|
||||
.fips_publication
|
||||
.as_ref()
|
||||
.context("Publish the website upstream first")?
|
||||
.html
|
||||
.as_bytes();
|
||||
let addresses: Vec<_> = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::net::lookup_host((host.as_str(), 443)),
|
||||
)
|
||||
.await
|
||||
.context("DNS lookup timed out")?
|
||||
.context("Domain DNS lookup failed")?
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
!addresses.is_empty() && addresses.iter().all(|a| publishing::public_ip(a.ip())),
|
||||
"HTTPS checks require DNS resolving exclusively to public addresses"
|
||||
);
|
||||
// Pin this validated resolution: do not resolve again, follow redirects,
|
||||
// inherit proxy settings, accept custom ports or relax TLS verification.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.resolve_to_addrs(&host, &addresses)
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
let mut response = client
|
||||
.get(format!("https://{host}/"))
|
||||
.header("Accept-Encoding", "identity")
|
||||
.send()
|
||||
.await
|
||||
.context("HTTPS connection failed; check DNS, proxy and certificate")?;
|
||||
anyhow::ensure!(
|
||||
response.status() == reqwest::StatusCode::OK,
|
||||
"Expected HTTP 200 from the website; received {}",
|
||||
response.status()
|
||||
);
|
||||
let mut offset = 0;
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
offset + chunk.len() <= expected.len()
|
||||
&& expected[offset..offset + chunk.len()] == chunk[..],
|
||||
"The HTTPS address serves different content from this published version"
|
||||
);
|
||||
offset += chunk.len();
|
||||
}
|
||||
anyhow::ensure!(offset == expected.len(), "Website response was incomplete");
|
||||
anyhow::ensure!(
|
||||
publishing::load(&self.config.data_dir).await?.version == request.version,
|
||||
"Settings changed during verification. Check the current version again"
|
||||
);
|
||||
Ok(
|
||||
json!({"hostname":host,"sha256":publishing::nsite::hash(expected),
|
||||
"checked_at":chrono::Utc::now().to_rfc3339()}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_publishing_access_create(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
app_id: String,
|
||||
label: String,
|
||||
hours: u32,
|
||||
}
|
||||
let request: Request =
|
||||
serde_json::from_value(params.context("Missing app access request")?)?;
|
||||
let label = request.label.trim();
|
||||
anyhow::ensure!(
|
||||
!label.is_empty() && label.len() <= 64 && !label.chars().any(char::is_control),
|
||||
"Enter a guest label of at most 64 characters"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
(1..=720).contains(&request.hours),
|
||||
"Choose an expiry between one hour and 30 days"
|
||||
);
|
||||
let map = crate::appgate::identity::build_port_map();
|
||||
let app = map
|
||||
.gated_ports()
|
||||
.find(|p| {
|
||||
p.app_id == request.app_id
|
||||
&& p.guest_access
|
||||
&& p.declared
|
||||
&& p.auth_enabled
|
||||
&& !p.session_passthrough
|
||||
})
|
||||
.context("This app has not opted in to external guest access")?;
|
||||
let id = format!("external:{}:{label}", uuid::Uuid::new_v4());
|
||||
let expires = chrono::Utc::now().timestamp() as u64 + u64::from(request.hours) * 3600;
|
||||
let token = crate::device_tokens::create_scoped_expiring(
|
||||
&self.config.data_dir,
|
||||
&id,
|
||||
Some(vec![app.app_id.clone()]),
|
||||
Some(expires),
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({"id":id, "token":token, "app_id":app.app_id, "expires_at":expires}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_publishing_access_revoke(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
id: String,
|
||||
}
|
||||
let request: Request =
|
||||
serde_json::from_value(params.context("Missing access credential")?)?;
|
||||
let credentials = crate::device_tokens::list(&self.config.data_dir).await;
|
||||
anyhow::ensure!(
|
||||
credentials.iter().any(|c| c.name == request.id
|
||||
&& c.name.starts_with("external:")
|
||||
&& c.apps.is_some()),
|
||||
"External app access credential not found"
|
||||
);
|
||||
Ok(
|
||||
json!({"revoked":crate::device_tokens::remove(&self.config.data_dir, &request.id).await?}),
|
||||
)
|
||||
}
|
||||
pub(super) async fn handle_publishing_blossom_prepare(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
id: String,
|
||||
version: u64,
|
||||
}
|
||||
let request: Request =
|
||||
serde_json::from_value(params.context("Missing local archive request")?)?;
|
||||
let state = publishing::load(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
state.version == request.version,
|
||||
"Publishing settings changed. Reload before storing"
|
||||
);
|
||||
let project = state
|
||||
.projects
|
||||
.get(&request.id)
|
||||
.context("Website project not found")?;
|
||||
anyhow::ensure!(
|
||||
!project.draft.trim().is_empty(),
|
||||
"Save a website draft first"
|
||||
);
|
||||
let digest = publishing::nsite::hash(project.draft.as_bytes());
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
Ok(
|
||||
json!({ "sha256": digest, "size": project.draft.len(), "authorization": {
|
||||
"kind":24242, "created_at":now, "content":"Store this website draft on my local node only",
|
||||
"tags":[["t","upload"],["x",digest],["server","127.0.0.1"],["expiration",(now+300).to_string()]]
|
||||
}}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_publishing_blossom_store(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
id: String,
|
||||
version: u64,
|
||||
authorization: nostr_sdk::Event,
|
||||
}
|
||||
let request: Request =
|
||||
serde_json::from_value(params.context("Missing local archive authorization")?)?;
|
||||
request
|
||||
.authorization
|
||||
.verify()
|
||||
.context("Invalid local upload signature")?;
|
||||
let state = publishing::load(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
state.version == request.version,
|
||||
"Publishing settings changed. Reload before storing"
|
||||
);
|
||||
let project = state
|
||||
.projects
|
||||
.get(&request.id)
|
||||
.context("Website project not found")?;
|
||||
anyhow::ensure!(
|
||||
!project.draft.trim().is_empty(),
|
||||
"Save a website draft first"
|
||||
);
|
||||
let digest = publishing::nsite::hash(project.draft.as_bytes());
|
||||
let event = serde_json::to_value(&request.authorization)?;
|
||||
let tags = event["tags"]
|
||||
.as_array()
|
||||
.context("Missing upload authorization tags")?;
|
||||
anyhow::ensure!(
|
||||
event["kind"] == 24242
|
||||
&& tags.contains(&json!(["t", "upload"]))
|
||||
&& tags.contains(&json!(["x", digest]))
|
||||
&& tags.contains(&json!(["server", "127.0.0.1"])),
|
||||
"Authorization does not match this local draft upload"
|
||||
);
|
||||
// This is a protocol adapter, not a general URL proxy. Resolve only the
|
||||
// manifest-owned Blossom backend and never send node session cookies.
|
||||
let map = crate::appgate::identity::build_port_map();
|
||||
let port = map
|
||||
.gated_ports()
|
||||
.find(|p| p.app_id == "blossom" && p.declared && p.auth_enabled)
|
||||
.context("Install local Blossom with its app gate enabled first")?
|
||||
.port;
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
let auth = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&request.authorization)?);
|
||||
let mut response = client
|
||||
.put(format!("{base}/upload"))
|
||||
.header("Authorization", format!("Nostr {auth}"))
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(project.draft.clone())
|
||||
.send()
|
||||
.await
|
||||
.context("Local Blossom is not responding. Start it from Apps")?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Local Blossom rejected the upload ({})",
|
||||
response.status()
|
||||
);
|
||||
let mut descriptor = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
descriptor.len() + chunk.len() <= 8192,
|
||||
"Invalid local Blossom receipt"
|
||||
);
|
||||
descriptor.extend_from_slice(&chunk);
|
||||
}
|
||||
let descriptor: serde_json::Value = serde_json::from_slice(&descriptor)?;
|
||||
anyhow::ensure!(
|
||||
descriptor["sha256"] == digest && descriptor["size"] == project.draft.len(),
|
||||
"Local Blossom returned another file receipt"
|
||||
);
|
||||
let mut response = client
|
||||
.get(format!("{base}/{digest}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let expected = project.draft.as_bytes();
|
||||
let mut offset = 0;
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
offset + chunk.len() <= expected.len()
|
||||
&& expected[offset..offset + chunk.len()] == chunk[..],
|
||||
"Local Blossom readback differs from the saved draft"
|
||||
);
|
||||
offset += chunk.len();
|
||||
}
|
||||
anyhow::ensure!(
|
||||
offset == expected.len(),
|
||||
"Local Blossom readback was incomplete"
|
||||
);
|
||||
let receipt = publishing::LocalArchive {
|
||||
sha256: digest,
|
||||
size: expected.len(),
|
||||
pubkey: request.authorization.pubkey.to_hex(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let (state, _) = publishing::update(
|
||||
&self.config.data_dir,
|
||||
publishing::Update {
|
||||
version: request.version,
|
||||
change: publishing::Change::RecordLocalArchive {
|
||||
id: request.id,
|
||||
receipt,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("The local file was stored, but its project receipt could not be saved")?;
|
||||
Ok(json!({"state":state}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_publishing_status(&self) -> Result<serde_json::Value> {
|
||||
let state = publishing::load(&self.config.data_dir).await?;
|
||||
let gate = crate::appgate::listener::shared_status();
|
||||
@@ -18,18 +334,24 @@ impl RpcHandler {
|
||||
"id": p.app_id, "name": p.app_name, "port": p.port,
|
||||
"authentication": if p.auth_enabled { "node-session" } else { "application" },
|
||||
"listener_claimed": crate::appgate::listener::port_claimed(&gate, p.port),
|
||||
"guest_access": p.guest_access && p.auth_enabled,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
apps.sort_by_key(|a| a["id"].as_str().unwrap_or_default().to_owned());
|
||||
drop(gate);
|
||||
let credentials = crate::device_tokens::list(&self.config.data_dir).await;
|
||||
let grants: Vec<_> = credentials.iter().filter(|c| c.name.starts_with("external:") && c.apps.is_some()).map(|c| json!({"id":c.name,"label":c.name.splitn(3, ':').nth(2).unwrap_or("Guest"),"apps":c.apps,"expires_at":c.expires_at})).collect();
|
||||
Ok(json!({
|
||||
"state": state,
|
||||
"fips_address": crate::fips::iface::fips0_ula().map(|a| a.to_string()),
|
||||
"apps": apps,
|
||||
"grants": grants,
|
||||
"nostr_relays": self.config.nostr_relays,
|
||||
"publication_enabled": true,
|
||||
"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. Automated gateways and Nostr publishing are 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.",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -37,11 +359,45 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let update = serde_json::from_value(params.context("Missing publishing settings")?)?;
|
||||
let update: publishing::Update =
|
||||
serde_json::from_value(params.context("Missing publishing settings")?)?;
|
||||
if let publishing::Change::RecordNsite { receipt, .. } = &update.change {
|
||||
let event: nostr_sdk::Event = serde_json::from_value(receipt.event.clone())?;
|
||||
event.verify().context("Invalid nsite event signature")?;
|
||||
}
|
||||
let (state, project_id) = publishing::update(&self.config.data_dir, update).await?;
|
||||
Ok(json!({ "state": state, "project_id": project_id }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_publishing_nsite_prepare(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
id: String,
|
||||
version: u64,
|
||||
server: String,
|
||||
html: String,
|
||||
}
|
||||
let request: Request = serde_json::from_value(params.context("Missing nsite settings")?)?;
|
||||
let state = publishing::load(&self.config.data_dir).await?;
|
||||
if state.version != request.version {
|
||||
anyhow::bail!("Publishing settings changed. Reload before preparing the nsite");
|
||||
}
|
||||
let project = state
|
||||
.projects
|
||||
.get(&request.id)
|
||||
.context("Website project not found")?;
|
||||
if request.html.len() > 512 * 1024 || request.html.contains('\0') {
|
||||
anyhow::bail!("Prepared website exceeds the HTML limit");
|
||||
}
|
||||
let mut prepared = project.clone();
|
||||
prepared.draft = request.html;
|
||||
publishing::nsite::prepare(&prepared, &request.server)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_publishing_dns(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
|
||||
Reference in New Issue
Block a user