// Copyright (c) 2019 Fabian Freyer . // Copyright (c) 2024 Jason Witty . // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // // 1. Redistributions of source code must retain the above copyright notice, // this list of conditions and the following disclaimer. // // 2: Redistributions in binary form must reproduce the above copyright notice, // this list of conditions and the following disclaimer in the documentation // and/or other materials provided with the distribution. // // 3. Neither the name of the copyright holder nor the names of its contributors // may be used to endorse or promote products derived from this software // without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE // ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE // LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. use actix::prelude::*; use actix::{Actor, StreamHandler}; use actix_web::{web, App, HttpRequest, HttpResponse}; use actix_web_actors::ws; use std::io::{Read, Write}; use std::process::Command; use std::time::{Duration, Instant}; use bytes::Bytes; use handlebars::Handlebars; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use serde_json::json; const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds mod event; mod terminado; use event::{ChildDied, TerminadoMessage, IO}; /// Actix WebSocket actor pub struct Websocket { cons: Option>, hb: Instant, command: Option, } impl Actor for Websocket { type Context = ws::WebsocketContext; fn started(&mut self, ctx: &mut Self::Context) { // Start heartbeat self.hb(ctx); let command = self .command .take() .expect("command was None at start of WebSocket."); // Start PTY self.cons = Some(Terminal::new(ctx.address(), command).start()); log::trace!("Started WebSocket"); } fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { log::trace!("Stopping WebSocket"); if let Some(_cons) = self.cons.take() { log::info!("WebSocket disconnecting, Terminal will timeout if idle"); } Running::Stop } fn stopped(&mut self, _ctx: &mut Self::Context) { log::trace!("Stopped WebSocket"); } } impl Handler for Websocket { type Result = (); fn handle(&mut self, msg: IO, ctx: &mut ::Context) { log::trace!("Websocket <- Terminal : {:?}", msg); ctx.binary(msg.0); } } impl Handler for Websocket { type Result = (); fn handle(&mut self, msg: TerminadoMessage, ctx: &mut ::Context) { log::trace!("Websocket <- Terminal : {:?}", msg); match msg { TerminadoMessage::Stdout(_) => { let json = serde_json::to_string(&msg); if let Ok(json) = json { ctx.text(json); } } _ => log::error!( r#"Invalid event::TerminadoMessage to Websocket: only "stdout" supported"# ), } } } impl Websocket { pub fn new(command: Command) -> Self { Self { hb: Instant::now(), cons: None, command: Some(command), } } fn hb(&self, ctx: &mut ::Context) { ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { log::warn!("Client heartbeat timeout, disconnecting."); ctx.stop(); return; } ctx.ping(b""); }); } } impl StreamHandler> for Websocket { fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { let cons: &mut Addr = match self.cons { Some(ref mut c) => c, None => { log::error!("Terminal died, closing websocket."); ctx.stop(); return; } }; let msg = match msg { Ok(msg) => msg, Err(e) => { log::error!("WebSocket protocol error: {}", e); ctx.stop(); return; } }; match msg { ws::Message::Ping(msg) => { self.hb = Instant::now(); ctx.pong(&msg); } ws::Message::Pong(_) => self.hb = Instant::now(), ws::Message::Text(t) => { // Attempt to parse the message as JSON. if let Ok(tmsg) = TerminadoMessage::from_json(t.as_ref()) { cons.do_send(tmsg); } else { // Otherwise, it's just byte data. cons.do_send(IO::from(t.to_string())); } } ws::Message::Binary(b) => cons.do_send(IO::from(b)), ws::Message::Close(_) => ctx.stop(), ws::Message::Nop | ws::Message::Continuation(_) => {} }; } } impl Handler for Websocket { type Result = (); fn handle(&mut self, _msg: ChildDied, ctx: &mut ::Context) { log::trace!("Websocket <- ChildDied"); ctx.close(None); ctx.stop(); } } /// Represents a PTY backend with attached child pub struct Terminal { pty_master: Option>, pty_writer: Option>, child: Option>, ws: Addr, command: Command, last_activity: Instant, idle_timeout: Duration, } impl Terminal { pub fn new(ws: Addr, command: Command) -> Self { Self { pty_master: None, pty_writer: None, child: None, ws, command, last_activity: Instant::now(), idle_timeout: IDLE_TIMEOUT, } } } impl Actor for Terminal { type Context = Context; fn started(&mut self, ctx: &mut Self::Context) { log::info!("Started Terminal"); let pty_system = native_pty_system(); let pty_pair = match pty_system.openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0, }) { Ok(pair) => pair, Err(e) => { log::error!("Unable to open PTY: {:?}", e); ctx.stop(); return; } }; let mut cmd_builder = CommandBuilder::new(self.command.get_program()); for arg in self.command.get_args() { cmd_builder.arg(arg); } for (key, val) in self.command.get_envs() { if let Some(val) = val { cmd_builder.env(key, val); } } let child = match pty_pair.slave.spawn_command(cmd_builder) { Ok(child) => child, Err(e) => { log::error!("Unable to spawn child: {:?}", e); ctx.stop(); return; } }; log::info!("Spawned new child process"); // Get reader and writer let reader = match pty_pair.master.try_clone_reader() { Ok(r) => r, Err(e) => { log::error!("Unable to clone reader: {:?}", e); ctx.stop(); return; } }; let writer = match pty_pair.master.take_writer() { Ok(w) => w, Err(e) => { log::error!("Unable to get writer: {:?}", e); ctx.stop(); return; } }; self.pty_master = Some(pty_pair.master); self.pty_writer = Some(writer); self.child = Some(child); // Spawn blocking thread to read from PTY let ws = self.ws.clone(); std::thread::spawn(move || { let mut reader = reader; let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) => { log::info!("PTY reader reached EOF"); break; } Ok(n) => { let data = Bytes::copy_from_slice(&buf[..n]); ws.do_send(TerminadoMessage::Stdout(IO(data))); } Err(e) => { log::error!("Error reading from PTY: {}", e); break; } } } }); // Start idle timeout checker ctx.run_interval(IDLE_CHECK_INTERVAL, |act, ctx| { let idle_duration = Instant::now().duration_since(act.last_activity); if idle_duration >= act.idle_timeout { log::info!( "Terminal idle timeout reached ({:?} idle), stopping session", idle_duration ); ctx.stop(); } }); } fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { log::info!("Stopping Terminal"); if let Some(mut child) = self.child.take() { let _ = child.kill(); let _ = child.wait(); } // Notify the websocket that the child died. self.ws.do_send(ChildDied()); Running::Stop } fn stopped(&mut self, _ctx: &mut Self::Context) { log::info!("Stopped Terminal"); } } impl Handler for Terminal { type Result = (); fn handle(&mut self, msg: IO, ctx: &mut ::Context) { // Reset idle timer on activity self.last_activity = Instant::now(); let writer = match &mut self.pty_writer { Some(w) => w, None => { log::error!("PTY writer died, stopping Terminal."); ctx.stop(); return; } }; if let Err(e) = writer.write_all(&msg.0) { log::error!("Could not write to PTY: {}", e); ctx.stop(); return; } log::trace!("Websocket -> Terminal : {:?}", msg); } } impl Handler for Terminal { type Result = (); fn handle(&mut self, msg: TerminadoMessage, ctx: &mut ::Context) { log::trace!("Websocket -> Terminal : {:?}", msg); match msg { TerminadoMessage::Stdin(io) => { // Reset idle timer on user input self.last_activity = Instant::now(); let writer = match &mut self.pty_writer { Some(w) => w, None => { log::error!("PTY writer died, stopping Terminal."); ctx.stop(); return; } }; if let Err(e) = writer.write_all(&io.0) { log::error!("Could not write to PTY: {}", e); ctx.stop(); } } TerminadoMessage::Resize { rows, cols } => { // Reset idle timer on resize (user interaction) self.last_activity = Instant::now(); // Ignore zero-sized resizes if rows == 0 || cols == 0 { log::trace!( "Ignoring zero-sized resize: cols = {}, rows = {}", cols, rows ); return; } log::info!("Resize: cols = {}, rows = {}", cols, rows); let pty = match &mut self.pty_master { Some(p) => p, None => { log::error!("PTY died, stopping Terminal."); ctx.stop(); return; } }; if let Err(e) = pty.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) { log::error!("Resize failed: {}", e); ctx.stop(); } } TerminadoMessage::Stdout(_) => { log::error!("Invalid Terminado Message: Stdout cannot go to PTY") } }; } } /// Trait to extend an [actix_web::App] by serving a web terminal. pub trait WebTermExt { /// Serve the websocket for the webterm fn webterm_socket(self, endpoint: &str, handler: F) -> Self where F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static; fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self; } impl WebTermExt for App where T: actix_web::dev::ServiceFactory< actix_web::dev::ServiceRequest, Config = (), Error = actix_web::Error, InitError = (), >, { fn webterm_socket(self, endpoint: &str, handler: F) -> Self where F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static, { self.route( endpoint, web::get().to(move |req: HttpRequest, stream: web::Payload| { let cmd = handler(&req); async move { ws::start(Websocket::new(cmd), &req, stream) } }), ) } fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self { let mut handlebars = Handlebars::new(); handlebars .register_template_file("term", "./templates/term.html") .unwrap(); let handlebars_ref = web::Data::new(handlebars); let static_path = static_path.to_owned(); let webterm_socket_endpoint = webterm_socket_endpoint.to_owned(); self.app_data(handlebars_ref.clone()).route( endpoint, web::get().to(move |hb: web::Data>| { let websocket_path = webterm_socket_endpoint.clone(); let static_path_clone = static_path.clone(); async move { let data = json!({ "websocket_path": websocket_path, "static_path": static_path_clone, }); let body = hb.render("term", &data).unwrap(); HttpResponse::Ok().body(body) } }), ) } }