use std::fmt;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::os::unix::net::UnixStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::cli::prompt::tilde;
use super::plist::LABEL;
use super::skill::{SkillDir, SkillLink};
use super::{CaState, Facts, OsFacts, Platform, SetupEnv, linux, macos};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Ok,
Warn,
Missing,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line {
pub status: Status,
pub label: String,
pub detail: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Report {
pub lines: Vec<Line>,
}
impl Report {
pub(super) fn push(&mut self, status: Status, label: &str, detail: impl Into<String>) {
self.lines.push(Line {
status,
label: label.to_string(),
detail: detail.into(),
});
}
pub fn ok(&self) -> bool {
!self.lines.iter().any(|l| l.status == Status::Missing)
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let width = self.lines.iter().map(|l| l.label.len()).max().unwrap_or(0);
for line in &self.lines {
let mark = match line.status {
Status::Ok => "ok",
Status::Warn => "!!",
Status::Missing => "--",
Status::Info => " ",
};
writeln!(f, " {mark} {:width$} {}", line.label, line.detail)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortUser {
pub command: String,
pub pid: u32,
pub address: String,
}
impl PortUser {
fn is_carte(&self) -> bool {
self.command == "carte"
}
fn loopback_only(&self) -> bool {
self.address.starts_with("127.") || self.address.starts_with("[::1]")
}
}
impl fmt::Display for PortUser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (pid {}) on {}", self.command, self.pid, self.address)
}
}
pub fn parse_lsof(output: &str) -> Vec<PortUser> {
let mut users = Vec::new();
let (mut pid, mut command) = (0, String::new());
for line in output.lines() {
let Some((tag, value)) = line.split_at_checked(1) else {
continue;
};
match tag {
"p" => pid = value.parse().unwrap_or(0),
"c" => command = value.to_string(),
"n" => {
let user = PortUser {
command: command.clone(),
pid,
address: value.to_string(),
};
if !users.contains(&user) {
users.push(user);
}
}
_ => {}
}
}
users
}
pub fn port_users(platform: Platform, protocol: &str, port: u16) -> Vec<PortUser> {
if platform == Platform::Linux {
return linux::port_users(protocol, port);
}
let mut command = Command::new("lsof");
command.args(["-nP", &format!("-i{protocol}:{port}"), "-Fpcn"]);
if protocol == "TCP" {
command.arg("-sTCP:LISTEN");
}
command
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map(|o| parse_lsof(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default()
}
pub fn parse_resolver(text: &str) -> (Option<&str>, Option<u16>) {
let mut nameserver = None;
let mut port = None;
for line in text.lines() {
let mut words = line.split_whitespace();
match (words.next(), words.next()) {
(Some("nameserver"), Some(value)) if nameserver.is_none() => nameserver = Some(value),
(Some("port"), Some(value)) => port = value.parse().ok(),
_ => {}
}
}
(nameserver, port)
}
pub fn check(env: &SetupEnv, facts: &Facts) -> Report {
let mut report = Report::default();
if let OsFacts::Linux(linux) = &facts.os {
super::resolved::check(&mut report, env, &linux.dns);
} else {
resolver_lines(&mut report, env, facts);
}
let previous = super::previous_tlds(env, facts);
if !env.tld_configured && !previous.is_empty() {
report.push(
Status::Warn,
"tld",
format!(
"carte serves .{}, but .{} was used here before: `carte setup --tld {}` keeps it, `--tld {}` confirms the switch",
env.machine.tld,
previous.join(", ."),
previous[0],
env.machine.tld
),
);
}
let ca = tilde(&env.ca_cert());
match &facts.ca {
CaState::Missing => report.push(Status::Missing, "CA", format!("{ca} doesn't exist yet")),
CaState::Replace(why) => report.push(
Status::Warn,
"CA",
format!("{ca} will be replaced by `carte setup`: {why}"),
),
CaState::Restore => report.push(
Status::Warn,
"CA",
format!("{ca} doesn't match the CA's key; `carte setup` restores it"),
),
CaState::Untrusted => report.push(Status::Missing, "CA", format!("{ca} is not trusted")),
CaState::Trusted => report.push(Status::Ok, "CA", format!("{ca} is trusted")),
CaState::Unknown(err) => report.push(
Status::Warn,
"CA",
format!("couldn't check whether {ca} is trusted: {err}"),
),
}
match &facts.os {
OsFacts::MacOs(mac) => macos::check_agent(&mut report, env, mac),
OsFacts::Linux(linux) => linux::check_units(&mut report, env, facts, linux),
}
report.push(
if facts.daemon_running {
Status::Ok
} else {
Status::Missing
},
"daemon",
if facts.daemon_running {
format!("answering on {}", tilde(&env.paths.socket()))
} else {
"not running".to_string()
},
);
if let Some(skill) = &facts.skill {
let dir = tilde(&env.skill_dir());
let (status, detail) = match skill.dir {
SkillDir::Missing => (
Status::Info,
format!("{dir} is missing; `carte setup` installs it"),
),
SkillDir::Unmanaged => (
Status::Warn,
format!("{dir} wasn't written by carte; setup leaves it alone"),
),
SkillDir::Managed { current: false } => (
Status::Info,
format!("{dir} is from another version; the next carte command updates it"),
),
SkillDir::Managed { current: true } => (Status::Ok, dir.clone()),
};
let link = match skill.link {
SkillLink::Ours => format!(", linked from {}", tilde(&env.skill_link())),
SkillLink::Other => format!(
"; {} is something else, so Claude Code doesn't see it",
tilde(&env.skill_link())
),
SkillLink::Missing if skill.dir != SkillDir::Unmanaged => {
format!("; not linked from {}", tilde(&env.skill_link()))
}
SkillLink::Missing | SkillLink::NoClaude => String::new(),
};
report.push(status, "agent skill", format!("{detail}{link}"));
}
match &facts.os {
OsFacts::MacOs(mac) => macos::check_puma_dev(&mut report, env, mac),
OsFacts::Linux(_) => {}
}
let ipv6 = match &facts.os {
OsFacts::MacOs(_) => true,
OsFacts::Linux(linux) => linux.ipv6,
};
let machine = &env.machine;
for (label, protocol, port) in [
("http port", "TCP", machine.http_port),
("https port", "TCP", machine.https_port),
("dns port", "UDP", machine.dns_port),
] {
let users = port_users(env.platform, protocol, port);
port_line(&mut report, label, port, &users, protocol == "TCP" && ipv6);
}
report
}
fn resolver_lines(report: &mut Report, env: &SetupEnv, facts: &Facts) {
let file = tilde(&env.resolver_file());
match facts.resolver(&env.machine.tld) {
_ if !env.needs_resolver() => report.push(
Status::Ok,
"resolver",
".localhost resolves to loopback without one",
),
None => report.push(Status::Missing, "resolver", format!("{file} is missing")),
Some(text) => {
let by = if text.contains("puma-dev") {
" (written by puma-dev)"
} else {
""
};
match parse_resolver(text) {
(Some("127.0.0.1"), Some(port)) if port == env.machine.dns_port => report.push(
Status::Ok,
"resolver",
format!("{file} points at 127.0.0.1 port {port}{by}"),
),
(nameserver, port) => report.push(
Status::Warn,
"resolver",
format!(
"{file} points at {} port {}{by}; carte's DNS is 127.0.0.1 port {}",
nameserver.unwrap_or("?"),
port.map_or("53".to_string(), |p| p.to_string()),
env.machine.dns_port
),
),
}
}
}
for tld in super::leftover_resolvers(env, facts) {
report.push(
Status::Warn,
"resolver",
format!(
"{} isn't needed for .{}; `carte setup` removes it",
tilde(&env.resolver_dir.join(tld)),
env.machine.tld
),
);
}
}
fn port_line(report: &mut Report, label: &str, port: u16, users: &[PortUser], dual_stack: bool) {
let list = |users: &[PortUser]| {
users
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
};
let label = format!("{label} {port}");
if users.is_empty() {
report.push(Status::Missing, &label, "nothing listening");
} else if users.iter().all(PortUser::is_carte) {
if !users.iter().all(PortUser::loopback_only) {
report.push(Status::Warn, &label, list(users));
} else if dual_stack && !users.iter().any(|u| u.address.starts_with("[::1]")) {
report.push(
Status::Warn,
&label,
format!("{} (not on [::1]; run `carte setup`)", list(users)),
);
} else {
report.push(Status::Ok, &label, list(users));
}
} else {
report.push(Status::Warn, &label, list(users));
}
}
pub fn verify(env: &SetupEnv) -> Report {
let socket = env.paths.socket();
let deadline = Instant::now() + Duration::from_secs(15);
while UnixStream::connect(&socket).is_err() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(250));
}
let mut report = Report::default();
match UnixStream::connect(&socket) {
Ok(_) => report.push(
Status::Ok,
"daemon",
format!("answering on {}", tilde(&socket)),
),
Err(err) => report.push(
Status::Missing,
"daemon",
format!(
"no answer on {} ({err}); see {}",
tilde(&socket),
tilde(&env.agent().log_path())
),
),
}
let (holder, ipv6) = match env.platform {
Platform::MacOs => ("launchd", true),
Platform::Linux => ("systemd", linux::has_ipv6_loopback()),
};
let machine = &env.machine;
for (label, port) in [
("http port", machine.http_port),
("https port", machine.https_port),
] {
let users = port_users(env.platform, "TCP", port);
port_line(&mut report, label, port, &users, ipv6);
if users.iter().any(|u| u.is_carte() && !u.loopback_only()) {
report.push(
Status::Warn,
"",
format!(
"{holder} listens on every interface: carte refuses connections from other \
machines, but they can still open TCP connections to this port"
),
);
}
}
let host = format!("carte.{}", machine.tld);
match http_status_line(SocketAddr::from(([127, 0, 0, 1], machine.http_port)), &host) {
Ok(line) => report.push(Status::Ok, "http", format!("http://{host} answered {line}")),
Err(err) => report.push(Status::Missing, "http", format!("http://{host}: {err}")),
}
let name = format!("carte-check.{}", machine.tld);
match (name.as_str(), 80).to_socket_addrs() {
Ok(addrs) => {
let addrs: Vec<SocketAddr> = addrs.collect();
if addrs.iter().any(|a| a.ip().is_loopback()) {
report.push(Status::Ok, "dns", format!("{name} resolves to loopback"));
} else {
report.push(
Status::Missing,
"dns",
format!("{name} resolves to {addrs:?}, not loopback"),
);
}
}
Err(err) => report.push(Status::Missing, "dns", format!("{name}: {err}")),
}
report
}
fn http_status_line(addr: SocketAddr, host: &str) -> std::io::Result<String> {
let timeout = Duration::from_secs(5);
let mut stream = TcpStream::connect_timeout(&addr, timeout)?;
stream.set_read_timeout(Some(timeout))?;
write!(stream, "GET / HTTP/1.0\r\nHost: {host}\r\n\r\n")?;
let mut buf = [0; 256];
let n = stream.read(&mut buf)?;
let text = String::from_utf8_lossy(&buf[..n]);
let line = text.lines().next().unwrap_or_default();
if line.starts_with("HTTP/1.") {
Ok(line.to_string())
} else {
Err(std::io::Error::other(format!(
"not an HTTP response: {line:?}"
)))
}
}
pub fn fallback(env: &SetupEnv) -> String {
if env.platform == Platform::Linux {
return linux::fallback(env);
}
format!(
"- `launchctl print gui/{uid}/{LABEL}` shows whether launchd started carte and why it stopped; \
its log is {log}.\n\
- If `launchctl bootstrap` failed with `5: Input/output error`, launchd still had carte \
loaded or was still stopping it; run `carte setup` again.\n\
- If launchd didn't hand over ports {http} and {https} on 127.0.0.1, carte can still run on \
high ports: set http_port = 8080 and https_port = 8443 in {config} and run `carte setup` \
again; URLs then carry the port.\n\
- `carte setup --uninstall` undoes these changes.",
uid = env.uid,
log = tilde(&env.agent().log_path()),
http = env.machine.http_port,
https = env.machine.https_port,
config = tilde(&env.paths.machine_config()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_lsof_field_output() {
let output = "p1475\ncpuma-dev\nf11\nn*:80\nf12\nn*:80\np88\nccarte\nf3\nn127.0.0.1:443\n";
assert_eq!(
parse_lsof(output),
[
PortUser {
command: "puma-dev".into(),
pid: 1475,
address: "*:80".into()
},
PortUser {
command: "carte".into(),
pid: 88,
address: "127.0.0.1:443".into()
},
]
);
assert!(parse_lsof("").is_empty());
}
#[test]
fn parses_resolver_files() {
assert_eq!(
parse_resolver("# Generated by puma-dev\nnameserver 127.0.0.1\nport 9253\n"),
(Some("127.0.0.1"), Some(9253))
);
assert_eq!(
parse_resolver("nameserver 10.0.0.1\n"),
(Some("10.0.0.1"), None)
);
}
#[test]
fn grades_port_users() {
let carte = |address: &str| PortUser {
command: "carte".into(),
pid: 1,
address: address.into(),
};
let status = |users: &[PortUser]| {
let mut report = Report::default();
port_line(&mut report, "http port", 80, users, true);
report.lines[0].status
};
assert_eq!(
status(&[carte("127.0.0.1:80"), carte("[::1]:80")]),
Status::Ok
);
assert_eq!(status(&[carte("127.0.0.1:80")]), Status::Warn);
assert_eq!(status(&[carte("*:80")]), Status::Warn);
assert_eq!(status(&[]), Status::Missing);
let other = PortUser {
command: "puma-dev".into(),
..carte("*:80")
};
assert_eq!(status(&[other]), Status::Warn);
}
#[test]
fn reports_missing_lines_as_failure() {
let mut report = Report::default();
report.push(Status::Ok, "a", "fine");
report.push(Status::Warn, "b", "hmm");
assert!(report.ok());
report.push(Status::Missing, "c", "gone");
assert!(!report.ok());
assert_eq!(
report.to_string(),
" ok a fine\n !! b hmm\n -- c gone\n"
);
}
}