1use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use tokio::net::TcpStream;
29
30pub 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
79pub const DEFAULT_PORT: u16 = 443;
82
83const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
85
86#[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
101fn 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 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 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
138pub struct CheckReport {
142 pub host: String,
143 pub port: u16,
144 pub addresses: Vec<String>,
146 pub dns_error: Option<String>,
147 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
186pub async fn check(target: &str) -> Result<CheckReport, NetError> {
188 let (host, port) = split_host_port(target, DEFAULT_PORT)?;
189
190 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 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
233pub 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 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#[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
353pub 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
413fn 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
454pub 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#[cfg(unix)]
473pub async fn trace(target: &str) -> Result<TraceReport, NetError> {
474 let (host, _) = split_host_port(target, DEFAULT_PORT)?;
476
477 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
514pub 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 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}