mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(relay): stop requiring operator API origin for admin console boot
RELAY_OPERATOR_PUBKEYS is the shared allowlist for both the NIP-98 admin console and the community-provisioning endpoints, but only provisioning needs RELAY_OPERATOR_API_ORIGIN. The boot hard-error forced admin-console operators to configure a provisioning surface they never use. Demote the boot error to a WARN naming the affected feature, and keep the provisioning endpoints fail-closed at request time: authorize_operator_request already rejects with a clean 500 when the origin is unset, before any replay or DB access. Document the decoupling and the NIP-11 admin_api advertisement in the env examples and the admin README. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -88,10 +88,22 @@ RELAY_URL=ws://localhost:3000
|
||||
# 2. RELAY_OWNER_PUBKEY — implicit Operator fallback when RELAY_OPERATOR_PUBKEYS is unset.
|
||||
# 3. relay_operators table — DB-managed Operator/Moderator roster.
|
||||
# The dashboard requires a NIP-07 browser extension.
|
||||
# Setting RELAY_OPERATOR_PUBKEYS for the admin console does NOT require
|
||||
# RELAY_OPERATOR_API_ORIGIN; that origin is only for community provisioning
|
||||
# (see below). When BUZZ_ADMIN_HOST is set, the relay advertises the admin
|
||||
# origin in its NIP-11 document (`admin_api` field) so clients can auto-discover
|
||||
# the console without manual URL entry.
|
||||
# RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
|
||||
#
|
||||
# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
|
||||
# BUZZ_ADMIN_WEB_DIR=./admin-web/dist
|
||||
#
|
||||
# Canonical origin (http(s)://host[:port], no path) that community-provisioning
|
||||
# NIP-98 requests are verified against. Required only to USE the provisioning
|
||||
# endpoints (POST /operator/communities) — not for the admin console. When
|
||||
# RELAY_OPERATOR_PUBKEYS is set but this is unset, the relay boots with a WARN
|
||||
# and provisioning requests fail closed until it is set.
|
||||
# RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000
|
||||
|
||||
# Shared Redis-backed admission limits. Defaults shown below; each value must
|
||||
# be a positive integer.
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
- `Host`/`Origin` matching is retained in all modes as defense-in-depth.
|
||||
- **Migration from the previous `BUZZ_ADMIN_INSECURE_NO_AUTH=true`:** replace
|
||||
with `BUZZ_ADMIN_AUTH=disabled`. The behavior is identical.
|
||||
- The relay NIP-11 relay-information document now advertises the admin API
|
||||
origin in an optional `admin_api` field (`scheme://host[:port]`) whenever the
|
||||
admin surface is configured (`BUZZ_ADMIN_HOST` set). The scheme follows the
|
||||
same loopback rule as NIP-98 `u`-tag verification (`http` for
|
||||
`localhost`/`127.x`/`::1`, else `https`). Clients can auto-discover the admin
|
||||
console instead of requiring manual URL entry; the field is omitted entirely
|
||||
when no admin surface is configured.
|
||||
- `RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
|
||||
`RELAY_OPERATOR_PUBKEYS` is set. The allowlist is shared by the NIP-98 admin
|
||||
console (which needs no origin) and the community-provisioning endpoints
|
||||
(which do). Setting the pubkeys for the admin console no longer forces an
|
||||
origin; the relay logs a `WARN` naming the affected feature, and the
|
||||
community-provisioning endpoints (`POST /operator/communities`) fail closed
|
||||
at request time until `RELAY_OPERATOR_API_ORIGIN` is set.
|
||||
|
||||
## v0.5.11
|
||||
|
||||
|
||||
@@ -1249,4 +1249,65 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Regression for the RELAY_OPERATOR_API_ORIGIN decoupling: with the
|
||||
/// operator allowlist set but no origin configured (the shape an
|
||||
/// admin-console-only operator boots in), the provisioning endpoints must
|
||||
/// fail closed with a clean 500 — never a panic, and never a silent
|
||||
/// success. This exercises the request-time guard that replaced the boot
|
||||
/// hard-error. It uses a lazy pool and needs no Postgres, because the
|
||||
/// origin check in `authorize_operator_request` runs before any DB access.
|
||||
#[tokio::test]
|
||||
async fn provisioning_fails_closed_when_origin_unset_but_pubkeys_set() {
|
||||
let operator = Keys::generate();
|
||||
|
||||
let mut config = crate::config::Config::from_env().expect("default config loads");
|
||||
config.require_relay_membership = false;
|
||||
config.redis_url = "redis://127.0.0.1:1".to_string();
|
||||
config.relay_operator_pubkeys = vec![operator.public_key().to_hex()];
|
||||
config.relay_operator_api_origin = None;
|
||||
|
||||
let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
|
||||
let db = buzz_db::Db::from_pool(pool.clone());
|
||||
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
|
||||
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||
.expect("redis pool");
|
||||
let pubsub = Arc::new(
|
||||
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
||||
.await
|
||||
.expect("pubsub manager"),
|
||||
);
|
||||
let audit = buzz_audit::AuditService::new(pool.clone());
|
||||
let auth = buzz_auth::AuthService::new(config.auth.clone());
|
||||
let search = buzz_search::SearchService::new(pool.clone());
|
||||
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
|
||||
db.clone(),
|
||||
buzz_workflow::WorkflowConfig::default(),
|
||||
));
|
||||
let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage");
|
||||
let (state, _audit_shutdown) = AppState::new(
|
||||
config,
|
||||
db,
|
||||
redis_pool,
|
||||
audit,
|
||||
pubsub,
|
||||
auth,
|
||||
search,
|
||||
workflow_engine,
|
||||
Keys::generate(),
|
||||
media_storage,
|
||||
);
|
||||
let state = Arc::new(state);
|
||||
|
||||
let response =
|
||||
provision_community(state, &operator, "acme.example", &Keys::generate()).await;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"provisioning must reject fail-closed when the operator API origin is unset"
|
||||
);
|
||||
let body = read_json(response).await;
|
||||
assert_eq!(body["error"], "internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,9 +279,14 @@ pub struct Config {
|
||||
/// Canonical HTTP origin of the deployment-global operator API.
|
||||
///
|
||||
/// Every operator NIP-98 `u` tag is verified against this origin, independent
|
||||
/// of the inbound HTTP `Host` header and tenant registry. Required when
|
||||
/// `RELAY_OPERATOR_PUBKEYS` is non-empty. Set via `RELAY_OPERATOR_API_ORIGIN`
|
||||
/// as an `http://` or `https://` origin with no path, query, or fragment.
|
||||
/// of the inbound HTTP `Host` header and tenant registry. Required only to
|
||||
/// *use* the community-provisioning endpoints: when it is unset, those
|
||||
/// endpoints fail closed at request time (see
|
||||
/// `api::operator::authorize_operator_request`). It is NOT required at boot
|
||||
/// even when `RELAY_OPERATOR_PUBKEYS` is set, because that allowlist is
|
||||
/// shared with the NIP-98 admin console, which needs no origin. Set via
|
||||
/// `RELAY_OPERATOR_API_ORIGIN` as an `http://` or `https://` origin with no
|
||||
/// path, query, or fragment.
|
||||
pub relay_operator_api_origin: Option<String>,
|
||||
|
||||
/// Deployment-level relay operator pubkeys allowed to use the
|
||||
@@ -780,10 +785,21 @@ impl Config {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
if !relay_operator_pubkeys.is_empty() && relay_operator_api_origin.is_none() {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"RELAY_OPERATOR_API_ORIGIN is required when RELAY_OPERATOR_PUBKEYS is configured"
|
||||
.to_string(),
|
||||
));
|
||||
// Do NOT fail closed at boot: RELAY_OPERATOR_PUBKEYS is the shared
|
||||
// allowlist for BOTH the community-provisioning endpoints and the
|
||||
// NIP-98 admin console. Only provisioning needs the canonical
|
||||
// origin, so requiring it at boot would force admin-console
|
||||
// operators to configure a provisioning surface they never use.
|
||||
// The provisioning endpoints stay fail-closed at request time
|
||||
// (see `api::operator::authorize_operator_request`, which rejects
|
||||
// when the origin is unconfigured); this warning names that so an
|
||||
// operator who *did* want provisioning knows why it 500s.
|
||||
warn!(
|
||||
"RELAY_OPERATOR_PUBKEYS is set but RELAY_OPERATOR_API_ORIGIN is not — \
|
||||
the community-provisioning endpoints (POST /operator/communities) will \
|
||||
reject every request until RELAY_OPERATOR_API_ORIGIN is set. The NIP-98 \
|
||||
admin console does not require it and is unaffected."
|
||||
);
|
||||
}
|
||||
|
||||
let auth = buzz_auth::AuthConfig {
|
||||
@@ -2004,7 +2020,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_operator_pubkeys_require_api_origin() {
|
||||
fn relay_operator_pubkeys_without_api_origin_boots_and_warns() {
|
||||
// Regression: RELAY_OPERATOR_PUBKEYS is the shared allowlist for both
|
||||
// community provisioning and the NIP-98 admin console. Configuring the
|
||||
// admin console (pubkeys) must NOT force the provisioning origin — boot
|
||||
// succeeds; provisioning stays fail-closed at request time.
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var(
|
||||
"RELAY_OPERATOR_PUBKEYS",
|
||||
@@ -2014,10 +2034,15 @@ mod tests {
|
||||
let result = Config::from_env();
|
||||
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_API_ORIGIN is required")
|
||||
));
|
||||
let config = result.expect("pubkeys-set/origin-unset must boot, not fail closed");
|
||||
assert_eq!(
|
||||
config.relay_operator_pubkeys,
|
||||
vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()]
|
||||
);
|
||||
assert!(
|
||||
config.relay_operator_api_origin.is_none(),
|
||||
"origin stays unset — only the provisioning path requires it, at request time"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -48,10 +48,16 @@ BUZZ_S3_ADDRESSING_STYLE=path
|
||||
# and the relay_operators table (DB-managed Operator/Moderator roster).
|
||||
# Dashboard requires a NIP-07 browser extension (nos2x or Alby).
|
||||
# Any unrecognised BUZZ_ADMIN_AUTH value, or conflicting combinations, aborts startup.
|
||||
# When BUZZ_ADMIN_HOST is set, the relay advertises the admin origin in its NIP-11
|
||||
# document (`admin_api` field) so clients auto-discover the console without manual entry.
|
||||
# Setting RELAY_OPERATOR_PUBKEYS for the console does NOT require RELAY_OPERATOR_API_ORIGIN;
|
||||
# that origin is only for community provisioning (POST /operator/communities), which fails
|
||||
# closed at request time until it is set (the relay boots with a WARN in the meantime).
|
||||
# BUZZ_ADMIN_HOST=admin.buzz.example.com
|
||||
# BUZZ_ADMIN_AUTH=token
|
||||
# BUZZ_ADMIN_TOKEN=CHANGE_ME_RANDOM_64_HEX
|
||||
# RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
|
||||
# RELAY_OPERATOR_API_ORIGIN=https://admin.buzz.example.com
|
||||
|
||||
# Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT.
|
||||
BUZZ_HTTP_PORT=3000
|
||||
|
||||
@@ -90,6 +90,27 @@ RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
|
||||
- `BUZZ_ADMIN_TOKEN` set alongside `nip98` is a startup error (ambiguous intent).
|
||||
- A malformed `RELAY_OWNER_PUBKEY` alongside `nip98` is a startup error (see
|
||||
owner fallback below).
|
||||
- `RELAY_OPERATOR_API_ORIGIN` is **not** required to run the admin console.
|
||||
That origin is only used by the community-provisioning endpoints
|
||||
(`POST /operator/communities`), which share the `RELAY_OPERATOR_PUBKEYS`
|
||||
allowlist. When the pubkeys are set but the origin is not, the relay boots
|
||||
with a `WARN` and provisioning requests fail closed at request time until the
|
||||
origin is set — the admin console is unaffected.
|
||||
|
||||
#### Auto-discovery via NIP-11
|
||||
|
||||
When `BUZZ_ADMIN_HOST` is set, the relay advertises the admin API origin in its
|
||||
NIP-11 relay-information document under an optional `admin_api` field:
|
||||
|
||||
```json
|
||||
{ "admin_api": "https://admin.example.com" }
|
||||
```
|
||||
|
||||
The value is the canonical origin `scheme://host[:port]` (no path), with the
|
||||
scheme derived by the same loopback rule as `u`-tag verification (`http` for
|
||||
`localhost`/`127.x`/`::1`, else `https`). The field is omitted entirely when no
|
||||
admin surface is configured. Clients (such as the desktop console) read this to
|
||||
auto-discover the admin endpoint instead of requiring manual URL entry.
|
||||
|
||||
Each request requires:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user