Files
archy/core/archipelago/src/publishing/mod.rs
T

901 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Node-owned publishing drafts. Saving intent never opens a listener or claims
//! reachability. Transport adapters must supply independent live evidence.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use tokio::sync::Mutex;
mod firewall;
pub mod gateway;
pub mod nsite;
pub mod serving;
pub mod tor;
static WRITE_LOCK: Mutex<()> = Mutex::const_new(());
const MAX_STATE: usize = 16 * 1024 * 1024;
const MAX_HTML: usize = 512 * 1024;
const MAX_PROJECTS: usize = 32;
const MAX_REVISIONS: usize = 20;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum Route {
Fips,
PublicWeb,
Tor,
Nostr,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Domain {
pub hostname: String,
pub destination: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Project {
pub id: String,
pub name: String,
pub routes: BTreeSet<Route>,
pub domain: Option<Domain>,
pub draft: String,
pub revisions: Vec<Revision>,
#[serde(default)]
pub fips_publication: Option<Publication>,
#[serde(default)]
pub tor_publication: Option<Publication>,
#[serde(default)]
pub nsite_receipt: Option<nsite::Receipt>,
#[serde(default)]
pub local_archive: Option<LocalArchive>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalArchive {
pub sha256: String,
pub size: usize,
pub pubkey: 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)]
#[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>,
pub port: u16,
pub html: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Revision {
pub id: String,
pub created_at: String,
pub html: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct State {
pub schema: u32,
pub version: u64,
pub connections: BTreeSet<Route>,
pub projects: BTreeMap<String, Project>,
}
impl Default for State {
fn default() -> Self {
Self {
schema: 1,
version: 0,
connections: BTreeSet::new(),
projects: BTreeMap::new(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Change {
// Only the local storage adapter can claim a verified archive receipt.
#[serde(skip_deserializing)]
RecordLocalArchive {
id: String,
receipt: LocalArchive,
},
RecordNsite {
id: String,
receipt: nsite::Receipt,
},
Connections {
routes: BTreeSet<Route>,
},
Create {
name: String,
},
Save {
id: String,
name: String,
routes: BTreeSet<Route>,
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,
acknowledge_public: bool,
},
UnshareArchive {
id: String,
route: Route,
},
PublishFips {
id: String,
acknowledge_public: bool,
},
PublishTor {
id: String,
acknowledge_public: bool,
},
UnpublishTor {
id: String,
},
UnpublishFips {
id: String,
},
Restore {
id: String,
revision: String,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Update {
pub version: u64,
pub change: Change,
}
/// ASCII DNS names only; callers may enter an IDNA A-label. No URLs, wildcards,
/// ports, path fragments, or private overlay suffixes as public domain names.
pub fn hostname(value: &str) -> Result<String> {
let value = value.trim().trim_end_matches('.').to_ascii_lowercase();
if value.len() > 253
|| !value.contains('.')
|| value.parse::<std::net::IpAddr>().is_ok()
|| [".fips", ".onion", ".local", ".localhost", ".internal"]
.iter()
.any(|s| value.ends_with(s))
|| !value.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
{
bail!("Enter a public domain name, without a protocol, port or path");
}
Ok(value)
}
fn name(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() || value.len() > 100 || value.chars().any(char::is_control) {
bail!("Website name must contain 1–100 characters without control characters");
}
Ok(value.to_owned())
}
pub(crate) fn public_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(a) => {
let o = a.octets();
!a.is_private()
&& !a.is_loopback()
&& !a.is_link_local()
&& !a.is_multicast()
&& !a.is_unspecified()
&& !a.is_broadcast()
&& !a.is_documentation()
&& o[0] != 0
&& o[0] < 240
&& !(o[0] == 100 && (64..=127).contains(&o[1]))
&& !(o[0] == 198 && (o[1] == 18 || o[1] == 19))
&& !(o[0] == 192 && o[1] == 0 && o[2] == 0)
}
std::net::IpAddr::V6(a) => {
let s = a.segments();
// Only global unicast; excludes ULA/FIPS, mapped-v4, loopback,
// multicast and link-local, plus documentation allocations.
(s[0] & 0xe000) == 0x2000
&& !(s[0] == 0x2001 && s[1] < 0x200)
&& s[0] != 0x2002
&& !(s[0] == 0x2001 && s[1] == 0x0db8)
&& !(s[0] == 0x3fff && s[1] < 0x1000)
}
}
}
#[derive(Debug, Serialize)]
pub struct DnsRecord {
pub record_type: &'static str,
pub name: String,
pub value: String,
pub ttl: u32,
}
pub fn dns_records(domain: &Domain) -> Result<Vec<DnsRecord>> {
let host = hostname(&domain.hostname)?;
let Some(raw) = &domain.destination else {
return Ok(vec![]);
};
let target = raw.trim();
if target.is_empty() {
return Ok(vec![]);
}
let (record_type, value) = match target.parse::<std::net::IpAddr>() {
Ok(ip) => {
if !public_ip(ip) {
bail!("Use the gateway's public IP or a verified public node IP; private and FIPS addresses are not public web destinations");
}
(if ip.is_ipv4() { "A" } else { "AAAA" }, ip.to_string())
}
Err(_) => {
let target = hostname(target)?;
if target == host {
bail!("A domain cannot point to itself with a CNAME");
}
("CNAME", target)
}
};
Ok(vec![DnsRecord {
record_type,
name: host,
value,
ttl: 3600,
}])
}
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
.get_mut(&id)
.context("Website project not found")?;
if receipt.sha256 != nsite::hash(p.draft.as_bytes())
|| receipt.size != p.draft.len()
{
bail!("The draft changed while storing it. The stored file is retained; review the current draft");
}
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")?;
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()
{
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 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")?;
publication.public_archive = None;
Ok(Some(id))
}
Change::RecordNsite { id, receipt } => {
receipt.validate(&id)?;
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
p.nsite_receipt = Some(receipt);
Ok(Some(id))
}
Change::Connections { routes } => {
self.connections = routes;
Ok(None)
}
Change::Create { name: raw } => {
if self.projects.len() >= MAX_PROJECTS {
bail!("Maximum number of website projects reached");
}
let name = name(&raw)?;
let id = uuid::Uuid::new_v4().to_string();
self.projects.insert(
id.clone(),
Project {
id: id.clone(),
name,
routes: self.connections.clone(),
domain: None,
draft: String::new(),
revisions: vec![],
fips_publication: None,
tor_publication: None,
nsite_receipt: None,
local_archive: None,
},
);
Ok(Some(id))
}
Change::Save {
id,
name: raw,
routes,
mut domain,
html,
} => {
let name = name(&raw)?;
if html.len() > MAX_HTML || html.contains('\0') {
bail!("Website HTML must be at most 512 KiB and contain no NUL bytes");
}
if let Some(d) = domain.as_mut() {
d.hostname = hostname(&d.hostname)?;
if !routes.contains(&Route::PublicWeb) && !routes.contains(&Route::Nostr) {
bail!("A public domain requires public web or an nsite gateway");
}
dns_records(d)?;
}
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
if p.fips_publication.is_some()
&& !routes.contains(&Route::Fips)
&& !routes.contains(&Route::PublicWeb)
{
bail!("Unpublish the FIPS website before removing its route");
}
if p.tor_publication.is_some() && !routes.contains(&Route::Tor) {
bail!("Unpublish the onion website before removing its route");
}
if p.draft != html {
p.revisions.push(Revision {
id: uuid::Uuid::new_v4().to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
html: html.clone(),
});
if p.revisions.len() > MAX_REVISIONS {
p.revisions.remove(0);
}
}
p.name = name;
p.routes = routes;
p.domain = domain;
p.draft = html;
Ok(Some(id))
}
Change::PublishFips {
id,
acknowledge_public,
} => {
if !acknowledge_public {
bail!("Confirm that anyone with a FIPS route may view this website");
}
let used: BTreeSet<u16> = self
.projects
.values()
.filter_map(|p| p.fips_publication.as_ref().map(|p| p.port))
.collect();
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
if (!p.routes.contains(&Route::Fips) && !p.routes.contains(&Route::PublicWeb))
|| p.draft.trim().is_empty()
{
bail!("Save a website draft and select FIPS or public web before publishing");
}
let port = match &p.fips_publication {
Some(old) => old.port,
None => (32000..32032)
.find(|port| !used.contains(port))
.context("No website ports available")?,
};
p.fips_publication = Some(Publication {
nsite_asset: None,
public_archive: None,
port,
html: p.draft.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
});
Ok(Some(id))
}
Change::PublishTor {
id,
acknowledge_public,
} => {
if !acknowledge_public {
bail!("Confirm that anyone with the onion address may view this website");
}
let used: BTreeSet<u16> = self
.projects
.values()
.filter_map(|p| p.tor_publication.as_ref().map(|p| p.port))
.collect();
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
if !p.routes.contains(&Route::Tor) || p.draft.trim().is_empty() {
bail!("Save a website draft and select Tor before publishing");
}
let port = match &p.tor_publication {
Some(old) => old.port,
None => (32100..32132)
.find(|port| !used.contains(port))
.context("No onion website ports available")?,
};
p.tor_publication = Some(Publication {
nsite_asset: None,
public_archive: None,
port,
html: p.draft.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
});
Ok(Some(id))
}
Change::UnpublishTor { id } => {
self.projects
.get_mut(&id)
.context("Website project not found")?
.tor_publication = None;
Ok(Some(id))
}
Change::UnpublishFips { id } => {
self.projects
.get_mut(&id)
.context("Website project not found")?
.fips_publication = None;
Ok(Some(id))
}
Change::Restore { id, revision } => {
let p = self
.projects
.get_mut(&id)
.context("Website project not found")?;
let previous = p
.revisions
.iter()
.find(|r| r.id == revision)
.context("Website revision not found")?
.html
.clone();
p.draft = previous;
Ok(Some(id))
}
}
}
}
pub async fn load(root: &Path) -> Result<State> {
let path = root.join("publishing/state.json");
if let Ok(meta) = tokio::fs::metadata(&path).await {
if meta.len() > MAX_STATE as u64 {
bail!("Publishing storage limit exceeded; existing state has been preserved");
}
}
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(State::default()),
Err(e) => return Err(e.into()),
};
let state: State = serde_json::from_slice(&bytes)
.context("Publishing state is unreadable; existing data has been preserved")?;
if state.schema != 1 {
bail!("Unsupported publishing state version; upgrade before making changes");
}
let mut ports = BTreeSet::new();
for (id, project) in &state.projects {
if project.id != *id
|| uuid::Uuid::parse_str(id)
.map(|u| u.to_string() != *id)
.unwrap_or(true)
{
bail!("Invalid stored website identity; existing state has been preserved");
}
for (publication, range) in [
(&project.fips_publication, 32000..32032),
(&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.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");
}
}
}
}
Ok(state)
}
/// Serialize read-modify-write and reject stale browser state. Atomic replacement
/// ensures a failed save cannot leave partial JSON or silently reset projects.
pub async fn update(root: &Path, request: Update) -> Result<(State, Option<String>)> {
let _guard = WRITE_LOCK.lock().await;
let mut state = load(root).await?;
if state.version != request.version {
bail!("Publishing settings changed in another window. Reload before saving");
}
let id = state.apply(request.change)?;
state.version = state
.version
.checked_add(1)
.context("Publishing version exhausted")?;
let bytes = serde_json::to_vec_pretty(&state)?;
if bytes.len() > MAX_STATE {
bail!(
"Publishing storage is full (16 MiB). Export older projects before adding more content"
);
}
let dir = root.join("publishing");
tokio::fs::create_dir_all(&dir).await?;
let tmp = dir.join(format!("state-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
use tokio::io::AsyncWriteExt;
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
opts.mode(0o600);
let mut f = opts.open(&tmp).await?;
f.write_all(&bytes).await?;
f.sync_all().await?;
tokio::fs::rename(&tmp, dir.join("state.json")).await?;
// Persist the rename as well as the file contents across power loss.
tokio::fs::File::open(&dir).await?.sync_all().await?;
Ok::<(), anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = tokio::fs::remove_file(&tmp).await;
}
result?;
serving::replace_snapshot(state.clone()).await;
Ok((state, id))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_archive_receipts_cannot_be_claimed_by_clients_or_publish_routes() {
assert!(serde_json::from_value::<Change>(
serde_json::json!({"action":"record-local-archive", "id":"x", "receipt":{}})
)
.is_err());
let mut state = State::default();
let id = state
.apply(Change::Create {
name: "Local archive".into(),
})
.unwrap()
.unwrap();
state.projects.get_mut(&id).unwrap().draft = "<p>Private draft</p>".into();
let draft = &state.projects[&id].draft;
let mut receipt = LocalArchive {
sha256: nsite::hash(draft.as_bytes()),
size: draft.len(),
pubkey: "a".repeat(64),
created_at: chrono::Utc::now().to_rfc3339(),
};
state
.apply(Change::RecordLocalArchive {
id: id.clone(),
receipt: receipt.clone(),
})
.unwrap();
let p = &state.projects[&id];
assert!(
p.routes.is_empty()
&& p.fips_publication.is_none()
&& p.tor_publication.is_none()
&& p.nsite_receipt.is_none()
);
receipt.sha256 = "b".repeat(64);
assert!(state
.apply(Change::RecordLocalArchive { id, receipt })
.is_err());
}
#[tokio::test]
async fn concurrent_edit_is_rejected_and_project_survives_reload() {
let d = tempfile::tempdir().unwrap();
let (s, id) = update(
d.path(),
Update {
version: 0,
change: Change::Create {
name: "My site".into(),
},
},
)
.await
.unwrap();
assert_eq!(s.version, 1);
assert!(update(
d.path(),
Update {
version: 0,
change: Change::Connections {
routes: BTreeSet::new()
}
}
)
.await
.is_err());
assert!(load(d.path())
.await
.unwrap()
.projects
.contains_key(&id.unwrap()));
}
#[tokio::test]
async fn corrupt_state_is_not_replaced() {
let d = tempfile::tempdir().unwrap();
tokio::fs::create_dir(d.path().join("publishing"))
.await
.unwrap();
let path = d.path().join("publishing/state.json");
tokio::fs::write(&path, "broken").await.unwrap();
assert!(update(
d.path(),
Update {
version: 0,
change: Change::Create {
name: "Site".into()
}
}
)
.await
.is_err());
assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), "broken");
}
#[test]
fn reject_private_targets_and_configuration_injection() {
for target in [
"127.0.0.1",
"10.0.0.1",
"100.64.0.1",
"fd12::1",
"::1",
"192.168.1.2",
"::ffff:8.8.8.8",
"2002:7f00:1::1",
"2001::1",
"192.0.0.1",
"node.fips",
"a.onion",
"example.com; bad",
"https://example.com",
] {
assert!(
dns_records(&Domain {
hostname: "www.example.com".into(),
destination: Some(target.into())
})
.is_err(),
"{target}"
);
}
for host in [
"../x",
"*.example.com",
"example.com:443",
"a\nb.example.com",
"-bad.com",
] {
assert!(hostname(host).is_err());
}
}
#[test]
fn public_web_reuses_fips_upstream_without_requiring_a_second_route_choice() {
let mut state = State::default();
state.connections.insert(Route::PublicWeb);
let id = state
.apply(Change::Create {
name: "Public site".into(),
})
.unwrap()
.unwrap();
state.projects.get_mut(&id).unwrap().draft = "<h1>Public</h1>".into();
assert!(state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: false
})
.is_err());
state
.apply(Change::PublishFips {
id: id.clone(),
acknowledge_public: true,
})
.unwrap();
assert!(state.projects[&id].fips_publication.is_some());
assert!(!state.projects[&id].routes.contains(&Route::Fips));
let save = |routes| Change::Save {
id: id.clone(),
name: "Public site".into(),
routes,
domain: None,
html: "<h1>Public</h1>".into(),
};
state
.apply(save([Route::PublicWeb].into_iter().collect()))
.unwrap();
assert!(state.apply(save(BTreeSet::new())).is_err());
}
#[test]
fn multiple_routes_and_restore_do_not_publish() {
let mut s = State::default();
s.apply(Change::Connections {
routes: [Route::Fips, Route::PublicWeb].into_iter().collect(),
})
.unwrap();
let id = s
.apply(Change::Create {
name: "Site".into(),
})
.unwrap()
.unwrap();
assert_eq!(s.projects[&id].routes, s.connections);
assert!(s.projects[&id].fips_publication.is_none());
let routes = [Route::Fips, Route::Tor, Route::PublicWeb, Route::Nostr]
.into_iter()
.collect();
s.apply(Change::Save {
id: id.clone(),
name: "Site".into(),
routes,
domain: None,
html: "<h1>Hello</h1>".into(),
})
.unwrap();
let revision = s.projects[&id].revisions[0].id.clone();
s.apply(Change::Save {
id: id.clone(),
name: "Site".into(),
routes: BTreeSet::new(),
domain: None,
html: "<h1>New</h1>".into(),
})
.unwrap();
s.apply(Change::Restore {
id: id.clone(),
revision,
})
.unwrap();
assert_eq!(s.projects[&id].draft, "<h1>Hello</h1>");
assert_eq!(s.projects[&id].revisions.len(), 2);
assert_eq!(s.connections.len(), 2);
}
#[test]
fn dns_records_distinguish_ip_and_alias() {
for (target, kind) in [
("8.8.8.8", "A"),
("2606:4700:4700::1111", "AAAA"),
("gateway.example.org", "CNAME"),
] {
let records = dns_records(&Domain {
hostname: "www.example.com".into(),
destination: Some(target.into()),
})
.unwrap();
assert_eq!(records[0].record_type, kind);
assert_eq!(records[0].name, "www.example.com");
}
}
}