mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(push): authenticate APNs with certificate
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
parent
eb02109d07
commit
a8eabb623d
@@ -2,3 +2,8 @@
|
||||
exclude_rules_for_files:
|
||||
sq.pii.cc.visa:
|
||||
- Cargo.lock
|
||||
# Self-signed test fixture generated solely to exercise reqwest identity parsing.
|
||||
kingfisher.privkey.2:
|
||||
- "*apns-test-identity.pem"
|
||||
np.pem.1:
|
||||
- "*apns-test-identity.pem"
|
||||
|
||||
Generated
-164
@@ -1101,7 +1101,6 @@ dependencies = [
|
||||
"metrics-exporter-prometheus",
|
||||
"minicbor",
|
||||
"nostr",
|
||||
"p256",
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"reqwest 0.13.4",
|
||||
@@ -1733,12 +1732,6 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1891,22 +1884,6 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"num-traits",
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1924,9 +1901,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2021,7 +1996,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2473,21 +2447,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0"
|
||||
@@ -2524,27 +2483,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"crypto-common 0.2.2",
|
||||
"digest 0.11.3",
|
||||
"ff",
|
||||
"group",
|
||||
"hybrid-array",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.10.1",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -2714,16 +2652,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -3148,17 +3076,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
@@ -3496,9 +3413,7 @@ version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
dependencies = [
|
||||
"subtle",
|
||||
"typenum",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6070,19 +5985,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primefield",
|
||||
"primeorder",
|
||||
"sha2 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.6"
|
||||
@@ -6538,33 +6440,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primefield"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
|
||||
dependencies = [
|
||||
"crypto-bigint",
|
||||
"crypto-common 0.2.2",
|
||||
"ff",
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
"primefield",
|
||||
"serdect",
|
||||
"wnaf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -7339,16 +7214,6 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
|
||||
dependencies = [
|
||||
"crypto-bigint",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -7708,20 +7573,6 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ctutils",
|
||||
"der",
|
||||
"hybrid-array",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
@@ -8091,10 +7942,6 @@ name = "signature"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
@@ -10466,17 +10313,6 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wnaf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"group",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@@ -29,7 +29,6 @@ getrandom = "0.4"
|
||||
metrics = { workspace = true }
|
||||
metrics-exporter-prometheus = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["http2"] }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
//! APNs envelope construction, endpoint encryption, and response classification.
|
||||
|
||||
use std::{sync::Mutex, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use p256::{
|
||||
ecdsa::{signature::Signer, Signature, SigningKey},
|
||||
pkcs8::DecodePrivateKey,
|
||||
};
|
||||
use reqwest::{
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
StatusCode,
|
||||
};
|
||||
use reqwest::{header::CONTENT_TYPE, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -32,7 +24,7 @@ pub enum DeliveryOutcome {
|
||||
/// Retry-After delay in seconds, clamped by the transport.
|
||||
retry_after_seconds: Option<i64>,
|
||||
},
|
||||
/// Refresh the cached provider JWT, then retry once within normal attempt bounds.
|
||||
/// Refresh a transport credential, then retry once within normal attempt bounds.
|
||||
RefreshCredential,
|
||||
/// Provider credential/profile configuration is unhealthy; do not invalidate endpoints.
|
||||
ConfigurationFault,
|
||||
@@ -52,7 +44,6 @@ pub fn classify(code: u16, reason: Option<&str>, timestamp: Option<i64>) -> Deli
|
||||
unregistered_at: None,
|
||||
}
|
||||
}
|
||||
(403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential,
|
||||
(403, _) | (429, Some("TooManyProviderTokenUpdates")) => {
|
||||
DeliveryOutcome::ConfigurationFault
|
||||
}
|
||||
@@ -91,107 +82,95 @@ pub trait PushTransport: Send + Sync {
|
||||
profile: AppProfile,
|
||||
endpoint: &str,
|
||||
) -> DeliveryOutcome;
|
||||
/// Discard a cached credential after APNs reports expiry.
|
||||
fn refresh_credential(&self) {}
|
||||
/// Refresh a transport credential after a refreshable provider outcome.
|
||||
fn refresh_credential(&self);
|
||||
}
|
||||
|
||||
struct CachedJwt {
|
||||
token: String,
|
||||
issued_at: i64,
|
||||
}
|
||||
|
||||
/// Direct HTTP/2 APNs transport using a cached ES256 provider token.
|
||||
/// Direct HTTP/2 APNs transport using a client certificate identity.
|
||||
pub struct ApnsTransport {
|
||||
client: reqwest::Client,
|
||||
signing_key: SigningKey,
|
||||
key_id: String,
|
||||
team_id: String,
|
||||
topic: String,
|
||||
production_base_url: String,
|
||||
sandbox_base_url: String,
|
||||
cached_jwt: Mutex<Option<CachedJwt>>,
|
||||
}
|
||||
|
||||
impl ApnsTransport {
|
||||
/// Build a reusable APNs client from an Apple `.p8` private key.
|
||||
pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result<Self, ApnsError> {
|
||||
let client = reqwest::Client::builder()
|
||||
// APNs requires HTTP/2. This no-op method reference is intentionally
|
||||
// feature-gated so removing reqwest's `http2` feature fails the build.
|
||||
.http2_keep_alive_while_idle(false)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|_| ApnsError::Client)?;
|
||||
Self::token_with_client(
|
||||
p8,
|
||||
key_id,
|
||||
team_id,
|
||||
/// Build a reusable APNs client from a combined PEM private key and certificate.
|
||||
pub fn certificate(identity_pem: &[u8], topic: String) -> Result<Self, ApnsError> {
|
||||
Self::certificate_with_base_urls(
|
||||
identity_pem,
|
||||
topic,
|
||||
client,
|
||||
"https://api.push.apple.com".to_owned(),
|
||||
"https://api.sandbox.push.apple.com".to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn token_with_client(
|
||||
p8: &[u8],
|
||||
key_id: &str,
|
||||
team_id: &str,
|
||||
fn certificate_with_base_urls(
|
||||
identity_pem: &[u8],
|
||||
topic: String,
|
||||
client: reqwest::Client,
|
||||
production_base_url: String,
|
||||
sandbox_base_url: String,
|
||||
) -> Result<Self, ApnsError> {
|
||||
let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?;
|
||||
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?;
|
||||
let identity =
|
||||
reqwest::Identity::from_pem(identity_pem).map_err(|_| ApnsError::Credential)?;
|
||||
let client = reqwest::Client::builder()
|
||||
// APNs requires HTTP/2. This no-op method reference is intentionally
|
||||
// feature-gated so removing reqwest's `http2` feature fails the build.
|
||||
.http2_keep_alive_while_idle(false)
|
||||
.identity(identity)
|
||||
.timeout(Duration::from_secs(15))
|
||||
// Identity validation completes while the TLS client is built, so a
|
||||
// malformed or mismatched certificate/key pair is a credential error.
|
||||
.build()
|
||||
.map_err(|_| ApnsError::Credential)?;
|
||||
Ok(Self {
|
||||
client,
|
||||
signing_key,
|
||||
key_id: key_id.to_owned(),
|
||||
team_id: team_id.to_owned(),
|
||||
topic,
|
||||
production_base_url,
|
||||
sandbox_base_url,
|
||||
cached_jwt: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn jwt(&self, now: i64) -> Result<String, ApnsError> {
|
||||
let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?;
|
||||
if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) {
|
||||
return Ok(jwt.token.clone());
|
||||
}
|
||||
let header = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id}))
|
||||
.map_err(|_| ApnsError::Credential)?,
|
||||
);
|
||||
let claims = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now}))
|
||||
.map_err(|_| ApnsError::Credential)?,
|
||||
);
|
||||
let signing_input = format!("{header}.{claims}");
|
||||
let signature: Signature = self.signing_key.sign(signing_input.as_bytes());
|
||||
let token = format!(
|
||||
"{signing_input}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
||||
);
|
||||
*cached = Some(CachedJwt {
|
||||
token: token.clone(),
|
||||
issued_at: now,
|
||||
});
|
||||
Ok(token)
|
||||
fn request(
|
||||
&self,
|
||||
attempt: DeliveryAttempt,
|
||||
profile: AppProfile,
|
||||
endpoint: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let base_url = match profile {
|
||||
AppProfile::BuzzIosProduction => &self.production_base_url,
|
||||
AppProfile::BuzzIosSandbox => &self.sandbox_base_url,
|
||||
};
|
||||
self.client
|
||||
.post(format!("{base_url}/3/device/{endpoint}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("apns-id", attempt.request_id.to_string())
|
||||
.header("apns-topic", &self.topic)
|
||||
.header("apns-push-type", "alert")
|
||||
.header("apns-priority", "10")
|
||||
.header("apns-expiration", attempt.expires_at.to_string())
|
||||
// This is the only APNs application body in the program. It is a
|
||||
// byte constant, not a serialization of the relay request, grant,
|
||||
// endpoint, headers, route, provider response, or any generic JSON map.
|
||||
.body(APNS_RECONNECT_PAYLOAD)
|
||||
}
|
||||
|
||||
async fn send_response(
|
||||
&self,
|
||||
attempt: DeliveryAttempt,
|
||||
profile: AppProfile,
|
||||
endpoint: &str,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
self.request(attempt, profile, endpoint).send().await
|
||||
}
|
||||
}
|
||||
|
||||
/// APNs transport setup failure. It intentionally carries no credential material.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApnsError {
|
||||
/// Invalid provider key material.
|
||||
/// Invalid client certificate identity material.
|
||||
#[error("invalid APNs credential")]
|
||||
Credential,
|
||||
/// HTTP client setup failed.
|
||||
#[error("failed to construct APNs client")]
|
||||
Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -208,32 +187,7 @@ impl PushTransport for ApnsTransport {
|
||||
profile: AppProfile,
|
||||
endpoint: &str,
|
||||
) -> DeliveryOutcome {
|
||||
// This is the only APNs application body in the program. It is a
|
||||
// byte constant, not a serialization of the relay request, grant,
|
||||
// endpoint, headers, route, provider response, or any generic JSON map.
|
||||
let body = APNS_RECONNECT_PAYLOAD;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let token = match self.jwt(now) {
|
||||
Ok(token) => token,
|
||||
Err(_) => return DeliveryOutcome::ConfigurationFault,
|
||||
};
|
||||
let base_url = match profile {
|
||||
AppProfile::BuzzIosProduction => &self.production_base_url,
|
||||
AppProfile::BuzzIosSandbox => &self.sandbox_base_url,
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{base_url}/3/device/{endpoint}"))
|
||||
.header(AUTHORIZATION, format!("bearer {token}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("apns-id", attempt.request_id.to_string())
|
||||
.header("apns-topic", &self.topic)
|
||||
.header("apns-push-type", "alert")
|
||||
.header("apns-priority", "10")
|
||||
.header("apns-expiration", attempt.expires_at.to_string())
|
||||
.body(body)
|
||||
.send()
|
||||
.await;
|
||||
let response = self.send_response(attempt, profile, endpoint).await;
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
@@ -267,44 +221,56 @@ impl PushTransport for ApnsTransport {
|
||||
}
|
||||
|
||||
fn refresh_credential(&self) {
|
||||
if let Ok(mut cached) = self.cached_jwt.lock() {
|
||||
*cached = None;
|
||||
}
|
||||
panic!("certificate-authenticated APNs transport has no refreshable credential")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router};
|
||||
use p256::pkcs8::{EncodePrivateKey, LineEnding};
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
async fn capture_body(
|
||||
State(bodies): State<Arc<Mutex<Vec<Vec<u8>>>>>,
|
||||
// Self-signed test-only identity. It is not an Apple credential.
|
||||
const TEST_IDENTITY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-identity.pem");
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturedRequest {
|
||||
headers: HeaderMap,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn capture_request(
|
||||
State(requests): State<Arc<Mutex<Vec<CapturedRequest>>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
bodies.lock().unwrap().push(body.to_vec());
|
||||
requests.lock().unwrap().push(CapturedRequest {
|
||||
headers,
|
||||
body: body.to_vec(),
|
||||
});
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() {
|
||||
let bodies = Arc::new(Mutex::new(Vec::new()));
|
||||
async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let app = Router::new()
|
||||
.route("/3/device/{endpoint}", post(capture_body))
|
||||
.with_state(bodies.clone());
|
||||
.route("/3/device/{endpoint}", post(capture_request))
|
||||
.with_state(requests.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let signing_key = SigningKey::from_slice(&[7; 32]).unwrap();
|
||||
let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
||||
let transport = ApnsTransport::token_with_client(
|
||||
pem.as_bytes(),
|
||||
"kid",
|
||||
"team",
|
||||
let transport = ApnsTransport::certificate_with_base_urls(
|
||||
TEST_IDENTITY_PEM,
|
||||
"app.topic".to_owned(),
|
||||
reqwest::Client::new(),
|
||||
base_url.clone(),
|
||||
base_url,
|
||||
)
|
||||
@@ -337,11 +303,69 @@ mod tests {
|
||||
DeliveryOutcome::Accepted
|
||||
);
|
||||
}
|
||||
let captured = bodies.lock().unwrap();
|
||||
let captured = requests.lock().unwrap();
|
||||
assert_eq!(captured.len(), 2);
|
||||
assert!(captured
|
||||
.iter()
|
||||
.all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD));
|
||||
.all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD));
|
||||
assert!(captured
|
||||
.iter()
|
||||
.all(|request| !request.headers.contains_key(reqwest::header::AUTHORIZATION)));
|
||||
assert!(captured.iter().all(|request| request
|
||||
.headers
|
||||
.get("apns-topic")
|
||||
.is_some_and(|topic| topic == "app.topic")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires the exported dogfood Apple Push Services PEM"]
|
||||
async fn live_sandbox_probe_reports_literal_status_and_body() {
|
||||
let cert_path = std::env::var("BUZZ_PUSH_LIVE_APNS_CERT_PATH")
|
||||
.expect("set BUZZ_PUSH_LIVE_APNS_CERT_PATH to the dogfood identity PEM");
|
||||
let topic = std::env::var("BUZZ_PUSH_LIVE_APNS_TOPIC")
|
||||
.expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the dogfood bundle id");
|
||||
let identity = std::fs::read(cert_path).unwrap();
|
||||
let transport = ApnsTransport::certificate_with_base_urls(
|
||||
&identity,
|
||||
topic,
|
||||
"https://api.push.apple.com".to_owned(),
|
||||
"https://api.sandbox.push.apple.com".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = transport
|
||||
.send_response(
|
||||
DeliveryAttempt {
|
||||
request_id: uuid::Uuid::nil(),
|
||||
expires_at: chrono::Utc::now().timestamp() + 60,
|
||||
},
|
||||
AppProfile::BuzzIosSandbox,
|
||||
&"00".repeat(32),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap();
|
||||
eprintln!("live APNs response: status={status}, body={body}");
|
||||
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body, r#"{"reason":"BadDeviceToken"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_certificate_identity_fails_as_a_credential_error() {
|
||||
assert!(matches!(
|
||||
ApnsTransport::certificate(b"not a PEM identity", "app.topic".to_owned()),
|
||||
Err(ApnsError::Credential)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "certificate-authenticated APNs transport has no refreshable credential"
|
||||
)]
|
||||
fn certificate_transport_fails_loudly_if_refresh_is_requested() {
|
||||
let transport =
|
||||
ApnsTransport::certificate(TEST_IDENTITY_PEM, "app.topic".to_owned()).unwrap();
|
||||
transport.refresh_credential();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -352,10 +376,12 @@ mod tests {
|
||||
unregistered_at: Some(7)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(403, Some("InvalidProviderToken"), None),
|
||||
DeliveryOutcome::ConfigurationFault
|
||||
);
|
||||
for reason in ["InvalidProviderToken", "ExpiredProviderToken"] {
|
||||
assert_eq!(
|
||||
classify(403, Some(reason), None),
|
||||
DeliveryOutcome::ConfigurationFault
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
classify(429, Some("TooManyRequests"), None),
|
||||
DeliveryOutcome::Retry {
|
||||
|
||||
@@ -30,9 +30,7 @@ pub struct Config {
|
||||
/// Independent token-custody keyring. These keys MUST NOT be reused for
|
||||
/// externally presented delivery capabilities.
|
||||
pub token_keys: Vec<KeyConfig>,
|
||||
pub apns_key_path: PathBuf,
|
||||
pub apns_key_id: String,
|
||||
pub apns_team_id: String,
|
||||
pub apns_cert_path: PathBuf,
|
||||
pub apns_topic: String,
|
||||
}
|
||||
#[derive(Debug, Error)]
|
||||
@@ -176,9 +174,7 @@ impl Config {
|
||||
app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(),
|
||||
grant_keys,
|
||||
token_keys,
|
||||
apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(),
|
||||
apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(),
|
||||
apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(),
|
||||
apns_cert_path: req(e, "BUZZ_PUSH_APNS_CERT_PATH")?.into(),
|
||||
apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(),
|
||||
})
|
||||
}
|
||||
@@ -220,20 +216,33 @@ mod tests {
|
||||
),
|
||||
(
|
||||
"DATABASE_URL".into(),
|
||||
"postgres://buzz:test@localhost/buzz".into(),
|
||||
"postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1
|
||||
),
|
||||
("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()),
|
||||
(
|
||||
"BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(),
|
||||
"/apple-root.pem".into(),
|
||||
),
|
||||
("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()),
|
||||
("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()),
|
||||
("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()),
|
||||
("BUZZ_PUSH_APNS_CERT_PATH".into(), "/identity.pem".into()),
|
||||
("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_path_and_topic_are_both_required() {
|
||||
let config = Config::from_map(&base()).unwrap();
|
||||
assert_eq!(config.apns_cert_path, PathBuf::from("/identity.pem"));
|
||||
assert_eq!(config.apns_topic, "app");
|
||||
|
||||
for variable in ["BUZZ_PUSH_APNS_CERT_PATH", "BUZZ_PUSH_APNS_TOPIC"] {
|
||||
let mut env = base();
|
||||
env.remove(variable);
|
||||
assert!(
|
||||
matches!(Config::from_map(&env), Err(ConfigError::Missing(key)) if key == variable)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyrings_preserve_current_then_predecessor_order_and_are_independent() {
|
||||
let config = Config::from_map(&base()).unwrap();
|
||||
|
||||
@@ -35,10 +35,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let c = Config::from_env()?;
|
||||
let metrics_handle = buzz_push_gateway::metrics::install()?;
|
||||
let transport = Arc::new(ApnsTransport::token(
|
||||
&fs::read(&c.apns_key_path)?,
|
||||
&c.apns_key_id,
|
||||
&c.apns_team_id,
|
||||
let transport = Arc::new(ApnsTransport::certificate(
|
||||
&fs::read(&c.apns_cert_path)?,
|
||||
c.apns_topic,
|
||||
)?);
|
||||
let grant_keyring = GrantKeyring::new(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0
|
||||
AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu
|
||||
RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS
|
||||
-----END PRIVATE KEY-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw
|
||||
ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2
|
||||
NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5
|
||||
LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy
|
||||
0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS
|
||||
o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU
|
||||
wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD
|
||||
AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP
|
||||
2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k
|
||||
-----END CERTIFICATE-----
|
||||
@@ -34,14 +34,14 @@ spec:
|
||||
- { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} }
|
||||
- { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} }
|
||||
- { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem }
|
||||
- { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 }
|
||||
{{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }}
|
||||
- { name: BUZZ_PUSH_APNS_CERT_PATH, value: /run/buzz/apns/identity.pem }
|
||||
{{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }}
|
||||
- name: {{ $name }}
|
||||
valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } }
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true }
|
||||
- { name: apns-key, mountPath: /run/buzz/apns, readOnly: true }
|
||||
- { name: apns-cert, mountPath: /run/buzz/apns, readOnly: true }
|
||||
livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 }
|
||||
readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 }
|
||||
startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 }
|
||||
@@ -49,8 +49,8 @@ spec:
|
||||
volumes:
|
||||
- name: app-attest-root
|
||||
secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] }
|
||||
- name: apns-key
|
||||
secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] }
|
||||
- name: apns-cert
|
||||
secret: { secretName: {{ .Values.apnsCert.secretName }}, items: [{ key: {{ .Values.apnsCert.secretKey }}, path: identity.pem }] }
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector: {{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -21,9 +21,9 @@ spec:
|
||||
annotations:
|
||||
summary: Push gateway APNs configuration faults
|
||||
description: >-
|
||||
APNs is returning configuration faults (bad/expired provider token
|
||||
or topic). Deliveries are failing without invalidating endpoints.
|
||||
See runbook: check the APNs .p8 key, key id, team id, and topic.
|
||||
APNs is returning certificate or topic configuration faults.
|
||||
Deliveries are failing without invalidating endpoints. See the
|
||||
runbook and check the APNs certificate identity and topic.
|
||||
# Authority store unavailable at admission = durable dependency is down.
|
||||
- alert: PushGatewayAdmissionUnavailable
|
||||
expr: |
|
||||
|
||||
@@ -42,7 +42,7 @@ assert j['metadata']['annotations']=={
|
||||
'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded',
|
||||
}
|
||||
env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']}
|
||||
required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'}
|
||||
required={'DATABASE_URL','BUZZ_PUSH_APNS_CERT_PATH','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'}
|
||||
assert required <= env
|
||||
assert d['spec']['replicas'] >= 2
|
||||
assert not any(x and x.get('kind')=='HTTPRoute' for x in xs)
|
||||
|
||||
@@ -27,9 +27,9 @@ appAttestAppId: TEAMID.xyz.buzz
|
||||
appAttestRoot:
|
||||
secretName: buzz-push-gateway
|
||||
secretKey: app-attest-root.pem
|
||||
apnsKey:
|
||||
apnsCert:
|
||||
secretName: buzz-push-gateway
|
||||
secretKey: apns-provider.p8
|
||||
secretKey: apns-identity.pem
|
||||
service:
|
||||
port: 8080
|
||||
httpRoute:
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated `buzz-ios-production` and/or `buzz-ios-sandbox`. |
|
||||
| `BUZZ_PUSH_APP_ATTEST_APP_ID` | Exact Apple App Attest application identifier (`TEAMID.bundle-id`). |
|
||||
| `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. |
|
||||
| `BUZZ_PUSH_APNS_KEY_PATH` | Read-only mounted Apple APNs `.p8` provider key. |
|
||||
| `BUZZ_PUSH_APNS_KEY_ID` | APNs provider key id. |
|
||||
| `BUZZ_PUSH_APNS_TEAM_ID` | Apple developer team id. |
|
||||
| `BUZZ_PUSH_APNS_CERT_PATH` | Read-only mounted PEM containing the Apple Push Services certificate and unencrypted PKCS#8 private key. |
|
||||
| `BUZZ_PUSH_APNS_TOPIC` | Buzz iOS bundle id. |
|
||||
| `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. |
|
||||
| `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. |
|
||||
@@ -30,7 +28,7 @@ Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SE
|
||||
|
||||
## Secret and key rotation rules
|
||||
|
||||
Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs key and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key.
|
||||
Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs certificate identity and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key.
|
||||
|
||||
The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefore contain ciphertext plus authority metadata and must receive the same access controls and retention treatment as the service secrets.
|
||||
|
||||
@@ -50,7 +48,7 @@ The gateway serves Prometheus metrics at `GET /metrics` on the **private health
|
||||
|---|---|---|---|
|
||||
| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `refresh_credential` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. |
|
||||
| `push_gateway_apns_delivery_seconds` | histogram | — | APNs send round-trip latency (seconds). |
|
||||
| `push_gateway_apns_credential_refreshes_total` | counter | — | Provider JWT refreshed after APNs reported expiry. |
|
||||
| `push_gateway_apns_credential_refreshes_total` | counter | — | Transport credential refreshes requested after a refreshable provider outcome. |
|
||||
| `push_gateway_admissions_total` | counter | `result` = `admitted` \| `rejected` \| `unavailable` | Outcome at the `authorize_delivery` replay/quota fence. |
|
||||
| `push_gateway_delivery_errors_total` | counter | `class` (static) | Selected delivery-handler exit classes only (see note). |
|
||||
| `push_gateway_reaper_failures_total` | counter | — | Retention reaper sweep failures. |
|
||||
@@ -64,7 +62,7 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe
|
||||
|
||||
| Alert | Fires when | Severity | Action |
|
||||
|---|---|---|---|
|
||||
| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | APNs provider token/topic is unhealthy. Check the `.p8` key, `BUZZ_PUSH_APNS_KEY_ID`, `..._TEAM_ID`, and `..._TOPIC`. No endpoints are being invalidated, but nothing is delivering. |
|
||||
| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | APNs certificate/topic is unhealthy. Check `BUZZ_PUSH_APNS_CERT_PATH` and `BUZZ_PUSH_APNS_TOPIC`. No endpoints are being invalidated, but nothing is delivering. |
|
||||
| `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. |
|
||||
| `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. |
|
||||
| `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. |
|
||||
@@ -103,7 +101,7 @@ Only after that command succeeds, set the exact digest as `image.digest`; the ch
|
||||
|
||||
Network policy keeps APNs HTTPS and PostgreSQL egress in separate CIDR lists. APNs currently requires broad TCP/443 reachability; `networkPolicy.postgresEgressCidrs` must be narrowed to the production database network, and the DNS namespace/pod selectors must match the cluster DNS deployment. The sample private CIDR is not a claim about the production topology.
|
||||
|
||||
Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs credential rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/<release>-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled.
|
||||
Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs certificate rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/<release>-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled.
|
||||
|
||||
## Gateway chart release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user