#![forbid(unsafe_code)]
use std::sync::Arc;
use crate::mixnet::{DeathReport, Indicator};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "RawMixnetStatus")]
pub struct MixnetStatus {
pub mode: Indicator,
#[serde(skip_serializing_if = "Option::is_none")]
pub socks5_addr: Option<std::net::SocketAddr>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub exits: Vec<crate::mixnet::ExitNodeId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bootstrap_detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub death: Option<DeathReport>,
}
#[derive(serde::Deserialize)]
struct RawMixnetStatus {
mode: Indicator,
#[serde(default)]
socks5_addr: Option<std::net::SocketAddr>,
#[serde(default)]
exits: Vec<crate::mixnet::ExitNodeId>,
#[serde(default)]
bootstrap_detail: Option<String>,
#[serde(default)]
death: Option<DeathReport>,
}
impl TryFrom<RawMixnetStatus> for MixnetStatus {
type Error = String;
fn try_from(raw: RawMixnetStatus) -> Result<Self, Self::Error> {
fn stray(field: &str, mode: Indicator) -> String {
format!("{field} is not evidence for mode {}", mode.as_str())
}
if raw.socks5_addr.is_some() && !raw.mode.is_ready() {
return Err(stray("socks5_addr", raw.mode));
}
if !raw.exits.is_empty() && !raw.mode.is_ready() {
return Err(stray("exits", raw.mode));
}
if raw.bootstrap_detail.is_some() && raw.mode != Indicator::Bootstrapping {
return Err(stray("bootstrap_detail", raw.mode));
}
if raw.death.is_some() && raw.mode != Indicator::Died {
return Err(stray("death", raw.mode));
}
Ok(MixnetStatus {
mode: raw.mode,
socks5_addr: raw.socks5_addr,
exits: raw.exits,
bootstrap_detail: raw.bootstrap_detail,
death: raw.death,
})
}
}
impl MixnetStatus {
pub(crate) fn slot_only(mode: Indicator) -> Self {
MixnetStatus {
mode,
socks5_addr: None,
exits: Vec::new(),
bootstrap_detail: None,
death: None,
}
}
pub(crate) fn evidenced(
mode: Indicator,
socks5_addr: Option<std::net::SocketAddr>,
exits: Vec<crate::mixnet::ExitNodeId>,
death: Option<DeathReport>,
) -> Self {
let routable = mode.is_ready();
MixnetStatus {
mode,
socks5_addr: if routable { socks5_addr } else { None },
exits: if routable { exits } else { Vec::new() },
bootstrap_detail: None,
death: if mode == Indicator::Died { death } else { None },
}
}
}
pub(crate) type StatusPublisher = Arc<tokio::sync::watch::Sender<MixnetStatus>>;
pub(crate) fn status_publisher() -> StatusPublisher {
let (sender, _initial_receiver) =
tokio::sync::watch::channel(MixnetStatus::slot_only(Indicator::Unattached));
Arc::new(sender)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MixnetStartPolicy {
ForcedOn,
OptedOutThisSession,
}
#[derive(Debug)]
pub enum ProvisionStrategy<'a> {
Spawn(crate::mixnet::provision::SpawnHints<'a>),
Attach {
socks5_addr: &'a str,
exits: &'a [crate::mixnet::ExitNodeId],
},
}
#[cfg(test)]
mod wire_contract {
use std::time::{Duration, UNIX_EPOCH};
use zingo_net_diag::{NetOpFailure, NetOpStage};
use super::MixnetStatus;
use crate::mixnet::{DeathReport, Indicator};
fn fixed_at() -> std::time::SystemTime {
UNIX_EPOCH + Duration::from_millis(1_753_900_000_000)
}
fn pin(status: &MixnetStatus, json: &str) {
assert_eq!(serde_json::to_string(status).unwrap(), json);
assert_eq!(&serde_json::from_str::<MixnetStatus>(json).unwrap(), status);
}
#[test]
fn a_malformed_snapshot_is_suspicious_and_refuses() {
for json in [
r#"{"mode":"ready","socks5_addr":"localhost:1080"}"#,
r#"{"mode":"ready","exits":[""]}"#,
] {
serde_json::from_str::<MixnetStatus>(json)
.expect_err("a malformed wire value must refuse as suspicious");
}
}
#[test]
fn unattached_carries_only_its_token() {
pin(
&MixnetStatus::slot_only(Indicator::Unattached),
r#"{"mode":"unattached"}"#,
);
}
#[test]
fn switched_off_carries_only_its_token() {
pin(
&MixnetStatus::slot_only(Indicator::SwitchedOff),
r#"{"mode":"switched_off"}"#,
);
}
#[test]
fn bootstrapping_carries_its_narration() {
pin(
&MixnetStatus {
mode: Indicator::Bootstrapping,
socks5_addr: None,
exits: Vec::new(),
bootstrap_detail: Some("connecting to gateway".into()),
death: None,
},
r#"{"mode":"bootstrapping","bootstrap_detail":"connecting to gateway"}"#,
);
}
#[test]
fn ready_carries_its_socks5_addr() {
pin(
&MixnetStatus {
mode: Indicator::Ready,
socks5_addr: Some("127.0.0.1:1080".parse().expect("the test address parses")),
exits: Vec::new(),
bootstrap_detail: None,
death: None,
},
r#"{"mode":"ready","socks5_addr":"127.0.0.1:1080"}"#,
);
}
#[test]
fn ready_carries_its_bound_exits() {
pin(
&MixnetStatus {
mode: Indicator::Ready,
socks5_addr: Some("127.0.0.1:1080".parse().expect("the test address parses")),
exits: vec!["exit-alpha".into(), "exit-beta".into()],
bootstrap_detail: None,
death: None,
},
r#"{"mode":"ready","socks5_addr":"127.0.0.1:1080","exits":["exit-alpha","exit-beta"]}"#,
);
}
#[test]
fn died_carries_a_typed_cause() {
pin(
&MixnetStatus {
mode: Indicator::Died,
socks5_addr: None,
exits: Vec::new(),
bootstrap_detail: None,
death: Some(DeathReport {
at: fixed_at(),
detail: Some(NetOpFailure {
stage: NetOpStage::TimedOut { after_ms: 25000 },
target: "https://indexer.example:443".into(),
cause_chain: vec!["deadline elapsed".into()],
}),
}),
},
r#"{"mode":"died","death":{"at":1753900000000,"detail":{"stage":{"timed-out":{"after_ms":25000}},"target":"https://indexer.example:443","cause_chain":["deadline elapsed"]}}}"#,
);
}
#[test]
fn died_without_a_held_cause_omits_the_detail() {
pin(
&MixnetStatus {
mode: Indicator::Died,
socks5_addr: None,
exits: Vec::new(),
bootstrap_detail: None,
death: Some(DeathReport {
at: fixed_at(),
detail: None,
}),
},
r#"{"mode":"died","death":{"at":1753900000000}}"#,
);
}
#[test]
fn a_hostile_at_lifts_to_a_result_not_a_panic() {
let hostile = format!(r#"{{"mode":"died","death":{{"at":{}}}}}"#, u64::MAX);
let _ = serde_json::from_str::<MixnetStatus>(&hostile);
}
#[test]
fn stray_evidence_is_refused() {
for hostile in [
r#"{"mode":"unattached","socks5_addr":"127.0.0.1:1080"}"#,
r#"{"mode":"ready","bootstrap_detail":"connecting to gateway"}"#,
r#"{"mode":"bootstrapping","death":{"at":0}}"#,
r#"{"mode":"bootstrapping","exits":["exit-alpha"]}"#,
] {
assert!(
serde_json::from_str::<MixnetStatus>(hostile).is_err(),
"lifted stray evidence: {hostile}"
);
}
}
#[test]
fn every_stage_token_is_its_display_mint() {
let unit_stages = [
NetOpStage::ProxyLaunch,
NetOpStage::RouteResolution,
NetOpStage::RemoteConnect,
NetOpStage::LocalProxyConnect,
NetOpStage::SocksHandshake,
NetOpStage::TunnelTransport,
NetOpStage::RemoteTls,
NetOpStage::RemoteHttp,
NetOpStage::PayloadDecode,
];
for stage in &unit_stages {
match stage {
NetOpStage::ProxyLaunch
| NetOpStage::RouteResolution
| NetOpStage::RemoteConnect
| NetOpStage::LocalProxyConnect
| NetOpStage::SocksHandshake
| NetOpStage::TunnelTransport
| NetOpStage::RemoteTls
| NetOpStage::RemoteHttp
| NetOpStage::PayloadDecode
| NetOpStage::TimedOut { .. } => {}
}
assert_eq!(
serde_json::to_string(stage).unwrap(),
format!("\"{stage}\"")
);
}
assert_eq!(
serde_json::to_string(&NetOpStage::TimedOut { after_ms: 7 }).unwrap(),
r#"{"timed-out":{"after_ms":7}}"#,
);
}
#[test]
fn a_unit_stage_is_its_kebab_token_not_a_substring() {
let failure = NetOpFailure {
stage: NetOpStage::RemoteTls,
target: "https://indexer.example:443".into(),
cause_chain: vec!["handshake failed".into(), "certificate expired".into()],
};
assert_eq!(
serde_json::to_string(&failure).unwrap(),
r#"{"stage":"remote-tls","target":"https://indexer.example:443","cause_chain":["handshake failed","certificate expired"]}"#,
);
}
}