mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
When the push gateway rejected a device's App Attest attestation or assertion, it logged the reason only at debug level. In production that left a bare `401 invalid_attestation` in the access logs, with no sign of which check failed. We hit this diagnosing an internal iPhone that never received push. Its enrollments failed with repeated `401`s, and the gateway logs could not say why. Rejections now log at warn level with: - `operation`: `attestation` or `assertion` - `reason`: the existing category, for example `certificate` or `identity` - `detail`: the specific failed check, for example `invalid AAGUID` or `invalid App ID` Malformed base64 now has its own `encoding` reason. The size, base64 and challenge checks that run before full verification now take the same path. Previously they rejected without logging anything. Without `RUST_LOG`, the gateway now defaults to `error,buzz_push_gateway=warn`, which keeps every target's errors plus the gateway's own warnings. Previously the default showed only errors, and the Helm chart sets no `RUST_LOG`, so a default deployment would have dropped these warnings. The HTTP response is unchanged and stays a uniform `invalid_attestation`. The logged fields are fixed strings, with no device or request data. Companion iOS PR [#8629](https://github.com/block/buzz/pull/8629) stops a device from replaying the same rejected enrollment indefinitely. ### Validation - Added tests: a single-test binary checks the emitted JSON warning (level, operation, reason, detail) through `AppAttestVerifier::verify_attestation`, for malformed base64, an empty proof, and malformed CBOR. Another checks that the default filter keeps gateway warnings and dependency errors, drops dependency warnings, and still honors an explicit `RUST_LOG`. Both fail when the warning is downgraded to debug or the default filter loses its `error` fallback. - The existing push gateway suite passes locally, including the App Attest rejection tests. Clippy is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz>
208 lines
7.3 KiB
Rust
208 lines
7.3 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;
|
|
|
|
/// Without `RUST_LOG`, keep the gateway's own warnings, such as App Attest
|
|
/// rejection reasons, alongside `EnvFilter`'s error-level default for all
|
|
/// other targets.
|
|
fn log_env_filter(rust_log: Option<&str>) -> EnvFilter {
|
|
EnvFilter::new(rust_log.unwrap_or("error,buzz_push_gateway=warn"))
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
tracing_subscriber::fmt()
|
|
.json()
|
|
.with_env_filter(log_env_filter(std::env::var("RUST_LOG").ok().as_deref()))
|
|
.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.application;
|
|
let application = {
|
|
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::ApplicationRuntime {
|
|
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),
|
|
application: Arc::new(application),
|
|
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
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::log_env_filter;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[derive(Clone, Default)]
|
|
struct Capture(Arc<Mutex<Vec<u8>>>);
|
|
impl std::io::Write for Capture {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.0.lock().unwrap().extend_from_slice(buf);
|
|
Ok(buf.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn emitted(rust_log: Option<&str>) -> String {
|
|
let capture = Capture::default();
|
|
let writer = capture.clone();
|
|
let subscriber = tracing_subscriber::fmt()
|
|
.with_env_filter(log_env_filter(rust_log))
|
|
.with_writer(move || writer.clone())
|
|
.finish();
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
tracing::info!(target: "buzz_push_gateway", "gateway-info");
|
|
tracing::warn!(target: "buzz_push_gateway", "gateway-warn");
|
|
tracing::warn!(target: "sqlx", "dependency-warn");
|
|
tracing::error!(target: "sqlx", "dependency-error");
|
|
});
|
|
let bytes = capture.0.lock().unwrap().clone();
|
|
String::from_utf8(bytes).unwrap()
|
|
}
|
|
|
|
// The only test in this binary, so no other thread races tracing's
|
|
// global callsite-interest cache.
|
|
#[test]
|
|
fn default_log_filter_keeps_gateway_warnings_and_dependency_errors() {
|
|
let logs = emitted(None);
|
|
assert!(logs.contains("gateway-warn"), "{logs}");
|
|
assert!(logs.contains("dependency-error"), "{logs}");
|
|
assert!(!logs.contains("gateway-info"), "{logs}");
|
|
assert!(!logs.contains("dependency-warn"), "{logs}");
|
|
|
|
let logs = emitted(Some("info"));
|
|
assert!(logs.contains("gateway-info"), "{logs}");
|
|
assert!(logs.contains("dependency-warn"), "{logs}");
|
|
}
|
|
}
|