mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): advertise admin API origin via NIP-11 admin_api field
Desktop had no way to discover the admin API endpoint and forced users to type its URL by hand. Advertise the canonical admin origin (scheme://host[:port], no path) in the NIP-11 relay-information document under an optional admin_api field, present iff the admin surface is configured (config.admin.is_some()). Extract the loopback scheme rule into a shared scheme_for_host helper so the advertised origin and the NIP-98 u-tag the relay verifies can never use different schemes; a test enforces the invariant. The helper now parses IPv6 authorities (bracketed [::1]:3000 and bare ::1) correctly instead of letting a colon-split mangle them. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -88,17 +88,48 @@ pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool {
|
||||
.is_some_and(|host| host == config.host)
|
||||
}
|
||||
|
||||
/// Derive the canonical URL for a NIP-98 `u`-tag check.
|
||||
/// Scheme for an admin authority: `http://` for loopback hosts (localhost,
|
||||
/// `::1`, 127.x), else `https://` — matching local dev via the Justfile.
|
||||
///
|
||||
/// Scheme: `https://` unless the host is a loopback address (localhost or
|
||||
/// 127.x.x.x), in which case `http://` is used — matching local dev via
|
||||
/// the Justfile.
|
||||
fn canonical_url(host: &str, path: &str) -> String {
|
||||
let host_part = host.split(':').next().unwrap_or(host);
|
||||
/// Shared by [`canonical_url`] (NIP-98 `u`-tag verification) and
|
||||
/// [`admin_api_origin`] (NIP-11 advertisement) so the origin the relay
|
||||
/// advertises and the origin it verifies against can never use different
|
||||
/// schemes.
|
||||
fn scheme_for_host(host: &str) -> &'static str {
|
||||
// Strip any `:port` to get the bare host. IPv6 literals carry their own
|
||||
// colons, so a plain `split(':')` would mangle them: bracketed authorities
|
||||
// (`[::1]:3000`) take the text inside the brackets, and an unbracketed
|
||||
// multi-colon host is a bare IPv6 literal with no port.
|
||||
let host_part = if let Some(rest) = host.strip_prefix('[') {
|
||||
rest.split(']').next().unwrap_or(rest)
|
||||
} else if host.matches(':').count() > 1 {
|
||||
host
|
||||
} else {
|
||||
host.split(':').next().unwrap_or(host)
|
||||
};
|
||||
let is_loopback =
|
||||
host_part == "localhost" || host_part == "::1" || host_part.starts_with("127.");
|
||||
let scheme = if is_loopback { "http" } else { "https" };
|
||||
format!("{scheme}://{host}{path}")
|
||||
if is_loopback {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the canonical URL for a NIP-98 `u`-tag check.
|
||||
fn canonical_url(host: &str, path: &str) -> String {
|
||||
format!("{}://{host}{path}", scheme_for_host(host))
|
||||
}
|
||||
|
||||
/// Canonical admin API origin (`scheme://host[:port]`, no path) advertised in
|
||||
/// the NIP-11 document so desktop can auto-discover the admin surface instead
|
||||
/// of requiring manual URL entry.
|
||||
///
|
||||
/// Scheme follows the same loopback rule as [`canonical_url`], so a client that
|
||||
/// discovers this origin signs NIP-98 `u` tags against the exact scheme the
|
||||
/// relay verifies.
|
||||
pub(crate) fn admin_api_origin(host: &str) -> String {
|
||||
format!("{}://{host}", scheme_for_host(host))
|
||||
}
|
||||
|
||||
/// Whether a request method typically carries a body.
|
||||
@@ -415,7 +446,8 @@ fn origin_matches_host(origin: &str, host: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bearer_credential, canonical_url, method_has_body, nostr_credential, origin_matches_host,
|
||||
admin_api_origin, bearer_credential, canonical_url, method_has_body, nostr_credential,
|
||||
origin_matches_host,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -485,6 +517,51 @@ mod tests {
|
||||
assert_eq!(canonical_url("127.0.0.1", "/path"), "http://127.0.0.1/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_api_origin_uses_https_for_non_loopback_hosts() {
|
||||
assert_eq!(
|
||||
admin_api_origin("admin.example.com"),
|
||||
"https://admin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
admin_api_origin("admin.example.com:8443"),
|
||||
"https://admin.example.com:8443"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_api_origin_uses_http_for_loopback_hosts() {
|
||||
assert_eq!(admin_api_origin("localhost:3000"), "http://localhost:3000");
|
||||
assert_eq!(admin_api_origin("127.0.0.1:3000"), "http://127.0.0.1:3000");
|
||||
assert_eq!(admin_api_origin("::1"), "http://::1");
|
||||
// Bracketed IPv6 authority with a port (the RFC 3986 Host-header form).
|
||||
assert_eq!(admin_api_origin("[::1]:3000"), "http://[::1]:3000");
|
||||
}
|
||||
|
||||
/// The advertised origin and the verified `u`-tag URL must agree on scheme
|
||||
/// for every host, or a discovered origin would sign against a scheme the
|
||||
/// relay rejects.
|
||||
#[test]
|
||||
fn admin_api_origin_scheme_matches_canonical_url_scheme() {
|
||||
for host in [
|
||||
"admin.example.com",
|
||||
"admin.example.com:8443",
|
||||
"localhost:3000",
|
||||
"127.0.0.1:3000",
|
||||
"::1",
|
||||
"[::1]:3000",
|
||||
] {
|
||||
let advertised = admin_api_origin(host);
|
||||
let verified = canonical_url(host, "/api/admin/v1/reports");
|
||||
let advertised_scheme = advertised.split("://").next().expect("scheme");
|
||||
let verified_scheme = verified.split("://").next().expect("scheme");
|
||||
assert_eq!(
|
||||
advertised_scheme, verified_scheme,
|
||||
"advertised and verified schemes must match for host {host}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_bearing_methods_are_correctly_identified() {
|
||||
for m in [
|
||||
|
||||
@@ -28,6 +28,11 @@ pub(crate) fn is_admin_host(state: &crate::state::AppState, headers: &HeaderMap)
|
||||
auth::is_admin_host(state, headers)
|
||||
}
|
||||
|
||||
/// Canonical admin API origin advertised in the NIP-11 document (see
|
||||
/// [`auth::admin_api_origin`]). Re-exported so the NIP-11 builder can derive
|
||||
/// the advertised origin without reaching into the private `auth` module.
|
||||
pub(crate) use auth::admin_api_origin;
|
||||
|
||||
/// Build the deployment-admin routes.
|
||||
///
|
||||
/// Read routes are available in all auth modes.
|
||||
|
||||
@@ -1446,6 +1446,7 @@ mod tests {
|
||||
false,
|
||||
crate::config::DEFAULT_MAX_FRAME_BYTES,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.limitation
|
||||
.expect("limitation")
|
||||
|
||||
@@ -52,6 +52,14 @@ pub struct RelayInfo {
|
||||
/// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pairing_relay_url: Option<String>,
|
||||
/// Canonical origin (`scheme://host[:port]`, no path) of the deployment
|
||||
/// admin API, advertised only when the admin surface is configured
|
||||
/// (`config.admin.is_some()`). Lets desktop auto-discover the admin
|
||||
/// console instead of requiring manual URL entry. Scheme follows the same
|
||||
/// loopback rule as NIP-98 `u`-tag verification (see
|
||||
/// [`crate::api::admin::admin_api_origin`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub admin_api: Option<String>,
|
||||
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
|
||||
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
|
||||
pub relay_self: Option<String>,
|
||||
@@ -138,12 +146,17 @@ impl RelayInfo {
|
||||
/// gates on NIP-43 events — i.e. has a stable key AND enforces
|
||||
/// membership. NIP-43 events are verified against `self`, so it is a
|
||||
/// programmer error to advertise NIP-43 without a `relay_self`.
|
||||
///
|
||||
/// `admin_api` is the canonical admin API origin, advertised only when the
|
||||
/// admin surface is configured; a per-deployment scalar derived from
|
||||
/// config by the caller (see [`nip11_document`]).
|
||||
pub fn build(
|
||||
relay_self: Option<&str>,
|
||||
icon: Option<&str>,
|
||||
advertise_nip43: bool,
|
||||
max_message_length: usize,
|
||||
pairing_relay_url: Option<&str>,
|
||||
admin_api: Option<&str>,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
!advertise_nip43 || relay_self.is_some(),
|
||||
@@ -168,6 +181,7 @@ impl RelayInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
limitation: Some(relay_limitation(max_message_length)),
|
||||
pairing_relay_url: pairing_relay_url.map(str::to_string),
|
||||
admin_api: admin_api.map(str::to_string),
|
||||
relay_self: relay_self.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -240,12 +254,14 @@ fn push_descriptor(
|
||||
pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
|
||||
let (relay_self, advertise_nip43) = nip11_facts(state);
|
||||
let icon = workspace_icon_for_host(state, raw_host).await;
|
||||
let admin_api = admin_api_advertisement(state.config.admin.as_ref());
|
||||
let mut info = RelayInfo::build(
|
||||
relay_self.as_deref(),
|
||||
icon.as_deref(),
|
||||
advertise_nip43,
|
||||
state.config.max_frame_bytes,
|
||||
state.config.pairing_relay_url.as_deref(),
|
||||
admin_api.as_deref(),
|
||||
);
|
||||
let tenant_host = if state.config.push_gateway_delivery_url.is_some() {
|
||||
crate::tenant::bind_community(&state.db, raw_host)
|
||||
@@ -310,6 +326,18 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
|
||||
(relay_self, advertise_nip43)
|
||||
}
|
||||
|
||||
/// Derives the NIP-11 `admin_api` advertisement: the canonical admin API
|
||||
/// origin, present iff the admin surface is configured
|
||||
/// (`config.admin.is_some()`), absent otherwise — never an empty string.
|
||||
///
|
||||
/// The origin is derived purely from the configured admin host by
|
||||
/// [`crate::api::admin::admin_api_origin`] (loopback → `http`, else `https`),
|
||||
/// so it is a per-deployment scalar with no unscoped DB/tenant input, keeping
|
||||
/// [`RelayInfo::build`] within its static-input contract.
|
||||
fn admin_api_advertisement(admin: Option<&crate::config::AdminConfig>) -> Option<String> {
|
||||
admin.map(|admin| crate::api::admin::admin_api_origin(&admin.host))
|
||||
}
|
||||
|
||||
/// Multi-tenant conformance static-input fence (surface row "NIP-11 relay info
|
||||
/// and relay `self`").
|
||||
///
|
||||
@@ -337,6 +365,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
|
||||
bool,
|
||||
usize,
|
||||
Option<&str>,
|
||||
Option<&str>,
|
||||
) -> RelayInfo = RelayInfo::build;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -391,7 +420,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_advertises_buzz_repository_url() {
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
assert_eq!(info.software, "https://github.com/block/buzz");
|
||||
}
|
||||
|
||||
@@ -403,6 +432,7 @@ mod tests {
|
||||
false,
|
||||
DEFAULT_MAX_FRAME_BYTES,
|
||||
Some("wss://pairing.buzz.xyz"),
|
||||
None,
|
||||
);
|
||||
let json = serde_json::to_value(&info).expect("serialize");
|
||||
assert_eq!(
|
||||
@@ -411,7 +441,7 @@ mod tests {
|
||||
Some("wss://pairing.buzz.xyz")
|
||||
);
|
||||
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
let json = serde_json::to_value(&info).expect("serialize");
|
||||
assert!(json.get("pairing_relay_url").is_none());
|
||||
}
|
||||
@@ -427,6 +457,7 @@ mod tests {
|
||||
false,
|
||||
DEFAULT_MAX_FRAME_BYTES,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
info.icon.as_deref(),
|
||||
@@ -439,7 +470,7 @@ mod tests {
|
||||
);
|
||||
|
||||
for icon in [None, Some("")] {
|
||||
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
assert!(info.icon.is_none());
|
||||
let json = serde_json::to_value(&info).expect("serialize");
|
||||
assert!(
|
||||
@@ -459,7 +490,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn max_message_length_uses_configured_frame_limit() {
|
||||
let info = RelayInfo::build(None, None, false, 262_144, None);
|
||||
let info = RelayInfo::build(None, None, false, 262_144, None, None);
|
||||
let limitation = info.limitation.expect("limitation");
|
||||
assert_eq!(limitation.max_message_length, Some(262_144));
|
||||
}
|
||||
@@ -490,7 +521,7 @@ mod tests {
|
||||
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
|
||||
#[test]
|
||||
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
assert!(info.relay_self.is_none());
|
||||
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
||||
}
|
||||
@@ -503,7 +534,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
|
||||
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
assert_eq!(info.relay_self.as_deref(), Some(pk));
|
||||
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
||||
}
|
||||
@@ -512,7 +543,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_membership_relay_advertises_self_and_nip43() {
|
||||
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
assert_eq!(info.relay_self.as_deref(), Some(pk));
|
||||
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
||||
}
|
||||
@@ -523,6 +554,60 @@ mod tests {
|
||||
#[test]
|
||||
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
|
||||
fn build_nip43_without_self_panics_in_debug() {
|
||||
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
}
|
||||
|
||||
fn admin_config(host: &str) -> crate::config::AdminConfig {
|
||||
crate::config::AdminConfig {
|
||||
host: host.to_string(),
|
||||
auth: crate::config::AdminAuth::Nip98,
|
||||
web_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The admin surface is unconfigured: `admin_api` must be absent, and the
|
||||
/// serialized document must omit the field entirely (not `null`).
|
||||
#[test]
|
||||
fn admin_api_absent_when_admin_surface_not_configured() {
|
||||
assert_eq!(admin_api_advertisement(None), None);
|
||||
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
|
||||
assert!(info.admin_api.is_none());
|
||||
let json = serde_json::to_value(&info).expect("serialize");
|
||||
assert!(
|
||||
json.get("admin_api").is_none(),
|
||||
"unconfigured admin surface must omit the `admin_api` field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Loopback admin host → advertised as an `http://` origin (matches the
|
||||
/// NIP-98 canonicalizer's loopback rule so a discovered origin signs
|
||||
/// against the scheme the relay verifies).
|
||||
#[test]
|
||||
fn admin_api_advertised_as_http_for_loopback_host() {
|
||||
let advertised = admin_api_advertisement(Some(&admin_config("127.0.0.1:3000")));
|
||||
assert_eq!(advertised.as_deref(), Some("http://127.0.0.1:3000"));
|
||||
|
||||
let info = RelayInfo::build(
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
DEFAULT_MAX_FRAME_BYTES,
|
||||
None,
|
||||
advertised.as_deref(),
|
||||
);
|
||||
let json = serde_json::to_value(&info).expect("serialize");
|
||||
assert_eq!(
|
||||
json.get("admin_api").and_then(|v| v.as_str()),
|
||||
Some("http://127.0.0.1:3000")
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-loopback admin host → advertised as an `https://` origin, with no
|
||||
/// path/query/fragment (a bare origin).
|
||||
#[test]
|
||||
fn admin_api_advertised_as_https_for_non_loopback_host() {
|
||||
let advertised = admin_api_advertisement(Some(&admin_config("admin.example.com")));
|
||||
assert_eq!(advertised.as_deref(), Some("https://admin.example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user