From ea1b513e12111fc399bac5ebf817fd2d9bb50751 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 01:26:46 +0000 Subject: [PATCH] Advertise an announced gateway origin in capability ads and /api/nodes A gateway whose operator set gateway.announce now puts its public origin in its capability ad (`gateway`), and the node board publishes it as `gateway` on /nodes and /api/nodes. Opt-in (nothing without the setting, never in private mode, only with the gateway role), re-validated by the aggregator to a bare http(s) origin with no credentials. This is how a client that is not a libp2p peer (the Moshpit c0mpute edge, profullstack/moshpit-proxy#31) finds gateways to fetch /s// from, without a central list. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/nodes.ts | 6 ++ docs/hosting.md | 16 +++- node/crates/c0mpute-core/src/capabilities.rs | 76 +++++++++++++++++++ .../c0mpute-core/src/status_aggregator.rs | 21 ++++- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/nodes.ts b/apps/web/src/lib/nodes.ts index 5bc40c28..53b06ef1 100644 --- a/apps/web/src/lib/nodes.ts +++ b/apps/web/src/lib/nodes.ts @@ -22,6 +22,12 @@ export interface NodeRow { name?: string | null; /** The operator's listing; absent on nodes older than 0.2.35. */ pricing?: Pricing | null; + /** + * The node's gateway origin (`http://host:7777`), only when its operator set + * `gateway.announce`. Clients that are not libp2p peers find gateways that + * serve hosted sites (`/s//`) here. Absent on older aggregators. + */ + gateway?: string | null; last_seen_s: number; } diff --git a/docs/hosting.md b/docs/hosting.md index 1adadbbc..347cb05d 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -54,9 +54,19 @@ domain and the domain's TXT names the site, so neither a site owner nor a domain owner can hijack the other. TXT answers are looked up over DNS-over-HTTPS and cached for 10 minutes (2 minutes for a miss). -**Moshpit names**: the gateway side is the same: a Moshpit name whose DNS answers -with gateway addresses and the TXT above is served. Having the Moshpit resolver hand -out live gateway addresses for a site is the next step, in moshcode. +**Moshpit names**: `moshcode name c0mpute ` points a Moshpit name at a +site. The Moshpit registry resolves it to a c0mpute edge (moshpit-proxy +`bin/moshpit-c0mpute-edge.ts`) that holds a Moshpit CA certificate for the name and +fetches `/s//` from any healthy gateway, so no DNS check on the gateway is +involved. + +## Finding gateways + +A gateway whose operator sets `gateway.announce` (its public origin, e.g. +`http://203.0.113.7:7777`) advertises it in its capability ad, and the node board +publishes it as `gateway` on `/api/nodes`. Nothing is advertised without that setting, +and never in private mode. Clients that are not libp2p peers (the Moshpit edge, for +one) use this list to find gateways to fetch sites from. ## Not yet diff --git a/node/crates/c0mpute-core/src/capabilities.rs b/node/crates/c0mpute-core/src/capabilities.rs index d70b1961..79c2f403 100644 --- a/node/crates/c0mpute-core/src/capabilities.rs +++ b/node/crates/c0mpute-core/src/capabilities.rs @@ -207,6 +207,9 @@ pub fn hardware_blob(config: &Config) -> serde_json::Value { blob["name"] = serde_json::Value::String(name); } blob["pricing"] = config.pricing.listing(); + if let Some(origin) = announced_gateway(config) { + blob["gateway"] = serde_json::Value::String(origin); + } if config.roles.contains(&c0mpute_proto::Role::Storage) { let cap = crate::opendisk::capacity(&config.storage, 0); blob["storage"] = serde_json::json!({ @@ -300,6 +303,37 @@ fn handle_setting(env: &str, file: &str) -> Option { sanitize_ref(&raw) } +/// The public HTTP origin this node's gateway answers on, when its operator +/// published one (`gateway.announce`) and the node runs the gateway role. +/// +/// Opt-in by construction: nothing is advertised unless the operator typed the +/// address into their config, which they already do to be listed on d1sks or +/// to front custom domains. Never in private mode. This is what lets a client +/// that is not a libp2p peer (the Moshpit c0mpute edge, for one) find gateways +/// to fetch `/s//` from without a central list of them. +pub fn announced_gateway(config: &Config) -> Option { + if config.private.enabled || !config.roles.contains(&c0mpute_proto::Role::Gateway) { + return None; + } + gateway_origin(config.gateway.announce.as_deref()?) +} + +/// `scheme://host[:port]` of an http(s) URL with a host and no credentials, +/// at most 200 characters, or nothing. Shared with the status aggregator, +/// which re-checks every ad before it publishes one. +pub fn gateway_origin(raw: &str) -> Option { + let url = reqwest::Url::parse(raw.trim()).ok()?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + { + return None; + } + let origin = url.origin().ascii_serialization(); + (origin.len() <= 200).then_some(origin) +} + /// Lowercase `[a-z0-9_-]`, 1..=32 chars, or nothing. pub fn sanitize_ref(raw: &str) -> Option { let code = raw.trim().to_ascii_lowercase(); @@ -541,6 +575,48 @@ mod tests { assert_eq!(sanitize_ref(&"a".repeat(33)), None); } + #[test] + fn gateway_origins_are_sanitized() { + assert_eq!( + gateway_origin(" http://1.2.3.4:7777/ ").as_deref(), + Some("http://1.2.3.4:7777") + ); + assert_eq!( + gateway_origin("https://gw.example.org/some/path?x=1").as_deref(), + Some("https://gw.example.org") + ); + assert_eq!(gateway_origin("ftp://gw.example.org"), None); + assert_eq!(gateway_origin("javascript:alert(1)"), None); + assert_eq!(gateway_origin("http://user:pw@gw.example.org"), None); + assert_eq!(gateway_origin("not a url"), None); + let long = format!("https://{}.example.org", "a".repeat(250)); + assert_eq!(gateway_origin(&long), None); + } + + #[test] + fn gateway_is_advertised_only_when_announced_and_public() { + let mut config = Config::default(); + config.gateway.announce = Some("http://203.0.113.7:7777".into()); + if !config.roles.contains(&c0mpute_proto::Role::Gateway) { + config.roles.push(c0mpute_proto::Role::Gateway); + } + assert_eq!( + announced_gateway(&config).as_deref(), + Some("http://203.0.113.7:7777") + ); + + config.private.enabled = true; + assert_eq!(announced_gateway(&config), None, "never in private mode"); + config.private.enabled = false; + + config.roles.retain(|r| *r != c0mpute_proto::Role::Gateway); + assert_eq!(announced_gateway(&config), None, "only gateways"); + config.roles.push(c0mpute_proto::Role::Gateway); + + config.gateway.announce = None; + assert_eq!(announced_gateway(&config), None, "only when the operator set one"); + } + fn tempdir(name: &str) -> std::path::PathBuf { let p = std::env::temp_dir().join(format!( "c0mpute-cap-test-{}-{}", diff --git a/node/crates/c0mpute-core/src/status_aggregator.rs b/node/crates/c0mpute-core/src/status_aggregator.rs index aff09a9f..edf1cfe2 100644 --- a/node/crates/c0mpute-core/src/status_aggregator.rs +++ b/node/crates/c0mpute-core/src/status_aggregator.rs @@ -391,6 +391,12 @@ struct NodeRow { /// What the node offers as storage (`free_bytes`, `total_bytes`, /// `per_gib_month`), when it runs the storage role. Numbers only. storage: Option, + /// The public origin of the node's gateway (`http://host:7777`), only + /// when its operator announced one. Opt-in, so it does not break the rule + /// above: the operator published this address themselves. Re-validated to + /// a bare http(s) origin. Clients that are not libp2p peers use it to find + /// gateways that serve `/s//`. + gateway: Option, last_seen_s: u64, } @@ -546,6 +552,11 @@ fn build_nodes(ads: &[(String, c0mpute_net::topics::CapabilityAd)], now: u64) -> .and_then(crate::capabilities::sanitize_ref), pricing: listing(&ad.hardware), storage: storage_offer(&ad.hardware), + gateway: ad + .hardware + .get("gateway") + .and_then(|v| v.as_str()) + .and_then(crate::capabilities::gateway_origin), last_seen_s: now.saturating_sub(ad.published_at_ms) / 1000, }) .collect(); @@ -798,6 +809,7 @@ mod tests { "version": "0.2.32", "ref": "chovy", "name": "profullstack", + "gateway": "http://203.0.113.7:7777/", "cpu": "Example CPU", "bench_at": "2026-11-01T12:00:00Z", "benchmark": {"workloads": {"fib": { @@ -812,7 +824,7 @@ mod tests { mk( &["c0mpute:cpu"], Some(100), - serde_json::json!({"ref": "", "name": "has space"}), + serde_json::json!({"ref": "", "name": "has space", "gateway": "javascript:alert(1)"}), ), ), ]; @@ -832,6 +844,13 @@ mod tests { assert_eq!(p.nodes[1].referral, None, "invalid ref dropped"); assert_eq!(p.nodes[1].name, None, "invalid name dropped"); assert_eq!(p.nodes[0].name.as_deref(), Some("profullstack")); + assert_eq!( + p.nodes[0].gateway.as_deref(), + Some("http://203.0.113.7:7777"), + "an announced gateway is published as a bare origin" + ); + assert_eq!(p.nodes[1].gateway, None, "an invalid gateway is dropped"); + assert_eq!(p.nodes[2].gateway, None, "no gateway unless announced"); assert_eq!( p.operators, vec![OperatorRow {