use std::process::Command;
pub fn cli_available() -> bool {
Command::new("tailscale")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn tailnet_suffix(node_dns_name: &str) -> Option<String> {
let lower = node_dns_name.to_ascii_lowercase();
if !lower.ends_with(".ts.net") {
return None;
}
lower.split_once('.').map(|(_, rest)| rest.to_string())
}
pub fn self_short_hostname() -> Option<String> {
self_dns_name().and_then(|name| name.split_once('.').map(|(host, _)| host.to_string()))
}
pub fn self_dns_name() -> Option<String> {
let out = Command::new("tailscale")
.args(["status", "--json"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let body = std::str::from_utf8(&out.stdout).ok()?;
let after_key = body.split_once("\"DNSName\"")?.1;
let after_colon = after_key
.trim_start()
.strip_prefix(':')?
.trim_start()
.strip_prefix('"')?;
let (value, _) = after_colon.split_once('"')?;
let name = value.trim_end_matches('.');
name.ends_with(".ts.net").then(|| name.to_string())
}
pub fn derive_service_url(service: &str) -> crate::error::Result<String> {
use crate::error::Error;
let node = self_dns_name()
.ok_or_else(|| Error::Tailscale("no logged-in tailnet: run `tailscale up` first".into()))?;
let host = self_short_hostname().ok_or_else(|| {
Error::Tailscale(format!(
"couldn't extract host label from MagicDNS name '{node}' \
(expected three-label `<host>.<tailnet>.ts.net`)"
))
})?;
let tailnet = tailnet_suffix(&node).ok_or_else(|| {
Error::Tailscale(format!(
"couldn't extract tailnet from MagicDNS name '{node}' \
(expected three-label `<host>.<tailnet>.ts.net`)"
))
})?;
Ok(format!("https://{service}-{host}.{tailnet}"))
}
pub fn is_service_approved(svc_name: &str) -> Option<bool> {
let out = Command::new("tailscale")
.args(["status", "--json"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let value: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let svc_key = format!("svc:{svc_name}");
let approved = value
.pointer("/Self/CapMap/service-host")
.and_then(|sh| sh.as_array())
.is_some_and(|arr| {
arr.iter().any(|entry| {
entry
.as_object()
.and_then(|o| o.get(&svc_key))
.and_then(|ips| ips.as_array())
.is_some_and(|ips| !ips.is_empty())
})
});
Some(approved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tailnet_suffix_strips_host_from_three_label_name() {
assert_eq!(
tailnet_suffix("debian.cobbler-tuna.ts.net"),
Some("cobbler-tuna.ts.net".into())
);
}
#[test]
fn tailnet_suffix_lowercases_input() {
assert_eq!(
tailnet_suffix("HOST.COBBLER-TUNA.TS.NET"),
Some("cobbler-tuna.ts.net".into())
);
}
#[test]
fn tailnet_suffix_rejects_non_ts_net() {
assert_eq!(tailnet_suffix("debian.example.com"), None);
assert_eq!(tailnet_suffix("not-a-dns-name"), None);
}
#[test]
fn tailnet_suffix_rejects_bare_ts_net() {
assert_eq!(tailnet_suffix("ts.net"), None);
}
}