const TAILSCALE_VERSION: &str = "1.100.0";
const GO_VERSION: &str = "go1.24.4";
#[derive(Debug, Clone)]
pub struct HostInfoData {
pub ipn_version: String,
pub os: String,
pub os_version: String,
pub go_arch: String,
pub go_version: String,
pub machine: String,
pub distro: String,
pub distro_version: String,
pub distro_code_name: String,
pub container: Option<bool>,
pub env: ts_control_serde::EnvType,
}
impl HostInfoData {
pub fn detect() -> Self {
let (distro, distro_version, distro_code_name) = distro_meta();
Self {
ipn_version: ipn_version_long(),
os: go_style_os(),
os_version: os_version(),
go_arch: go_style_arch(),
go_version: GO_VERSION.to_string(),
machine: uname_machine(),
distro,
distro_version,
distro_code_name,
container: in_container(),
env: env_type(),
}
}
}
fn in_container() -> Option<bool> {
if !cfg!(target_os = "linux") {
return None;
}
if std::path::Path::new("/.dockerenv").exists()
|| std::path::Path::new("/run/.containerenv").exists()
{
return Some(true);
}
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
&& cgroup
.lines()
.any(|l| l.contains("/docker/") || l.contains("/lxc/"))
{
return Some(true);
}
if let Ok(mounts) = std::fs::read_to_string("/proc/mounts")
&& mounts
.lines()
.any(|l| l.contains("lxcfs /proc/cpuinfo fuse.lxcfs"))
{
return Some(true);
}
Some(false)
}
fn env_type() -> ts_control_serde::EnvType {
env_type_from(|k| std::env::var(k).ok())
}
fn env_type_from(get: impl Fn(&str) -> Option<alloc::string::String>) -> ts_control_serde::EnvType {
use ts_control_serde::EnvType;
let set = |k: &str| get(k).is_some_and(|v| !v.is_empty());
let eq = |k: &str, want: &str| get(k).as_deref() == Some(want);
if set("K_REVISION") && set("K_CONFIGURATION") && set("K_SERVICE") && set("PORT") {
return EnvType::KNative;
}
if set("AWS_LAMBDA_FUNCTION_NAME")
&& set("AWS_LAMBDA_FUNCTION_VERSION")
&& set("AWS_LAMBDA_INITIALIZATION_TYPE")
&& set("AWS_LAMBDA_RUNTIME_API")
{
return EnvType::AWSLambda;
}
if set("PORT") && set("DYNO") {
return EnvType::Heroku;
}
if set("APPSVC_RUN_ZIP") && set("WEBSITE_STACK") && set("WEBSITE_AUTH_AUTO_AAD") {
return EnvType::AzureAppService;
}
if eq("AWS_EXECUTION_ENV", "AWS_ECS_FARGATE") {
return EnvType::AWSFargate;
}
if set("FLY_APP_NAME") && set("FLY_REGION") {
return EnvType::FlyDotIo;
}
if set("KUBERNETES_SERVICE_HOST") && set("KUBERNETES_SERVICE_PORT") {
return EnvType::Kubernetes;
}
if eq("TS_HOST_ENV", "dde") {
return EnvType::DockerDesktop;
}
if set("REPL_OWNER") && set("REPL_SLUG") {
return EnvType::Replit;
}
if set("SUPERVISOR_TOKEN") || set("HASSIO_TOKEN") {
return EnvType::HomeAssistantAddOn;
}
EnvType::Unknown
}
pub const PACKAGE_TSNET: &str = "tsnet";
fn ipn_version_long() -> String {
TAILSCALE_VERSION.to_string()
}
fn go_style_os() -> String {
match std::env::consts::OS {
"macos" => "macOS".to_string(),
"ios" => "iOS".to_string(),
other => other.to_string(),
}
}
fn go_style_arch() -> String {
match std::env::consts::ARCH {
"x86_64" => "amd64".to_string(),
"aarch64" => "arm64".to_string(),
"x86" => "386".to_string(),
other => other.to_string(),
}
}
#[cfg(target_os = "macos")]
fn os_version() -> String {
macos_product_version().unwrap_or_else(|| uname_field(UnameField::Release))
}
#[cfg(all(unix, not(target_os = "macos")))]
fn os_version() -> String {
uname_field(UnameField::Release)
}
#[cfg(not(unix))]
fn os_version() -> String {
String::new()
}
#[cfg(target_os = "macos")]
fn macos_product_version() -> Option<String> {
let name = c"kern.osproductversion";
unsafe {
let mut len: libc::size_t = 0;
if libc::sysctlbyname(
name.as_ptr(),
core::ptr::null_mut(),
&mut len,
core::ptr::null_mut(),
0,
) != 0
|| len == 0
{
return None;
}
let mut buf = alloc::vec![0u8; len];
if libc::sysctlbyname(
name.as_ptr(),
buf.as_mut_ptr().cast::<libc::c_void>(),
&mut len,
core::ptr::null_mut(),
0,
) != 0
{
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
if end == 0 {
return None;
}
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
}
#[cfg(unix)]
fn uname_machine() -> String {
uname_field(UnameField::Machine)
}
#[cfg(not(unix))]
fn uname_machine() -> String {
go_style_arch()
}
#[cfg(unix)]
#[derive(Clone, Copy)]
enum UnameField {
Release,
Machine,
}
#[cfg(unix)]
fn uname_field(field: UnameField) -> String {
unsafe {
let mut uts: libc::utsname = core::mem::zeroed();
if libc::uname(&mut uts) != 0 {
return String::new();
}
let buf: &[libc::c_char] = match field {
UnameField::Release => &uts.release,
UnameField::Machine => &uts.machine,
};
let bytes: &[u8] = core::slice::from_raw_parts(buf.as_ptr().cast::<u8>(), buf.len());
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
}
#[cfg(target_os = "linux")]
fn distro_meta() -> (String, String, String) {
let Ok(contents) = std::fs::read_to_string("/etc/os-release") else {
return (String::new(), String::new(), String::new());
};
let (distro, mut version, mut code_name) = parse_os_release(&contents);
if distro == "debian"
&& let Ok(dv) = std::fs::read_to_string("/etc/debian_version")
{
let dv = dv.trim();
if dv.starts_with(|c: char| c.is_ascii_digit()) {
version = dv.to_string();
} else if code_name.is_empty() && !dv.is_empty() {
code_name = dv.to_string();
}
}
(distro, version, code_name)
}
#[cfg(not(target_os = "linux"))]
fn distro_meta() -> (String, String, String) {
(String::new(), String::new(), String::new())
}
#[cfg(target_os = "linux")]
fn parse_os_release(contents: &str) -> (String, String, String) {
let (mut id, mut version_id, mut codename) = (String::new(), String::new(), String::new());
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string();
match key.trim() {
"ID" => id = value,
"VERSION_ID" => version_id = value,
"VERSION_CODENAME" => codename = value,
_ => {}
}
}
(id, version_id, codename)
}
#[cfg(test)]
mod tests {
use ts_control_serde::HostInfo;
use super::*;
#[test]
fn ipn_version_is_tailscale_shaped_not_crate_version() {
let v = ipn_version_long();
assert_eq!(v, "1.100.0");
assert!(
v.starts_with("1."),
"IPNVersion must look like a Tailscale 1.x release, got {v:?}"
);
}
#[test]
fn os_is_go_style_and_nonempty() {
let os = go_style_os();
assert!(!os.is_empty(), "OS must never be empty (the loudest tell)");
match std::env::consts::OS {
"macos" => assert_eq!(os, "macOS"),
"ios" => assert_eq!(os, "iOS"),
other => assert_eq!(os, other),
}
}
#[test]
fn arch_maps_rust_to_go_spelling() {
let arch = go_style_arch();
assert!(!arch.is_empty());
match std::env::consts::ARCH {
"x86_64" => assert_eq!(arch, "amd64"),
"aarch64" => assert_eq!(arch, "arm64"),
"x86" => assert_eq!(arch, "386"),
other => assert_eq!(arch, other),
}
}
#[test]
fn detect_fills_the_loud_fingerprint_fields() {
let h = HostInfoData::detect();
assert!(!h.ipn_version.is_empty());
assert_ne!(h.ipn_version, env!("CARGO_PKG_VERSION"));
assert!(!h.os.is_empty());
assert!(!h.go_arch.is_empty());
assert!(h.go_version.starts_with("go1."));
assert!(!h.machine.is_empty());
}
#[test]
fn env_type_detects_known_environments() {
use alloc::{collections::BTreeMap, string::ToString};
use ts_control_serde::EnvType;
let env = |pairs: &[(&str, &str)]| {
let map: BTreeMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k: &str| map.get(k).cloned()
};
assert_eq!(env_type_from(env(&[])), EnvType::Unknown);
assert_eq!(
env_type_from(env(&[
("K_REVISION", "r"),
("K_CONFIGURATION", "c"),
("K_SERVICE", "s"),
("PORT", "8080"),
])),
EnvType::KNative,
);
assert_eq!(
env_type_from(env(&[
("AWS_LAMBDA_FUNCTION_NAME", "f"),
("AWS_LAMBDA_FUNCTION_VERSION", "1"),
("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand"),
("AWS_LAMBDA_RUNTIME_API", "127.0.0.1:9001"),
])),
EnvType::AWSLambda,
);
assert_eq!(
env_type_from(env(&[("PORT", "5000"), ("DYNO", "web.1")])),
EnvType::Heroku,
);
assert_eq!(
env_type_from(env(&[("AWS_EXECUTION_ENV", "AWS_ECS_FARGATE")])),
EnvType::AWSFargate,
);
assert_eq!(
env_type_from(env(&[("FLY_APP_NAME", "a"), ("FLY_REGION", "iad")])),
EnvType::FlyDotIo,
);
assert_eq!(
env_type_from(env(&[
("KUBERNETES_SERVICE_HOST", "10.0.0.1"),
("KUBERNETES_SERVICE_PORT", "443"),
])),
EnvType::Kubernetes,
);
assert_eq!(
env_type_from(env(&[("TS_HOST_ENV", "dde")])),
EnvType::DockerDesktop,
);
assert_eq!(
env_type_from(env(&[("REPL_OWNER", "o"), ("REPL_SLUG", "s")])),
EnvType::Replit,
);
assert_eq!(
env_type_from(env(&[("SUPERVISOR_TOKEN", "t")])),
EnvType::HomeAssistantAddOn,
);
assert_eq!(
env_type_from(env(&[
("K_REVISION", "r"),
("K_CONFIGURATION", "c"),
("K_SERVICE", "s"),
("PORT", "8080"),
("DYNO", "web.1"),
])),
EnvType::KNative,
"Knative is checked before Heroku in Go's cascade",
);
assert_eq!(
env_type_from(env(&[("FLY_APP_NAME", ""), ("FLY_REGION", "iad")])),
EnvType::Unknown,
"an empty FLY_APP_NAME is not 'set'",
);
}
#[test]
fn borrows_into_hostinfo_like_the_call_sites() {
let h = HostInfoData::detect();
let hi = HostInfo {
ipn_version: &h.ipn_version,
os: &h.os,
os_version: &h.os_version,
go_arch: &h.go_arch,
go_version: &h.go_version,
machine: &h.machine,
distro: &h.distro,
distro_version: &h.distro_version,
distro_code_name: &h.distro_code_name,
package: PACKAGE_TSNET,
userspace: Some(true),
..Default::default()
};
assert_eq!(hi.ipn_version, h.ipn_version);
assert_eq!(hi.os, h.os);
assert_eq!(hi.go_arch, h.go_arch);
assert_eq!(hi.go_version, h.go_version);
assert_eq!(hi.machine, h.machine);
assert_eq!(hi.distro, h.distro);
assert_eq!(hi.distro_version, h.distro_version);
assert_eq!(hi.distro_code_name, h.distro_code_name);
assert_eq!(hi.package, "tsnet");
assert_eq!(hi.userspace, Some(true));
}
#[cfg(target_os = "linux")]
#[test]
fn parse_os_release_extracts_id_version_codename() {
let sample = r#"
# /etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
ID=ubuntu
ID_LIKE=debian
VERSION_ID="24.04"
VERSION_CODENAME=noble
HOME_URL="https://www.ubuntu.com/"
"#;
let (id, ver, code) = parse_os_release(sample);
assert_eq!(id, "ubuntu");
assert_eq!(ver, "24.04");
assert_eq!(code, "noble");
}
#[cfg(target_os = "linux")]
#[test]
fn parse_os_release_handles_single_quotes_and_missing_fields() {
let sample = "ID='debian'\nVERSION_ID=\"12\"\n";
let (id, ver, code) = parse_os_release(sample);
assert_eq!(id, "debian");
assert_eq!(ver, "12");
assert_eq!(
code, "",
"absent VERSION_CODENAME stays empty (wire-omitted)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_is_product_not_kernel_release() {
let product = os_version();
assert!(
!product.is_empty(),
"macOS OSVersion must be populated (sysctl kern.osproductversion)"
);
let kernel = uname_field(UnameField::Release);
assert_ne!(
product, kernel,
"OSVersion must be the macOS product version, not the Darwin kernel release"
);
if let Some(direct) = macos_product_version() {
assert_eq!(product, direct);
}
}
}