pub mod client;
pub mod http;
pub mod server;
pub mod token;
use std::net::{Ipv4Addr, SocketAddrV4};
use std::process::Command;
use anyhow::{Context, Result, bail};
pub const DEFAULT_CONTROL_PORT: u16 = 4840;
pub const DEFAULT_SERIAL_PORT: u16 = 4841;
pub const CONTROL_ENV: &str = "SATURNUS_CONTROL";
pub fn parse_control(s: &str) -> Result<u16> {
let t = s.strip_prefix("http://").unwrap_or(s).trim_end_matches('/');
let (host, port) = t.rsplit_once(':').unwrap_or(("127.0.0.1", t));
if host != "127.0.0.1" && !host.eq_ignore_ascii_case("localhost") {
bail!(
"the control API listens on 127.0.0.1 only; {s:?} names another address \
(give PORT or 127.0.0.1:PORT)"
);
}
port.parse()
.with_context(|| format!("bad port in control address {s:?}"))
}
pub fn resolve_control(flag: Option<&str>) -> Result<SocketAddrV4> {
let port = match flag {
Some(f) => parse_control(f)?,
None => match std::env::var(CONTROL_ENV) {
Ok(v) if !v.is_empty() => {
parse_control(&v).with_context(|| format!("in {CONTROL_ENV}"))?
}
_ => DEFAULT_CONTROL_PORT,
},
};
Ok(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port))
}
pub fn bind_error(what: &str, addr: &str, e: std::io::Error) -> anyhow::Error {
if e.kind() != std::io::ErrorKind::AddrInUse {
return anyhow::Error::new(e).context(format!("cannot listen on {addr} for the {what}"));
}
let port = addr
.rsplit_once(':')
.and_then(|(_, p)| p.parse::<u16>().ok());
let who = port
.and_then(who_listens)
.unwrap_or_else(|| "another program (its process ID is not available)".to_string());
anyhow::anyhow!(
"cannot listen on {addr} for the {what}: the port is in use by {who}; \
choose another port (for a second instance: --control PORT or {CONTROL_ENV}, \
--serial tcp:PORT)"
)
}
pub fn who_listens(port: u16) -> Option<String> {
#[cfg(unix)]
{
lsof(port).or_else(|| ss(port))
}
#[cfg(windows)]
{
netstat(port)
}
#[cfg(not(any(unix, windows)))]
{
let _ = port;
None
}
}
fn output(cmd: &mut Command) -> Option<String> {
let out = cmd.output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(unix)]
fn lsof(port: u16) -> Option<String> {
let text = output(Command::new("lsof").args([
"-nP",
&format!("-iTCP:{port}"),
"-sTCP:LISTEN",
"-Fpc",
]))?;
let mut pid = None;
let mut name = None;
for line in text.lines() {
if let Some(p) = line.strip_prefix('p') {
if pid.is_some() {
break;
}
pid = Some(p.to_string());
} else if let Some(c) = line.strip_prefix('c') {
name = Some(c.to_string());
}
}
let pid = pid?;
Some(match name {
Some(n) => format!("pid {pid} ({n})"),
None => format!("pid {pid}"),
})
}
#[cfg(unix)]
fn ss(port: u16) -> Option<String> {
let text = output(Command::new("ss").args(["-Hltnp", &format!("sport = :{port}")]))?;
let users = text
.lines()
.find_map(|l| l.split_once("users:((").map(|(_, u)| u))?;
let name = users.split('"').nth(1).unwrap_or("?");
let pid = users
.split("pid=")
.nth(1)?
.split(|c: char| !c.is_ascii_digit())
.next()?;
Some(format!("pid {pid} ({name})"))
}
#[cfg(windows)]
fn netstat(port: u16) -> Option<String> {
let text = output(Command::new("netstat").args(["-ano", "-p", "TCP"]))?;
let suffix = format!(":{port}");
let pid = text.lines().find_map(|l| {
let f: Vec<&str> = l.split_whitespace().collect();
(f.len() >= 5 && f[1].ends_with(&suffix) && f[3].eq_ignore_ascii_case("LISTENING"))
.then(|| f[4].to_string())
})?;
let name = output(Command::new("tasklist").args([
"/FI",
&format!("PID eq {pid}"),
"/FO",
"CSV",
"/NH",
]))
.and_then(|t| t.split('"').nth(1).map(str::to_string));
Some(match name {
Some(n) => format!("pid {pid} ({n})"),
None => format!("pid {pid}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_addresses_are_loopback_only() {
assert_eq!(parse_control("4890").unwrap(), 4890);
assert_eq!(parse_control("127.0.0.1:4891").unwrap(), 4891);
assert_eq!(parse_control("localhost:4892").unwrap(), 4892);
assert_eq!(parse_control("http://127.0.0.1:4893/").unwrap(), 4893);
assert_eq!(parse_control("127.0.0.1:0").unwrap(), 0);
for bad in [
"0.0.0.0:4840",
"192.168.1.2:4840",
"[::1]:4840",
"::1:4840",
"evil:1",
] {
let e = parse_control(bad).unwrap_err().to_string();
assert!(e.contains("127.0.0.1 only"), "{bad}: {e}");
}
assert!(parse_control("127.0.0.1:99999").is_err());
assert!(parse_control("").is_err());
assert_eq!(
resolve_control(Some("4894")).unwrap(),
SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4894)
);
}
#[test]
fn a_busy_port_names_its_listener() {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap().to_string();
let e = std::net::TcpListener::bind(&addr).unwrap_err();
let msg = bind_error("control API", &addr, e).to_string();
assert!(msg.contains("in use by"), "{msg}");
let named = msg.contains(&format!("pid {}", std::process::id()));
assert!(named || msg.contains("another program"), "{msg}");
}
}