use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use anyhow::{Context, Result};
use tracing::{info, warn};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindMode {
Local,
Tailscale,
Explicit(String),
}
impl BindMode {
pub fn from_env_and_flags(
explicit_http: &str,
default_http: &str,
tailscale_flag: bool,
) -> Self {
let bind_env = std::env::var("TRUSTY_CONSOLE_BIND").ok();
Self::from_flags_and_bind_env(
explicit_http,
default_http,
tailscale_flag,
bind_env.as_deref(),
)
}
pub fn from_flags_and_bind_env(
explicit_http: &str,
default_http: &str,
tailscale_flag: bool,
bind_env: Option<&str>,
) -> Self {
if explicit_http != default_http {
return BindMode::Explicit(explicit_http.to_owned());
}
if let Some(val) = bind_env {
let val = val.trim().to_lowercase();
if val == "tailscale" {
return BindMode::Tailscale;
}
if !val.is_empty() {
return BindMode::Explicit(val);
}
}
if tailscale_flag {
return BindMode::Tailscale;
}
BindMode::Local
}
}
pub fn resolve_bind_addrs(
mode: &BindMode,
default_port: u16,
ip_detector: impl FnOnce() -> Option<IpAddr>,
) -> Vec<SocketAddr> {
match mode {
BindMode::Local => {
let addr = SocketAddr::from(([127, 0, 0, 1], default_port));
vec![addr]
}
BindMode::Tailscale => {
let loopback = SocketAddr::from(([127, 0, 0, 1], default_port));
match ip_detector() {
Some(ts_ip) => {
let ts_addr = SocketAddr::new(ts_ip, default_port);
info!("tailscale mode: binding loopback and {ts_addr}");
vec![loopback, ts_addr]
}
None => {
warn!(
"tailscale mode requested but could not detect Tailscale IPv4 — \
falling back to localhost-only"
);
vec![loopback]
}
}
}
BindMode::Explicit(addr_str) => match SocketAddr::from_str(addr_str) {
Ok(addr) => vec![addr],
Err(e) => {
warn!("could not parse bind address {addr_str:?}: {e}; falling back to localhost");
vec![SocketAddr::from(([127, 0, 0, 1], default_port))]
}
},
}
}
pub fn detect_tailscale_ipv4() -> Option<IpAddr> {
let output = std::process::Command::new("tailscale")
.args(["ip", "-4"])
.output();
match output {
Err(e) => {
warn!("could not run `tailscale ip -4`: {e}");
None
}
Ok(out) if !out.status.success() => {
let stderr = String::from_utf8_lossy(&out.stderr);
warn!(
"tailscale ip -4 exited with status {}: {stderr}",
out.status
);
None
}
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let raw = stdout.trim();
match IpAddr::from_str(raw) {
Ok(ip) => {
info!("detected Tailscale IPv4: {ip}");
Some(ip)
}
Err(e) => {
warn!("could not parse Tailscale IP {raw:?}: {e}");
None
}
}
}
}
}
pub fn port_from_addr(addr: &str, default: u16) -> u16 {
addr.parse::<SocketAddr>()
.map(|a| a.port())
.unwrap_or(default)
}
pub async fn bind_listener(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("failed to bind {addr}"))
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use super::*;
#[test]
fn test_bind_mode_explicit_http_wins() {
let mode = BindMode::from_flags_and_bind_env(
"0.0.0.0:9000",
"127.0.0.1:7788",
true,
Some("tailscale"),
);
assert_eq!(mode, BindMode::Explicit("0.0.0.0:9000".to_owned()));
}
#[test]
fn test_bind_mode_env_tailscale() {
let mode = BindMode::from_flags_and_bind_env(
"127.0.0.1:7788",
"127.0.0.1:7788",
false,
Some("tailscale"),
);
assert_eq!(mode, BindMode::Tailscale);
}
#[test]
fn test_bind_mode_env_tailscale_uppercase() {
let mode = BindMode::from_flags_and_bind_env(
"127.0.0.1:7788",
"127.0.0.1:7788",
false,
Some("TAILSCALE"),
);
assert_eq!(mode, BindMode::Tailscale);
}
#[test]
fn test_bind_mode_env_explicit_addr() {
let mode = BindMode::from_flags_and_bind_env(
"127.0.0.1:7788",
"127.0.0.1:7788",
false,
Some("0.0.0.0:8080"),
);
assert_eq!(mode, BindMode::Explicit("0.0.0.0:8080".to_owned()));
}
#[test]
fn test_bind_mode_tailscale_flag() {
let mode =
BindMode::from_flags_and_bind_env("127.0.0.1:7788", "127.0.0.1:7788", true, None);
assert_eq!(mode, BindMode::Tailscale);
}
#[test]
fn test_bind_mode_env_beats_tailscale_flag() {
let mode = BindMode::from_flags_and_bind_env(
"127.0.0.1:7788",
"127.0.0.1:7788",
true,
Some("0.0.0.0:9999"),
);
assert_eq!(mode, BindMode::Explicit("0.0.0.0:9999".to_owned()));
}
#[test]
fn test_bind_mode_default_is_local() {
let mode =
BindMode::from_flags_and_bind_env("127.0.0.1:7788", "127.0.0.1:7788", false, None);
assert_eq!(mode, BindMode::Local);
}
#[test]
fn test_bind_mode_env_empty_is_local() {
let mode =
BindMode::from_flags_and_bind_env("127.0.0.1:7788", "127.0.0.1:7788", false, Some(""));
assert_eq!(mode, BindMode::Local);
}
#[test]
fn test_resolve_local() {
let addrs = resolve_bind_addrs(&BindMode::Local, 7788, || panic!("should not call"));
assert_eq!(addrs, vec![SocketAddr::from(([127, 0, 0, 1], 7788))]);
}
#[test]
fn test_resolve_tailscale_with_ip() {
let ts_ip = IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1));
let addrs = resolve_bind_addrs(&BindMode::Tailscale, 7788, || Some(ts_ip));
assert_eq!(addrs.len(), 2);
assert_eq!(addrs[0], SocketAddr::from(([127, 0, 0, 1], 7788)));
assert_eq!(addrs[1], SocketAddr::new(ts_ip, 7788));
}
#[test]
fn test_resolve_tailscale_fallback() {
let addrs = resolve_bind_addrs(&BindMode::Tailscale, 7788, || None);
assert_eq!(addrs, vec![SocketAddr::from(([127, 0, 0, 1], 7788))]);
}
#[test]
fn test_resolve_explicit_valid() {
let mode = BindMode::Explicit("0.0.0.0:9000".to_owned());
let addrs = resolve_bind_addrs(&mode, 7788, || panic!("should not call"));
assert_eq!(addrs, vec![SocketAddr::from(([0, 0, 0, 0], 9000))]);
}
#[test]
fn test_resolve_explicit_invalid_fallback() {
let mode = BindMode::Explicit("not-an-addr".to_owned());
let addrs = resolve_bind_addrs(&mode, 7788, || panic!("should not call"));
assert_eq!(addrs, vec![SocketAddr::from(([127, 0, 0, 1], 7788))]);
}
#[test]
fn test_port_from_addr_valid() {
assert_eq!(port_from_addr("127.0.0.1:7788", 7788), 7788);
assert_eq!(port_from_addr("0.0.0.0:9000", 7788), 9000);
}
#[test]
fn test_port_from_addr_invalid() {
assert_eq!(port_from_addr("garbage", 7788), 7788);
}
#[test]
fn test_parse_tailscale_output() {
let raw = "100.64.0.1\n";
let ip: IpAddr = raw.trim().parse().expect("parse");
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)));
}
}