Skip to main content

brainos_backends/
net.rs

1//! Network diagnostics — the executor behind `brain net check/trace/cert` and
2//! the `net.check` / `net.trace` / `net.cert` native capabilities (Issue 139).
3//!
4//! Three probes, each with a structured report and a `render()` for human/LLM
5//! output:
6//!
7//! * **check** — DNS resolution + a timed TCP connect to a host[:port]. Pure
8//!   Rust (std + tokio), no subprocess, no sandbox concern.
9//! * **cert** — a TLS handshake that *captures* the presented certificate chain
10//!   with a permissive verifier, so it can inspect expired / self-signed /
11//!   untrusted certs and **report** the problem rather than fail. X.509 fields
12//!   (subject, issuer, validity, SANs) are parsed with `x509-cert`. Also pure
13//!   Rust.
14//! * **trace** — `traceroute` (Unix). traceroute needs privileged raw sockets,
15//!   so — exactly like `brain logs analyze --source system` — it runs as a
16//!   timeout-bounded child process rather than inside the exec sandbox (Apple's
17//!   Seatbelt and Linux namespaces both deny the raw-socket path). This is the
18//!   macOS-sandbox gotcha called out for slice 139.
19//!
20//! The same core functions back both surfaces: the CLI calls them directly and
21//! prints `render()`; [`NetDiagnostics`] wraps them to satisfy the
22//! [`cortex::actions::NetDiagnosticsBackend`] trait the chat tool-loop dispatches
23//! through.
24
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use tokio::net::TcpStream;
29
30/// The capabilities this backend declares: read-only network diagnostics
31/// (check/trace/cert). Always wired — they neither search nor fetch, so they
32/// don't track `actions.web_search`; egress consent is enforced at dispatch.
33pub fn capabilities(_config: &brain::BrainConfig) -> Vec<intent::ToolDescriptor> {
34    use crate::capabilities::{backend, native, read_only, usage};
35    let net = || backend("net");
36    vec![
37        native(
38            "net",
39            "check",
40            net(),
41            read_only(),
42            usage(
43                "The user asks whether a host/endpoint is reachable, or to diagnose a connectivity problem.",
44                "For fetching a page's contents — use net.http (web search/fetch).",
45                &["Network egress is permitted."],
46                "network call (DNS + a single TCP connect)",
47                "\"Can you reach api.github.com:443?\"",
48            ),
49        ),
50        native(
51            "net",
52            "trace",
53            net(),
54            read_only(),
55            usage(
56                "The user wants to see the network path/hops to a host, e.g. to locate where traffic stalls.",
57                "When only reachability matters — net.check is faster.",
58                &["Network egress is permitted.", "`traceroute` is available (Unix)."],
59                "network call (spawns a bounded traceroute child process)",
60                "\"Trace the route to example.com\"",
61            ),
62        ),
63        native(
64            "net",
65            "cert",
66            net(),
67            read_only(),
68            usage(
69                "The user asks about a site's TLS certificate — expiry, issuer, or SANs.",
70                "For plain reachability — use net.check.",
71                &["Network egress is permitted."],
72                "network call (a single TLS handshake)",
73                "\"When does the cert for example.com expire?\"",
74            ),
75        ),
76    ]
77}
78
79/// Default port used when a `check`/`cert` target names only a host. 443 is the
80/// most useful default for a local-first kernel probing HTTPS API endpoints.
81pub const DEFAULT_PORT: u16 = 443;
82
83/// Per-probe wall-clock budget. Diagnostics must fail fast, not hang.
84const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
85
86/// Errors from a network probe.
87#[derive(Debug, thiserror::Error)]
88pub enum NetError {
89    #[error("{0}")]
90    Msg(String),
91    #[error("I/O error: {0}")]
92    Io(#[from] std::io::Error),
93}
94
95impl NetError {
96    fn msg(s: impl Into<String>) -> Self {
97        NetError::Msg(s.into())
98    }
99}
100
101// ─── target parsing ─────────────────────────────────────────────────────────
102
103/// Split a target into `(host, port)`. Accepts a bare host, `host:port`, or a
104/// full URL (`https://host[:port]/…`); falls back to `default_port` when no
105/// port is present.
106fn split_host_port(target: &str, default_port: u16) -> Result<(String, u16), NetError> {
107    let target = target.trim();
108    if target.is_empty() {
109        return Err(NetError::msg("empty target"));
110    }
111
112    // A full URL: let `url` extract host + port.
113    if target.contains("://") {
114        let parsed =
115            url::Url::parse(target).map_err(|e| NetError::msg(format!("invalid URL: {e}")))?;
116        let host = parsed
117            .host_str()
118            .ok_or_else(|| NetError::msg("URL has no host"))?
119            .to_string();
120        let port = parsed.port_or_known_default().unwrap_or(default_port);
121        return Ok((host, port));
122    }
123
124    // `host:port` — but don't mistake a bare IPv6 literal for one. We only
125    // accept the simple `host:port` shape here; bracketed IPv6 falls through to
126    // the bare-host branch.
127    if let Some((host, port)) = target.rsplit_once(':') {
128        if !host.is_empty() && !host.contains(':') {
129            if let Ok(p) = port.parse::<u16>() {
130                return Ok((host.to_string(), p));
131            }
132        }
133    }
134
135    Ok((target.to_string(), default_port))
136}
137
138// ─── check ──────────────────────────────────────────────────────────────────
139
140/// Result of a reachability check.
141pub struct CheckReport {
142    pub host: String,
143    pub port: u16,
144    /// Resolved addresses (may be empty if DNS failed — then `dns_error` is set).
145    pub addresses: Vec<String>,
146    pub dns_error: Option<String>,
147    /// `Ok(rtt)` on a successful TCP connect, `Err(reason)` otherwise. `None`
148    /// when DNS failed and there was nothing to connect to.
149    pub connect: Option<Result<Duration, String>>,
150}
151
152impl CheckReport {
153    pub fn render(&self) -> String {
154        let mut s = format!("net check {}:{}\n", self.host, self.port);
155        match &self.dns_error {
156            Some(e) => s.push_str(&format!("  DNS:     failed — {e}\n")),
157            None => s.push_str(&format!(
158                "  DNS:     {} → {}\n",
159                self.addresses.len(),
160                self.addresses.join(", ")
161            )),
162        }
163        match &self.connect {
164            None => s.push_str("  TCP:     skipped (no address)\n"),
165            Some(Ok(rtt)) => {
166                s.push_str(&format!("  TCP:     connected in {} ms\n", rtt.as_millis()))
167            }
168            Some(Err(e)) => s.push_str(&format!("  TCP:     failed — {e}\n")),
169        }
170        s.push_str(&format!(
171            "  Status:  {}",
172            if self.reachable() {
173                "reachable"
174            } else {
175                "unreachable"
176            }
177        ));
178        s
179    }
180
181    fn reachable(&self) -> bool {
182        matches!(self.connect, Some(Ok(_)))
183    }
184}
185
186/// Resolve `target` and time a TCP connect to it.
187pub async fn check(target: &str) -> Result<CheckReport, NetError> {
188    let (host, port) = split_host_port(target, DEFAULT_PORT)?;
189
190    // DNS — `lookup_host` wants `host:port` and yields `SocketAddr`s.
191    let lookup = tokio::time::timeout(
192        PROBE_TIMEOUT,
193        tokio::net::lookup_host(format!("{host}:{port}")),
194    )
195    .await;
196
197    let mut report = CheckReport {
198        host: host.clone(),
199        port,
200        addresses: Vec::new(),
201        dns_error: None,
202        connect: None,
203    };
204
205    let addrs: Vec<std::net::SocketAddr> = match lookup {
206        Err(_) => {
207            report.dns_error = Some("resolution timed out".to_string());
208            return Ok(report);
209        }
210        Ok(Err(e)) => {
211            report.dns_error = Some(e.to_string());
212            return Ok(report);
213        }
214        Ok(Ok(iter)) => iter.collect(),
215    };
216    report.addresses = addrs.iter().map(|a| a.ip().to_string()).collect();
217    if addrs.is_empty() {
218        report.dns_error = Some("no addresses returned".to_string());
219        return Ok(report);
220    }
221
222    // TCP connect to the first address, timing the handshake.
223    let start = Instant::now();
224    let connect = tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(addrs[0])).await;
225    report.connect = Some(match connect {
226        Err(_) => Err("connection timed out".to_string()),
227        Ok(Err(e)) => Err(e.to_string()),
228        Ok(Ok(_stream)) => Ok(start.elapsed()),
229    });
230    Ok(report)
231}
232
233// ─── cert ─────────────────────────────────────────────────────────────────
234
235/// Result of a TLS certificate inspection.
236pub struct CertReport {
237    pub host: String,
238    pub port: u16,
239    pub tls_version: Option<String>,
240    pub cipher_suite: Option<String>,
241    pub subject: String,
242    pub issuer: String,
243    pub not_before: Option<chrono::DateTime<chrono::Utc>>,
244    pub not_after: Option<chrono::DateTime<chrono::Utc>>,
245    pub sans: Vec<String>,
246    pub chain_len: usize,
247}
248
249impl CertReport {
250    /// Days until expiry (negative if already expired), or `None` when the
251    /// `notAfter` date couldn't be parsed.
252    pub fn days_until_expiry(&self) -> Option<i64> {
253        self.not_after
254            .map(|exp| (exp - chrono::Utc::now()).num_days())
255    }
256
257    pub fn render(&self) -> String {
258        let mut s = format!("net cert {}:{}\n", self.host, self.port);
259        if let Some(v) = &self.tls_version {
260            let cipher = self.cipher_suite.as_deref().unwrap_or("?");
261            s.push_str(&format!("  TLS:      {v} ({cipher})\n"));
262        }
263        s.push_str(&format!("  Subject:  {}\n", self.subject));
264        s.push_str(&format!("  Issuer:   {}\n", self.issuer));
265        if let Some(nb) = self.not_before {
266            s.push_str(&format!(
267                "  Valid:    {}\n",
268                nb.format("%Y-%m-%d %H:%M UTC")
269            ));
270        }
271        match (self.not_after, self.days_until_expiry()) {
272            (Some(na), Some(days)) => {
273                let note = if days < 0 {
274                    format!("EXPIRED {} days ago", -days)
275                } else {
276                    format!("{days} days left")
277                };
278                s.push_str(&format!(
279                    "  Expires:  {} ({note})\n",
280                    na.format("%Y-%m-%d %H:%M UTC")
281                ));
282            }
283            _ => s.push_str("  Expires:  (unparsed)\n"),
284        }
285        if !self.sans.is_empty() {
286            s.push_str(&format!("  SANs:     {}\n", self.sans.join(", ")));
287        }
288        s.push_str(&format!("  Chain:    {} certificate(s)", self.chain_len));
289        s
290    }
291}
292
293/// A rustls verifier that accepts every certificate. We are *inspecting*, not
294/// trusting: capturing an expired or self-signed chain to report on it is the
295/// whole point, so a real verifier would defeat the diagnostic. Trust decisions
296/// live elsewhere (reqwest's verified client handles actual egress).
297#[derive(Debug)]
298struct AcceptAny;
299
300impl tokio_rustls::rustls::client::danger::ServerCertVerifier for AcceptAny {
301    fn verify_server_cert(
302        &self,
303        _end_entity: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
304        _intermediates: &[tokio_rustls::rustls::pki_types::CertificateDer<'_>],
305        _server_name: &tokio_rustls::rustls::pki_types::ServerName<'_>,
306        _ocsp: &[u8],
307        _now: tokio_rustls::rustls::pki_types::UnixTime,
308    ) -> Result<tokio_rustls::rustls::client::danger::ServerCertVerified, tokio_rustls::rustls::Error>
309    {
310        Ok(tokio_rustls::rustls::client::danger::ServerCertVerified::assertion())
311    }
312
313    fn verify_tls12_signature(
314        &self,
315        _message: &[u8],
316        _cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
317        _dss: &tokio_rustls::rustls::DigitallySignedStruct,
318    ) -> Result<
319        tokio_rustls::rustls::client::danger::HandshakeSignatureValid,
320        tokio_rustls::rustls::Error,
321    > {
322        Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion())
323    }
324
325    fn verify_tls13_signature(
326        &self,
327        _message: &[u8],
328        _cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
329        _dss: &tokio_rustls::rustls::DigitallySignedStruct,
330    ) -> Result<
331        tokio_rustls::rustls::client::danger::HandshakeSignatureValid,
332        tokio_rustls::rustls::Error,
333    > {
334        Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion())
335    }
336
337    fn supported_verify_schemes(&self) -> Vec<tokio_rustls::rustls::SignatureScheme> {
338        use tokio_rustls::rustls::SignatureScheme::*;
339        vec![
340            RSA_PKCS1_SHA256,
341            RSA_PKCS1_SHA384,
342            RSA_PKCS1_SHA512,
343            ECDSA_NISTP256_SHA256,
344            ECDSA_NISTP384_SHA384,
345            RSA_PSS_SHA256,
346            RSA_PSS_SHA384,
347            RSA_PSS_SHA512,
348            ED25519,
349        ]
350    }
351}
352
353/// Open a TLS connection to `target` and report on the presented certificate.
354pub async fn cert(target: &str) -> Result<CertReport, NetError> {
355    use tokio_rustls::rustls::{self, pki_types::ServerName};
356    use tokio_rustls::TlsConnector;
357
358    let (host, port) = split_host_port(target, DEFAULT_PORT)?;
359
360    let config = rustls::ClientConfig::builder()
361        .dangerous()
362        .with_custom_certificate_verifier(Arc::new(AcceptAny))
363        .with_no_client_auth();
364    let connector = TlsConnector::from(Arc::new(config));
365
366    let server_name = ServerName::try_from(host.clone())
367        .map_err(|_| NetError::msg(format!("invalid server name '{host}'")))?;
368
369    let tcp = tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect((host.as_str(), port)))
370        .await
371        .map_err(|_| NetError::msg("connection timed out"))??;
372
373    let tls = tokio::time::timeout(PROBE_TIMEOUT, connector.connect(server_name, tcp))
374        .await
375        .map_err(|_| NetError::msg("TLS handshake timed out"))?
376        .map_err(|e| NetError::msg(format!("TLS handshake failed: {e}")))?;
377
378    let (_io, conn) = tls.get_ref();
379    let tls_version = conn.protocol_version().map(|v| format!("{v:?}"));
380    let cipher_suite = conn
381        .negotiated_cipher_suite()
382        .map(|c| format!("{:?}", c.suite()));
383    let chain = conn
384        .peer_certificates()
385        .ok_or_else(|| NetError::msg("server presented no certificate"))?;
386    let leaf = chain
387        .first()
388        .ok_or_else(|| NetError::msg("empty certificate chain"))?;
389
390    let parsed = parse_leaf(leaf.as_ref())?;
391    Ok(CertReport {
392        host,
393        port,
394        tls_version,
395        cipher_suite,
396        subject: parsed.subject,
397        issuer: parsed.issuer,
398        not_before: parsed.not_before,
399        not_after: parsed.not_after,
400        sans: parsed.sans,
401        chain_len: chain.len(),
402    })
403}
404
405struct ParsedCert {
406    subject: String,
407    issuer: String,
408    not_before: Option<chrono::DateTime<chrono::Utc>>,
409    not_after: Option<chrono::DateTime<chrono::Utc>>,
410    sans: Vec<String>,
411}
412
413/// Parse the leaf certificate's display fields with `x509-cert`. SAN parsing is
414/// best-effort: a malformed extension yields an empty SAN list, never an error,
415/// since the validity/subject/issuer are the load-bearing facts.
416fn parse_leaf(der: &[u8]) -> Result<ParsedCert, NetError> {
417    use x509_cert::der::{oid::AssociatedOid, Decode};
418    use x509_cert::ext::pkix::{name::GeneralName, SubjectAltName};
419    use x509_cert::Certificate;
420
421    let cert = Certificate::from_der(der)
422        .map_err(|e| NetError::msg(format!("certificate parse failed: {e}")))?;
423    let tbs = &cert.tbs_certificate;
424
425    let to_utc = |t: &x509_cert::time::Time| -> Option<chrono::DateTime<chrono::Utc>> {
426        let secs = t.to_unix_duration().as_secs() as i64;
427        chrono::DateTime::from_timestamp(secs, 0)
428    };
429
430    let mut sans = Vec::new();
431    if let Some(exts) = &tbs.extensions {
432        for ext in exts {
433            if ext.extn_id == SubjectAltName::OID {
434                if let Ok(san) = SubjectAltName::from_der(ext.extn_value.as_bytes()) {
435                    for name in &san.0 {
436                        if let GeneralName::DnsName(dns) = name {
437                            sans.push(dns.to_string());
438                        }
439                    }
440                }
441            }
442        }
443    }
444
445    Ok(ParsedCert {
446        subject: tbs.subject.to_string(),
447        issuer: tbs.issuer.to_string(),
448        not_before: to_utc(&tbs.validity.not_before),
449        not_after: to_utc(&tbs.validity.not_after),
450        sans,
451    })
452}
453
454// ─── trace ────────────────────────────────────────────────────────────────
455
456/// Result of a traceroute.
457pub struct TraceReport {
458    pub host: String,
459    pub raw: String,
460}
461
462impl TraceReport {
463    pub fn render(&self) -> String {
464        format!("net trace {}\n{}", self.host, self.raw.trim_end())
465    }
466}
467
468/// Trace the route to `target`. traceroute needs privileged raw sockets, so it
469/// runs as a timeout-bounded child process (not inside the exec sandbox, which
470/// denies the raw-socket path on both macOS Seatbelt and Linux namespaces — the
471/// same reason `brain logs analyze --source system` bypasses it).
472#[cfg(unix)]
473pub async fn trace(target: &str) -> Result<TraceReport, NetError> {
474    // Strip any scheme/port: traceroute wants a bare host.
475    let (host, _) = split_host_port(target, DEFAULT_PORT)?;
476
477    // `-m 20` caps hops; `-w 2` caps per-hop wait. A generous outer timeout
478    // guards against the whole run hanging.
479    let output = tokio::time::timeout(
480        Duration::from_secs(40),
481        tokio::process::Command::new("traceroute")
482            .args(["-m", "20", "-w", "2", &host])
483            .output(),
484    )
485    .await
486    .map_err(|_| NetError::msg("traceroute timed out"))?
487    .map_err(|e| {
488        NetError::msg(format!(
489            "could not run `traceroute` (is it installed?): {e}"
490        ))
491    })?;
492
493    let mut raw = String::from_utf8_lossy(&output.stdout).into_owned();
494    if !output.status.success() {
495        let err = String::from_utf8_lossy(&output.stderr);
496        let err = err.trim();
497        if !err.is_empty() {
498            raw.push_str(&format!("\n[traceroute stderr] {err}"));
499        }
500    }
501    if raw.trim().is_empty() {
502        return Err(NetError::msg("traceroute produced no output"));
503    }
504    Ok(TraceReport { host, raw })
505}
506
507#[cfg(not(unix))]
508pub async fn trace(_target: &str) -> Result<TraceReport, NetError> {
509    Err(NetError::msg(
510        "`net trace` is only supported on Unix platforms",
511    ))
512}
513
514// ─── cortex backend wiring ──────────────────────────────────────────────────
515
516/// The [`cortex::actions::NetDiagnosticsBackend`] implementation. Each method
517/// runs the matching probe and returns the rendered report (or an error string
518/// the dispatcher relays). The chat tool-loop dispatches `net.check/trace/cert`
519/// through this; the CLI calls the free functions above directly.
520pub struct NetDiagnostics;
521
522#[async_trait::async_trait]
523impl cortex::actions::NetDiagnosticsBackend for NetDiagnostics {
524    async fn check(&self, target: &str) -> Result<String, cortex::actions::ActionError> {
525        check(target)
526            .await
527            .map(|r| r.render())
528            .map_err(to_action_err)
529    }
530
531    async fn trace(&self, target: &str) -> Result<String, cortex::actions::ActionError> {
532        trace(target)
533            .await
534            .map(|r| r.render())
535            .map_err(to_action_err)
536    }
537
538    async fn cert(&self, target: &str) -> Result<String, cortex::actions::ActionError> {
539        cert(target)
540            .await
541            .map(|r| r.render())
542            .map_err(to_action_err)
543    }
544}
545
546fn to_action_err(e: NetError) -> cortex::actions::ActionError {
547    cortex::actions::ActionError::ExecutionFailed(e.to_string())
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn split_host_port_handles_bare_host_pair_and_url() {
556        assert_eq!(
557            split_host_port("example.com", 443).unwrap(),
558            ("example.com".to_string(), 443)
559        );
560        assert_eq!(
561            split_host_port("example.com:8443", 443).unwrap(),
562            ("example.com".to_string(), 8443)
563        );
564        assert_eq!(
565            split_host_port("https://example.com/path", 443).unwrap(),
566            ("example.com".to_string(), 443)
567        );
568        assert_eq!(
569            split_host_port("http://example.com", 443).unwrap(),
570            ("example.com".to_string(), 80)
571        );
572        assert_eq!(
573            split_host_port("https://example.com:9000/x", 443).unwrap(),
574            ("example.com".to_string(), 9000)
575        );
576    }
577
578    #[test]
579    fn split_host_port_rejects_empty() {
580        assert!(split_host_port("   ", 443).is_err());
581    }
582
583    #[test]
584    fn check_report_renders_reachable_and_unreachable() {
585        let reachable = CheckReport {
586            host: "h".into(),
587            port: 443,
588            addresses: vec!["1.2.3.4".into()],
589            dns_error: None,
590            connect: Some(Ok(Duration::from_millis(12))),
591        };
592        let r = reachable.render();
593        assert!(r.contains("connected in 12 ms"));
594        assert!(r.contains("Status:  reachable"));
595        assert!(reachable.reachable());
596
597        let dns_fail = CheckReport {
598            host: "h".into(),
599            port: 443,
600            addresses: vec![],
601            dns_error: Some("nxdomain".into()),
602            connect: None,
603        };
604        let r = dns_fail.render();
605        assert!(r.contains("DNS:     failed — nxdomain"));
606        assert!(r.contains("unreachable"));
607        assert!(!dns_fail.reachable());
608    }
609
610    #[test]
611    fn cert_report_flags_expiry() {
612        let mut report = CertReport {
613            host: "h".into(),
614            port: 443,
615            tls_version: Some("TLSv1_3".into()),
616            cipher_suite: Some("TLS13_AES_128_GCM_SHA256".into()),
617            subject: "CN=h".into(),
618            issuer: "CN=CA".into(),
619            not_before: Some(chrono::Utc::now() - chrono::Duration::days(30)),
620            // +1h buffer so the truncating `num_days()` lands on 40 and not 39
621            // when `days_until_expiry()` re-reads the clock microseconds later.
622            not_after: Some(
623                chrono::Utc::now() + chrono::Duration::days(40) + chrono::Duration::hours(1),
624            ),
625            sans: vec!["h".into(), "www.h".into()],
626            chain_len: 2,
627        };
628        assert_eq!(report.days_until_expiry(), Some(40));
629        assert!(report.render().contains("40 days left"));
630        assert!(report.render().contains("SANs:     h, www.h"));
631
632        report.not_after = Some(chrono::Utc::now() - chrono::Duration::days(5));
633        assert_eq!(report.days_until_expiry(), Some(-5));
634        assert!(report.render().contains("EXPIRED 5 days ago"));
635    }
636
637    #[test]
638    fn trace_report_renders_with_header() {
639        let t = TraceReport {
640            host: "h".into(),
641            raw: "1  gw  1ms\n2  isp  10ms\n".into(),
642        };
643        let r = t.render();
644        assert!(r.starts_with("net trace h\n"));
645        assert!(r.contains("gw"));
646    }
647}