mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Capture TLS fingerprints in report context
This commit is contained in:
@@ -121,6 +121,10 @@ pub(crate) fn is_json_request(headers: &http::HeaderMap) -> bool {
|
||||
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(
|
||||
auth_context: &crate::control::GatewayControlAuthContext,
|
||||
) -> ExecutionRuntimeAuthContext {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use aether_ai_serving::{
|
||||
run_ai_candidate_preselection, AiCandidatePreselectionOutcome, AiCandidatePreselectionPort,
|
||||
};
|
||||
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
|
||||
use aether_scheduler_core::{
|
||||
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,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
@@ -14,7 +16,8 @@ use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::data::candidate_selection::{
|
||||
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::GatewayError;
|
||||
@@ -203,6 +206,7 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> {
|
||||
requested_name_offsets: BTreeMap<String, u32>,
|
||||
scanned_rows_by_format: BTreeMap<String, u32>,
|
||||
resolved_global_model_names: BTreeMap<String, String>,
|
||||
fallback_scanned_api_formats: BTreeSet<String>,
|
||||
seen_candidate_keys: BTreeSet<String>,
|
||||
}
|
||||
|
||||
@@ -255,6 +259,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
|
||||
requested_name_offsets: BTreeMap::new(),
|
||||
scanned_rows_by_format: BTreeMap::new(),
|
||||
resolved_global_model_names: BTreeMap::new(),
|
||||
fallback_scanned_api_formats: BTreeSet::new(),
|
||||
seen_candidate_keys: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
@@ -319,7 +324,13 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
|
||||
.entry(normalized_api_format.clone())
|
||||
.or_insert(0);
|
||||
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() {
|
||||
self.requested_name_indexes
|
||||
@@ -364,121 +375,201 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
|
||||
}
|
||||
if page.scanned_rows == 0 {
|
||||
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;
|
||||
}
|
||||
|
||||
let mut rows = page
|
||||
.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,
|
||||
if let Some(outcome) = self
|
||||
.build_page_outcome_from_rows(
|
||||
candidate_api_format,
|
||||
&normalized_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(),
|
||||
page.rows,
|
||||
)
|
||||
.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<_>>();
|
||||
|
||||
return Ok(Some(AiCandidatePreselectionOutcome {
|
||||
candidates,
|
||||
skipped_candidates,
|
||||
}));
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(outcome));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
@@ -603,3 +694,120 @@ pub(crate) fn auth_snapshot_allows_cross_format_candidate(
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ pub(crate) fn build_local_execution_report_context(
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
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"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{collections::BTreeMap, net::SocketAddr};
|
||||
|
||||
use crate::constants::*;
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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> {
|
||||
header_value_str(headers, "x-forwarded-for")
|
||||
.and_then(|value| {
|
||||
@@ -133,9 +172,11 @@ pub(crate) fn header_equals(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 serde_json::json;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
@@ -184,4 +225,40 @@ mod tests {
|
||||
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"
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ use aether_ai_formats::api::{
|
||||
ExecutionRuntimeAuthContext, EXECUTION_RUNTIME_STREAM_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};
|
||||
|
||||
@@ -43,8 +47,14 @@ pub struct AiExecutionDecisionResponseParts {
|
||||
}
|
||||
|
||||
pub fn build_ai_execution_decision_response(
|
||||
parts: AiExecutionDecisionResponseParts,
|
||||
mut parts: AiExecutionDecisionResponseParts,
|
||||
) -> AiExecutionDecision {
|
||||
parts.report_context = attach_outgoing_tls_fingerprint(
|
||||
parts.report_context,
|
||||
parts.transport_profile.as_ref(),
|
||||
parts.proxy.as_ref(),
|
||||
);
|
||||
|
||||
AiExecutionDecision {
|
||||
action: ai_execution_decision_action(parts.decision_is_stream).to_string(),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "billing_rule_snapshot");
|
||||
copy_non_null_value(source, target, "scheduling_audit");
|
||||
copy_non_null_value(source, target, "tls_fingerprint");
|
||||
copy_number(source, target, "rate_multiplier");
|
||||
copy_bool(source, target, "is_free_tier");
|
||||
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, "billing_rule_snapshot");
|
||||
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_bool(&mut source, target, "is_free_tier");
|
||||
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]
|
||||
fn builds_seed_from_context_and_allowlisted_metadata_only() {
|
||||
let metadata = build_usage_request_metadata_seed(
|
||||
|
||||
94
docs/operations/tls-fingerprint-capture.md
Normal file
94
docs/operations/tls-fingerprint-capture.md
Normal 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`.
|
||||
Reference in New Issue
Block a user