use super::audit::{Entry, append_audit_to};
use super::caller::{ancestry, caller_cwd, peer_pid, prompt_agent, remote_agent, ssh_requester};
use super::tokens::{Tokens, mint_child, token_value};
use super::{Broker, KEY_TTL_SECS, MAX_GRANT_SECS, Op, Request, Response, TAILNET_PORT};
use crate::store::{self, Store};
use crate::{ssh, tailnet};
use anyhow::Result;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::sync::atomic::{AtomicU64, Ordering};
static LAST_SEEN: AtomicU64 = AtomicU64::new(0);
static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Origin {
Local,
Tailnet,
}
const SERVE: &str = "tailscale serve --bg --proxy-protocol 1 --tcp 8787 tcp://127.0.0.1:8787";
fn serve_ssh(listener: &UnixListener, shared: &std::sync::Arc<Shared>) {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let shared = std::sync::Arc::clone(shared);
std::thread::spawn(move || {
let pid = peer_pid(&stream);
let mut connection = ssh::Connection::default();
while let Ok(Some(request)) = ssh::read_message(&mut stream) {
IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
let offered = ssh::offered(&shared.store);
let reply = ssh::answer(&request, &offered, &mut connection, |key, data, host| {
let caller = pid.map_or_else(|| "unknown".to_string(), ancestry);
let agent = ssh_requester(&caller);
shared
.broker
.lock()
.expect("broker")
.sign(key, data, host, &agent, &caller)
});
LAST_SEEN.store(store::now(), Ordering::Relaxed);
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
if ssh::write_message(&mut stream, &reply).is_err() {
break;
}
}
});
}
}
pub(super) struct Shared {
broker: std::sync::Mutex<Broker>,
pub(super) tokens: Tokens,
pub(super) store: Store,
}
fn reply<S: std::io::Read + Write>(
shared: &Shared,
stream: &mut S,
caller: &str,
cwd: Option<&std::path::Path>,
origin: Origin,
) -> Result<()> {
let mut line = String::new();
let mut reader = BufReader::new(&mut *stream);
if let Err(e) = reader.read_line(&mut line) {
eprintln!("could not read the request: {e}");
return Ok(());
}
eprintln!("{} read {} bytes of request", clock(), line.len());
let response = match serde_json::from_str::<Request>(&line) {
Ok(mut request) => {
if request.agent.is_empty() {
request.agent = match origin {
Origin::Local => prompt_agent(caller),
Origin::Tailnet => "passbox".to_string(),
};
}
if origin == Origin::Tailnet {
request.agent = remote_agent(&request.agent, caller);
}
let by_token = (request.op == Op::Get && !request.token.is_empty())
.then(|| token_value(&shared.tokens, &shared.store, &request.token, &request.name))
.flatten();
let child = (request.op == Op::Grant && !request.token.is_empty())
.then(|| mint_child(shared, &request, caller))
.flatten();
let answer = match (by_token, child) {
(Some((value, grant)), _) => shared
.store
.recipient()
.and_then(|to| {
append_audit_to(
&shared.store,
&to,
&Entry {
agent: &request.agent,
secret: &request.name,
decision: "by token",
caller,
grant: &grant,
..Entry::default()
},
)
})
.map(|()| value),
(None, Some(answer)) => answer,
(None, None) => shared
.broker
.lock()
.expect("broker")
.handle(&request, caller, cwd, origin),
};
match answer {
Ok(value) => Response {
ok: true,
value: Some(value),
error: None,
},
Err(e) => Response {
ok: false,
value: None,
error: Some(format!("{e:#}")),
},
}
}
Err(e) => Response {
ok: false,
value: None,
error: Some(format!("bad request: {e}")),
},
};
writeln!(stream, "{}", serde_json::to_string(&response)?)?;
Ok(())
}
fn serve_tailnet(listener: &std::net::TcpListener, shared: &std::sync::Arc<Shared>) {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
LAST_SEEN.store(store::now(), Ordering::Relaxed);
let shared = std::sync::Arc::clone(shared);
IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
std::thread::spawn(move || {
let Some(peer) = tailnet::proxied_peer(&mut stream) else {
eprintln!("tailnet request refused: no PROXY header, see `tailscale serve` below");
let refusal = Response {
ok: false,
value: None,
error: Some(format!(
"the broker cannot tell which host asked, run on the Mac:\n {SERVE}"
)),
};
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&refusal).unwrap_or_default()
);
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
return;
};
let caller =
tailnet::whois(peer).unwrap_or_else(|| format!("an unknown tailnet host ({peer})"));
eprintln!("tailnet request from {caller}");
if let Err(e) = reply(&shared, &mut stream, &caller, None, Origin::Tailnet) {
eprintln!("tailnet request failed: {e:#}");
}
LAST_SEEN.store(store::now(), Ordering::Relaxed);
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
});
}
}
#[cfg(all(feature = "host", target_os = "macos"))]
fn harden() {
unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) };
let none = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
unsafe { libc::setrlimit(libc::RLIMIT_CORE, &raw const none) };
}
fn bind_private(path: &std::path::Path) -> Result<UnixListener> {
if path.exists() {
std::fs::remove_file(path)?;
}
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
Ok(listener)
}
pub(crate) fn serve(store: Store) -> Result<()> {
#[cfg(target_os = "macos")]
harden();
let socket = store.socket_path();
store::private_dir(&store.dir)?;
let listener = bind_private(&socket)?;
let agent = bind_private(&store.ssh_agent_path())?;
LAST_SEEN.store(store::now(), Ordering::Relaxed);
let tokens = Tokens::default();
let shared = std::sync::Arc::new(Shared {
broker: std::sync::Mutex::new(Broker::new(store.clone(), std::sync::Arc::clone(&tokens))),
tokens,
store,
});
watchdog(socket, std::sync::Arc::clone(&shared));
let for_agent = std::sync::Arc::clone(&shared);
std::thread::spawn(move || serve_ssh(&agent, &for_agent));
if tailnet::address().is_some() {
match std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, TAILNET_PORT)) {
Ok(tcp) => {
eprintln!("listening on 127.0.0.1:{TAILNET_PORT}; expose it with\n {SERVE}");
let shared = std::sync::Arc::clone(&shared);
std::thread::spawn(move || serve_tailnet(&tcp, &shared));
}
Err(e) => eprintln!("not listening for tailnet peers: {e}"),
}
}
eprintln!(
"{} broker ready, waiting for requests. Ctrl+C to stop",
clock()
);
for stream in listener.incoming() {
let mut stream = stream?;
LAST_SEEN.store(store::now(), Ordering::Relaxed);
let shared = std::sync::Arc::clone(&shared);
IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
std::thread::spawn(move || {
let pid = peer_pid(&stream);
let caller = pid.map_or_else(|| "unknown".to_string(), ancestry);
let cwd = pid.and_then(caller_cwd);
let _ = reply(&shared, &mut stream, &caller, cwd.as_deref(), Origin::Local);
LAST_SEEN.store(store::now(), Ordering::Relaxed);
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
});
}
Ok(())
}
fn file_id(path: &std::path::Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))
}
struct Binary {
launch: std::path::PathBuf,
running: Option<(u64, u64)>,
}
impl Binary {
fn current() -> Self {
let exe = std::env::current_exe().ok();
let launch = std::env::args_os()
.next()
.map(std::path::PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| exe.clone())
.unwrap_or_default();
let running = exe
.and_then(|e| std::fs::canonicalize(e).ok())
.and_then(|e| file_id(&e));
Binary { launch, running }
}
fn replaced(&self) -> bool {
self.running.is_some() && file_id(&self.launch) != self.running
}
}
fn watchdog(socket: std::path::PathBuf, shared: std::sync::Arc<Shared>) {
let binary = Binary::current();
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_secs(30));
if !socket.exists() {
eprintln!("{} socket is gone, exiting", clock());
std::process::exit(0);
}
if binary.replaced()
&& IN_FLIGHT.load(Ordering::SeqCst) == 0
&& let Ok(_quiet) = shared.broker.try_lock()
{
eprintln!(
"{} {} was replaced, exiting so the new version starts",
clock(),
binary.launch.display()
);
let _ = std::fs::remove_file(&socket);
std::process::exit(0);
}
let idle = store::now().saturating_sub(LAST_SEEN.load(Ordering::Relaxed));
let mut broker = match shared.broker.try_lock() {
Ok(broker) => broker,
Err(std::sync::TryLockError::WouldBlock) => continue,
Err(std::sync::TryLockError::Poisoned(_)) => {
eprintln!("{} a request panicked, exiting", clock());
let _ = std::fs::remove_file(&socket);
std::process::exit(1);
}
};
broker.sweep(idle);
}
});
}
pub(super) fn clock() -> String {
let t = libc::time_t::try_from(store::now()).unwrap_or_default();
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
unsafe { libc::localtime_r(&raw const t, &raw mut tm) };
format!(
"{}-{:02}-{:02} {:02}:{:02}:{:02}",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min,
tm.tm_sec
)
}
impl Broker {
fn sweep(&mut self, idle: u64) {
if idle < KEY_TTL_SECS {
return;
}
let now = store::now();
self.tokens
.lock()
.expect("tokens")
.retain(|_, g| now < g.expires);
self.windows
.retain(|_, w| now < w.opened.saturating_add(MAX_GRANT_SECS));
self.ssh_windows
.retain(|_, w| now < w.opened.saturating_add(MAX_GRANT_SECS));
if self.key.take().is_some() {
eprintln!("{} idle {idle}s, dropped the key", clock());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::broker::tokens::TokenGrant;
use age::x25519;
use std::collections::HashMap;
#[test]
fn a_repointed_or_deleted_binary_reads_as_replaced() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("passbox-old");
let new = tmp.path().join("passbox-new");
let launch = tmp.path().join("passbox");
std::fs::write(&old, "old").unwrap();
std::fs::write(&new, "new").unwrap();
std::os::unix::fs::symlink(&old, &launch).unwrap();
let binary = Binary {
launch: launch.clone(),
running: file_id(&old),
};
assert!(!binary.replaced());
std::fs::remove_file(&launch).unwrap();
std::os::unix::fs::symlink(&new, &launch).unwrap();
assert!(binary.replaced());
let direct = Binary {
launch: old.clone(),
running: file_id(&old),
};
assert!(!direct.replaced());
std::fs::remove_file(&old).unwrap();
assert!(direct.replaced());
}
#[test]
fn an_idle_broker_drops_the_key_and_keeps_live_tokens() {
let tmp = tempfile::tempdir().unwrap();
let mut broker = Broker::new(
Store {
dir: tmp.path().to_path_buf(),
},
Tokens::default(),
);
broker.key = Some(Box::new(x25519::Identity::generate()));
broker.tokens.lock().unwrap().insert(
"t".into(),
TokenGrant {
values: HashMap::from([("a/b".into(), "v".into())]),
stamps: HashMap::new(),
expires: store::now() + 43_200,
grant: "g".into(),
},
);
broker.sweep(KEY_TTL_SECS - 1);
assert!(broker.key.is_some());
broker.sweep(KEY_TTL_SECS);
assert!(broker.key.is_none());
assert_eq!(
broker.value_for_token("t", "a/b"),
Some(("v".into(), "g".into()))
);
broker.tokens.lock().unwrap().get_mut("t").unwrap().expires = store::now();
broker.sweep(KEY_TTL_SECS);
assert!(broker.tokens.lock().unwrap().is_empty());
}
}