Files
buzz/crates/buzz-push-gateway/src/main.rs
Tom Brow 781d39510c feat(push): support configurable HTTP(S) delivery URLs (#7877)
The relay can use a configurable HTTP(S) gateway delivery URL and signs
the same URL it sends. The gateway verifies NIP-98 against the received
request URL, including authority, port, path and query. App Attest,
scoped grants, replay checks and delivery quotas are unchanged.

Forwarding proxies must preserve Host and supply the original request
scheme through X-Forwarded-Proto. The NIP relay-delivery wording now
defines the configured HTTP(S) URL as the signed URL and labels the
public URL as an example.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-25 18:57:49 -07:00

150 lines
5.1 KiB
Rust

use buzz_push_gateway::{
apns::ApnsTransport,
app_attest::AppAttestVerifier,
authority::AuthorityStore,
config::Config,
grant::{GrantKey, GrantKeyring},
postgres::PostgresAuthorityStore,
router_with_metrics,
token::{TokenKey, TokenKeyring},
AppState,
};
use std::{
fs,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.json()
.with_env_filter(EnvFilter::from_default_env())
.init();
if std::env::args().nth(1).as_deref() == Some("--migrate-only") {
let database_url = std::env::var("DATABASE_URL")?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await?;
let runtime_role = std::env::var("BUZZ_PUSH_RUNTIME_DATABASE_ROLE")?;
PostgresAuthorityStore::apply_migrations_and_grants(&pool, &runtime_role).await?;
return Ok(());
}
let c = Config::from_env()?;
let metrics_handle = buzz_push_gateway::metrics::install()?;
let app_attest_root = fs::read(&c.app_attest_root_cert_path)?;
let configured = &c.profile;
let profile = {
let apple = AppAttestVerifier::with_environment(
configured.app_attest_app_id.clone(),
app_attest_root,
configured.app_attest_environment,
)?;
tracing::info!(environment = ?apple.environment(), "App Attest verifier configured");
let transport = Arc::new(ApnsTransport::certificate(
&fs::read(&configured.apns_cert_path)?,
configured.apns_topic.clone(),
configured.apns_environment,
)?);
buzz_push_gateway::http::ProfileRuntime {
app_attest: Arc::new(apple),
transport,
}
};
let grant_keyring = GrantKeyring::new(
c.grant_keys
.iter()
.map(|key| GrantKey::new(&key.id, &key.key))
.collect::<Result<_, _>>()?,
)?;
let token_keyring = TokenKeyring::new(
c.token_keys
.iter()
.map(|key| TokenKey::new(&key.id, &key.key))
.collect::<Result<_, _>>()?,
)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.connect(&c.database_url)
.await?;
let authority = Arc::new(PostgresAuthorityStore::new(pool));
authority
.reap_expired(chrono::Utc::now().timestamp())
.await?;
let reaper_authority = Arc::clone(&authority);
let reaper = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
interval.tick().await;
loop {
interval.tick().await;
if reaper_authority
.reap_expired(chrono::Utc::now().timestamp())
.await
.is_err()
{
buzz_push_gateway::metrics::record_reaper_failure();
tracing::warn!("push gateway retention reaper failed");
}
}
});
let accepting = Arc::new(AtomicBool::new(true));
let (public, health) = router_with_metrics(
AppState {
grant_keyring: Arc::new(grant_keyring),
authority,
token_keyring: Arc::new(token_keyring),
profile: Arc::new(profile),
max_grant_lifetime_seconds: c.max_grant_lifetime_seconds,
max_installation_lifetime_seconds: c.max_installation_lifetime_seconds,
endpoint_quota_window_seconds: c.endpoint_quota_window_seconds,
endpoint_quota_max_deliveries: c.endpoint_quota_max_deliveries,
now: || chrono::Utc::now().timestamp(),
accepting: accepting.clone(),
},
Some(metrics_handle),
);
let pl = tokio::net::TcpListener::bind(c.bind_addr).await?;
let hl = tokio::net::TcpListener::bind(c.health_addr).await?;
let (ptx, prx) = tokio::sync::watch::channel(false);
let (htx, hrx) = tokio::sync::watch::channel(false);
let p = tokio::spawn(async move {
axum::serve(pl, public)
.with_graceful_shutdown(async move {
let mut rx = prx;
let _ = rx.changed().await;
})
.await
});
let h = tokio::spawn(async move {
axum::serve(hl, health)
.with_graceful_shutdown(async move {
let mut rx = hrx;
let _ = rx.changed().await;
})
.await
});
shutdown_signal().await?;
accepting.store(false, Ordering::SeqCst);
let _ = ptx.send(true);
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), p).await;
let _ = htx.send(true);
let _ = h.await;
reaper.abort();
Ok(())
}
async fn shutdown_signal() -> std::io::Result<()> {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = signal(SignalKind::terminate())?;
tokio::select! {r=tokio::signal::ctrl_c()=>r,_=term.recv()=>Ok(())}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await
}
}