Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/lib/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<site_id>/`) here. Absent on older aggregators.
*/
gateway?: string | null;
last_seen_s: number;
}

Expand Down
16 changes: 13 additions & 3 deletions docs/hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <site_id>` 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/<site_id>/` 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

Expand Down
76 changes: 76 additions & 0 deletions node/crates/c0mpute-core/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down Expand Up @@ -300,6 +303,37 @@ fn handle_setting(env: &str, file: &str) -> Option<String> {
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/<site>/` from without a central list of them.
pub fn announced_gateway(config: &Config) -> Option<String> {
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<String> {
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<String> {
let code = raw.trim().to_ascii_lowercase();
Expand Down Expand Up @@ -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-{}-{}",
Expand Down
21 changes: 20 additions & 1 deletion node/crates/c0mpute-core/src/status_aggregator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value>,
/// 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/<site>/`.
gateway: Option<String>,
last_seen_s: u64,
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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": {
Expand All @@ -812,7 +824,7 @@ mod tests {
mk(
&["c0mpute:cpu"],
Some(100),
serde_json::json!({"ref": "<bad>", "name": "has space"}),
serde_json::json!({"ref": "<bad>", "name": "has space", "gateway": "javascript:alert(1)"}),
),
),
];
Expand All @@ -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 {
Expand Down
Loading