#[cfg(feature = "host")]
use crate::project::{self, Grant};
#[cfg(feature = "host")]
use crate::store;
use crate::store::Store;
#[cfg(feature = "host")]
use age::x25519;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
#[cfg(feature = "host")]
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
#[cfg(feature = "host")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(feature = "host")]
const KEY_TTL_SECS: u64 = 300;
#[cfg(feature = "host")]
const MAX_GRANT_SECS: u64 = 86_400;
#[cfg(feature = "host")]
const MAX_CHILDREN: usize = 64;
#[cfg(feature = "host")]
static LAST_SEEN: AtomicU64 = AtomicU64::new(0);
#[cfg(feature = "host")]
static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(feature = "host")]
fn reject_namespaces(asked: &[String], known: &[String]) -> Result<()> {
for pattern in asked {
if known.iter().any(|n| n == pattern) {
continue;
}
let under = store::select(known, Some(pattern));
if under.is_empty() {
bail!("no secret named {pattern}");
}
bail!(
"{pattern} is a namespace holding {} secrets, ask for the ones this needs:\n {}",
under.len(),
under.join("\n ")
);
}
Ok(())
}
#[cfg(feature = "host")]
fn token_lookup(
tokens: &HashMap<String, TokenGrant>,
token: &str,
name: &str,
now: u64,
) -> Option<String> {
let grant = tokens.get(token)?;
(now < grant.expires)
.then(|| grant.values.get(name).cloned())
.flatten()
}
#[cfg(feature = "host")]
struct TokenGrant {
values: HashMap<String, String>,
expires: u64,
grant: String,
}
#[cfg(feature = "host")]
fn delegate(
tokens: &HashMap<String, TokenGrant>,
parent: &str,
names: &[String],
ttl: u64,
now: u64,
) -> Option<Result<TokenGrant>> {
let parent = tokens.get(parent).filter(|p| now < p.expires)?;
let expires = if ttl == 0 {
parent.expires
} else {
now.saturating_add(ttl)
};
if names.is_empty() || expires > parent.expires {
return None;
}
let mut values = HashMap::new();
for name in names {
values.insert(name.clone(), parent.values.get(name)?.clone());
}
let live = tokens
.values()
.filter(|g| g.grant == parent.grant && now < g.expires)
.count();
if live > MAX_CHILDREN {
return Some(Err(anyhow!(
"this grant already has {MAX_CHILDREN} live child tokens, wait for one to lapse"
)));
}
Some(Ok(TokenGrant {
values,
expires,
grant: parent.grant.clone(),
}))
}
#[cfg(feature = "host")]
fn mint_child(shared: &Shared, request: &Request, caller: &str) -> Option<Result<String>> {
let now = store::now();
let mut tokens = shared.tokens.lock().expect("tokens");
tokens.retain(|_, g| now < g.expires);
let child = match delegate(&tokens, &request.token, &request.names, request.ttl, now)? {
Ok(child) => child,
Err(e) => return Some(Err(e)),
};
let token = store::new_id() + &store::new_id();
let mut granted: Vec<String> = child.values.keys().cloned().collect();
granted.sort();
let grant = child.grant.clone();
tokens.insert(token.clone(), child);
drop(tokens);
for name in &granted {
let to = match shared.store.recipient() {
Ok(to) => to,
Err(e) => return Some(Err(e)),
};
if let Err(e) = append_audit_to(
&shared.store,
&to,
&request.agent,
name,
"delegated",
caller,
&grant,
) {
return Some(Err(e));
}
}
Some(Ok(format!("{token}\n{}", granted.join("\n"))))
}
#[derive(Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Op {
#[default]
Get,
List,
Grant,
Plugin,
}
#[derive(Serialize, Deserialize)]
pub struct Request {
#[serde(default)]
pub name: String,
pub agent: String,
#[serde(default)]
pub op: Op,
#[serde(default)]
pub token: String,
#[serde(default)]
pub ttl: u64,
#[serde(default)]
pub names: Vec<String>,
#[serde(default)]
pub action: String,
#[serde(default)]
pub params: Vec<(String, String)>,
#[serde(default)]
pub run: bool,
}
#[derive(Serialize, Deserialize)]
pub struct Response {
pub ok: bool,
pub value: Option<String>,
pub error: Option<String>,
}
#[derive(Serialize)]
struct AuditRecord<'a> {
at: u64,
agent: &'a str,
secret: &'a str,
decision: &'a str,
caller: &'a str,
#[serde(skip_serializing_if = "str::is_empty")]
grant: &'a str,
}
#[cfg(feature = "host")]
type Tokens = std::sync::Arc<std::sync::Mutex<HashMap<String, TokenGrant>>>;
#[cfg(feature = "host")]
fn token_value(tokens: &Tokens, token: &str, name: &str) -> Option<(String, String)> {
let now = store::now();
let mut tokens = tokens.lock().expect("tokens");
tokens.retain(|_, g| now < g.expires);
let value = token_lookup(&tokens, token, name, now)?;
Some((value, tokens[token].grant.clone()))
}
#[cfg(feature = "host")]
struct Broker {
store: Store,
key: Option<Box<x25519::Identity>>,
tokens: Tokens,
key_at: u64,
#[cfg(feature = "host")]
grants: Vec<Grant>,
}
#[cfg(feature = "host")]
impl Broker {
fn new(store: Store, tokens: Tokens) -> Self {
Broker {
store,
key: None,
key_at: 0,
tokens,
#[cfg(feature = "host")]
grants: Vec::new(),
}
}
fn cached_key(&mut self) -> Option<&x25519::Identity> {
if store::now().saturating_sub(self.key_at) >= KEY_TTL_SECS {
self.key = None;
}
self.key.as_deref()
}
fn prompt(&mut self, agent: &str, secret: &str) -> Result<()> {
self.ask(&format!("release the password for {secret} to {agent}"))
}
fn refused(
&self,
agent: &str,
secrets: &[&str],
error: anyhow::Error,
caller: &str,
) -> anyhow::Error {
let decision = if error.root_cause().to_string().starts_with("denied") {
"denied"
} else {
"prompt failed"
};
for secret in secrets {
let _ = self.audit_by(agent, secret, decision, caller);
}
error
}
fn ask(&mut self, reason: &str) -> Result<()> {
let key = self.store.unlock_with_se(reason)?;
#[cfg(feature = "host")]
{
self.grants = project::load(&self.store, &key);
}
self.key = Some(Box::new(key));
self.key_at = store::now();
Ok(())
}
#[cfg(feature = "host")]
fn granted(&self, secret: &str, cwd: Option<&std::path::Path>) -> bool {
let Some(manifest) = cwd.and_then(project::find) else {
return false;
};
let now = store::now();
self.grants.iter().any(|g| g.covers(&manifest, secret, now))
}
#[cfg(feature = "host")]
fn grant_manifest(
&mut self,
request: &Request,
cwd: Option<&std::path::Path>,
caller: &str,
) -> Result<bool> {
let Some(manifest) = cwd.and_then(project::find) else {
return Ok(false);
};
if !manifest.secrets.iter().any(|s| s == &request.name) {
return Ok(false);
}
let declared: Vec<&str> = manifest.secrets.iter().map(String::as_str).collect();
self.ask(&format!(
"give {} access to {} secrets declared by {} for {} minutes",
request.agent,
manifest.secrets.len(),
manifest.dir.display(),
manifest.window_secs / 60
))
.map_err(|e| self.refused(&request.agent, &declared, e, caller))?;
self.grants.push(Grant {
dir: manifest.dir.to_string_lossy().to_string(),
hash: manifest.hash,
secrets: manifest.secrets,
until: store::now() + manifest.window_secs,
});
project::save(&self.store, &self.grants)?;
Ok(true)
}
fn handle(
&mut self,
request: &Request,
caller: &str,
cwd: Option<&std::path::Path>,
) -> Result<String> {
match request.op {
Op::Get => self.handle_get(request, caller, cwd),
Op::List => self.handle_list(request, caller),
Op::Grant => self.handle_grant(request, caller),
Op::Plugin => self.handle_plugin(request, caller),
}
}
#[cfg(feature = "host")]
fn handle_plugin(&mut self, request: &Request, caller: &str) -> Result<String> {
let manifest = crate::plugin::load(&self.store, &request.name)?;
if manifest.stdin.is_some() {
return self.handle_fed_plugin(request, caller);
}
let tool = manifest
.tool(&request.action)
.ok_or_else(|| anyhow!("{} has no action {}", request.name, request.action))?;
let slot_name = format!("plugin:{}.{}", request.name, request.action);
self.ask(&format!("run {slot_name} for {}", request.agent))
.map_err(|e| self.refused(&request.agent, &[&slot_name], e, caller))?;
self.audit_by(&request.agent, &slot_name, "approved", caller)?;
if !request.run {
return Ok("ok".to_string());
}
if manifest.runs_in_caller_dir() {
bail!("{} runs on the caller, not on the host", request.name);
}
manifest.ensure_available()?;
let bound: HashMap<String, String> = request.params.iter().cloned().collect();
let mut command = tool.command(&bound)?;
command.current_dir(self.store.plugins_dir().join(&request.name));
run_plugin_command(command)
}
#[cfg(feature = "host")]
fn handle_fed_plugin(&mut self, request: &Request, caller: &str) -> Result<String> {
if !request.run {
bail!(
"{} runs on the broker so its secret never leaves it, send a run request",
request.name
);
}
let bound: HashMap<String, String> = request.params.iter().cloned().collect();
let (manifest, command) =
crate::plugin::fed_command(&self.store, &request.name, &request.action, &bound)?;
let secret = manifest
.stdin
.as_deref()
.ok_or_else(|| anyhow!("{} reads no secret in its bundled copy", request.name))?;
let slot_name = format!("plugin:{}.{}", request.name, request.action);
self.ask(&manifest.approval(&request.action, &request.agent))
.map_err(|e| self.refused(&request.agent, &[&slot_name, secret], e, caller))?;
self.audit_by(&request.agent, &slot_name, "approved", caller)?;
let key = self.key.clone().expect("just unlocked");
let Some((_, entry)) = self.store.find(secret, &key)? else {
return Err(crate::plugin::missing(&request.name, secret));
};
self.audit(&key, &request.agent, secret, "released", caller)?;
crate::plugin::run_fed(command, &entry.value)
}
fn handle_list(&mut self, request: &Request, caller: &str) -> Result<String> {
self.ask(&format!("list your secret names for {}", request.agent))
.map_err(|e| self.refused(&request.agent, &["<list>"], e, caller))?;
let key = self.key.clone().expect("just unlocked");
self.audit(&key, &request.agent, "<list>", "approved", caller)?;
let names = self.store.names(&key)?;
if let Err(e) =
crate::ssh::keys(&self.store, &key).and_then(|k| crate::ssh::refresh(&self.store, &k))
{
eprintln!("{} could not refresh the SSH keys: {e:#}", clock());
}
Ok(names.join("\n"))
}
fn sign(
&mut self,
wanted: &ssh_key::PublicKey,
data: &[u8],
agent: &str,
caller: &str,
) -> Result<ssh_key::Signature> {
let label = crate::ssh::label(wanted);
let slot = format!("ssh:{label}");
let purpose = crate::ssh::purpose(data);
self.ask(&format!("sign {purpose} with SSH key {label} for {agent}"))
.map_err(|e| self.refused(agent, &[&slot], e, caller))?;
let key = self.key.clone().expect("just unlocked");
let keys = crate::ssh::keys(&self.store, &key)?;
crate::ssh::refresh(&self.store, &keys)?;
let Some((name, private)) = keys
.iter()
.find(|(_, k)| k.public_key().key_data() == wanted.key_data())
else {
self.audit(&key, agent, &slot, "unknown", caller)?;
bail!("no SSH key in the store matches {label}");
};
self.audit(&key, agent, name, "signed", caller)?;
signature::Signer::try_sign(private, data).map_err(|e| anyhow!("could not sign: {e}"))
}
fn handle_grant(&mut self, request: &Request, caller: &str) -> Result<String> {
let asked = if request.ttl == 0 { 300 } else { request.ttl };
let ttl = asked.min(MAX_GRANT_SECS);
if ttl < asked {
eprintln!("capped {asked}s at {MAX_GRANT_SECS}s");
}
if let Some(key) = self.cached_key().cloned() {
reject_namespaces(&request.names, &self.store.names(&key)?)?;
}
let reason = format!(
"grant {} to {} for {ttl}s",
request.names.join(", "),
request.agent
);
let asked_for: Vec<&str> = request.names.iter().map(String::as_str).collect();
self.ask(&reason)
.map_err(|e| self.refused(&request.agent, &asked_for, e, caller))?;
let key = self.key.clone().expect("just unlocked");
let all = self.store.names(&key)?;
reject_namespaces(&request.names, &all)?;
let mut wanted = request.names.clone();
wanted.sort();
wanted.dedup();
let mut values = HashMap::new();
for name in &wanted {
let Some((_, secret)) = self.store.find(name, &key)? else {
continue;
};
values.insert(name.clone(), secret.value.clone());
}
if values.is_empty() {
bail!("{} matched no secrets", request.names.join(", "));
}
let token = store::new_id() + &store::new_id();
let grant = store::new_id();
let granted: Vec<String> = values.keys().cloned().collect();
self.tokens.lock().expect("tokens").insert(
token.clone(),
TokenGrant {
values,
expires: store::now() + ttl,
grant: grant.clone(),
},
);
for name in &granted {
let to = key.to_public();
append_audit_to(
&self.store,
&to,
&request.agent,
name,
"granted",
caller,
&grant,
)?;
}
Ok(format!("{token}\n{}", granted.join("\n")))
}
fn value_for_token(&self, token: &str, name: &str) -> Option<(String, String)> {
token_value(&self.tokens, token, name)
}
fn handle_get(
&mut self,
request: &Request,
caller: &str,
cwd: Option<&std::path::Path>,
) -> Result<String> {
if !request.token.is_empty()
&& let Some((value, grant)) = self.value_for_token(&request.token, &request.name)
{
let to = self.store.recipient()?;
append_audit_to(
&self.store,
&to,
&request.agent,
&request.name,
"by token",
caller,
&grant,
)?;
return Ok(value);
}
let covered = (self.cached_key().is_some() && self.granted(&request.name, cwd))
|| (self.cached_key().is_none() && self.grant_manifest(request, cwd, caller)?);
let outcome = if covered {
"project grant"
} else {
self.prompt(&request.agent, &request.name)
.map_err(|e| self.refused(&request.agent, &[&request.name], e, caller))?;
"released"
};
let key = self.key.clone().expect("just unlocked");
let Some((_, secret)) = self.store.find(&request.name, &key)? else {
self.audit(&key, &request.agent, &request.name, "unknown", caller)?;
bail!("no secret named {}", request.name);
};
self.audit(&key, &request.agent, &request.name, outcome, caller)?;
Ok(secret.value.clone())
}
fn audit_by(&self, agent: &str, secret: &str, decision: &str, caller: &str) -> Result<()> {
let to = self.store.recipient()?;
self.audit_to(&to, agent, secret, decision, caller)
}
fn audit(
&self,
key: &x25519::Identity,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
self.audit_to(&key.to_public(), agent, secret, decision, caller)
}
fn audit_to(
&self,
to: &x25519::Recipient,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
append_audit_to(&self.store, to, agent, secret, decision, caller, "")
}
}
pub const TAILNET_PORT: u16 = 8787;
#[cfg(feature = "host")]
const SERVE: &str = "tailscale serve --bg --proxy-protocol 1 --tcp 8787 tcp://127.0.0.1:8787";
#[cfg(feature = "host")]
const SHELLS: [&str; 6] = ["sh", "bash", "zsh", "fish", "dash", "ksh"];
#[cfg(feature = "host")]
fn remote_agent(agent: &str, host: &str) -> String {
let agent: String = agent.chars().filter(|c| !c.is_control()).take(64).collect();
format!("{} on {host}", agent.trim())
}
#[cfg(feature = "host")]
fn programs(chain: &str) -> impl Iterator<Item = &str> {
chain.split(" < ").map(|comm| {
comm.trim()
.rsplit('/')
.next()
.unwrap_or("")
.trim_start_matches('-')
})
}
#[cfg(feature = "host")]
fn prompt_agent(chain: &str) -> String {
for name in programs(chain) {
if name.is_empty() || name == "unknown" || name == "passbox" || SHELLS.contains(&name) {
continue;
}
return name.to_string();
}
"passbox".to_string()
}
#[cfg(feature = "host")]
fn ssh_requester(chain: &str) -> String {
let mut names = programs(chain);
match (names.next(), names.next()) {
(Some("ssh"), Some(parent)) if !parent.is_empty() && !SHELLS.contains(&parent) => {
parent.to_string()
}
(Some(peer), _) if !peer.is_empty() && peer != "unknown" => peer.to_string(),
_ => "ssh".to_string(),
}
}
#[cfg(feature = "host")]
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);
while let Ok(Some(request)) = crate::ssh::read_message(&mut stream) {
IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
let offered = crate::ssh::offered(&shared.store);
let reply = crate::ssh::answer(&request, &offered, |key, data| {
let caller = pid.map_or_else(|| "unknown".to_string(), ancestry);
let agent = ssh_requester(&caller);
shared
.broker
.lock()
.expect("broker")
.sign(key, data, &agent, &caller)
});
LAST_SEEN.store(store::now(), Ordering::Relaxed);
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
if crate::ssh::write_message(&mut stream, &reply).is_err() {
break;
}
}
});
}
}
#[cfg(feature = "host")]
struct Shared {
broker: std::sync::Mutex<Broker>,
tokens: Tokens,
store: Store,
}
#[cfg(feature = "host")]
fn reply<S: std::io::Read + Write>(
shared: &Shared,
stream: &mut S,
caller: &str,
cwd: Option<&std::path::Path>,
local: bool,
) -> 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 = if local {
prompt_agent(caller)
} else {
"passbox".to_string()
};
}
if !local {
request.agent = remote_agent(&request.agent, caller);
}
let by_token = (request.op == Op::Get && !request.token.is_empty())
.then(|| token_value(&shared.tokens, &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,
&request.agent,
&request.name,
"by token",
caller,
&grant,
)
})
.map(|()| value),
(None, Some(answer)) => answer,
(None, None) => shared
.broker
.lock()
.expect("broker")
.handle(&request, caller, cwd),
};
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(())
}
#[cfg(feature = "host")]
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) = crate::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 = crate::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, false) {
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,
};
libc::setrlimit(libc::RLIMIT_CORE, &none);
}
}
#[cfg(feature = "host")]
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)
}
#[cfg(feature = "host")]
pub 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(), tokens.clone())),
tokens,
store,
});
watchdog(socket.clone(), shared.clone());
let for_agent = std::sync::Arc::clone(&shared);
std::thread::spawn(move || serve_ssh(agent, for_agent));
if crate::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(), true);
LAST_SEEN.store(store::now(), Ordering::Relaxed);
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
});
}
Ok(())
}
#[cfg(feature = "host")]
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()))
}
#[cfg(feature = "host")]
struct Binary {
launch: std::path::PathBuf,
running: Option<(u64, u64)>,
}
#[cfg(feature = "host")]
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
}
}
#[cfg(feature = "host")]
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);
}
});
}
#[cfg(feature = "host")]
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);
if self.key.take().is_some() {
eprintln!("{} idle {idle}s, dropped the key", clock());
}
}
}
#[cfg(feature = "host")]
fn clock() -> String {
let t = store::now() as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
unsafe { libc::localtime_r(&t, &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
)
}
#[cfg(all(feature = "host", target_os = "macos"))]
fn peer_pid(stream: &UnixStream) -> Option<libc::pid_t> {
use std::os::unix::io::AsRawFd;
const SOL_LOCAL: libc::c_int = 0;
const LOCAL_PEERPID: libc::c_int = 0x002;
let mut pid: libc::pid_t = 0;
let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
let got = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
SOL_LOCAL,
LOCAL_PEERPID,
(&raw mut pid).cast(),
&raw mut len,
)
};
(got == 0 && pid > 0).then_some(pid)
}
#[cfg(all(feature = "host", not(target_os = "macos")))]
fn peer_pid(stream: &UnixStream) -> Option<libc::pid_t> {
use std::os::unix::io::AsRawFd;
let mut cred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = size_of::<libc::ucred>() as libc::socklen_t;
let got = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&raw mut cred).cast(),
&raw mut len,
)
};
(got == 0 && cred.pid > 0).then_some(cred.pid)
}
#[cfg(all(feature = "host", target_os = "macos"))]
fn caller_cwd(pid: libc::pid_t) -> Option<std::path::PathBuf> {
let out = std::process::Command::new("lsof")
.args(["-a", "-d", "cwd", "-Fn", "-p", &pid.to_string()])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.strip_prefix('n'))
.map(std::path::PathBuf::from)
}
#[cfg(all(feature = "host", not(target_os = "macos")))]
fn caller_cwd(pid: libc::pid_t) -> Option<std::path::PathBuf> {
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
}
pub(crate) fn append_audit(
store: &Store,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
) -> Result<()> {
append_audit_to(
store,
&store.recipient()?,
agent,
secret,
decision,
caller,
"",
)
}
fn append_audit_to(
store: &Store,
to: &age::x25519::Recipient,
agent: &str,
secret: &str,
decision: &str,
caller: &str,
grant: &str,
) -> Result<()> {
let record = AuditRecord {
at: crate::store::now(),
agent,
secret,
decision,
caller,
grant,
};
let line = crate::crypto::encrypt_line(&serde_json::to_vec(&record)?, to)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(store.audit_path())?;
writeln!(file, "{line}")?;
Ok(())
}
pub(crate) fn ancestry(pid: libc::pid_t) -> String {
let mut chain = Vec::new();
let mut current = pid;
for _ in 0..6 {
let out = std::process::Command::new("ps")
.args(["-o", "ppid=,comm=", "-p", ¤t.to_string()])
.output();
let Ok(out) = out else { break };
let text = String::from_utf8_lossy(&out.stdout);
let Some((ppid, comm)) = text.trim().split_once(char::is_whitespace) else {
break;
};
chain.push(comm.trim().to_string());
match ppid.trim().parse::<libc::pid_t>() {
Ok(next) if next > 1 => current = next,
_ => break,
}
}
if chain.is_empty() {
return "unknown".to_string();
}
chain.join(" < ")
}
pub fn request(store: &Store, name: &str, agent: &str) -> Result<String> {
request_token(store, name, agent, crate::held_token().unwrap_or_default())
}
pub fn request_token(store: &Store, name: &str, agent: &str, token: &str) -> Result<String> {
ask_broker(
store,
Request {
name: name.to_string(),
agent: agent.to_string(),
op: Op::Get,
token: token.to_string(),
ttl: 0,
names: Vec::new(),
action: String::new(),
params: Vec::new(),
run: false,
},
)
}
pub fn list(store: &Store, agent: &str) -> Result<Vec<String>> {
let names = ask_broker(
store,
Request {
name: String::new(),
agent: agent.to_string(),
op: Op::List,
token: String::new(),
ttl: 0,
names: Vec::new(),
action: String::new(),
params: Vec::new(),
run: false,
},
)?;
Ok(names.lines().map(str::to_string).collect())
}
pub fn grant(store: &Store, names: &[String], agent: &str, ttl: u64) -> Result<String> {
ask_broker(
store,
Request {
name: String::new(),
agent: agent.to_string(),
op: Op::Grant,
token: crate::held_token().unwrap_or_default().to_string(),
ttl,
names: names.to_vec(),
action: String::new(),
params: Vec::new(),
run: false,
},
)
}
#[cfg(feature = "host")]
pub fn plugin_approve(store: &Store, name: &str, action: &str, agent: &str) -> Result<()> {
ask_broker(
store,
Request {
name: name.to_string(),
agent: agent.to_string(),
op: Op::Plugin,
token: String::new(),
ttl: 0,
names: Vec::new(),
action: action.to_string(),
params: Vec::new(),
run: false,
},
)
.map(|_| ())
}
pub fn plugin_run_remote(
store: &Store,
name: &str,
action: &str,
params: &[(String, String)],
agent: &str,
) -> Result<String> {
ask_broker(
store,
Request {
name: name.to_string(),
agent: agent.to_string(),
op: Op::Plugin,
token: String::new(),
ttl: 0,
names: Vec::new(),
action: action.to_string(),
params: params.to_vec(),
run: true,
},
)
}
fn ask_broker(store: &Store, request: Request) -> Result<String> {
if let Some(host) = store.host() {
let target = if host.contains(':') {
host.clone()
} else {
format!("{host}:{TAILNET_PORT}")
};
let mut stream = std::net::TcpStream::connect(&target)
.with_context(|| format!("no passbox host at {target}"))?;
writeln!(stream, "{}", serde_json::to_string(&request)?)?;
let mut line = String::new();
BufReader::new(stream.try_clone()?).read_line(&mut line)?;
return unwrap_response(&line);
}
let socket = store.socket_path();
let mut stream = match UnixStream::connect(&socket) {
Ok(s) => s,
Err(_) => {
#[cfg(feature = "host")]
{
spawn(store)?;
UnixStream::connect(&socket).context("the broker did not come up")?
}
#[cfg(not(feature = "host"))]
bail!(
"no broker at {}, and a client cannot start one",
socket.display()
)
}
};
writeln!(stream, "{}", serde_json::to_string(&request)?)?;
let mut line = String::new();
BufReader::new(&stream).read_line(&mut line)?;
unwrap_response(&line)
}
fn unwrap_response(line: &str) -> Result<String> {
let response: Response = serde_json::from_str(line).context("broker sent nonsense")?;
if !response.ok {
bail!(
"{}",
response.error.unwrap_or_else(|| "refused".to_string())
);
}
response
.value
.ok_or_else(|| anyhow!("broker sent no value"))
}
#[cfg(feature = "host")]
fn spawn(store: &Store) -> Result<()> {
std::process::Command::new(std::env::current_exe()?)
.arg("broker")
.env("PASSBOX_DIR", &store.dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("could not start the broker")?;
for _ in 0..50 {
if store.socket_path().exists() {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(40));
}
bail!("the broker never opened its socket")
}
#[cfg(feature = "host")]
fn run_plugin_command(mut command: std::process::Command) -> Result<String> {
let out = command
.output()
.context("could not run the plugin command")?;
Ok(crate::plugin::render(&out))
}
#[cfg(all(test, feature = "host"))]
mod tests {
use super::*;
#[test]
fn a_remote_agent_cannot_forge_a_line_or_a_second_host() {
let host = "server (someone@example.com, 100.64.0.7)";
assert_eq!(remote_agent("timeout", host), format!("timeout on {host}"));
let forged = remote_agent("job\nrelease all to\r\u{1b}[2K", host);
assert!(!forged.chars().any(char::is_control), "{forged}");
assert!(forged.ends_with(&format!(" on {host}")));
let long = remote_agent(&"a".repeat(500), host);
assert_eq!(long, format!("{} on {host}", "a".repeat(64)));
}
#[test]
fn the_prompt_name_comes_from_the_first_real_ancestor() {
assert_eq!(prompt_agent("passbox < zsh < claude < login"), "claude");
assert_eq!(prompt_agent("passbox < -zsh < codex"), "codex");
assert_eq!(prompt_agent("passbox < /bin/bash < node"), "node");
assert_eq!(prompt_agent("passbox < zsh"), "passbox");
assert_eq!(prompt_agent("unknown"), "passbox");
}
#[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_ssh_request_is_named_by_the_program_that_ran_ssh() {
assert_eq!(ssh_requester("ssh < git < zsh < login"), "git");
assert_eq!(ssh_requester("/usr/bin/ssh < -zsh < login"), "ssh");
assert_eq!(ssh_requester("ssh"), "ssh");
assert_eq!(ssh_requester("unknown"), "ssh");
assert_eq!(ssh_requester("evil < node < zsh"), "evil");
}
#[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())]),
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());
}
#[test]
fn run_plugin_command_returns_output_then_stderr_and_exit() {
let mut ok = std::process::Command::new("printf");
ok.args(["%s", "hello"]);
assert_eq!(run_plugin_command(ok).unwrap(), "hello");
let mut bad = std::process::Command::new("sh");
bad.args(["-c", "printf out; printf oops >&2; exit 3"]);
let text = run_plugin_command(bad).unwrap();
assert!(text.contains("out"), "{text}");
assert!(text.contains("stderr:") && text.contains("oops"), "{text}");
assert!(text.contains("exit: 3"), "{text}");
}
fn grant_of(pairs: &[(&str, &str)], expires: u64) -> TokenGrant {
TokenGrant {
values: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
expires,
grant: "root".into(),
}
}
#[test]
fn a_namespace_is_refused_and_its_names_listed() {
let known: Vec<String> = ["acme/pk", "acme/addr", "personal/github"]
.iter()
.map(|s| s.to_string())
.collect();
let err = reject_namespaces(&["acme".to_string()], &known)
.unwrap_err()
.to_string();
assert!(err.contains("namespace holding 2"), "{err}");
assert!(
err.contains("acme/pk") && err.contains("acme/addr"),
"{err}"
);
}
#[test]
fn exact_names_are_accepted() {
let known: Vec<String> = ["acme/pk", "personal/github"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(reject_namespaces(&["acme/pk".to_string()], &known).is_ok());
assert!(
reject_namespaces(
&["acme/pk".to_string(), "personal/github".to_string()],
&known
)
.is_ok()
);
}
#[test]
fn a_name_that_is_not_there_is_refused() {
let known = vec!["token".to_string(), "other".to_string()];
let err = reject_namespaces(&["nope".to_string()], &known)
.unwrap_err()
.to_string();
assert!(err.contains("no secret named nope"), "{err}");
assert!(reject_namespaces(&["token".to_string()], &known).is_ok());
}
#[test]
fn a_token_opens_only_what_it_was_granted() {
let mut tokens = HashMap::new();
tokens.insert("tok".to_string(), grant_of(&[("acme/pk", "value")], 2_000));
assert_eq!(
token_lookup(&tokens, "tok", "acme/pk", 1_000).as_deref(),
Some("value")
);
assert!(token_lookup(&tokens, "tok", "personal/github", 1_000).is_none());
}
#[test]
fn an_unknown_token_opens_nothing() {
let mut tokens = HashMap::new();
tokens.insert("tok".to_string(), grant_of(&[("acme/pk", "v")], 2_000));
assert!(token_lookup(&tokens, "guessed", "acme/pk", 1_000).is_none());
}
#[test]
fn a_lapsed_token_opens_nothing() {
let mut tokens = HashMap::new();
tokens.insert("tok".to_string(), grant_of(&[("acme/pk", "v")], 2_000));
assert!(token_lookup(&tokens, "tok", "acme/pk", 2_000).is_none());
assert!(token_lookup(&tokens, "tok", "acme/pk", 9_999).is_none());
}
#[test]
fn a_child_opens_a_subset_of_its_parent_and_never_outlives_it() {
let mut tokens = HashMap::new();
let parent = grant_of(&[("w/a", "1"), ("w/b", "2")], 2_000);
tokens.insert("parent".to_string(), parent);
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let mint = |tokens: &HashMap<String, TokenGrant>, from: &str, n: &[&str], ttl, now| {
delegate(tokens, from, &names(n), ttl, now).map(|r| r.unwrap())
};
let child = mint(&tokens, "parent", &["w/a"], 0, 1_000).unwrap();
assert_eq!(child.expires, 2_000);
assert_eq!(child.grant, "root");
assert_eq!(child.values.keys().collect::<Vec<_>>(), ["w/a"]);
assert_eq!(
mint(&tokens, "parent", &["w/a"], 500, 1_000)
.unwrap()
.expires,
1_500
);
assert_eq!(
mint(&tokens, "parent", &["w/a"], 1_000, 1_000)
.unwrap()
.expires,
2_000
);
let twice = mint(&tokens, "parent", &["w/a", "w/a"], 0, 1_000).unwrap();
assert_eq!(twice.values.len(), 1);
assert!(mint(&tokens, "parent", &["w/a", "w/c"], 0, 1_000).is_none());
assert!(mint(&tokens, "parent", &["w/a"], 1_001, 1_000).is_none());
assert!(mint(&tokens, "parent", &["w/a"], u64::MAX, 1_000).is_none());
assert!(mint(&tokens, "parent", &["w/a"], 0, 2_000).is_none());
assert!(mint(&tokens, "guessed", &["w/a"], 0, 1_000).is_none());
assert!(mint(&tokens, "", &["w/a"], 0, 1_000).is_none());
assert!(mint(&tokens, "parent", &[], 0, 1_000).is_none());
tokens.insert("child".to_string(), child);
let grandchild = mint(&tokens, "child", &["w/a"], 0, 1_500).unwrap();
assert_eq!(grandchild.expires, 2_000);
assert!(mint(&tokens, "child", &["w/a"], 600, 1_500).is_none());
assert!(mint(&tokens, "child", &["w/b"], 0, 1_500).is_none());
}
#[test]
fn a_grant_mints_at_most_its_cap_of_live_children() {
let mut tokens = HashMap::new();
tokens.insert("root".to_string(), grant_of(&[("w/a", "1")], 2_000));
let names = vec!["w/a".to_string()];
for i in 0..MAX_CHILDREN {
let child = delegate(&tokens, "root", &names, 0, 1_000)
.unwrap()
.unwrap();
tokens.insert(format!("c{i}"), child);
}
let err = delegate(&tokens, "c0", &names, 0, 1_000)
.unwrap()
.err()
.unwrap();
assert!(err.to_string().contains("64 live child tokens"), "{err}");
assert!(
delegate(&tokens, "root", &names, 0, 1_000)
.unwrap()
.is_err()
);
tokens.get_mut("c1").unwrap().expires = 1_000;
assert!(delegate(&tokens, "root", &names, 0, 1_000).unwrap().is_ok());
let mut other = grant_of(&[("w/a", "1")], 2_000);
other.grant = "other".into();
tokens.insert("other".to_string(), other);
assert!(
delegate(&tokens, "other", &names, 0, 1_000)
.unwrap()
.is_ok()
);
}
#[test]
fn a_grant_is_capped_at_a_day() {
let asked: u64 = 7 * 86_400;
assert_eq!(asked.min(MAX_GRANT_SECS), MAX_GRANT_SECS);
assert_eq!(600u64.min(MAX_GRANT_SECS), 600);
}
}