pub mod access;
pub mod aliases;
pub use isb_core::serve_client as client;
#[cfg(test)]
mod client_tests;
pub mod http;
pub mod mcp;
pub mod openapi;
pub mod service;
pub mod ssh;
pub mod ssh_config;
pub mod tailnet;
pub mod terminal;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
pub use access::{AccessValidator, Identity};
pub use http::Shutdown;
pub use mcp::{Authenticated, Caller, Hooks, Registry, Tool, ToolHandler, ToolPolicy};
use crate::error::{Error, Result};
use http::{Handler, HttpListener, HttpServer, Limits};
use mcp::Endpoint;
pub type Healthz = Arc<dyn Fn() -> (bool, Value) + Send + Sync>;
pub type Routes = Arc<dyn Fn(&http::Request) -> Option<http::Response> + Send + Sync>;
#[derive(Clone)]
pub enum ListenerKind {
Tcp(String),
Unix(PathBuf),
Mtls(String, http::TlsConfig),
}
impl std::fmt::Debug for ListenerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ListenerKind::Tcp(a) => write!(f, "Tcp({a:?})"),
ListenerKind::Unix(p) => write!(f, "Unix({p:?})"),
ListenerKind::Mtls(a, _) => write!(f, "Mtls({a:?})"),
}
}
}
#[derive(Clone)]
pub struct Listener {
pub kind: ListenerKind,
pub access: Option<Arc<AccessValidator>>,
pub policy: ToolPolicy,
pub allow_unauthenticated: bool,
pub tailnet: bool,
pub routes: Option<Routes>,
pub public_routes: Option<Routes>,
pub preview: Option<Routes>,
pub hooks: mcp::Hooks,
}
impl std::fmt::Debug for Listener {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Listener")
.field("kind", &self.kind)
.field("access", &self.access)
.field("policy", &self.policy)
.field("allow_unauthenticated", &self.allow_unauthenticated)
.field("routes", &self.routes.is_some())
.finish()
}
}
impl Listener {
pub fn tcp(addr: impl Into<String>) -> Self {
Self::new(ListenerKind::Tcp(addr.into()))
}
pub fn unix(path: impl Into<PathBuf>) -> Self {
Self::new(ListenerKind::Unix(path.into()))
}
pub fn mtls(addr: impl Into<String>, tls: http::TlsConfig) -> Self {
Self::new(ListenerKind::Mtls(addr.into(), tls))
}
fn new(kind: ListenerKind) -> Self {
Listener {
kind,
access: None,
policy: ToolPolicy::default(),
allow_unauthenticated: false,
tailnet: false,
routes: None,
public_routes: None,
preview: None,
hooks: mcp::Hooks::default(),
}
}
pub fn hooks(mut self, h: mcp::Hooks) -> Self {
self.hooks = h;
self
}
pub fn routes(mut self, r: Routes) -> Self {
self.routes = Some(r);
self
}
pub fn public_routes(mut self, r: Routes) -> Self {
self.public_routes = Some(r);
self
}
pub fn preview(mut self, r: Routes) -> Self {
self.preview = Some(r);
self
}
pub fn access(mut self, v: AccessValidator) -> Self {
self.access = Some(Arc::new(v));
self
}
pub fn access_shared(mut self, v: Arc<AccessValidator>) -> Self {
self.access = Some(v);
self
}
pub fn tailnet(mut self, yes: bool) -> Self {
self.tailnet = yes;
self
}
pub fn policy(mut self, p: ToolPolicy) -> Self {
self.policy = p;
self
}
pub fn allow_unauthenticated(mut self, yes: bool) -> Self {
self.allow_unauthenticated = yes;
self
}
pub fn is_trusted(&self) -> bool {
matches!(self.kind, ListenerKind::Unix(_))
}
fn check(&self) -> Result<()> {
match (&self.kind, &self.access) {
(ListenerKind::Unix(p), Some(_)) => Err(Error::invalid(format!(
"unix socket {}: Cloudflare Access applies to TCP listeners only",
p.display()
))),
(ListenerKind::Mtls(a, _), Some(_)) => Err(Error::invalid(format!(
"{a}: Cloudflare Access does not apply to an mTLS listener"
))),
(ListenerKind::Tcp(a), Some(_)) if self.tailnet => Err(Error::invalid(format!(
"{a}: Cloudflare Access applies to loopback listeners (behind the tunnel), not a tailnet one"
))),
(ListenerKind::Tcp(a), None) if !self.allow_unauthenticated => {
Err(Error::invalid(format!(
"TCP listener {a} needs Cloudflare Access (team domain and audience), \
or an explicit opt-in to serve it unauthenticated"
)))
}
_ => Ok(()),
}
}
fn describe(&self, tools: usize) -> String {
match (&self.kind, &self.access) {
(ListenerKind::Unix(p), _) => {
format!("unix:{} (trusted local, {tools} tools)", p.display())
}
(ListenerKind::Mtls(a, _), _) => {
format!("https://{a} (control plane mTLS only, {tools} tools)")
}
(ListenerKind::Tcp(a), Some(v)) => format!(
"http://{a}/mcp (Cloudflare Access: {}, {tools} tools)",
v.issuer()
),
(ListenerKind::Tcp(a), None) if self.tailnet => format!(
"http://{a}/mcp on the tailnet ({tools} tools): callers sign in with isb API tokens \
or sessions, or are superadmins by tailnet identity"
),
(ListenerKind::Tcp(a), None) if self.hooks.authorize.is_some() => format!(
"http://{a}/mcp ({tools} tools) without Cloudflare Access: callers sign in \
with isb API tokens or sessions"
),
(ListenerKind::Tcp(a), None) => format!(
"http://{a}/mcp ({tools} tools) WITHOUT Cloudflare Access: anything that \
reaches this port can call these tools"
),
}
}
}
pub fn default_socket_path() -> PathBuf {
if let Some(s) = std::env::var_os("ISB_SERVE_SOCKET").filter(|s| !s.is_empty()) {
return PathBuf::from(s);
}
#[cfg(target_os = "macos")]
if let Ok(s) = crate::machine::serve_socket(crate::machine::DEFAULT_NAME) {
return s;
}
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()) {
return PathBuf::from(d).join("isb/serve.sock");
}
std::env::temp_dir()
.join(format!("isb-{}", rustix::process::getuid().as_raw()))
.join("serve.sock")
}
pub fn serve(listeners: Vec<Listener>, registry: Registry, healthz: Healthz) -> Result<()> {
serve_until(listeners, registry, healthz, Shutdown::on_signals()?)
}
pub fn serve_shared(
listeners: Vec<Listener>,
registry: Arc<Registry>,
healthz: Healthz,
) -> Result<()> {
serve_until_shared(listeners, registry, healthz, Shutdown::on_signals()?)
}
pub fn handler(l: &Listener, registry: Arc<Registry>, healthz: Healthz) -> Handler {
let ep = Endpoint {
registry,
policy: l.policy.clone(),
access: l.access.clone(),
healthz,
routes: l.routes.clone(),
public_routes: l.public_routes.clone(),
hooks: l.hooks.clone(),
};
let preview = l.preview.clone();
Arc::new(move |r: &http::Request| {
if let Some(resp) = preview.as_ref().and_then(|p| p(r)) {
return resp;
}
ep.handle(r)
})
}
pub fn spawn_private(
addr: std::net::SocketAddr,
handler: Handler,
stop: Shutdown,
) -> Result<std::thread::JoinHandle<()>> {
let sock = HttpListener::bind_tcp_private(addr)?;
let server = HttpServer::new(Limits::default(), stop);
std::thread::Builder::new()
.name(format!("isb-listen-{addr}"))
.spawn(move || {
if let Err(e) = server.run(sock, handler) {
eprintln!("isb serve: listener {addr}: {e}");
}
server.drain(Duration::from_secs(5));
})
.map_err(|e| Error::Protocol(format!("cannot start a listener thread: {e}")))
}
pub fn serve_until(
listeners: Vec<Listener>,
registry: Registry,
healthz: Healthz,
shutdown: Shutdown,
) -> Result<()> {
serve_until_shared(listeners, Arc::new(registry), healthz, shutdown)
}
pub fn serve_until_shared(
listeners: Vec<Listener>,
registry: Arc<Registry>,
healthz: Healthz,
shutdown: Shutdown,
) -> Result<()> {
if listeners.is_empty() {
return Err(Error::invalid("isb serve needs at least one listener"));
}
let mut bound: Vec<(HttpListener, Handler)> = Vec::new();
for l in &listeners {
l.check()?;
let sock = match &l.kind {
ListenerKind::Tcp(a) if l.tailnet => HttpListener::bind_tcp_tailnet(a)?,
ListenerKind::Tcp(a) => HttpListener::bind_tcp(a)?,
ListenerKind::Unix(p) => HttpListener::bind_unix(p)?,
ListenerKind::Mtls(a, t) => HttpListener::bind_tls(a, t.clone())?,
};
let tools = registry
.tools()
.iter()
.filter(|t| l.policy.allows(&t.name))
.count();
let line = l.describe(tools);
let mtls = matches!(l.kind, ListenerKind::Mtls(..));
if l.access.is_none() && !l.is_trusted() && !mtls && l.hooks.authorize.is_none() {
eprintln!("isb serve: WARNING: {line}");
} else {
eprintln!("isb serve: listening on {line}");
}
bound.push((sock, handler(l, registry.clone(), healthz.clone())));
}
let server = HttpServer::new(Limits::default(), shutdown.clone());
let threads: Vec<_> = bound
.into_iter()
.map(|(sock, h)| {
let (srv, stop) = (server.clone(), shutdown.clone());
std::thread::spawn(move || {
let r = srv.run(sock, h);
stop.trigger();
r
})
})
.collect();
let mut first = Ok(());
for t in threads {
let r = t
.join()
.unwrap_or_else(|_| Err(Error::Protocol("listener thread panicked".into())));
if first.is_ok() {
first = r;
}
}
server.drain(Duration::from_secs(10));
if server.active() > 0 {
eprintln!(
"isb serve: exiting with {} request(s) still running",
server.active()
);
}
first
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn listener_config_is_checked() {
assert!(Listener::tcp("127.0.0.1:0").check().is_err());
assert!(
Listener::tcp("127.0.0.1:0")
.allow_unauthenticated(true)
.check()
.is_ok()
);
let v = || AccessValidator::new("team.cloudflareaccess.com", "aud").unwrap();
assert!(Listener::unix("/x").access(v()).check().is_err());
assert!(Listener::tcp("127.0.0.1:0").access(v()).check().is_ok());
assert!(Listener::unix("/x").is_trusted());
let r = serve_until(
vec![Listener::tcp("0.0.0.0:0").allow_unauthenticated(true)],
Registry::new(),
Arc::new(|| (true, json!({}))),
Shutdown::new(),
);
assert!(r.unwrap_err().to_string().contains("not loopback"));
}
}