use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
#[cfg(feature = "cli")]
use clap::Args as ClapArgs;
use iroh::EndpointId;
use tokio::signal::unix::{signal, SignalKind};
use tokio_util::sync::CancellationToken;
use crate::server::audit::{auth_event, Outcome};
use crate::server::session::{ClientId, HostProvider, PtyHosts, SessionHost};
use crate::server::{run_attached, session, SessionExit};
use crate::transport_iroh::{
bind_endpoint_alpns, bind_endpoint_local_alpns, bind_endpoint_with_relay_alpns,
format_endpoint_id, load_or_create_secret_key, parse_endpoint_id, parse_relay_url,
TERMINAL_ALPN,
};
use tracing::{error, info, warn};
const ACCEPT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct ServeConfig {
pub key_file: Option<PathBuf>,
pub allow: Vec<String>,
pub command: Vec<String>,
pub scrollback: u64,
pub session_ttl_secs: u64,
pub relay_url: Option<String>,
pub local: bool,
pub max_connections: u32,
pub max_sessions: u32,
}
pub const DEFAULT_SCROLLBACK: u64 = 1000;
pub const DEFAULT_SESSION_TTL_SECS: u64 = 86_400;
pub const DEFAULT_MAX_CONNECTIONS: u32 = 64;
pub const DEFAULT_MAX_SESSIONS: u32 = 64;
pub const MAX_SCROLLBACK: u64 = 1_000_000;
impl Default for ServeConfig {
fn default() -> Self {
Self {
key_file: None,
allow: Vec::new(),
command: Vec::new(),
scrollback: DEFAULT_SCROLLBACK,
session_ttl_secs: DEFAULT_SESSION_TTL_SECS,
relay_url: None,
local: false,
max_connections: DEFAULT_MAX_CONNECTIONS,
max_sessions: DEFAULT_MAX_SESSIONS,
}
}
}
#[cfg(feature = "cli")]
#[derive(ClapArgs, Debug)]
pub struct ServeArgs {
#[arg(long)]
key_file: Option<PathBuf>,
#[arg(long = "allow", value_name = "ENDPOINT_ID")]
allow: Vec<String>,
#[arg(long, value_name = "PROGRAM_OR_ARG")]
shell: Vec<String>,
#[arg(long, default_value_t = DEFAULT_SCROLLBACK, value_parser = clap::value_parser!(u64).range(0..=MAX_SCROLLBACK))]
scrollback: u64,
#[arg(long, default_value_t = DEFAULT_SESSION_TTL_SECS)]
session_ttl_secs: u64,
#[arg(long, value_name = "URL")]
relay_url: Option<String>,
#[arg(long, conflicts_with = "relay_url")]
local: bool,
#[arg(long, default_value_t = DEFAULT_MAX_CONNECTIONS, value_parser = clap::value_parser!(u32).range(1..))]
max_connections: u32,
#[arg(long, default_value_t = DEFAULT_MAX_SESSIONS, value_parser = clap::value_parser!(u32).range(1..))]
max_sessions: u32,
}
#[cfg(feature = "cli")]
impl From<ServeArgs> for ServeConfig {
fn from(a: ServeArgs) -> Self {
Self {
key_file: a.key_file,
allow: a.allow,
command: a.shell,
scrollback: a.scrollback,
session_ttl_secs: a.session_ttl_secs,
relay_url: a.relay_url,
local: a.local,
max_connections: a.max_connections,
max_sessions: a.max_sessions,
}
}
}
fn connect_qr(data: &str) -> Option<String> {
use qrcode::render::unicode::Dense1x2;
let code = qrcode::QrCode::new(data).ok()?;
Some(
code.render::<Dense1x2>()
.dark_color(Dense1x2::Light)
.light_color(Dense1x2::Dark)
.quiet_zone(true)
.build(),
)
}
pub async fn serve(config: impl Into<ServeConfig>) -> anyhow::Result<()> {
let args: ServeConfig = config.into();
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "koh=info".into()),
)
.with_writer(std::io::stderr)
.try_init();
anyhow::ensure!(
args.scrollback <= MAX_SCROLLBACK,
"scrollback {} exceeds the maximum of {MAX_SCROLLBACK}",
args.scrollback
);
anyhow::ensure!(args.max_sessions >= 1, "max_sessions must be at least 1");
let provider = PtyHosts::new(
args.command.clone(),
args.scrollback as usize,
args.max_sessions as usize,
);
serve_with(args, Hosts::new().with(TERMINAL_ALPN, provider)).await
}
trait ErasedProvider: Send + Sync {
fn serve_conn(
&self,
conn: iroh::endpoint::Connection,
peer: EndpointId,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>>;
fn spawn_reaper(
&self,
ttl: Duration,
interval: Duration,
shutdown: CancellationToken,
) -> tokio::task::JoinHandle<()>;
}
struct Typed<H, P> {
provider: Arc<P>,
_host: std::marker::PhantomData<fn() -> H>,
}
impl<H: SessionHost, P: HostProvider<H>> ErasedProvider for Typed<H, P> {
fn serve_conn(
&self,
conn: iroh::endpoint::Connection,
peer: EndpointId,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
Box::pin(async move {
let (handle, attach_kind) = match self.provider.attach(peer).await {
Ok(Some(pair)) => pair,
Ok(None) => {
warn!(peer = %format_endpoint_id(&peer), "refusing session: at max-sessions capacity");
conn.close(1u32.into(), b"server at session capacity");
return;
}
Err(e) => {
error!(error = %e, "failed to start session");
conn.close(1u32.into(), b"session error");
return;
}
};
match attach_kind {
session::AttachKind::Created => {
info!(peer = %format_endpoint_id(&peer), "started a new session");
}
session::AttachKind::Reattached { detached_for } => {
info!(
peer = %format_endpoint_id(&peer),
detached_secs = detached_for.map(|d| d.as_secs()),
"reattaching to this peer's existing session"
);
}
session::AttachKind::Joined { viewers } => {
info!(
peer = %format_endpoint_id(&peer),
viewers,
"joined the shared session"
);
}
}
let client = ClientId::next();
let attach_guard = session::AttachGuard::new(self.provider.clone(), peer);
let outcome = run_attached(conn, handle.clone(), client).await;
attach_guard.disarm();
handle.session.lock().await.host.client_detached(client);
match outcome {
Ok(SessionExit::Detached) => {
self.provider.detach(peer).await;
info!(peer = %format_endpoint_id(&peer), "client detached (session retained)");
}
Ok(SessionExit::ShellExited) => {
self.provider.reap(peer).await;
info!(peer = %format_endpoint_id(&peer), "shell exited; session reaped");
}
Err(e) => {
error!(error = %e, "session loop error");
self.provider.detach(peer).await;
}
}
})
}
fn spawn_reaper(
&self,
ttl: Duration,
interval: Duration,
shutdown: CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(session::run_reaper(
self.provider.store(),
ttl,
interval,
shutdown,
))
}
}
#[derive(Default)]
pub struct Hosts {
entries: Vec<(Vec<u8>, Arc<dyn ErasedProvider>)>,
}
impl Hosts {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with<H: SessionHost, P: HostProvider<H>>(mut self, alpn: &[u8], provider: P) -> Self {
self.entries.push((
alpn.to_vec(),
Arc::new(Typed::<H, P> {
provider: Arc::new(provider),
_host: std::marker::PhantomData,
}),
));
self
}
pub fn alpns(&self) -> Vec<Vec<u8>> {
self.entries.iter().map(|(a, _)| a.clone()).collect()
}
fn for_alpn(&self, alpn: &[u8]) -> Option<Arc<dyn ErasedProvider>> {
self.entries
.iter()
.find(|(a, _)| a == alpn)
.map(|(_, p)| p.clone())
}
pub async fn serve_connection(&self, conn: iroh::endpoint::Connection) {
let peer = conn.remote_id();
let Some(provider) = self.for_alpn(conn.alpn()) else {
error!(alpn = %String::from_utf8_lossy(conn.alpn()), "no host provider for negotiated alpn");
conn.close(1u32.into(), b"no host for alpn");
return;
};
match tokio::time::timeout(
Duration::from_secs(3),
crate::transport_iroh::admission::admit(&conn),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(e)) => {
warn!(error = %e, "admission ack failed");
return;
}
Err(_) => {
warn!("admission ack timed out");
return;
}
}
auth_event(Outcome::Accepted, &peer, "authorized; attaching session");
provider.serve_conn(conn, peer).await;
}
}
pub async fn serve_with(config: impl Into<ServeConfig>, hosts: Hosts) -> anyhow::Result<()> {
let args: ServeConfig = config.into();
anyhow::ensure!(
!hosts.entries.is_empty(),
"serve_with needs at least one host"
);
let mut allow: HashSet<EndpointId> = HashSet::new();
for s in &args.allow {
let id = parse_endpoint_id(s).with_context(|| format!("bad --allow id: {s}"))?;
allow.insert(id);
}
if allow.is_empty() {
anyhow::bail!(
"no clients authorized: pass --allow <endpoint-id> (repeatable; get one from `koh id`)"
);
}
anyhow::ensure!(
args.max_connections >= 1,
"max_connections must be at least 1"
);
let key_file = match args.key_file.clone() {
Some(p) => p,
None => crate::transport_iroh::default_key_path("server")?,
};
let secret = load_or_create_secret_key(&key_file).with_context(|| {
format!(
"loading server key from {} (pass --key-file to use a writable path)",
key_file.display()
)
})?;
let alpns = hosts.alpns();
let endpoint = if let Some(url) = &args.relay_url {
let relay = parse_relay_url(url)?;
bind_endpoint_with_relay_alpns(secret, alpns, relay)
.await
.context("binding endpoint")?
} else if args.local {
bind_endpoint_local_alpns(secret, alpns)
.await
.context("binding endpoint")?
} else {
bind_endpoint_alpns(secret, alpns)
.await
.context("binding endpoint")?
};
let my_id = endpoint.id();
let id_str = format_endpoint_id(&my_id);
let connect_hint = if let Some(url) = &args.relay_url {
format!("koh connect {id_str} --relay-url {url}")
} else if args.local {
let port = endpoint
.bound_sockets()
.iter()
.find(|s| s.is_ipv4())
.map_or(0, std::net::SocketAddr::port);
format!("koh connect {id_str} --direct <this-host-ip>:{port}")
} else {
format!("koh connect {id_str}")
};
eprintln!("┌─ koh server ready ──────────────────────────────────────");
eprintln!("│ endpoint id : {id_str}");
eprintln!("│ key file : {}", key_file.display());
eprintln!(
"│ alpn : {}",
hosts
.entries
.iter()
.map(|(a, _)| String::from_utf8_lossy(a).into_owned())
.collect::<Vec<_>>()
.join(", ")
);
eprintln!("│ auth : allowlist ({} client(s))", allow.len());
eprintln!("│ connect : {connect_hint}");
eprintln!("└───────────────────────────────────────────────────────────");
if let Some(qr) = connect_qr(&id_str) {
eprintln!(
"\nScan for the endpoint id (point a phone camera at it). Assumes a dark-background \
terminal;\non a light background it renders inverted — copy the id above instead:\n"
);
eprintln!("{qr}");
} else {
warn!("could not render the connect QR (endpoint id too large to encode)");
}
info!(
transport = "QUIC + TLS 1.3 (iroh)",
kex = "X25519",
post_quantum = false,
"transport crypto posture"
);
let allow = std::sync::Arc::new(allow);
let hosts = Arc::new(hosts);
let session_ttl = Duration::from_secs(args.session_ttl_secs);
let reaper_shutdown = tokio_util::sync::CancellationToken::new();
let reapers: Vec<_> = hosts
.entries
.iter()
.map(|(_, p)| p.spawn_reaper(session_ttl, session::REAP_INTERVAL, reaper_shutdown.clone()))
.collect();
let shutdown = CancellationToken::new();
spawn_signal_drain(shutdown.clone())?;
let conn_limit = Arc::new(tokio::sync::Semaphore::new(args.max_connections as usize));
let pending_cap = (args.max_connections as usize).div_ceil(4).max(4);
let handshake_limit = Arc::new(tokio::sync::Semaphore::new(pending_cap));
loop {
let incoming = tokio::select! {
biased;
() = shutdown.cancelled() => break,
inc = endpoint.accept() => match inc {
Some(i) => i,
None => break, },
};
let Ok(permit) = conn_limit.clone().try_acquire_owned() else {
warn!("refusing connection: at max-connections capacity");
incoming.refuse();
continue;
};
let Ok(pending_permit) = handshake_limit.clone().try_acquire_owned() else {
warn!("refusing connection: too many handshakes in flight");
incoming.refuse();
continue;
};
let allow = allow.clone();
let hosts = hosts.clone();
tokio::spawn(async move {
let _permit = permit;
let pending_permit = pending_permit;
let conn = match tokio::time::timeout(ACCEPT_HANDSHAKE_TIMEOUT, incoming).await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
warn!(error = %e, "incoming handshake failed");
return;
}
Err(_) => {
warn!("incoming handshake timed out (stalled QUIC handshake)");
return;
}
};
let peer = conn.remote_id();
if !allow.contains(&peer) {
auth_event(Outcome::Rejected, &peer, "not on allowlist");
conn.close(1u32.into(), b"not authorized");
return;
}
drop(pending_permit);
hosts.serve_connection(conn).await;
});
}
info!("draining: stopping reaper and closing endpoint");
shutdown.cancel();
reaper_shutdown.cancel();
for r in reapers {
let _ = r.await;
}
endpoint.close().await;
Ok(())
}
fn spawn_signal_drain(shutdown: CancellationToken) -> anyhow::Result<()> {
let mut term = signal(SignalKind::terminate()).context("installing SIGTERM handler")?;
let mut intr = signal(SignalKind::interrupt()).context("installing SIGINT handler")?;
tokio::spawn(async move {
tokio::select! {
_ = term.recv() => {}
_ = intr.recv() => {}
}
info!("received shutdown signal; draining");
shutdown.cancel();
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serve_config_default_matches_the_cli_defaults() {
let c = ServeConfig::default();
assert!(c.allow.is_empty() && c.command.is_empty());
assert_eq!(c.scrollback, 1000);
assert_eq!(c.session_ttl_secs, 86_400);
assert_eq!(c.max_connections, 64);
assert_eq!(c.max_sessions, 64);
assert!(!c.local && c.relay_url.is_none() && c.key_file.is_none());
}
#[cfg(feature = "cli")]
#[test]
fn serve_args_map_shell_to_command_argv_and_keep_defaults() {
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(flatten)]
serve: ServeArgs,
}
let cli = Cli::parse_from([
"koh", "--allow", "abc", "--shell", "zellij", "--shell", "attach",
]);
let c: ServeConfig = cli.serve.into();
assert_eq!(c.command, ["zellij", "attach"]);
assert_eq!(c.allow, ["abc"]);
let d = ServeConfig::default();
assert_eq!(c.scrollback, d.scrollback);
assert_eq!(c.session_ttl_secs, d.session_ttl_secs);
assert_eq!(c.max_connections, d.max_connections);
assert_eq!(c.max_sessions, d.max_sessions);
let cli = Cli::parse_from(["koh", "--allow", "abc", "--shell", "/bin/zsh"]);
assert_eq!(ServeConfig::from(cli.serve).command, ["/bin/zsh"]);
let cli = Cli::parse_from(["koh", "--allow", "abc"]);
assert!(ServeConfig::from(cli.serve).command.is_empty());
}
#[test]
fn connect_qr_renders_an_id_and_handles_overlong_input() {
let id = "3f9c".repeat(16);
let qr = connect_qr(&id).expect("an endpoint id must fit in a QR");
assert!(qr.lines().count() > 5, "a QR should be a multi-row block");
assert!(
qr.contains('█') || qr.contains('▀') || qr.contains('▄'),
"the unicode renderer uses half-block glyphs"
);
assert!(
connect_qr(&"a".repeat(10_000)).is_none(),
"overlong input must return None, not panic"
);
}
}