Capture TLS fingerprints in report context

This commit is contained in:
fawney19
2026-05-06 15:01:31 +08:00
parent 621eb55ae1
commit bfda9b3e78
7 changed files with 818 additions and 111 deletions

View File

@@ -121,6 +121,10 @@ pub(crate) fn is_json_request(headers: &http::HeaderMap) -> bool {
crate::headers::is_json_request(headers) crate::headers::is_json_request(headers)
} }
pub(crate) fn tls_fingerprint_from_headers(headers: &http::HeaderMap) -> Option<serde_json::Value> {
crate::headers::tls_fingerprint_from_headers(headers)
}
pub(crate) fn build_execution_runtime_auth_context( pub(crate) fn build_execution_runtime_auth_context(
auth_context: &crate::control::GatewayControlAuthContext, auth_context: &crate::control::GatewayControlAuthContext,
) -> ExecutionRuntimeAuthContext { ) -> ExecutionRuntimeAuthContext {

View File

@@ -1,9 +1,11 @@
use aether_ai_serving::{ use aether_ai_serving::{
run_ai_candidate_preselection, AiCandidatePreselectionOutcome, AiCandidatePreselectionPort, run_ai_candidate_preselection, AiCandidatePreselectionOutcome, AiCandidatePreselectionPort,
}; };
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
use aether_scheduler_core::{ use aether_scheduler_core::{
enumerate_minimal_candidate_selection_with_model_directives, normalize_api_format, enumerate_minimal_candidate_selection_with_model_directives, normalize_api_format,
resolve_requested_global_model_name_with_model_directives, ClientSessionAffinity, resolve_requested_global_model_name_with_model_directives,
row_supports_requested_model_with_model_directives, ClientSessionAffinity,
EnumerateMinimalCandidateSelectionInput, SchedulerMinimalCandidateSelectionCandidate, EnumerateMinimalCandidateSelectionInput, SchedulerMinimalCandidateSelectionCandidate,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -14,7 +16,8 @@ use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
use crate::data::candidate_selection::{ use crate::data::candidate_selection::{
read_requested_model_rows_fast_path_page, requested_model_candidate_names, read_requested_model_rows_fast_path_page, requested_model_candidate_names,
REQUESTED_MODEL_CANDIDATE_PAGE_SIZE, REQUESTED_MODEL_MAX_SCANNED_ROWS, MinimalCandidateSelectionRowSource, REQUESTED_MODEL_CANDIDATE_PAGE_SIZE,
REQUESTED_MODEL_MAX_SCANNED_ROWS,
}; };
use crate::scheduler::candidate::SchedulerSkippedCandidate; use crate::scheduler::candidate::SchedulerSkippedCandidate;
use crate::GatewayError; use crate::GatewayError;
@@ -203,6 +206,7 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> {
requested_name_offsets: BTreeMap<String, u32>, requested_name_offsets: BTreeMap<String, u32>,
scanned_rows_by_format: BTreeMap<String, u32>, scanned_rows_by_format: BTreeMap<String, u32>,
resolved_global_model_names: BTreeMap<String, String>, resolved_global_model_names: BTreeMap<String, String>,
fallback_scanned_api_formats: BTreeSet<String>,
seen_candidate_keys: BTreeSet<String>, seen_candidate_keys: BTreeSet<String>,
} }
@@ -255,6 +259,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
requested_name_offsets: BTreeMap::new(), requested_name_offsets: BTreeMap::new(),
scanned_rows_by_format: BTreeMap::new(), scanned_rows_by_format: BTreeMap::new(),
resolved_global_model_names: BTreeMap::new(), resolved_global_model_names: BTreeMap::new(),
fallback_scanned_api_formats: BTreeSet::new(),
seen_candidate_keys: BTreeSet::new(), seen_candidate_keys: BTreeSet::new(),
} }
} }
@@ -319,7 +324,13 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.entry(normalized_api_format.clone()) .entry(normalized_api_format.clone())
.or_insert(0); .or_insert(0);
let Some(requested_name) = requested_names.get(requested_name_index) else { let Some(requested_name) = requested_names.get(requested_name_index) else {
return Ok(None); return self
.next_fallback_page_for_api_format(
candidate_api_format,
&normalized_api_format,
enable_model_directives,
)
.await;
}; };
if requested_name.trim().is_empty() { if requested_name.trim().is_empty() {
self.requested_name_indexes self.requested_name_indexes
@@ -364,121 +375,201 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
} }
if page.scanned_rows == 0 { if page.scanned_rows == 0 {
if requested_name_index + 1 >= requested_names.len() { if requested_name_index + 1 >= requested_names.len() {
return Ok(None); return self
.next_fallback_page_for_api_format(
candidate_api_format,
&normalized_api_format,
enable_model_directives,
)
.await;
} }
continue; continue;
} }
let mut rows = page if let Some(outcome) = self
.rows .build_page_outcome_from_rows(
.into_iter()
.filter(|row| {
self.seen_candidate_keys.insert(format!(
"{}:{}:{}:{}",
row.endpoint_id, row.key_id, row.model_id, row.endpoint_api_format
))
})
.collect::<Vec<_>>();
if rows.is_empty() {
continue;
}
let resolved_global_model_name =
if let Some(value) = self.resolved_global_model_names.get(&normalized_api_format) {
value.clone()
} else {
let Some(value) = resolve_requested_global_model_name_with_model_directives(
&rows,
&self.requested_model,
&normalized_api_format,
enable_model_directives,
) else {
continue;
};
self.resolved_global_model_names
.insert(normalized_api_format.clone(), value.clone());
value
};
rows.retain(|row| row.global_model_name == resolved_global_model_name);
if rows.is_empty() {
continue;
}
let auth_constraints = matches_client_api_format(
self.use_api_format_alias_match,
candidate_api_format,
&self.client_api_format,
)
.then_some(&self.auth_snapshot)
.map(crate::data::candidate_selection::auth_snapshot_constraints);
let enumerated_candidates =
enumerate_minimal_candidate_selection_with_model_directives(
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format: &normalized_api_format,
requested_model_name: &self.requested_model,
resolved_global_model_name: resolved_global_model_name.as_str(),
require_streaming: self.require_streaming,
required_capabilities: self.required_capabilities.as_ref(),
auth_constraints: auth_constraints.as_ref(),
},
enable_model_directives,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let mut candidates = Vec::new();
for candidate in enumerated_candidates {
if !self.candidate_allowed_for_page(
&candidate,
candidate_api_format, candidate_api_format,
&normalized_api_format,
enable_model_directives, enable_model_directives,
) { page.rows,
continue;
}
if !self
.seen_candidate_keys
.insert(local_candidate_preselection_key(&candidate, self.key_mode))
{
continue;
}
candidates.push(candidate);
}
let matches_client_format = matches_client_api_format(
self.use_api_format_alias_match,
candidate_api_format,
&self.client_api_format,
);
let auth_snapshot = matches_client_format.then_some(&self.auth_snapshot);
let (candidates, skipped_candidates) = self
.state
.list_selectable_enumerated_candidates_with_skip_reasons(
candidate_api_format,
&resolved_global_model_name,
candidates,
self.required_capabilities.as_ref(),
auth_snapshot,
self.client_session_affinity.as_ref(),
current_unix_secs(),
) )
.await?; .await?
let skipped_candidates = skipped_candidates {
.into_iter() return Ok(Some(outcome));
.map(skipped_local_execution_candidate_from_scheduler_skip) }
.filter(|skipped_candidate| {
self.skipped_candidate_allowed_for_page(
skipped_candidate,
candidate_api_format,
enable_model_directives,
)
})
.collect::<Vec<_>>();
return Ok(Some(AiCandidatePreselectionOutcome {
candidates,
skipped_candidates,
}));
} }
} }
async fn next_fallback_page_for_api_format(
&mut self,
candidate_api_format: &str,
normalized_api_format: &str,
enable_model_directives: bool,
) -> Result<
Option<
AiCandidatePreselectionOutcome<
SchedulerMinimalCandidateSelectionCandidate,
SkippedLocalExecutionCandidate,
>,
>,
GatewayError,
> {
if !self
.fallback_scanned_api_formats
.insert(normalized_api_format.to_string())
{
return Ok(None);
}
let rows = self
.state
.app()
.data
.read_minimal_candidate_selection_rows_for_api_format(normalized_api_format)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.filter(|row| {
row_supports_requested_model_with_model_directives(
row,
&self.requested_model,
normalized_api_format,
enable_model_directives,
)
})
.collect::<Vec<_>>();
self.build_page_outcome_from_rows(
candidate_api_format,
normalized_api_format,
enable_model_directives,
rows,
)
.await
}
async fn build_page_outcome_from_rows(
&mut self,
candidate_api_format: &str,
normalized_api_format: &str,
enable_model_directives: bool,
rows: Vec<StoredMinimalCandidateSelectionRow>,
) -> Result<
Option<
AiCandidatePreselectionOutcome<
SchedulerMinimalCandidateSelectionCandidate,
SkippedLocalExecutionCandidate,
>,
>,
GatewayError,
> {
let mut rows = rows
.into_iter()
.filter(|row| {
self.seen_candidate_keys.insert(format!(
"{}:{}:{}:{}",
row.endpoint_id, row.key_id, row.model_id, row.endpoint_api_format
))
})
.collect::<Vec<_>>();
if rows.is_empty() {
return Ok(None);
}
let resolved_global_model_name =
if let Some(value) = self.resolved_global_model_names.get(normalized_api_format) {
value.clone()
} else {
let Some(value) = resolve_requested_global_model_name_with_model_directives(
&rows,
&self.requested_model,
normalized_api_format,
enable_model_directives,
) else {
return Ok(None);
};
self.resolved_global_model_names
.insert(normalized_api_format.to_string(), value.clone());
value
};
rows.retain(|row| row.global_model_name == resolved_global_model_name);
if rows.is_empty() {
return Ok(None);
}
let auth_constraints = matches_client_api_format(
self.use_api_format_alias_match,
candidate_api_format,
&self.client_api_format,
)
.then_some(&self.auth_snapshot)
.map(crate::data::candidate_selection::auth_snapshot_constraints);
let enumerated_candidates = enumerate_minimal_candidate_selection_with_model_directives(
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format,
requested_model_name: &self.requested_model,
resolved_global_model_name: resolved_global_model_name.as_str(),
require_streaming: self.require_streaming,
required_capabilities: self.required_capabilities.as_ref(),
auth_constraints: auth_constraints.as_ref(),
},
enable_model_directives,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let mut candidates = Vec::new();
for candidate in enumerated_candidates {
if !self.candidate_allowed_for_page(
&candidate,
candidate_api_format,
enable_model_directives,
) {
continue;
}
if !self
.seen_candidate_keys
.insert(local_candidate_preselection_key(&candidate, self.key_mode))
{
continue;
}
candidates.push(candidate);
}
let matches_client_format = matches_client_api_format(
self.use_api_format_alias_match,
candidate_api_format,
&self.client_api_format,
);
let auth_snapshot = matches_client_format.then_some(&self.auth_snapshot);
let (candidates, skipped_candidates) = self
.state
.list_selectable_enumerated_candidates_with_skip_reasons(
candidate_api_format,
&resolved_global_model_name,
candidates,
self.required_capabilities.as_ref(),
auth_snapshot,
self.client_session_affinity.as_ref(),
current_unix_secs(),
)
.await?;
let skipped_candidates = skipped_candidates
.into_iter()
.map(skipped_local_execution_candidate_from_scheduler_skip)
.filter(|skipped_candidate| {
self.skipped_candidate_allowed_for_page(
skipped_candidate,
candidate_api_format,
enable_model_directives,
)
})
.collect::<Vec<_>>();
Ok(Some(AiCandidatePreselectionOutcome {
candidates,
skipped_candidates,
}))
}
fn candidate_allowed_for_page( fn candidate_allowed_for_page(
&self, &self,
candidate: &SchedulerMinimalCandidateSelectionCandidate, candidate: &SchedulerMinimalCandidateSelectionCandidate,
@@ -603,3 +694,120 @@ pub(crate) fn auth_snapshot_allows_cross_format_candidate(
true true
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::data::GatewayDataState;
use crate::AppState;
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
use aether_data_contracts::repository::candidate_selection::MinimalCandidateSelectionReadRepository;
use std::sync::Arc;
fn unrestricted_auth_snapshot() -> GatewayAuthApiKeySnapshot {
GatewayAuthApiKeySnapshot {
user_id: "user-1".to_string(),
username: "alice".to_string(),
email: None,
user_role: "user".to_string(),
user_auth_source: "local".to_string(),
user_is_active: true,
user_is_deleted: false,
user_rate_limit: None,
user_allowed_providers: None,
user_allowed_api_formats: None,
user_allowed_models: None,
api_key_id: "api-key-1".to_string(),
api_key_name: Some("default".to_string()),
api_key_is_active: true,
api_key_is_locked: false,
api_key_is_standalone: false,
api_key_rate_limit: None,
api_key_concurrent_limit: None,
api_key_expires_at_unix_secs: None,
api_key_allowed_providers: None,
api_key_allowed_api_formats: None,
api_key_allowed_models: None,
currently_usable: true,
}
}
fn openai_responses_mapping_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-openai-responses-mapped-1".to_string(),
provider_name: "openai".to_string(),
provider_type: "custom".to_string(),
provider_priority: 10,
provider_is_active: true,
endpoint_id: "endpoint-openai-responses-mapped-1".to_string(),
endpoint_api_format: "openai:responses".to_string(),
endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()),
endpoint_is_active: true,
key_id: "key-openai-responses-mapped-1".to_string(),
key_name: "prod".to_string(),
key_auth_type: "bearer".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["openai:responses".to_string()]),
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 5,
key_global_priority_by_format: Some(serde_json::json!({"openai:responses": 1})),
model_id: "model-openai-responses-mapped-1".to_string(),
global_model_id: "global-model-openai-responses-mapped-1".to_string(),
global_model_name: "gpt-5".to_string(),
global_model_mappings: Some(vec!["gpt-5(?:\\.\\d+)?".to_string()]),
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-5-upstream".to_string(),
model_provider_model_mappings: None,
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
#[tokio::test]
async fn paged_preselection_falls_back_to_format_scan_for_directive_mapping_match() {
let repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([
openai_responses_mapping_row(),
]));
let data_state =
GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository)
.with_system_config_values_for_tests([(
crate::system_features::ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string(),
serde_json::json!(true),
)]);
let app = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = unrestricted_auth_snapshot();
let mut cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
"claude:messages",
"gpt-5.5-xhigh",
false,
None,
&auth_snapshot,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
)
.await;
let page = cursor
.next_page()
.await
.expect("preselection should succeed")
.expect("mapping fallback should find a provider");
assert_eq!(page.skipped_candidates.len(), 0);
assert_eq!(page.candidates.len(), 1);
assert_eq!(page.candidates[0].endpoint_api_format, "openai:responses");
assert_eq!(page.candidates[0].global_model_name, "gpt-5");
assert_eq!(
page.candidates[0].selected_provider_model_name,
"gpt-5-upstream"
);
}
}

