pub use rich_embed::{Command, ExitStatus, LocalPty, PtyHost};
use std::io::{self, Write};
use std::net::{
IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use intuituive::App;
use tungstenite::WebSocket;
use tungstenite::protocol::{Role, WebSocketConfig};
use crate::http::{self, Head};
use crate::{isolate, pty, session};
pub use crate::isolate::{Isolation, SESSION_ENV};
pub const XTERM_VERSION: &str = "6.0.0";
pub const DEFAULT_MAX_SESSIONS: usize = 8;
pub const DEFAULT_MAX_BUFFERED: usize = 1 << 20;
const MAX_PENDING: usize = 64;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const MAX_MESSAGE: usize = 1 << 20;
const PAGE: &str = include_str!("../assets/index.html");
const PAGE_JS: &str = include_str!("../assets/app.js");
const DOM_PAGE: &str = include_str!("../assets/dom.html");
const DOM_JS: &str = include_str!("../assets/dom.js");
const XTERM_JS: &str = include_str!("../assets/xterm/xterm.js");
const XTERM_CSS: &str = include_str!("../assets/xterm/xterm.css");
const FIT_JS: &str = include_str!("../assets/xterm/addon-fit.js");
pub fn serve<A, F>(addr: A, app: F) -> io::Result<()>
where
A: ToSocketAddrs,
F: Fn() -> App + Send + Sync + 'static,
{
Server::bind(addr, app)?.run()
}
pub fn serve_command<A>(addr: A, command: impl Into<Command>) -> io::Result<()>
where
A: ToSocketAddrs,
{
Server::bind_command(addr, command)?.run()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Renderer {
#[default]
Xterm,
Dom,
}
impl Renderer {
pub fn name(self) -> &'static str {
match self {
Renderer::Xterm => "xterm",
Renderer::Dom => "dom",
}
}
pub fn parse(name: &str) -> Option<Renderer> {
match name {
"xterm" => Some(Renderer::Xterm),
"dom" => Some(Renderer::Dom),
_ => None,
}
}
}
enum Mode {
App(Box<dyn Fn() -> App + Send + Sync>),
Command(Command),
Host(Box<dyn Fn() -> Box<dyn PtyHost> + Send + Sync>),
}
pub struct Server {
listener: TcpListener,
shared: Shared,
}
struct Shared {
mode: Mode,
token: String,
max_sessions: usize,
max_buffered: usize,
renderer: Renderer,
compress: Option<usize>,
origins: Vec<String>,
title: String,
hosts: Vec<String>,
sessions: AtomicUsize,
pending: AtomicUsize,
programs: AtomicUsize,
stopping: AtomicBool,
isolation: Isolation,
spawn: isolate::Spawn,
bind: usize,
}
impl Server {
pub fn bind<A, F>(addr: A, app: F) -> io::Result<Server>
where
A: ToSocketAddrs,
F: Fn() -> App + Send + Sync + 'static,
{
let bind = isolate::next_bind();
if let Some(spec) = isolate::session_for(bind) {
std::process::exit(isolate::run_child(spec, app));
}
let mode = Mode::App(Box::new(app));
let mut server = if isolate::is_session_process() {
Server::with_mode(("127.0.0.1", 0), mode, "intuiTUIve".into())?
} else {
Server::with_mode(addr, mode, "intuiTUIve".into())?
};
server.shared.bind = bind;
Ok(server)
}
pub fn bind_command<A>(addr: A, command: impl Into<Command>) -> io::Result<Server>
where
A: ToSocketAddrs,
{
let command = command.into();
let title = command.program().to_string_lossy().into_owned();
Server::with_mode(addr, Mode::Command(command), title)
}
pub fn bind_host<A, F>(addr: A, host: F) -> io::Result<Server>
where
A: ToSocketAddrs,
F: Fn() -> Box<dyn PtyHost> + Send + Sync + 'static,
{
Server::with_mode(addr, Mode::Host(Box::new(host)), "terminal".into())
}
fn with_mode<A: ToSocketAddrs>(addr: A, mode: Mode, title: String) -> io::Result<Server> {
let listener = TcpListener::bind(addr)?;
let hosts = http::allowed_hosts(listener.local_addr()?);
Ok(Server {
listener,
shared: Shared {
mode,
token: random_token()?,
max_sessions: DEFAULT_MAX_SESSIONS,
max_buffered: DEFAULT_MAX_BUFFERED,
renderer: Renderer::Xterm,
compress: None,
origins: Vec::new(),
title,
hosts,
sessions: AtomicUsize::new(0),
pending: AtomicUsize::new(0),
programs: AtomicUsize::new(0),
stopping: AtomicBool::new(false),
isolation: Isolation::default(),
spawn: isolate::Spawn::default(),
bind: 0,
},
})
}
#[must_use]
pub fn isolation(mut self, isolation: Isolation) -> Server {
self.shared.isolation = isolation;
self
}
#[must_use]
pub fn session_command<P, I, S>(mut self, program: P, args: I) -> Server
where
P: Into<std::path::PathBuf>,
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString>,
{
let args = args.into_iter().map(Into::into).collect();
self.shared.spawn.command = Some((program.into(), args));
self
}
#[must_use]
pub fn session_memory(mut self, bytes: u64) -> Server {
self.shared.spawn.memory = Some(bytes);
self
}
#[must_use]
pub fn session_cpu(mut self, time: Duration) -> Server {
self.shared.spawn.cpu = Some(time);
self
}
#[must_use]
pub fn max_sessions(mut self, n: usize) -> Server {
self.shared.max_sessions = n;
self
}
#[must_use]
pub fn max_buffered(mut self, bytes: usize) -> Server {
self.shared.max_buffered = bytes.max(2);
self
}
#[must_use]
pub fn compress(mut self, from: Option<usize>) -> Server {
self.shared.compress = from;
self
}
#[must_use]
pub fn renderer(mut self, renderer: Renderer) -> Server {
self.shared.renderer = renderer;
self
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Server {
let token = token.into();
assert!(
!token.is_empty()
&& token
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.~".contains(&b)),
"a token is ASCII letters, digits, '-', '_', '.' and '~'"
);
self.shared.token = token;
self
}
#[must_use]
pub fn allow_host(mut self, host: impl AsRef<str>) -> Server {
let port = self.local_addr().port();
self.shared
.hosts
.extend(http::host_entries(host.as_ref(), port));
self
}
#[must_use]
pub fn allow_origin(mut self, origin: impl Into<String>) -> Server {
self.shared.origins.push(origin.into());
self
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Server {
self.shared.title = title.into();
self
}
pub fn local_addr(&self) -> SocketAddr {
self.listener
.local_addr()
.expect("a bound listener has an address")
}
pub fn access_token(&self) -> &str {
&self.shared.token
}
pub fn url(&self) -> String {
url_for(self.local_addr(), &self.shared.token)
}
pub fn run(self) -> io::Result<()> {
self.announce()?;
accept(self.listener, Arc::new(self.shared));
Ok(())
}
pub fn run_until(self, until: impl FnOnce()) -> io::Result<()> {
self.announce()?;
let handle = self.spawn()?;
until();
handle.stop();
Ok(())
}
fn announce(&self) -> io::Result<()> {
let local = self.local_addr();
let mut out = io::stdout().lock();
writeln!(out, "Serving {} at {}", self.shared.title, self.url())?;
out.flush()?;
drop(out);
if !local.ip().is_loopback() {
let what = match self.shared.mode {
Mode::App(_) => "the app",
Mode::Command(_) | Mode::Host(_) => "the program",
};
eprintln!(
"warning: listening on {local}, beyond this computer. Anyone who can reach it \
with the token can use {what}: there is no other authentication and no TLS. \
Put it behind a reverse proxy that has both."
);
}
Ok(())
}
pub fn spawn(self) -> io::Result<Handle> {
let local = self.local_addr();
let url = self.url();
let shared = Arc::new(self.shared);
let listener = self.listener;
let thread = {
let shared = shared.clone();
thread::Builder::new()
.name("rich-web accept".into())
.spawn(move || accept(listener, shared))?
};
Ok(Handle {
local,
url,
shared,
thread: Some(thread),
})
}
}
pub struct Handle {
local: SocketAddr,
url: String,
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
impl Handle {
pub fn local_addr(&self) -> SocketAddr {
self.local
}
pub fn url(&self) -> &str {
&self.url
}
pub fn sessions(&self) -> usize {
self.shared.sessions.load(Ordering::SeqCst)
}
pub fn stop(mut self) {
self.shutdown();
}
fn shutdown(&mut self) {
self.shared.stopping.store(true, Ordering::SeqCst);
if let Some(accepting) = self.thread.take() {
let _ = TcpStream::connect_timeout(&reachable(self.local), Duration::from_secs(1));
let _ = accepting.join();
while self.shared.programs.load(Ordering::SeqCst) > 0 {
thread::sleep(Duration::from_millis(10));
}
}
}
}
impl Drop for Handle {
fn drop(&mut self) {
self.shutdown();
}
}
fn reachable(local: SocketAddr) -> SocketAddr {
let ip = match local.ip() {
IpAddr::V4(v4) if v4.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(v6) if v6.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
ip => ip,
};
SocketAddr::new(ip, local.port())
}
fn url_for(local: SocketAddr, token: &str) -> String {
format!("http://{}/?token={token}", reachable(local))
}
pub(crate) fn random_token() -> io::Result<String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|e| io::Error::other(format!("no random token: {e}")))?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
fn accept(listener: TcpListener, shared: Arc<Shared>) {
for stream in listener.incoming() {
if shared.stopping.load(Ordering::SeqCst) {
break;
}
let Ok(stream) = stream else {
thread::sleep(Duration::from_millis(10));
continue;
};
if shared.pending.fetch_add(1, Ordering::SeqCst) >= MAX_PENDING {
shared.pending.fetch_sub(1, Ordering::SeqCst);
busy(stream);
continue;
}
let pending = Pending(shared.clone());
let spawned = thread::Builder::new()
.name("rich-web connection".into())
.spawn(move || connection(stream, pending));
if spawned.is_err() {
continue;
}
}
}
fn busy(mut stream: TcpStream) {
if stream.set_nonblocking(true).is_err() {
return;
}
let _ = io::Read::read(&mut stream, &mut [0u8; 4096]);
let _ = http::refuse(
&mut stream,
"503 Service Unavailable",
"Too many connections at once: try again in a moment.",
);
let _ = stream.shutdown(Shutdown::Write);
}
struct Pending(Arc<Shared>);
impl Drop for Pending {
fn drop(&mut self) {
self.0.pending.fetch_sub(1, Ordering::SeqCst);
}
}
struct Slot(Arc<Shared>);
impl Slot {
fn acquire(shared: &Arc<Shared>) -> Option<Slot> {
let mut n = shared.sessions.load(Ordering::SeqCst);
loop {
if n >= shared.max_sessions {
return None;
}
match shared
.sessions
.compare_exchange(n, n + 1, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) => return Some(Slot(shared.clone())),
Err(now) => n = now,
}
}
}
}
impl Drop for Slot {
fn drop(&mut self) {
self.0.sessions.fetch_sub(1, Ordering::SeqCst);
}
}
fn connection(mut stream: TcpStream, pending: Pending) {
let _ = stream.set_nodelay(true);
if stream.set_write_timeout(Some(WRITE_TIMEOUT)).is_err() {
return;
}
let until = Instant::now() + HANDSHAKE_TIMEOUT;
let Ok(bytes) = http::read_head(&mut http::Deadline::new(&stream, until)) else {
return;
};
if pending.0.stopping.load(Ordering::SeqCst) {
return;
}
let Some(head) = Head::parse(&bytes) else {
let _ = http::refuse(&mut stream, "400 Bad Request", "Bad request");
return;
};
let shared = pending.0.clone();
if head.method != "GET" {
let _ = http::refuse(&mut stream, "405 Method Not Allowed", "Only GET");
return;
}
let js = "text/javascript; charset=utf-8";
let asset = match head.path.as_str() {
"/ws" => {
websocket(stream, &head, &shared, pending);
return;
}
"/" | "/index.html" => {
page(&mut stream, &head, &shared);
return;
}
"/app.js" => (js, PAGE_JS),
"/dom.js" => (js, DOM_JS),
"/xterm.js" => (js, XTERM_JS),
"/addon-fit.js" => (js, FIT_JS),
"/xterm.css" => ("text/css; charset=utf-8", XTERM_CSS),
_ => {
let _ = http::refuse(&mut stream, "404 Not Found", "Not found");
return;
}
};
let _ = http::respond(&mut stream, "200 OK", asset.0, &[], asset.1.as_bytes());
}
fn renderer_for(head: &Head, shared: &Shared) -> Renderer {
match shared.mode {
Mode::App(_) => head
.param("renderer")
.and_then(Renderer::parse)
.unwrap_or(shared.renderer),
Mode::Command(_) | Mode::Host(_) => Renderer::Xterm,
}
}
fn page(stream: &mut TcpStream, head: &Head, shared: &Shared) {
if !head
.param("token")
.is_some_and(|t| http::same_token(t, &shared.token))
{
let _ = http::refuse(
stream,
"403 Forbidden",
"Open the address the server printed when it started, with its token.",
);
return;
}
let mut connect = "'self'".to_string();
if let Some(host) = head.header("host").filter(|h| {
h.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-:[]".contains(&b))
}) {
connect.push_str(&format!(" ws://{host} wss://{host}"));
}
let policy = format!(
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; font-src 'self' data:; connect-src {connect}; \
frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
);
let template = match renderer_for(head, shared) {
Renderer::Xterm => PAGE,
Renderer::Dom => DOM_PAGE,
};
let mode = match shared.mode {
Mode::App(_) => "app",
Mode::Command(_) | Mode::Host(_) => "program",
};
let body = template
.replace("{{title}}", &http::escape_html(&shared.title))
.replace("{{mode}}", mode);
let _ = http::respond(
stream,
"200 OK",
"text/html; charset=utf-8",
&[
("Content-Security-Policy", policy),
("X-Frame-Options", "DENY".to_string()),
],
body.as_bytes(),
);
}
fn check_upgrade(
head: &Head,
shared: &Shared,
local: Option<SocketAddr>,
) -> Option<(&'static str, &'static str)> {
if http::websocket_key(head).is_none() {
return Some(("400 Bad Request", "Expected a WebSocket upgrade."));
}
if !head
.param("token")
.is_some_and(|t| http::same_token(t, &shared.token))
{
return Some(("403 Forbidden", "Wrong or missing token."));
}
let mut hosts = shared.hosts.clone();
hosts.extend(local.map(http::connection_hosts).unwrap_or_default());
if !http::origin_allowed(
head.header("origin"),
head.header("host"),
&hosts,
&shared.origins,
) {
return Some(("403 Forbidden", "This origin may not open a session."));
}
None
}
fn websocket(mut stream: TcpStream, head: &Head, shared: &Arc<Shared>, pending: Pending) {
if let Some((status, why)) = check_upgrade(head, shared, stream.local_addr().ok()) {
let _ = http::refuse(&mut stream, status, why);
return;
}
let Some(_slot) = Slot::acquire(shared) else {
let _ = http::refuse(
&mut stream,
"503 Service Unavailable",
"Too many sessions: try again when one has ended.",
);
return;
};
let key = http::websocket_key(head).expect("checked");
let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
let answer = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Accept: {accept}\r\n\r\n"
);
if stream.write_all(answer.as_bytes()).is_err() || stream.flush().is_err() {
return;
}
drop(pending);
let size = |name, default| {
head.param(name)
.and_then(|n| n.parse::<u16>().ok())
.filter(|n| (1..=session::MAX_CELLS).contains(n))
.unwrap_or(default)
};
let (columns, rows) = (size("cols", 80), size("rows", 24));
let z = session::Compress::negotiate(shared.compress, head.param("z") == Some("1"));
if let (Mode::App(_), Isolation::Processes) = (&shared.mode, shared.isolation) {
let session = isolate::Session {
spawn: &shared.spawn,
bind: shared.bind,
columns,
rows,
renderer: renderer_for(head, shared),
compress: z.0,
stopping: &shared.stopping,
running: &shared.programs,
};
isolate::run_parent(stream, session);
return;
}
let config = WebSocketConfig::default()
.max_message_size(Some(MAX_MESSAGE))
.max_frame_size(Some(MAX_MESSAGE));
let mut socket = WebSocket::from_raw_socket(stream, Role::Server, Some(config));
let (stopping, programs) = (&shared.stopping, &shared.programs);
let _ = match &shared.mode {
Mode::App(app) => {
let renderer = renderer_for(head, shared);
session::run(&mut socket, app(), columns, rows, stopping, renderer, z)
}
Mode::Command(command) => {
let (window, held) = pty::split(shared.max_buffered);
let host = LocalPty::new(command.clone()).backpressure(held);
let host = Box::new(host);
pty::run(
&mut socket,
host,
(columns, rows),
stopping,
programs,
window,
z,
)
}
Mode::Host(host) => {
let (window, _) = pty::split(shared.max_buffered);
let host = host();
pty::run(
&mut socket,
host,
(columns, rows),
stopping,
programs,
window,
z,
)
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_are_random_hex() {
let a = random_token().unwrap();
let b = random_token().unwrap();
assert_eq!(a.len(), 32);
assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b);
}
#[test]
fn urls_point_somewhere_reachable() {
let token = "t0k";
assert_eq!(
url_for("127.0.0.1:8080".parse().unwrap(), token),
"http://127.0.0.1:8080/?token=t0k"
);
assert_eq!(
url_for("0.0.0.0:80".parse().unwrap(), token),
"http://127.0.0.1:80/?token=t0k"
);
assert_eq!(
url_for("[::]:80".parse().unwrap(), token),
"http://[::1]:80/?token=t0k"
);
}
#[test]
#[should_panic(expected = "a token is")]
fn tokens_must_be_url_safe() {
let _ = Server::bind("127.0.0.1:0", || App::new(|| intuituive::label("x")))
.unwrap()
.token("has space");
}
#[test]
fn renderers_have_names() {
for renderer in [Renderer::Xterm, Renderer::Dom] {
assert_eq!(Renderer::parse(renderer.name()), Some(renderer));
}
assert_eq!(Renderer::parse("canvas"), None);
assert_eq!(Renderer::default(), Renderer::Xterm);
}
#[test]
fn connections_past_the_pending_cap_are_answered() {
use std::io::Read;
let handle = Server::bind("127.0.0.1:0", || App::new(|| intuituive::label("x")))
.unwrap()
.spawn()
.unwrap();
let addr = handle.local_addr();
let idle: Vec<TcpStream> = (0..MAX_PENDING)
.map(|_| TcpStream::connect(addr).unwrap())
.collect();
let start = Instant::now();
while handle.shared.pending.load(Ordering::SeqCst) < MAX_PENDING {
assert!(
start.elapsed() < Duration::from_secs(5),
"the idle connections"
);
thread::sleep(Duration::from_millis(10));
}
let mut late = TcpStream::connect(addr).unwrap();
late.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut answer = String::new();
let _ = late.read_to_string(&mut answer);
assert!(answer.starts_with("HTTP/1.1 503 "), "{answer:?}");
drop(idle);
}
#[test]
fn a_command_is_titled_by_its_program() {
let server = Server::bind_command("127.0.0.1:0", ["htop", "-d", "10"]).unwrap();
assert_eq!(server.shared.title, "htop");
assert_eq!(server.shared.max_buffered, DEFAULT_MAX_BUFFERED);
}
}