Initial commit: Socktop WebTerm with k3s deployment
- Multi-architecture Docker image (ARM64 + AMD64) - Kubernetes manifests for 3-replica deployment - Traefik ingress configuration - NGINX Proxy Manager integration - ConfigMap-based configuration - Automated build and deployment scripts - Session monitoring tools
This commit is contained in:
+55
-1
@@ -48,6 +48,8 @@ use handlebars::Handlebars;
|
||||
|
||||
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;
|
||||
@@ -80,6 +82,14 @@ impl Actor for Websocket {
|
||||
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
|
||||
trace!("Stopping WebSocket");
|
||||
|
||||
// When the WebSocket disconnects, the Terminal's idle timeout will
|
||||
// automatically clean up the PTY session after IDLE_TIMEOUT (5 minutes).
|
||||
// This prevents "grey goo" accumulation of orphaned terminal processes
|
||||
// while giving reconnecting clients a grace period.
|
||||
if let Some(_cons) = self.cons.take() {
|
||||
info!("WebSocket disconnecting, Terminal will timeout if idle");
|
||||
}
|
||||
|
||||
Running::Stop
|
||||
}
|
||||
|
||||
@@ -186,6 +196,8 @@ pub struct Terminal {
|
||||
child: Option<Child>,
|
||||
ws: Addr<Websocket>,
|
||||
command: Command,
|
||||
last_activity: Instant,
|
||||
idle_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
@@ -195,6 +207,8 @@ impl Terminal {
|
||||
child: None,
|
||||
ws,
|
||||
command,
|
||||
last_activity: Instant::now(),
|
||||
idle_timeout: IDLE_TIMEOUT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,12 +245,32 @@ impl Actor for Terminal {
|
||||
|
||||
info!("Spawned new child process with PID {}", child.id());
|
||||
|
||||
let (pty_read, pty_write) = pty.split();
|
||||
let (pty_read, mut pty_write) = pty.split();
|
||||
|
||||
// Set a sensible default PTY size immediately after splitting the PTY.
|
||||
// This avoids sending an initial 0x0 resize to the backend which can
|
||||
// cause panics in terminal UI libraries like ratatui.
|
||||
//
|
||||
// We use the Resize helper which accepts a mutable reference to the
|
||||
// write-half of the PTY and block until the resize completes.
|
||||
let _ = event::Resize::new(&mut pty_write, 24, 80).wait();
|
||||
|
||||
self.pty_write = Some(pty_write);
|
||||
self.child = Some(child);
|
||||
|
||||
Self::add_stream(FramedRead::new(pty_read, BytesCodec::new()), ctx);
|
||||
|
||||
// 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 {
|
||||
info!(
|
||||
"Terminal idle timeout reached ({:?} idle), stopping session",
|
||||
idle_duration
|
||||
);
|
||||
ctx.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
|
||||
@@ -274,6 +308,9 @@ impl Handler<event::IO> for Terminal {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: event::IO, ctx: &mut <Self as Actor>::Context) {
|
||||
// Reset idle timer on activity
|
||||
self.last_activity = Instant::now();
|
||||
|
||||
let pty = match self.pty_write {
|
||||
Some(ref mut p) => p,
|
||||
None => {
|
||||
@@ -308,12 +345,29 @@ impl Handler<event::TerminadoMessage> for Terminal {
|
||||
trace!("Websocket -> Terminal : {:?}", msg);
|
||||
match msg {
|
||||
event::TerminadoMessage::Stdin(io) => {
|
||||
// Reset idle timer on user input
|
||||
self.last_activity = Instant::now();
|
||||
|
||||
if let Err(e) = pty.write(io.as_ref()) {
|
||||
error!("Could not write to PTY: {}", e);
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
event::TerminadoMessage::Resize { rows, cols } => {
|
||||
// Reset idle timer on resize (user interaction)
|
||||
self.last_activity = Instant::now();
|
||||
|
||||
// Ignore zero-sized resizes which can cause panics in backends
|
||||
// such as ratatui when they receive a Rect with width or height 0.
|
||||
if rows == 0 || cols == 0 {
|
||||
trace!(
|
||||
"Ignoring zero-sized resize: cols = {}, rows = {}",
|
||||
cols,
|
||||
rows
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Resize: cols = {}, rows = {}", cols, rows);
|
||||
if let Err(e) = event::Resize::new(pty, rows, cols).wait() {
|
||||
error!("Resize failed: {}", e);
|
||||
|
||||
+50
-7
@@ -1,17 +1,19 @@
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use actix_files;
|
||||
use actix_web::{App, HttpServer};
|
||||
use structopt::StructOpt;
|
||||
use webterm::WebTermExt;
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
#[structopt(name = "webterm-server")]
|
||||
struct Opt {
|
||||
/// The port to listen on
|
||||
#[structopt(short, long, default_value = "8080")]
|
||||
#[structopt(short, long, default_value = "8082")]
|
||||
port: u16,
|
||||
|
||||
/// The host or IP to listen on
|
||||
@@ -30,18 +32,59 @@ lazy_static! {
|
||||
fn main() {
|
||||
pretty_env_logger::init();
|
||||
|
||||
HttpServer::new(|| {
|
||||
// Normalize common hostnames that sometimes resolve to IPv6-only addresses
|
||||
// which can cause platform-specific bind failures. Mapping `localhost` to
|
||||
// 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise
|
||||
// be selected.
|
||||
let host = if OPT.host == "localhost" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
OPT.host.clone()
|
||||
};
|
||||
|
||||
let bind_addr = format!("{}:{}", host, OPT.port);
|
||||
println!("Starting webterm server on http://{}", bind_addr);
|
||||
|
||||
// Single factory closure variable that we reuse for HttpServer::new.
|
||||
// The closure does not capture any stack variables (it references the static
|
||||
// `OPT`), so it can act as a simple, repeated factory for the server.
|
||||
let factory = || {
|
||||
App::new()
|
||||
.service(actix_files::Files::new("/assets", "./static"))
|
||||
.service(actix_files::Files::new("/static", "./node_modules"))
|
||||
.webterm_socket("/websocket", |_req| {
|
||||
// Use the static OPT inside the handler; this does not make the
|
||||
// outer `factory` closure capture stack variables, so factory
|
||||
// remains a zero-capture closure (a function item/type).
|
||||
let mut cmd = Command::new(OPT.command.clone());
|
||||
cmd.env("TERM", "xterm");
|
||||
cmd
|
||||
})
|
||||
.webterm_ui("/", "/websocket", "/static")
|
||||
})
|
||||
.bind(format!("{}:{}", OPT.host, OPT.port))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
// Bind a std::net::TcpListener ourselves and hand it to actix via `listen`.
|
||||
// This avoids actix's address parser producing EINVAL on some platforms.
|
||||
let listener = match TcpListener::bind(&bind_addr) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to bind TcpListener to {}: {}", bind_addr, e);
|
||||
eprintln!("Try `--host 0.0.0.0` or `--host 127.0.0.1` to bind explicitly.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let server = HttpServer::new(factory)
|
||||
.listen(listener)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to listen on {}: {}", bind_addr, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
println!("Listening on http://{}", bind_addr);
|
||||
|
||||
if let Err(e) = server.run() {
|
||||
eprintln!("Server run failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user