View File

@@ -75,6 +75,11 @@ pub(crate) fn build_local_execution_report_context(
value, value,
); );
} }
if let Some(incoming_tls) =
crate::ai_serving::tls_fingerprint_from_headers(parts.original_headers)
{
merge_incoming_tls_fingerprint(&mut extra_fields, incoming_tls);
}
build_ai_execution_report_context(AiExecutionReportContextParts { build_ai_execution_report_context(AiExecutionReportContextParts {
auth_context: parts.auth_context, auth_context: parts.auth_context,
@@ -129,6 +134,15 @@ pub(crate) fn insert_provider_stream_event_api_format(
insert_ai_provider_stream_event_api_format(extra_fields, provider_type); insert_ai_provider_stream_event_api_format(extra_fields, provider_type);
} }
fn merge_incoming_tls_fingerprint(extra_fields: &mut Map<String, Value>, incoming_tls: Value) {
let entry = extra_fields
.entry("tls_fingerprint".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(object) = entry {
object.insert("incoming".to_string(), incoming_tls);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -242,4 +256,69 @@ mod tests {
}) })
); );
} }
#[test]
fn local_execution_report_context_records_forwarded_tls_fingerprint() {
let auth_context = ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
api_key_id: "api-key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: None,
access_allowed: true,
api_key_is_standalone: false,
};
let mut original_headers = http::HeaderMap::new();
original_headers.insert("x-aether-tls-ja3", "ja3-value".parse().unwrap());
original_headers.insert("x-aether-tls-ja4", "ja4-value".parse().unwrap());
original_headers.insert("x-aether-tls-protocol", "TLSv1.3".parse().unwrap());
let provider_request_headers = BTreeMap::new();
let report_context =
build_local_execution_report_context(LocalExecutionReportContextParts {
auth_context: &auth_context,
request_id: "trace-1",
candidate_id: "candidate-1",
attempt_identity: ExecutionAttemptIdentity::new(0, 0),
model: "gpt-5",
provider_name: "OpenAI",
provider_id: "provider-1",
endpoint_id: "endpoint-1",
key_id: "key-1",
key_name: None,
model_id: None,
global_model_id: None,
global_model_name: None,
provider_api_format: "openai:chat",
client_api_format: "openai:chat",
mapped_model: None,
candidate_group_id: None,
ranking: None,
upstream_url: None,
header_rules: None,
body_rules: None,
provider_request_method: None,
provider_request_headers: Some(&provider_request_headers),
original_headers: &original_headers,
request_origin: None,
original_request_body_json: Some(&json!({"model": "gpt-5"})),
original_request_body_base64: None,
client_session_affinity: None,
client_requested_stream: false,
upstream_is_stream: false,
has_envelope: false,
needs_conversion: false,
extra_fields: Map::new(),
});
assert_eq!(
report_context["tls_fingerprint"]["incoming"],
json!({
"source": "forwarded_header",
"ja3": "ja3-value",
"ja4": "ja4-value",
"protocol": "TLSv1.3"
})
);
}
} }

