Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/buzz-relay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
metrics-util = { workspace = true }
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
sfv = "0.15.0"

[features]
dev = ["buzz-auth/dev"]
Expand Down
291 changes: 291 additions & 0 deletions crates/buzz-relay/src/client_info.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
//! Advisory parsing for the mobile `Buzz-Client` structured field.

use axum::http::HeaderMap;
use sfv::{BareItem, Dictionary, ListEntry, Parser};

/// Parsed, untrusted metadata supplied by a Buzz client.
///
/// This data is for observability only. It must never participate in
/// authentication, authorization, or tenant selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientInfo {
/// Structured header format version.
pub format_version: i64,
/// Logical application identifier.
pub app: String,
/// Client platform (`ios` or `android`).
pub platform: String,
/// User-visible application version.
pub app_version: String,
/// Bounded, normalized label derived from the application version.
metric_app_version: String,
/// Platform build identifier, constrained to decimal digits.
pub app_build: String,
/// Coarse public operating-system version.
pub os_version: String,
/// Android API level, present only on Android.
pub os_api: Option<i64>,
}

impl ClientInfo {
/// Parse `Buzz-Client` from a request header map.
///
/// A missing header is a supported state and returns `None` without a
/// metric. A present but invalid header increments the parse-failure
/// counter and also returns `None`, so it can never reject a request.
#[must_use]
pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
let value = headers.get("buzz-client")?;
Comment thread
brow marked this conversation as resolved.
Outdated
let parsed = value.to_str().ok().and_then(|raw| Self::parse(raw).ok());
if parsed.is_none() {
metrics::counter!("buzz_client_header_parse_failures_total").increment(1);
}
parsed
}

fn parse(raw: &str) -> Result<Self, ()> {
let dictionary: Dictionary = Parser::new(raw).parse_dictionary().map_err(|_| ())?;

let format_version = integer(&dictionary, "v")?;
if format_version != 1 {
return Err(());
}

let app = token(&dictionary, "app")?;
if app != "buzz-mobile" {
return Err(());
}

let platform = token(&dictionary, "platform")?;
if platform != "ios" && platform != "android" {
return Err(());
}

let app_version = string(&dictionary, "app-version")?;
let metric_app_version = normalize_app_version(&app_version)?;
let app_build = string(&dictionary, "app-build")?;
let os_version = string(&dictionary, "os-version")?;
if app_build.is_empty()
|| !app_build.bytes().all(|byte| byte.is_ascii_digit())
|| os_version.is_empty()
{
return Err(());
}

let os_api = optional_integer(&dictionary, "os-api")?;
match platform.as_str() {
"android" if !matches!(os_api, Some(api) if api > 0) => return Err(()),
"ios" if os_api.is_some() => return Err(()),
_ => {}
}

Ok(Self {
format_version,
app,
platform,
app_version,
metric_app_version,
app_build,
os_version,
os_api,
})
}

/// Record a low-cardinality observation for a parsed client.
pub fn record_observation(&self) {
metrics::counter!(
"buzz_client_connections_total",
"app" => self.app.clone(),
"platform" => self.platform.clone(),
"app_version" => self.metric_app_version.clone(),
)
.increment(1);
}
Comment thread
brow marked this conversation as resolved.
}

const MAX_APP_VERSION_COMPONENT_LENGTH: usize = 5;

fn normalize_app_version(app_version: &str) -> Result<String, ()> {
let mut components = app_version.split('.');
let Some(major) = components.next() else {
return Err(());
};
let Some(minor) = components.next() else {
return Err(());
};
let patch = components.next();
if components.next().is_some() {
return Err(());
}

if ![Some(major), Some(minor), patch]
.into_iter()
.flatten()
.all(|component| {
!component.is_empty()
&& component.len() <= MAX_APP_VERSION_COMPONENT_LENGTH
&& component.bytes().all(|byte| byte.is_ascii_digit())
})
{
return Err(());
}

Ok(format!("{major}.{minor}"))
}

fn bare_item<'a>(dictionary: &'a Dictionary, key: &str) -> Result<&'a BareItem, ()> {
let Some(ListEntry::Item(item)) = dictionary.get(key) else {
return Err(());
};
if !item.params.is_empty() {
return Err(());
}
Ok(&item.bare_item)
}

fn token(dictionary: &Dictionary, key: &str) -> Result<String, ()> {
bare_item(dictionary, key)?
.as_token()
.map(|value| value.as_str().to_owned())
.ok_or(())
}

fn string(dictionary: &Dictionary, key: &str) -> Result<String, ()> {
bare_item(dictionary, key)?
.as_string()
.map(|value| value.as_str().to_owned())
.ok_or(())
}

fn integer(dictionary: &Dictionary, key: &str) -> Result<i64, ()> {
bare_item(dictionary, key)?
.as_integer()
.map(Into::into)
.ok_or(())
}

fn optional_integer(dictionary: &Dictionary, key: &str) -> Result<Option<i64>, ()> {
if !dictionary.contains_key(key) {
return Ok(None);
}
integer(dictionary, key).map(Some)
}

#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use metrics_util::debugging::{DebugValue, DebuggingRecorder};

use super::*;

fn metric_counter(
recorder: &DebuggingRecorder,
name: &str,
) -> Vec<(Vec<(String, String)>, u64)> {
recorder
.snapshotter()
.snapshot()
.into_vec()
.into_iter()
.filter_map(|(key, _, _, value)| {
(key.key().name() == name).then(|| {
let labels = key
.key()
.labels()
.map(|label| (label.key().to_owned(), label.value().to_owned()))
.collect();
let DebugValue::Counter(value) = value else {
panic!("{name} must be a counter");
};
(labels, value)
})
})
.collect()
}

