use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::error::{OlError, ERR_INVALID_CONFIG};
use super::state::EgressSnapshot;
const MEMO_FILE: &str = "proxy-shape.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "snake_case")]
pub struct ProxyShape {
pub proxy_in_use: bool,
pub proxy_type: String,
pub source: Option<String>,
pub auth_scheme: String,
pub ca_source: String,
pub tls_intercepted: Option<bool>,
pub os: String,
}
impl ProxyShape {
pub fn of(snapshot: &EgressSnapshot) -> Self {
Self {
proxy_in_use: snapshot.proxy_in_use,
proxy_type: snapshot.proxy_type().as_str().to_string(),
source: snapshot.source.map(|s| s.as_str().to_string()),
auth_scheme: snapshot.auth_scheme.as_str().to_string(),
ca_source: snapshot.ca_source.as_str().to_string(),
tls_intercepted: snapshot.tls_intercepted,
os: std::env::consts::OS.to_string(),
}
}
pub fn path_in(dir: &Path) -> PathBuf {
dir.join(MEMO_FILE)
}
pub fn load_from(path: &Path) -> Option<Self> {
let raw = std::fs::read_to_string(path).ok()?;
match serde_json::from_str::<Self>(&raw) {
Ok(shape) => Some(shape),
Err(e) => {
tracing::debug!(
target: "egress",
error = %e,
path = %path.display(),
"proxy-shape.json is unreadable; treating the shape as never emitted"
);
None
}
}
}
pub fn save_to(&self, path: &Path) -> Result<(), OlError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("create proxy-shape dir {}: {e}", parent.display()),
)
})?;
}
let body = serde_json::to_string_pretty(self)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("serialise proxy-shape: {e}")))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, body).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("write proxy-shape tmp {}: {e}", tmp.display()),
)
})?;
std::fs::rename(&tmp, path).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!(
"rename proxy-shape {} -> {}: {e}",
tmp.display(),
path.display()
),
)
})
}
pub fn event(&self, discovery_attempts: u32) -> crate::telemetry::Event {
crate::telemetry::Event::proxy_configured(
self.proxy_in_use,
&self.proxy_type,
self.source.as_deref(),
&self.auth_scheme,
&self.ca_source,
self.tls_intercepted,
discovery_attempts,
&self.os,
)
}
}
pub fn emit_if_changed(dir: &Path, snapshot: &EgressSnapshot, discovery_attempts: u32) -> bool {
let path = ProxyShape::path_in(dir);
let current = ProxyShape::of(snapshot);
if ProxyShape::load_from(&path).as_ref() == Some(¤t) {
tracing::debug!(
target: "egress",
"egress shape is unchanged since the last emission; no proxy_configured"
);
return false;
}
crate::telemetry::capture_global(current.event(discovery_attempts));
if let Err(e) = current.save_to(&path) {
tracing::debug!(
target: "egress",
error = %e.message,
"could not write proxy-shape.json; the shape may be re-emitted on the next start"
);
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::egress::config::{EgressConfig, EnvSource, ProxyToml};
use crate::core::egress::state::EgressStatus;
struct NoEnv;
impl EnvSource for NoEnv {
fn var(&self, _key: &str) -> Option<String> {
None
}
}
fn snapshot_of(toml: ProxyToml) -> EgressSnapshot {
let cfg = EgressConfig::resolve(Some(&toml), &NoEnv, 7443, 7444).expect("resolve");
EgressSnapshot::from_config(&cfg)
}
fn proxied_snapshot() -> EgressSnapshot {
snapshot_of(ProxyToml {
mode: Some("manual".into()),
url: Some("http://proxy.corp:8080".into()),
source: Some("windows".into()),
..Default::default()
})
}
#[test]
fn a_direct_shape_reports_direct() {
let shape = ProxyShape::of(&EgressSnapshot::from_config(&EgressConfig::direct()));
assert!(!shape.proxy_in_use);
assert_eq!(shape.proxy_type, "direct");
assert_eq!(shape.source, None);
assert_eq!(shape.tls_intercepted, None);
}
#[test]
fn a_pac_source_reports_pac_whatever_the_scheme_is() {
let shape = ProxyShape::of(&snapshot_of(ProxyToml {
mode: Some("manual".into()),
url: Some("http://proxy.corp:8080".into()),
source: Some("pac".into()),
..Default::default()
}));
assert_eq!(shape.proxy_type, "pac");
}
#[test]
fn socks_and_https_proxies_keep_their_own_type() {
for (url, want) in [
("socks5://proxy.corp:1080", "socks5"),
("socks5h://proxy.corp:1080", "socks5"),
("https://proxy.corp:8443", "https"),
("http://proxy.corp:8080", "http"),
] {
let shape = ProxyShape::of(&snapshot_of(ProxyToml {
mode: Some("manual".into()),
url: Some(url.into()),
..Default::default()
}));
assert_eq!(shape.proxy_type, want, "{url}");
}
}
#[test]
fn an_absent_memo_emits_once_and_then_stays_quiet() {
let dir = tempfile::tempdir().expect("tempdir");
let snapshot = proxied_snapshot();
assert!(
emit_if_changed(dir.path(), &snapshot, 0),
"the first ever start must emit"
);
assert!(
!emit_if_changed(dir.path(), &snapshot, 0),
"a restart with an unchanged config must NOT re-emit"
);
}
#[test]
fn a_corrupt_memo_is_treated_as_absent() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(ProxyShape::path_in(dir.path()), "{ not json").expect("write");
assert!(
emit_if_changed(dir.path(), &proxied_snapshot(), 0),
"an unreadable memo must not silence the event"
);
assert!(!emit_if_changed(dir.path(), &proxied_snapshot(), 0));
}
#[test]
fn a_changed_shape_emits_again() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(emit_if_changed(dir.path(), &proxied_snapshot(), 0));
let moved = snapshot_of(ProxyToml {
mode: Some("manual".into()),
url: Some("socks5://proxy.corp:1080".into()),
source: Some("gnome".into()),
..Default::default()
});
assert!(
emit_if_changed(dir.path(), &moved, 3),
"a different proxy_type + source is a different shape"
);
}
#[test]
fn the_status_field_is_not_part_of_the_shape() {
let dir = tempfile::tempdir().expect("tempdir");
let mut snapshot = proxied_snapshot();
assert!(emit_if_changed(dir.path(), &snapshot, 0));
snapshot.status = EgressStatus::Failed;
assert!(
!emit_if_changed(dir.path(), &snapshot, 0),
"health is not shape"
);
}
#[test]
fn discovery_attempts_never_re_emits_on_its_own() {
let dir = tempfile::tempdir().expect("tempdir");
let snapshot = proxied_snapshot();
assert!(emit_if_changed(dir.path(), &snapshot, 0));
assert!(
!emit_if_changed(dir.path(), &snapshot, 7),
"discovery_attempts is excluded from the comparison"
);
}
#[test]
fn no_event_property_can_carry_an_address() {
let snapshot = snapshot_of(ProxyToml {
mode: Some("manual".into()),
url: Some("http://proxy.corp:8080".into()),
username: Some("alice".into()),
auth: Some("basic".into()),
source: Some("windows".into()),
..Default::default()
});
let event = ProxyShape::of(&snapshot).event(2);
let payload = serde_json::to_string(&event.properties).expect("serialise");
for forbidden in ["proxy.corp", "8080", "://", "alice", "@"] {
assert!(
!payload.contains(forbidden),
"proxy_configured leaked {forbidden:?}: {payload}"
);
}
assert_eq!(event.name, "proxy_configured");
}
#[test]
fn the_tls_property_is_omitted_until_an_observation_exists() {
let mut snapshot = proxied_snapshot();
assert!(!ProxyShape::of(&snapshot)
.event(0)
.properties
.contains_key("tls_intercepted"));
snapshot.tls_intercepted = Some(true);
let event = ProxyShape::of(&snapshot).event(0);
assert_eq!(
event.properties.get("tls_intercepted"),
Some(&serde_json::json!(true))
);
}
#[test]
fn the_memo_round_trips_through_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = ProxyShape::path_in(dir.path());
let shape = ProxyShape::of(&proxied_snapshot());
shape.save_to(&path).expect("save");
assert_eq!(ProxyShape::load_from(&path).as_ref(), Some(&shape));
assert!(!path.with_extension("json.tmp").exists());
}
}