View File

@@ -1,6 +1,7 @@
use std::{collections::BTreeMap, net::SocketAddr}; use std::{collections::BTreeMap, net::SocketAddr};
use crate::constants::*; use crate::constants::*;
use serde_json::{Map, Value};
use uuid::Uuid; use uuid::Uuid;
pub(crate) fn extract_or_generate_trace_id(headers: &http::HeaderMap) -> String { pub(crate) fn extract_or_generate_trace_id(headers: &http::HeaderMap) -> String {
@@ -53,6 +54,44 @@ pub(crate) fn request_origin_from_parts(parts: &http::request::Parts) -> Request
.unwrap_or_else(|| request_origin_from_headers(&parts.headers)) .unwrap_or_else(|| request_origin_from_headers(&parts.headers))
} }
pub(crate) fn tls_fingerprint_from_headers(headers: &http::HeaderMap) -> Option<Value> {
let mut object = Map::new();
copy_tls_header(headers, &mut object, "x-aether-tls-ja3", "ja3");
copy_tls_header(headers, &mut object, "x-aether-tls-ja3-hash", "ja3_hash");
copy_tls_header(headers, &mut object, "x-aether-tls-ja4", "ja4");
copy_tls_header(headers, &mut object, "x-aether-tls-protocol", "protocol");
copy_tls_header(headers, &mut object, "x-aether-tls-version", "tls_version");
copy_tls_header(headers, &mut object, "x-aether-tls-cipher", "cipher");
copy_tls_header(headers, &mut object, "x-aether-tls-sni", "sni");
copy_tls_header(headers, &mut object, "x-aether-tls-alpn", "alpn");
if object.is_empty() {
return None;
}
let source = header_value_str(headers, "x-aether-tls-source")
.unwrap_or_else(|| "forwarded_header".to_string());
object.insert("source".to_string(), Value::String(source));
Some(Value::Object(object))
}
fn copy_tls_header(
headers: &http::HeaderMap,
object: &mut Map<String, Value>,
header_name: &str,
field_name: &str,
) {
let Some(value) = header_value_str(headers, header_name) else {
return;
};
object.insert(
field_name.to_string(),
Value::String(truncate_chars(&value, 512)),
);
}
fn client_ip_from_headers(headers: &http::HeaderMap) -> Option<String> { fn client_ip_from_headers(headers: &http::HeaderMap) -> Option<String> {
header_value_str(headers, "x-forwarded-for") header_value_str(headers, "x-forwarded-for")
.and_then(|value| { .and_then(|value| {
@@ -133,9 +172,11 @@ pub(crate) fn header_equals(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
request_origin_from_headers, request_origin_from_headers_and_remote_addr, RequestOrigin, request_origin_from_headers, request_origin_from_headers_and_remote_addr,
tls_fingerprint_from_headers, RequestOrigin,
}; };
use http::{HeaderMap, HeaderValue}; use http::{HeaderMap, HeaderValue};
use serde_json::json;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
#[test] #[test]
@@ -184,4 +225,40 @@ mod tests {
Some("192.0.2.10") Some("192.0.2.10")
); );
} }
#[test]
fn tls_fingerprint_from_headers_collects_forwarded_tls_fields() {
let mut headers = HeaderMap::new();
headers.insert("x-aether-tls-ja3", HeaderValue::from_static("ja3-value"));
headers.insert(
"x-aether-tls-ja3-hash",
HeaderValue::from_static("ja3-hash"),
);
headers.insert("x-aether-tls-ja4", HeaderValue::from_static("ja4-value"));
headers.insert("x-aether-tls-protocol", HeaderValue::from_static("TLSv1.3"));
headers.insert(
"x-aether-tls-cipher",
HeaderValue::from_static("TLS_AES_128_GCM_SHA256"),
);
headers.insert(
"x-aether-tls-sni",
HeaderValue::from_static("api.example.com"),
);
headers.insert("x-aether-tls-alpn", HeaderValue::from_static("h2"));
headers.insert("x-aether-tls-source", HeaderValue::from_static("nginx"));
assert_eq!(
tls_fingerprint_from_headers(&headers),
Some(json!({
"source": "nginx",
"ja3": "ja3-value",
"ja3_hash": "ja3-hash",
"ja4": "ja4-value",
"protocol": "TLSv1.3",
"cipher": "TLS_AES_128_GCM_SHA256",
"sni": "api.example.com",
"alpn": "h2"
}))
);
}
} }