fn parse_failures(recorder: &DebuggingRecorder) -> u64 {
metric_counter(recorder, "buzz_client_header_parse_failures_total")
.into_iter()
.map(|(_, value)| value)
.sum()
}

#[test]
fn parses_valid_ios_and_android_headers() {
let ios = ClientInfo::parse(
r#"v=1, app=buzz-mobile, platform=ios, app-version="0.4.5", app-build="6", os-version="18.5""#,
)
.expect("valid iOS header");
assert_eq!(
ios,
ClientInfo {
format_version: 1,
app: "buzz-mobile".to_owned(),
platform: "ios".to_owned(),
app_version: "0.4.5".to_owned(),
metric_app_version: "0.4".to_owned(),
app_build: "6".to_owned(),
os_version: "18.5".to_owned(),
os_api: None,
}
);

let android = ClientInfo::parse(
r#"v=1, app=buzz-mobile, platform=android, app-version="0.4.5", app-build="7", os-version="15", os-api=35, future-key=ignored"#,
)
.expect("valid Android header");
assert_eq!(android.os_api, Some(35));
}

#[test]
fn observations_bucket_versions_to_major_minor() {
let client = ClientInfo::parse(
r#"v=1, app=buzz-mobile, platform=ios, app-version="12.34.56", app-build="6", os-version="18.5""#,
)
.expect("valid iOS header");
let recorder = DebuggingRecorder::new();

metrics::with_local_recorder(&recorder, || client.record_observation());

let counters = metric_counter(&recorder, "buzz_client_connections_total");
assert_eq!(counters.len(), 1);
let (labels, value) = &counters[0];
assert_eq!(*value, 1);
assert!(labels.contains(&("app".to_owned(), "buzz-mobile".to_owned())));
assert!(labels.contains(&("platform".to_owned(), "ios".to_owned())));
assert!(labels.contains(&("app_version".to_owned(), "12.34".to_owned())));
}

#[test]
fn missing_header_is_absent_without_parse_failure() {
let recorder = DebuggingRecorder::new();
metrics::with_local_recorder(&recorder, || {
assert_eq!(ClientInfo::from_headers(&HeaderMap::new()), None);
});
assert_eq!(parse_failures(&recorder), 0);
}

#[test]
fn malformed_or_semantically_invalid_header_is_absent_and_counted() {
for raw in [
"not a dictionary",
r#"v=2, app=buzz-mobile, platform=ios, app-version="1", app-build="1", os-version="18""#,
r#"v=1, app=buzz-mobile, platform=android, app-version="1", app-build="1", os-version="15""#,
r#"v=1, app=buzz-mobile, platform=ios, app-version="1", app-build="1.beta", os-version="18""#,
r#"v=1, app=buzz-mobile, platform=ios, app-version="random-connection-value", app-build="1", os-version="18""#,
r#"v=1, app=buzz-mobile, platform=ios, app-version="1.2.3.4", app-build="1", os-version="18""#,
r#"v=1, app=buzz-mobile, platform=ios, app-version="123456.2", app-build="1", os-version="18""#,
] {
let recorder = DebuggingRecorder::new();
let mut headers = HeaderMap::new();
headers.insert(
"buzz-client",
HeaderValue::from_str(raw).expect("test header value"),
);
metrics::with_local_recorder(&recorder, || {
assert_eq!(ClientInfo::from_headers(&headers), None, "{raw}");
});
assert_eq!(parse_failures(&recorder), 1, "{raw}");
}
}
}
30 changes: 28 additions & 2 deletions crates/buzz-relay/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use buzz_auth::{generate_challenge, AuthContext, LimitType};
use buzz_core::tenant::TenantContext;
use nostr::Filter;

use crate::client_info::ClientInfo;
use crate::handlers;
use crate::protocol::{ClientMessage, RelayMessage};
use crate::state::{run_registered_community_connection, AppState};
Expand Down Expand Up @@ -120,6 +121,7 @@ pub async fn handle_connection(
state: Arc<AppState>,
addr: SocketAddr,
tenant: TenantContext,
client_info: Option<ClientInfo>,
) {
let conn_id = Uuid::new_v4();
let cancel = CancellationToken::new();
Expand All @@ -133,7 +135,17 @@ pub async fn handle_connection(
community_id,
cancel.clone(),
move || async move { check_state.db.is_community_active(community_id).await },
move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel),
move || {
handle_active_connection(
socket,
run_state,
addr,
tenant,
conn_id,
cancel,
client_info,
)
},
)
.await;
}
Expand All @@ -145,6 +157,7 @@ async fn handle_active_connection(
tenant: TenantContext,
conn_id: Uuid,
cancel: CancellationToken,
client_info: Option<ClientInfo>,
) {
let permit = match state.conn_semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Expand Down Expand Up @@ -179,7 +192,20 @@ async fn handle_active_connection(
grace_limit: state.config.slow_client_grace_limit,
});

info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established");
if let Some(client) = &client_info {
client.record_observation();
}
info!(
conn_id = %conn_id,
addr = %addr,
client.app = client_info.as_ref().map(|client| client.app.as_str()),
client.platform = client_info.as_ref().map(|client| client.platform.as_str()),
client.app_version = client_info.as_ref().map(|client| client.app_version.as_str()),
client.app_build = client_info.as_ref().map(|client| client.app_build.as_str()),
client.os_version = client_info.as_ref().map(|client| client.os_version.as_str()),
client.os_api = client_info.as_ref().and_then(|client| client.os_api),
"WebSocket connection established"
);
metrics::counter!(
"buzz_ws_connections_total",
"community" => conn.tenant.host().to_owned()
Expand Down
Loading
Loading