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:
2025-11-28 01:31:33 -08:00
parent 627073ef2d
commit 6e48c095ab
68 changed files with 12391 additions and 1007 deletions
+55 -1
View File
@@ -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
View File
@@ -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);
}
}