View File

@@ -4,7 +4,11 @@ use aether_ai_formats::api::{
ExecutionRuntimeAuthContext, EXECUTION_RUNTIME_STREAM_DECISION_ACTION, ExecutionRuntimeAuthContext, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
}; };
use aether_contracts::{ExecutionTimeouts, ProxySnapshot, ResolvedTransportProfile}; use aether_contracts::{
ExecutionTimeouts, ProxySnapshot, ResolvedTransportProfile, TRANSPORT_BACKEND_HYPER_RUSTLS,
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
};
use serde_json::{json, Map, Value};
use crate::{AiExecutionDecision, ConversionMode, ExecutionStrategy}; use crate::{AiExecutionDecision, ConversionMode, ExecutionStrategy};
@@ -43,8 +47,14 @@ pub struct AiExecutionDecisionResponseParts {
} }
pub fn build_ai_execution_decision_response( pub fn build_ai_execution_decision_response(
parts: AiExecutionDecisionResponseParts, mut parts: AiExecutionDecisionResponseParts,
) -> AiExecutionDecision { ) -> AiExecutionDecision {
parts.report_context = attach_outgoing_tls_fingerprint(
parts.report_context,
parts.transport_profile.as_ref(),
parts.proxy.as_ref(),
);
AiExecutionDecision { AiExecutionDecision {
action: ai_execution_decision_action(parts.decision_is_stream).to_string(), action: ai_execution_decision_action(parts.decision_is_stream).to_string(),
decision_kind: Some(parts.decision_kind), decision_kind: Some(parts.decision_kind),
@@ -90,3 +100,196 @@ pub const fn ai_execution_decision_action(decision_is_stream: bool) -> &'static
EXECUTION_RUNTIME_SYNC_DECISION_ACTION EXECUTION_RUNTIME_SYNC_DECISION_ACTION
} }
} }
fn attach_outgoing_tls_fingerprint(
report_context: Option<Value>,
transport_profile: Option<&ResolvedTransportProfile>,
proxy: Option<&ProxySnapshot>,
) -> Option<Value> {
let mut report_context = match report_context {
Some(Value::Object(object)) => object,
_ => Map::new(),
};
let tls_fingerprint = report_context
.entry("tls_fingerprint".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(object) = tls_fingerprint {
object.insert(
"outgoing".to_string(),
outgoing_tls_fingerprint_value(transport_profile, proxy),
);
}
Some(Value::Object(report_context))
}
fn outgoing_tls_fingerprint_value(
transport_profile: Option<&ResolvedTransportProfile>,
proxy: Option<&ProxySnapshot>,
) -> Value {
let via_local_proxy = proxy
.and_then(|value| value.node_id.as_deref())
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let backend = transport_profile
.map(|profile| profile.backend.trim())
.filter(|value| !value.is_empty())
.unwrap_or(if via_local_proxy {
TRANSPORT_BACKEND_HYPER_RUSTLS
} else {
TRANSPORT_BACKEND_REQWEST_RUSTLS
});
let http_mode = transport_profile
.map(|profile| profile.http_mode.trim())
.filter(|value| !value.is_empty())
.unwrap_or("auto");
let alpn_offered = if http_mode.eq_ignore_ascii_case(TRANSPORT_HTTP_MODE_HTTP1_ONLY) {
json!(["http/1.1"])
} else {
json!(["h2", "http/1.1"])
};
let transport_path = if via_local_proxy {
"aether_proxy_tunnel"
} else if proxy.is_some() {
"direct_with_proxy"
} else {
"direct"
};
let mut object = Map::new();
object.insert(
"source".to_string(),
Value::String("aether_transport_config".to_string()),
);
object.insert("observed".to_string(), Value::Bool(false));
object.insert(
"transport_path".to_string(),
Value::String(transport_path.to_string()),
);
object.insert("backend".to_string(), Value::String(backend.to_string()));
object.insert(
"http_mode".to_string(),
Value::String(http_mode.to_string()),
);
object.insert("tls_stack".to_string(), Value::String("rustls".to_string()));
object.insert(
"tls_versions_offered".to_string(),
json!(["TLS1.3", "TLS1.2"]),
);
object.insert("alpn_offered".to_string(), alpn_offered);
if let Some(profile) = transport_profile {
object.insert(
"profile_id".to_string(),
Value::String(profile.profile_id.clone()),
);
object.insert(
"pool_scope".to_string(),
Value::String(profile.pool_scope.clone()),
);
}
Value::Object(object)
}
#[cfg(test)]
mod tests {
use super::*;
use aether_contracts::{TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY};
fn sample_parts() -> AiExecutionDecisionResponseParts {
AiExecutionDecisionResponseParts {
decision_is_stream: false,
decision_kind: "local_sync".to_string(),
execution_strategy: ExecutionStrategy::LocalSameFormat,
conversion_mode: ConversionMode::None,
request_id: "trace-1".to_string(),
candidate_id: "candidate-1".to_string(),
provider_name: "OpenAI".to_string(),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
upstream_base_url: "https://api.example.com".to_string(),
upstream_url: "https://api.example.com/v1/chat/completions".to_string(),
provider_request_method: None,
auth_header: None,
auth_value: None,
provider_api_format: "openai:chat".to_string(),
client_api_format: "openai:chat".to_string(),
model_name: "gpt-5".to_string(),
mapped_model: "gpt-5".to_string(),
prompt_cache_key: None,
provider_request_headers: BTreeMap::new(),
provider_request_body: Some(json!({"model": "gpt-5"})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
upstream_is_stream: false,
report_kind: None,
report_context: Some(json!({
"tls_fingerprint": {
"incoming": {
"source": "forwarded_header",
"ja3": "incoming-ja3"
}
}
})),
auth_context: ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
api_key_id: "api-key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: None,
access_allowed: true,
api_key_is_standalone: false,
},
}
}
#[test]
fn decision_response_records_outgoing_tls_fingerprint_without_dropping_incoming() {
let mut parts = sample_parts();
parts.transport_profile = Some(ResolvedTransportProfile {
profile_id: "claude_code_nodejs".to_string(),
backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.to_string(),
http_mode: TRANSPORT_HTTP_MODE_AUTO.to_string(),
pool_scope: TRANSPORT_POOL_SCOPE_KEY.to_string(),
header_fingerprint: None,
extra: None,
});
let decision = build_ai_execution_decision_response(parts);
let tls_fingerprint = &decision.report_context.unwrap()["tls_fingerprint"];
assert_eq!(tls_fingerprint["incoming"]["ja3"], "incoming-ja3");
assert_eq!(
tls_fingerprint["outgoing"]["profile_id"],
"claude_code_nodejs"
);
assert_eq!(
tls_fingerprint["outgoing"]["backend"],
TRANSPORT_BACKEND_REQWEST_RUSTLS
);
assert_eq!(tls_fingerprint["outgoing"]["observed"], false);
assert_eq!(
tls_fingerprint["outgoing"]["alpn_offered"],
json!(["h2", "http/1.1"])
);
}
#[test]
fn decision_response_defaults_outgoing_tls_to_proxy_hyper_backend_for_node_proxy() {
let mut parts = sample_parts();
parts.proxy = Some(ProxySnapshot {
node_id: Some("proxy-node-1".to_string()),
..ProxySnapshot::default()
});
let decision = build_ai_execution_decision_response(parts);
let outgoing = &decision.report_context.unwrap()["tls_fingerprint"]["outgoing"];
assert_eq!(outgoing["transport_path"], "aether_proxy_tunnel");
assert_eq!(outgoing["backend"], TRANSPORT_BACKEND_HYPER_RUSTLS);
}
}

View File

@@ -87,6 +87,7 @@ fn copy_allowed_metadata_fields(source: &Map<String, Value>, target: &mut Map<St
copy_non_null_value(source, target, "dimensions"); copy_non_null_value(source, target, "dimensions");
copy_non_null_value(source, target, "billing_rule_snapshot"); copy_non_null_value(source, target, "billing_rule_snapshot");
copy_non_null_value(source, target, "scheduling_audit"); copy_non_null_value(source, target, "scheduling_audit");
copy_non_null_value(source, target, "tls_fingerprint");
copy_number(source, target, "rate_multiplier"); copy_number(source, target, "rate_multiplier");
copy_bool(source, target, "is_free_tier"); copy_bool(source, target, "is_free_tier");
copy_number(source, target, "input_price_per_1m"); copy_number(source, target, "input_price_per_1m");
@@ -119,6 +120,7 @@ fn move_allowed_metadata_fields(mut source: Map<String, Value>, target: &mut Map
remove_non_null_value(&mut source, target, "dimensions"); remove_non_null_value(&mut source, target, "dimensions");
remove_non_null_value(&mut source, target, "billing_rule_snapshot"); remove_non_null_value(&mut source, target, "billing_rule_snapshot");
remove_non_null_value(&mut source, target, "scheduling_audit"); remove_non_null_value(&mut source, target, "scheduling_audit");
remove_non_null_value(&mut source, target, "tls_fingerprint");
remove_number(&mut source, target, "rate_multiplier"); remove_number(&mut source, target, "rate_multiplier");
remove_bool(&mut source, target, "is_free_tier"); remove_bool(&mut source, target, "is_free_tier");
remove_number(&mut source, target, "input_price_per_1m"); remove_number(&mut source, target, "input_price_per_1m");
@@ -476,6 +478,46 @@ mod tests {
); );
} }
#[test]
fn sanitizes_request_metadata_preserves_tls_fingerprint() {
let metadata = sanitize_usage_request_metadata(Some(json!({
"tls_fingerprint": {
"incoming": {
"source": "forwarded_header",
"ja3": "incoming-ja3",
"ja4": "incoming-ja4"
},
"outgoing": {
"source": "aether_transport_config",
"backend": "reqwest_rustls",
"observed": false
}
},
"untrusted_tls_fingerprint": {
"ja3": "spoofed"
}
})))
.expect("metadata should remain");
assert_eq!(
metadata,
json!({
"tls_fingerprint": {
"incoming": {
"source": "forwarded_header",
"ja3": "incoming-ja3",
"ja4": "incoming-ja4"
},
"outgoing": {
"source": "aether_transport_config",
"backend": "reqwest_rustls",
"observed": false
}
}
})
);
}
#[test] #[test]
fn builds_seed_from_context_and_allowlisted_metadata_only() { fn builds_seed_from_context_and_allowlisted_metadata_only() {
let metadata = build_usage_request_metadata_seed( let metadata = build_usage_request_metadata_seed(

View File

@@ -0,0 +1,94 @@
# TLS Fingerprint Capture
Aether stores per-request TLS capture under `usage.request_metadata.tls_fingerprint`.
```json
{
"tls_fingerprint": {
"incoming": {
"source": "forwarded_header",
"ja3": "...",
"ja3_hash": "...",
"ja4": "...",
"protocol": "TLSv1.3",
"cipher": "TLS_AES_128_GCM_SHA256",
"sni": "api.example.com",
"alpn": "h2"
},
"outgoing": {
"source": "aether_transport_config",
"observed": false,
"transport_path": "direct",
"backend": "reqwest_rustls",
"http_mode": "auto",
"tls_stack": "rustls",
"tls_versions_offered": ["TLS1.3", "TLS1.2"],
"alpn_offered": ["h2", "http/1.1"]
}
}
}
```
`incoming` is the client-to-Aether TLS fingerprint. It can be populated by Aether native TLS capture in direct deployments or by trusted reverse-proxy headers when TLS terminates before Aether.
`outgoing` is the Aether-to-provider TLS transport record. The current gateway records the exact transport configuration it controls. It sets `observed: false` because reqwest/rustls does not expose the emitted ClientHello bytes on the direct path. A future connector-level ClientHello capture or probe result can reuse the same object with `observed: true` plus `ja3`, `ja3_hash`, and `ja4`.
## Nginx TLS Termination
When nginx terminates HTTPS and proxies HTTP to Aether, Aether cannot see the original ClientHello. Configure nginx to forward the TLS fields it can observe:
```nginx
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Aether-TLS-Source nginx;
proxy_set_header X-Aether-TLS-Protocol $ssl_protocol;
proxy_set_header X-Aether-TLS-Cipher $ssl_cipher;
proxy_set_header X-Aether-TLS-SNI $ssl_server_name;
}
}
```
Stock nginx does not provide JA3/JA4 variables. The forwarded record is still useful, but it is not a complete TLS fingerprint. To forward JA3/JA4 through nginx, use an nginx build/module or edge layer that computes them and set:
```nginx
proxy_set_header X-Aether-TLS-JA3 $ja3;
proxy_set_header X-Aether-TLS-JA3-Hash $ja3_hash;
proxy_set_header X-Aether-TLS-JA4 $ja4;
```
Only accept these headers from trusted infrastructure. Do not expose Aether directly to public clients while also trusting client-supplied `X-Aether-TLS-*` headers.
## Nginx TCP Passthrough
If Aether terminates TLS itself, nginx can pass TCP through without decrypting:
```nginx
stream {
map $ssl_preread_server_name $aether_backend {
api.example.com 127.0.0.1:3443;
default 127.0.0.1:3443;
}
server {
listen 443;
proxy_pass $aether_backend;
ssl_preread on;
}
}
```
In this mode nginx cannot inject HTTP headers because it never sees HTTP. Aether native TLS capture is responsible for populating `tls_fingerprint.incoming`.