use std::{error::Error, path::PathBuf, time::Duration};
use clap::Parser;
use tailscale::{Config, Device, RegistrationError, config::BadFormatBehavior};
use tokio::signal::unix::{SignalKind, signal};
use tracing_subscriber::filter::LevelFilter;
#[derive(clap::Parser)]
#[command(version, about)]
struct Args {
#[arg(long)]
state_dir: PathBuf,
#[arg(long)]
auth_key_file: Option<PathBuf>,
#[arg(long, conflicts_with = "auth_key_file")]
interactive: bool,
#[arg(long, default_value = "rust-exit")]
hostname: String,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long, default_value_t = 41642)]
port: u16,
#[arg(long)]
no_advertise: bool,
#[arg(long)]
exit_dns: Option<std::net::SocketAddr>,
#[arg(long, conflicts_with = "exit_dns")]
no_exit_dns: bool,
#[arg(long, default_value_t = 41643)]
peerapi_port: u16,
#[arg(long)]
tcp_buffer_size: Option<usize>,
#[arg(long, default_value_t = 0)]
sink_port: u16,
#[arg(long, default_value_t = 4)]
worker_threads: usize,
#[arg(long, default_value_t = 30)]
status_interval: u64,
}
fn main() -> Result<(), Box<dyn Error>> {
let args = Args::parse();
tokio::runtime::Builder::new_multi_thread()
.worker_threads(args.worker_threads.max(1))
.enable_all()
.build()?
.block_on(run(args))
}
async fn run(args: Args) -> Result<(), Box<dyn Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
tokio::fs::create_dir_all(&args.state_dir).await?;
let key_file = args.state_dir.join("keys.json");
let first_run = !tokio::fs::try_exists(&key_file).await?;
let auth_key = match (&args.auth_key_file, first_run) {
(Some(p), _) => Some(tokio::fs::read_to_string(p).await?.trim().to_owned()),
(None, true) if !args.interactive => {
return Err("first run needs --auth-key-file or --interactive".into());
}
(None, _) => None,
};
let exit_dns = if args.no_exit_dns {
None
} else {
args.exit_dns.or_else(system_resolver)
};
tracing::info!(?exit_dns, "exit-node DNS for unreachable resolvers");
let config = Config {
key_state: tailscale::config::load_key_file(&key_file, BadFormatBehavior::Error).await?,
client_name: Some("ts-exit-node".to_owned()),
requested_hostname: Some(args.hostname.clone()),
requested_tags: args.tags.clone(),
ephemeral: false,
accept_dns: true,
advertise_exit_node: !args.no_advertise,
forward_all_ports: !args.no_advertise,
forward_exit_egress: !args.no_advertise,
exit_node_allow_lan_access: false,
exit_node_dns: exit_dns,
peerapi_port: (args.peerapi_port != 0).then_some(args.peerapi_port),
tcp_buffer_size: args.tcp_buffer_size,
wireguard_listen_port: Some(args.port),
netmap_cache_dir: None,
..Config::default()
};
let dev = Device::new(&config, auth_key).await?;
let deadline =
tokio::time::Instant::now() + Duration::from_secs(if args.interactive { 3600 } else { 90 });
let mut shown = None;
loop {
let left = deadline.saturating_duration_since(tokio::time::Instant::now());
match dev.wait_until_running(Some(left)).await {
Ok(()) => break,
Err(RegistrationError::NeedsLogin(url)) if args.interactive && !left.is_zero() => {
if shown.as_ref() != Some(&url) {
tracing::info!(%url, "open this URL to log the node in");
shown = Some(url);
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err(e) => return Err(e.into()),
}
}
let (v4, v6) = dev.tailscale_ips().await?;
tracing::info!(
?v4,
?v6,
advertise_exit_node = !args.no_advertise,
"running"
);
if args.sink_port != 0 {
let listener = dev
.tcp_listen(std::net::SocketAddr::from((
std::net::Ipv4Addr::UNSPECIFIED,
args.sink_port,
)))
.await?;
tracing::info!(port = args.sink_port, "benchmark sink listening");
tokio::spawn(async move {
while let Ok(conn) = listener.accept().await {
tokio::spawn(serve_sink(conn));
}
});
}
let mut sigterm = signal(SignalKind::terminate())?;
let mut ticker = (args.status_interval > 0)
.then(|| tokio::time::interval(Duration::from_secs(args.status_interval)));
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break,
_ = sigterm.recv() => break,
_ = async { ticker.as_mut().unwrap().tick().await }, if ticker.is_some() => {
log_status(&dev).await;
}
}
}
tracing::info!("shutting down");
let clean = dev.shutdown(Some(Duration::from_secs(5))).await;
tracing::info!(clean, "stopped");
Ok(())
}
async fn log_status(dev: &Device) {
let status = match tokio::time::timeout(Duration::from_secs(5), dev.status()).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
tracing::warn!(error = %e, "status");
return;
}
Err(_) => {
let t = Duration::from_secs(2);
let self_node = tokio::time::timeout(t, dev.self_node()).await.is_ok();
let peer_tracker =
tokio::time::timeout(t, dev.peers_with_route("203.0.113.1".parse().unwrap()))
.await
.is_ok();
tracing::warn!(self_node, peer_tracker, "status query timed out");
return;
}
};
match &status.self_node {
Some(me) => tracing::info!(
routes = ?me.allowed_routes,
exit_node = me.is_exit_node,
peers = status.peers.len(),
health = ?status.health,
"self"
),
None => {
tracing::info!(peers = status.peers.len(), health = ?status.health, "self (no self node yet)")
}
}
for p in status.peers.iter().filter(|p| p.online != Some(false)) {
let path = match (&p.cur_addr, &p.relay) {
(Some(a), _) => format!("direct {a}"),
(None, Some(r)) => format!("derp {r}"),
(None, None) => "idle".to_owned(),
};
tracing::info!(peer = %p.display_name, ip = %p.ipv4, online = ?p.online, %path, "peer");
}
}
fn system_resolver() -> Option<std::net::SocketAddr> {
const TAILSCALE_SERVICE_IPS: [std::net::IpAddr; 2] = [
std::net::IpAddr::V4(std::net::Ipv4Addr::new(100, 100, 100, 100)),
std::net::IpAddr::V6(std::net::Ipv6Addr::new(
0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0x53,
)),
];
let conf = std::fs::read_to_string("/etc/resolv.conf").ok()?;
conf.lines()
.filter_map(|l| l.trim().strip_prefix("nameserver"))
.filter_map(|ip| ip.trim().parse::<std::net::IpAddr>().ok())
.filter(|ip| ip.is_ipv4() && !TAILSCALE_SERVICE_IPS.contains(ip))
.map(|ip| std::net::SocketAddr::new(ip, 53))
.next()
}
async fn serve_sink(mut conn: tailscale::netstack::TcpStream) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 64 * 1024];
let Ok(n) = conn.read(&mut buf[..1]).await else {
return;
};
match (n, buf[0]) {
(1, b'd') => {
buf.fill(0);
while conn.write_all(&buf).await.is_ok() {}
}
(1, b'u') => while matches!(conn.read(&mut buf).await, Ok(n) if n > 0) {},
(1, b'e') => loop {
match conn.read(&mut buf).await {
Ok(n) if n > 0 => {
if conn.write_all(&buf[..n]).await.is_err() {
break;
}
}
_ => break,
}
},
_ => {}
}
}