Compare commits

...

2 Commits

Author SHA1 Message Date
12f2d6e6af - modernize packages and rust edition. - increase timeout for rollout -
Some checks failed
Build and Deploy to K3s / deploy (push) Blocked by required conditions
Build and Deploy to K3s / build-and-push (push) Has been cancelled
increment cargo version
2025-11-28 14:43:49 -08:00
b365ac38e3 add screenshot 2025-11-28 14:12:14 -08:00
12 changed files with 1999 additions and 1925 deletions

View File

@ -62,7 +62,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4gt.wittyoneoff.com/jason/socktop-webterm:0.2.2
- name: Install kubectl - name: Install kubectl
run: | run: |
@ -111,7 +111,7 @@ jobs:
- name: Wait for rollout to complete - name: Wait for rollout to complete
run: | run: |
kubectl rollout status deployment/socktop-webterm -n socktop --timeout=5m kubectl rollout status deployment/socktop-webterm -n socktop --timeout=30m
- name: Verify deployment - name: Verify deployment
run: | run: |

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ scripts/docker-quickstart.sh
scripts/publish-to-gitea-multiarch.sh scripts/publish-to-gitea-multiarch.sh
scripts/publish-to-gitea.sh scripts/publish-to-gitea.sh
scripts/verify_upgrade.sh scripts/verify_upgrade.sh
scripts/check-setup.sh

3104
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,34 @@
[package] [package]
name = "webterm" name = "webterm"
description = "xterm.js - based webterminal" description = "socktop xterm.js - based webterminal"
repository = "https://github.com/fubarnetes/webterm" repository = "https://gt.wittyoneoff.com/jason/socktop-webterm"
documentation = "https://docs.rs/webterm" documentation = "https://docs.rs/webterm"
readme = "README.md" readme = "README.md"
categories = ["web-programming", "web-programming::websocket", "web-programming::http-server", "command-line-utilities"] categories = ["web-programming", "web-programming::websocket", "web-programming::http-server", "command-line-utilities"]
keywords = ["terminal", "xterm", "websocket", "terminus", "console"] keywords = ["terminal", "xterm", "websocket", "terminus", "console"]
version = "0.2.2" version = "0.2.2"
authors = ["fabian.freyer@physik.tu-berlin.de","jasonpwitty+socktop@proton.me"] authors = ["fabian.freyer@physik.tu-berlin.de","jasonpwitty+socktop@proton.me"]
edition = "2018" edition = "2021"
license = "BSD-3-Clause" license = "BSD-3-Clause"
[badges]
travis-ci = { repository = "fubarnetes/webterm", branch = "master" }
maintenance = { status = "actively-developed" }
[dependencies] [dependencies]
actix-files = "0.1.6" actix-files = "0.6"
actix-service = "0.4.2" actix-web = "4.9"
actix-web-actors = "1.0.2" actix-web-actors = "4.3"
actix-web= "1.0.8" actix = "0.13"
actix= "0.8.3" actix-rt = "2.10"
futures = "0.1.29" futures = "0.3"
handlebars = "2.0.2" handlebars = "6.3"
lazy_static = "1.4.0" serde = { version = "1.0", features = ["derive"] }
libc = "0.2.66" serde_json = "1.0"
log = "0.4.8" clap = { version = "4.5", features = ["derive"] }
pretty_env_logger = "0.3.1" tokio = { version = "1.42", features = ["full"] }
serde = "1.0.104" tokio-util = { version = "0.7", features = ["codec"] }
serde_json = "1.0.44" portable-pty = "0.8"
structopt = "0.3.7" bytes = "1.9"
tokio = "0.1.22" log = "0.4"
tokio-codec= "0.1.1" env_logger = "0.11"
tokio-io= "0.1.12" libc = "0.2"
tokio-pty-process = "0.4"
[lib] [lib]
name = "webterm" name = "webterm"

222
README.md
View File

@ -1,31 +1,217 @@
# webterm # Socktop WebTerm
web terminal based on xterm.js in rust
A web-based terminal using xterm.js and Rust, part of the Socktop project.
![Screenshot](screenshots/screenshot.png) ![Screenshot](screenshots/screenshot.png)
# Is it any good? ## About
[Yes.](https://news.ycombinator.com/item?id=3067434)
# How does it work? This is a modern web terminal server that provides browser-based terminal access with WebSocket support. It's built with Rust using the Actix-Web framework and xterm.js for the frontend.
There is a rust backend based [Actix], consisting of two actors: ## Features
* `Websocket` implements a websocket that speaks the [Terminado] protocol
* `Terminal` handles communication to a child spawned on a PTY using [tokio-pty-process].
The frontend is a static HTML page served by [actix-web][Actix] providing an [xterm.js] UI. - 🖥️ Full-featured terminal emulation via xterm.js
- 🔌 WebSocket-based communication using the Terminado protocol
- 🚀 High-performance Rust backend
- ⚡ Zero-downtime rolling deployments via CI/CD
- 🐳 Containerized deployment with Docker
- ☸️ Kubernetes/k3s ready with automated deployments
[Actix]: https://actix.rs ## Architecture
[Terminado]: https://github.com/jupyter/terminado
[tokio-pty-process]: https://crates.io/crates/tokio-pty-process
[xterm.js]: https://xtermjs.org/
# Local development The application consists of two main components:
```
### Backend (Rust)
- **Websocket Actor**: Implements WebSocket communication using the [Terminado](https://github.com/jupyter/terminado) protocol
- **Terminal Actor**: Manages PTY communication with spawned processes using [portable-pty](https://crates.io/crates/portable-pty)
- **Actix-Web Server**: Serves static files and handles HTTP/WebSocket routing
### Frontend
- **xterm.js**: Provides the terminal UI in the browser
- **Static Assets**: HTML, CSS, and JavaScript served by Actix-Web
## Prerequisites
- Rust 1.90+ (2021 edition)
- Node.js and npm (for xterm.js dependencies)
- Docker (optional, for containerized deployment)
- Kubernetes/k3s (optional, for orchestrated deployment)
## Local Development
```bash
# Clone the repository
git clone https://gt.wittyoneoff.com/jason/socktop-webterm
cd socktop-webterm
# Install npm dependencies
npm install
# Verify setup (optional but recommended)
./check-setup.sh
# Build and run
cargo build cargo build
cargo run cargo run
``` ```
Then head to `http://localhost:8080/` to see it in action!
# Should I run this on the internet? Then head to `http://localhost:8082/` to see it in action!
Probably not. It lets anyone who can access the webpage control your system. ### Setup Verification
Before running the server, you can verify that all required files and dependencies are in place:
```bash
./check-setup.sh
```
This will check for:
- Required directories (static, templates, node_modules)
- Critical files (templates, JavaScript, CSS)
- xterm.js installation
- Build tools (cargo, npm)
**Note**: The server must be run from the project root directory, as it expects `./static`, `./templates`, and `./node_modules` to be accessible in the current working directory.
### Command Line Options
```bash
webterm-server --help
```
Options:
- `-p, --port <PORT>` - The port to listen on (default: 8082)
- `-H, --host <HOST>` - The host or IP to listen on (default: localhost)
- `-c, --command <COMMAND>` - The command to execute (default: /bin/sh)
Example:
```bash
cargo run -- --port 8080 --host 0.0.0.0 --command /bin/bash
```
## Production Deployment
### Docker
```bash
# Build the image
docker build -t socktop-webterm:latest .
# Run the container
docker run -d -p 8082:8082 socktop-webterm:latest
```
### Kubernetes/k3s
Automated deployment via Gitea Actions CI/CD:
1. Push to `main` branch
2. Workflow automatically builds arm64 image
3. Image tagged with version from `Cargo.toml`
4. Deployed to k3s cluster with zero-downtime rolling update
See `.gitea/workflows/build-and-deploy.yaml` for the complete workflow.
Manual deployment:
```bash
kubectl apply -f kubernetes/
```
## CI/CD Pipeline
This project includes a complete CI/CD pipeline using Gitea Actions:
- **Automatic builds** on every push to main/master
- **Version tagging** from Cargo.toml
- **Container registry** integration
- **Automated k3s deployment** with rolling updates
- **Zero downtime** deployments
For setup instructions, see:
- [Getting Started Guide](GETTING-STARTED-CI-CD.md)
- [CI/CD Overview](CI-CD-OVERVIEW.md)
- [Quick Reference](.gitea/QUICKSTART.md)
## Technology Stack
- **Backend**: Rust 2021, Actix-Web 4.x, Actix 0.13, portable-pty
- **Frontend**: xterm.js, HTML5, CSS3
- **Templating**: Handlebars 6.x
- **CLI**: clap 4.x
- **Async Runtime**: Tokio 1.x
- **Containerization**: Docker, Kubernetes/k3s
- **CI/CD**: Gitea Actions
## Security Considerations
⚠️ **Warning**: This application provides shell access to anyone who can access the web interface. It is intended for use in controlled environments only. Only run in Run in isolated containers/namespaces.
## Credits
### Original Author
This project is a fork of [webterm](https://github.com/fubarnetes/webterm) originally created by:
- **Fabian Freyer** (fabian.freyer@physik.tu-berlin.de)
### Socktop Enhancements
Modernized and enhanced for the Socktop project by:
- **Jason Witty** (jasonpwitty+socktop@proton.me)
#### Major Changes from Original
- Updated to Rust 2021 edition
- Modernized dependencies (Actix-Web 1.x → 4.x, Tokio 0.1 → 1.x)
- Replaced deprecated `tokio-pty-process` with `portable-pty`
- Added automated CI/CD pipeline with Gitea Actions
- Containerized deployment with Docker
- Kubernetes/k3s orchestration support
- Added idle session timeout (5 minutes)
- Improved error handling and logging
- Modern CLI with clap instead of structopt
- Updated to latest xterm.js
## License
This project is licensed under the **BSD 3-Clause License** - see below:
```
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.
```
## Contributing
Contributions are welcome! Please feel free to submit pull requests or open issues.
## Links
- **Repository**: https://gt.wittyoneoff.com/jason/socktop-webterm
- **Original Project**: https://github.com/fubarnetes/webterm
- **xterm.js**: https://xtermjs.org/
- **Actix-Web**: https://actix.rs
- **portable-pty**: https://crates.io/crates/portable-pty

54
build.rs Normal file
View File

@ -0,0 +1,54 @@
use std::path::Path;
fn main() {
// Verify that required directories exist at build time
let required_dirs = vec!["static", "templates", "node_modules"];
let mut missing_dirs = Vec::new();
for dir in &required_dirs {
if !Path::new(dir).exists() {
missing_dirs.push(*dir);
}
}
if !missing_dirs.is_empty() {
println!("cargo:warning=Missing required directories:");
for dir in &missing_dirs {
println!("cargo:warning= - {}", dir);
}
if missing_dirs.contains(&"node_modules") {
println!("cargo:warning=Run 'npm install' to install frontend dependencies");
}
}
// Verify critical files
let required_files = vec![
"templates/term.html",
"static/terminal.js",
"static/terminado-addon.js",
"static/styles.css",
];
let mut missing_files = Vec::new();
for file in &required_files {
if !Path::new(file).exists() {
missing_files.push(*file);
}
}
if !missing_files.is_empty() {
println!("cargo:warning=Missing required files:");
for file in &missing_files {
println!("cargo:warning= - {}", file);
}
}
// Tell cargo to rerun if these directories change
println!("cargo:rerun-if-changed=static/");
println!("cargo:rerun-if-changed=templates/");
println!("cargo:rerun-if-changed=package.json");
println!("cargo:rerun-if-changed=package-lock.json");
}

BIN
screenshots/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

View File

@ -1,71 +1,34 @@
use actix::Message; use actix::Message;
use futures::{Future, Poll}; use bytes::Bytes;
use libc::c_ushort;
use tokio_pty_process::PtyMaster;
pub use crate::terminado::TerminadoMessage; pub use crate::terminado::TerminadoMessage;
use tokio_codec::{BytesCodec, Decoder};
type BytesMut = <BytesCodec as Decoder>::Item;
pub struct Resize<T: PtyMaster> {
pty: T,
rows: c_ushort,
cols: c_ushort,
}
impl<T: PtyMaster> Resize<T> {
pub fn new(pty: T, rows: c_ushort, cols: c_ushort) -> Self {
Self { pty, rows, cols }
}
}
impl<T: PtyMaster> Future for Resize<T> {
type Item = ();
type Error = std::io::Error;
fn poll(&mut self) -> Poll<Self::Item, Self::Error> {
self.pty.resize(self.rows, self.cols)
}
}
#[derive(Debug, Eq, PartialEq, Clone)] #[derive(Debug, Eq, PartialEq, Clone)]
pub struct IO(pub BytesMut); pub struct IO(pub Bytes);
impl Message for IO { impl Message for IO {
type Result = (); type Result = ();
} }
impl Into<actix_web::web::Bytes> for IO { impl From<Bytes> for IO {
fn into(self) -> actix_web::web::Bytes { fn from(b: Bytes) -> Self {
self.0.into() Self(b)
}
}
impl AsRef<[u8]> for IO {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
impl From<actix_web::web::Bytes> for IO {
fn from(b: actix_web::web::Bytes) -> Self {
Self(b.as_ref().into())
} }
} }
impl From<String> for IO { impl From<String> for IO {
fn from(s: String) -> Self { fn from(s: String) -> Self {
Self(s.into()) Self(Bytes::from(s))
} }
} }
impl From<&str> for IO { impl From<&str> for IO {
fn from(s: &str) -> Self { fn from(s: &str) -> Self {
Self(s.into()) Self(Bytes::from(s.to_owned()))
} }
} }
#[derive(Debug, Clone)]
pub struct ChildDied(); pub struct ChildDied();
impl Message for ChildDied { impl Message for ChildDied {

View File

@ -1,4 +1,5 @@
// Copyright (c) 2019 Fabian Freyer <fabian.freyer@physik.tu-berlin.de>. // Copyright (c) 2019 Fabian Freyer <fabian.freyer@physik.tu-berlin.de>.
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved. // All rights reserved.
// //
// Redistribution and use in source and binary forms, with or without // Redistribution and use in source and binary forms, with or without
@ -27,24 +28,19 @@
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE. // POSSIBILITY OF SUCH DAMAGE.
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate log;
use actix::prelude::*; use actix::prelude::*;
use actix::{Actor, StreamHandler}; use actix::{Actor, StreamHandler};
use actix_web::{web, App, HttpRequest, HttpResponse}; use actix_web::{web, App, HttpRequest, HttpResponse};
use actix_web_actors::ws; use actix_web_actors::ws;
use std::io::Write; use std::io::{Read, Write};
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio_codec::{BytesCodec, Decoder, FramedRead}; use bytes::Bytes;
use tokio_pty_process::{AsyncPtyMaster, AsyncPtyMasterWriteHalf, Child, CommandExt};
use handlebars::Handlebars; use handlebars::Handlebars;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use serde_json::json;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
@ -54,6 +50,8 @@ const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30
mod event; mod event;
mod terminado; mod terminado;
use event::{ChildDied, TerminadoMessage, IO};
/// Actix WebSocket actor /// Actix WebSocket actor
pub struct Websocket { pub struct Websocket {
cons: Option<Addr<Terminal>>, cons: Option<Addr<Terminal>>,
@ -76,51 +74,49 @@ impl Actor for Websocket {
// Start PTY // Start PTY
self.cons = Some(Terminal::new(ctx.address(), command).start()); self.cons = Some(Terminal::new(ctx.address(), command).start());
trace!("Started WebSocket"); log::trace!("Started WebSocket");
} }
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
trace!("Stopping WebSocket"); log::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() { if let Some(_cons) = self.cons.take() {
info!("WebSocket disconnecting, Terminal will timeout if idle"); log::info!("WebSocket disconnecting, Terminal will timeout if idle");
} }
Running::Stop Running::Stop
} }
fn stopped(&mut self, _ctx: &mut Self::Context) { fn stopped(&mut self, _ctx: &mut Self::Context) {
trace!("Stopped WebSocket"); log::trace!("Stopped WebSocket");
} }
} }
impl Handler<event::IO> for Websocket { impl Handler<IO> for Websocket {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::IO, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: IO, ctx: &mut <Self as Actor>::Context) {
trace!("Websocket <- Terminal : {:?}", msg); log::trace!("Websocket <- Terminal : {:?}", msg);
ctx.binary(msg); ctx.binary(msg.0);
} }
} }
impl Handler<event::TerminadoMessage> for Websocket { impl Handler<TerminadoMessage> for Websocket {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::TerminadoMessage, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
trace!("Websocket <- Terminal : {:?}", msg); log::trace!("Websocket <- Terminal : {:?}", msg);
match msg { match msg {
event::TerminadoMessage::Stdout(_) => { TerminadoMessage::Stdout(_) => {
let json = serde_json::to_string(&msg); let json = serde_json::to_string(&msg);
if let Ok(json) = json { if let Ok(json) = json {
ctx.text(json); ctx.text(json);
} }
} }
_ => error!(r#"Invalid event::TerminadoMessage to Websocket: only "stdout" supported"#), _ => log::error!(
r#"Invalid event::TerminadoMessage to Websocket: only "stdout" supported"#
),
} }
} }
} }
@ -137,22 +133,31 @@ impl Websocket {
fn hb(&self, ctx: &mut <Self as Actor>::Context) { fn hb(&self, ctx: &mut <Self as Actor>::Context) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
warn!("Client heartbeat timeout, disconnecting."); log::warn!("Client heartbeat timeout, disconnecting.");
ctx.stop(); ctx.stop();
return; return;
} }
ctx.ping(""); ctx.ping(b"");
}); });
} }
} }
impl StreamHandler<ws::Message, ws::ProtocolError> for Websocket { impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for Websocket {
fn handle(&mut self, msg: ws::Message, ctx: &mut Self::Context) { fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
let cons: &mut Addr<Terminal> = match self.cons { let cons: &mut Addr<Terminal> = match self.cons {
Some(ref mut c) => c, Some(ref mut c) => c,
None => { None => {
error!("Terminalole died, closing websocket."); 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(); ctx.stop();
return; return;
} }
@ -166,34 +171,35 @@ impl StreamHandler<ws::Message, ws::ProtocolError> for Websocket {
ws::Message::Pong(_) => self.hb = Instant::now(), ws::Message::Pong(_) => self.hb = Instant::now(),
ws::Message::Text(t) => { ws::Message::Text(t) => {
// Attempt to parse the message as JSON. // Attempt to parse the message as JSON.
if let Ok(tmsg) = event::TerminadoMessage::from_json(&t) { if let Ok(tmsg) = TerminadoMessage::from_json(t.as_ref()) {
cons.do_send(tmsg); cons.do_send(tmsg);
} else { } else {
// Otherwise, it's just byte data. // Otherwise, it's just byte data.
cons.do_send(event::IO::from(t)); cons.do_send(IO::from(t.to_string()));
} }
} }
ws::Message::Binary(b) => cons.do_send(event::IO::from(b)), ws::Message::Binary(b) => cons.do_send(IO::from(b)),
ws::Message::Close(_) => ctx.stop(), ws::Message::Close(_) => ctx.stop(),
ws::Message::Nop => {} ws::Message::Nop | ws::Message::Continuation(_) => {}
}; };
} }
} }
impl Handler<event::ChildDied> for Websocket { impl Handler<ChildDied> for Websocket {
type Result = (); type Result = ();
fn handle(&mut self, _msg: event::ChildDied, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, _msg: ChildDied, ctx: &mut <Self as Actor>::Context) {
trace!("Websocket <- ChildDied"); log::trace!("Websocket <- ChildDied");
ctx.close(None); ctx.close(None);
ctx.stop(); ctx.stop();
} }
} }
/// Represents a PTY backenActix WebSocket actor.d with attached child /// Represents a PTY backend with attached child
pub struct Terminal { pub struct Terminal {
pty_write: Option<AsyncPtyMasterWriteHalf>, pty_master: Option<Box<dyn portable_pty::MasterPty + Send>>,
child: Option<Child>, pty_writer: Option<Box<dyn Write + Send>>,
child: Option<Box<dyn portable_pty::Child + Send>>,
ws: Addr<Websocket>, ws: Addr<Websocket>,
command: Command, command: Command,
last_activity: Instant, last_activity: Instant,
@ -203,7 +209,8 @@ pub struct Terminal {
impl Terminal { impl Terminal {
pub fn new(ws: Addr<Websocket>, command: Command) -> Self { pub fn new(ws: Addr<Websocket>, command: Command) -> Self {
Self { Self {
pty_write: None, pty_master: None,
pty_writer: None,
child: None, child: None,
ws, ws,
command, command,
@ -213,58 +220,101 @@ impl Terminal {
} }
} }
impl StreamHandler<<BytesCodec as Decoder>::Item, <BytesCodec as Decoder>::Error> for Terminal {
fn handle(&mut self, msg: <BytesCodec as Decoder>::Item, _ctx: &mut Self::Context) {
self.ws
.do_send(event::TerminadoMessage::Stdout(event::IO(msg)));
}
}
impl Actor for Terminal { impl Actor for Terminal {
type Context = Context<Self>; type Context = Context<Self>;
fn started(&mut self, ctx: &mut Self::Context) { fn started(&mut self, ctx: &mut Self::Context) {
info!("Started Terminal"); log::info!("Started Terminal");
let pty = match AsyncPtyMaster::open() {
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) => { Err(e) => {
error!("Unable to open PTY: {:?}", e); log::error!("Unable to open PTY: {:?}", e);
ctx.stop(); ctx.stop();
return; return;
} }
Ok(pty) => pty,
}; };
let child = match self.command.spawn_pty_async(&pty) { let mut cmd_builder = CommandBuilder::new(self.command.get_program());
Err(e) => { for arg in self.command.get_args() {
error!("Unable to spawn child: {:?}", e); cmd_builder.arg(arg);
ctx.stop();
return;
} }
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, Ok(child) => child,
Err(e) => {
log::error!("Unable to spawn child: {:?}", e);
ctx.stop();
return;
}
}; };
info!("Spawned new child process with PID {}", child.id()); log::info!("Spawned new child process");
let (pty_read, mut pty_write) = pty.split(); // 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;
}
};
// Set a sensible default PTY size immediately after splitting the PTY. let writer = match pty_pair.master.take_writer() {
// This avoids sending an initial 0x0 resize to the backend which can Ok(w) => w,
// cause panics in terminal UI libraries like ratatui. Err(e) => {
// log::error!("Unable to get writer: {:?}", e);
// We use the Resize helper which accepts a mutable reference to the ctx.stop();
// write-half of the PTY and block until the resize completes. return;
let _ = event::Resize::new(&mut pty_write, 24, 80).wait(); }
};
self.pty_write = Some(pty_write); self.pty_master = Some(pty_pair.master);
self.pty_writer = Some(writer);
self.child = Some(child); self.child = Some(child);
Self::add_stream(FramedRead::new(pty_read, BytesCodec::new()), ctx); // 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 // Start idle timeout checker
ctx.run_interval(IDLE_CHECK_INTERVAL, |act, ctx| { ctx.run_interval(IDLE_CHECK_INTERVAL, |act, ctx| {
let idle_duration = Instant::now().duration_since(act.last_activity); let idle_duration = Instant::now().duration_since(act.last_activity);
if idle_duration >= act.idle_timeout { if idle_duration >= act.idle_timeout {
info!( log::info!(
"Terminal idle timeout reached ({:?} idle), stopping session", "Terminal idle timeout reached ({:?} idle), stopping session",
idle_duration idle_duration
); );
@ -274,93 +324,81 @@ impl Actor for Terminal {
} }
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
info!("Stopping Terminal"); log::info!("Stopping Terminal");
let child = self.child.take(); if let Some(mut child) = self.child.take() {
let _ = child.kill();
if child.is_none() { let _ = child.wait();
// Great, child is already dead!
return Running::Stop;
} }
let mut child = child.unwrap();
match child.kill() {
Ok(()) => match child.wait() {
Ok(exit) => info!("Child died: {:?}", exit),
Err(e) => error!("Child wouldn't die: {}", e),
},
Err(e) => error!("Could not kill child with PID {}: {}", child.id(), e),
};
// Notify the websocket that the child died. // Notify the websocket that the child died.
self.ws.do_send(event::ChildDied()); self.ws.do_send(ChildDied());
Running::Stop Running::Stop
} }
fn stopped(&mut self, _ctx: &mut Self::Context) { fn stopped(&mut self, _ctx: &mut Self::Context) {
info!("Stopped Terminal"); log::info!("Stopped Terminal");
} }
} }
impl Handler<event::IO> for Terminal { impl Handler<IO> for Terminal {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::IO, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: IO, ctx: &mut <Self as Actor>::Context) {
// Reset idle timer on activity // Reset idle timer on activity
self.last_activity = Instant::now(); self.last_activity = Instant::now();
let pty = match self.pty_write { let writer = match &mut self.pty_writer {
Some(ref mut p) => p, Some(w) => w,
None => { None => {
error!("Write half of PTY died, stopping Terminal."); log::error!("PTY writer died, stopping Terminal.");
ctx.stop(); ctx.stop();
return; return;
} }
}; };
if let Err(e) = pty.write(msg.as_ref()) { if let Err(e) = writer.write_all(&msg.0) {
error!("Could not write to PTY: {}", e); log::error!("Could not write to PTY: {}", e);
ctx.stop(); ctx.stop();
return;
} }
trace!("Websocket -> Terminal : {:?}", msg); log::trace!("Websocket -> Terminal : {:?}", msg);
} }
} }
impl Handler<event::TerminadoMessage> for Terminal { impl Handler<TerminadoMessage> for Terminal {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::TerminadoMessage, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
let pty = match self.pty_write { log::trace!("Websocket -> Terminal : {:?}", msg);
Some(ref mut p) => p, 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 => { None => {
error!("Write half of PTY died, stopping Terminal."); log::error!("PTY writer died, stopping Terminal.");
ctx.stop(); ctx.stop();
return; return;
} }
}; };
trace!("Websocket -> Terminal : {:?}", msg); if let Err(e) = writer.write_all(&io.0) {
match msg { log::error!("Could not write to PTY: {}", e);
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(); ctx.stop();
} }
} }
event::TerminadoMessage::Resize { rows, cols } => { TerminadoMessage::Resize { rows, cols } => {
// Reset idle timer on resize (user interaction) // Reset idle timer on resize (user interaction)
self.last_activity = Instant::now(); self.last_activity = Instant::now();
// Ignore zero-sized resizes which can cause panics in backends // Ignore zero-sized resizes
// such as ratatui when they receive a Rect with width or height 0.
if rows == 0 || cols == 0 { if rows == 0 || cols == 0 {
trace!( log::trace!(
"Ignoring zero-sized resize: cols = {}, rows = {}", "Ignoring zero-sized resize: cols = {}, rows = {}",
cols, cols,
rows rows
@ -368,14 +406,29 @@ impl Handler<event::TerminadoMessage> for Terminal {
return; return;
} }
info!("Resize: cols = {}, rows = {}", cols, rows); log::info!("Resize: cols = {}, rows = {}", cols, rows);
if let Err(e) = event::Resize::new(pty, rows, cols).wait() {
error!("Resize failed: {}", e); 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(); ctx.stop();
} }
} }
event::TerminadoMessage::Stdout(_) => { TerminadoMessage::Stdout(_) => {
error!("Invalid Terminado Message: Stdin cannot go to PTY") log::error!("Invalid Terminado Message: Stdout cannot go to PTY")
} }
}; };
} }
@ -384,63 +437,57 @@ impl Handler<event::TerminadoMessage> for Terminal {
/// Trait to extend an [actix_web::App] by serving a web terminal. /// Trait to extend an [actix_web::App] by serving a web terminal.
pub trait WebTermExt { pub trait WebTermExt {
/// Serve the websocket for the webterm /// Serve the websocket for the webterm
fn webterm_socket<F>(self: Self, endpoint: &str, handler: F) -> Self fn webterm_socket<F>(self, endpoint: &str, handler: F) -> Self
where where
F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static; F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static;
fn webterm_ui( fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self;
self: Self,
endpoint: &str,
webterm_socket_endpoint: &str,
static_path: &str,
) -> Self;
} }
impl<T, B> WebTermExt for App<T, B> impl<T> WebTermExt for App<T>
where where
B: actix_web::body::MessageBody, T: actix_web::dev::ServiceFactory<
T: actix_service::NewService< actix_web::dev::ServiceRequest,
Config = (), Config = (),
Request = actix_web::dev::ServiceRequest,
Response = actix_web::dev::ServiceResponse<B>,
Error = actix_web::Error, Error = actix_web::Error,
InitError = (), InitError = (),
>, >,
{ {
fn webterm_socket<F>(self: Self, endpoint: &str, handler: F) -> Self fn webterm_socket<F>(self, endpoint: &str, handler: F) -> Self
where where
F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static, F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static,
{ {
self.route( self.route(
endpoint, endpoint,
web::get().to(move |req: HttpRequest, stream: web::Payload| { web::get().to(move |req: HttpRequest, stream: web::Payload| {
ws::start(Websocket::new(handler(&req)), &req, stream) let cmd = handler(&req);
async move { ws::start(Websocket::new(cmd), &req, stream) }
}), }),
) )
} }
fn webterm_ui( fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self {
self: Self,
endpoint: &str,
webterm_socket_endpoint: &str,
static_path: &str,
) -> Self {
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
handlebars handlebars
.register_templates_directory(".html", "./templates") .register_template_file("term", "./templates/term.html")
.unwrap(); .unwrap();
let handlebars_ref = web::Data::new(handlebars); let handlebars_ref = web::Data::new(handlebars);
let static_path = static_path.to_owned(); let static_path = static_path.to_owned();
let webterm_socket_endpoint = webterm_socket_endpoint.to_owned(); let webterm_socket_endpoint = webterm_socket_endpoint.to_owned();
self.register_data(handlebars_ref.clone()).route(
self.app_data(handlebars_ref.clone()).route(
endpoint, endpoint,
web::get().to(move |hb: web::Data<Handlebars>| { web::get().to(move |hb: web::Data<Handlebars<'static>>| {
let websocket_path = webterm_socket_endpoint.clone();
let static_path_clone = static_path.clone();
async move {
let data = json!({ let data = json!({
"websocket_path": webterm_socket_endpoint, "websocket_path": websocket_path,
"static_path": static_path, "static_path": static_path_clone,
}); });
let body = hb.render("term", &data).unwrap(); let body = hb.render("term", &data).unwrap();
HttpResponse::Ok().body(body) HttpResponse::Ok().body(body)
}
}), }),
) )
} }

View File

@ -1,90 +1,60 @@
#[macro_use]
extern crate lazy_static;
use actix_files;
use actix_web::{App, HttpServer}; use actix_web::{App, HttpServer};
use structopt::StructOpt; use clap::Parser;
use webterm::WebTermExt; use webterm::WebTermExt;
use std::net::TcpListener;
use std::process::Command; use std::process::Command;
#[derive(StructOpt, Debug)] #[derive(Parser, Debug)]
#[structopt(name = "webterm-server")] #[command(name = "webterm-server")]
#[command(about = "Web terminal server based on xterm.js")]
struct Opt { struct Opt {
/// The port to listen on /// The port to listen on
#[structopt(short, long, default_value = "8082")] #[arg(short, long, default_value = "8082")]
port: u16, port: u16,
/// The host or IP to listen on /// The host or IP to listen on
#[structopt(short, long, default_value = "localhost")] #[arg(short = 'H', long, default_value = "localhost")]
host: String, host: String,
/// The command to execute /// The command to execute
#[structopt(short, long, default_value = "/bin/sh")] #[arg(short, long, default_value = "/bin/sh")]
command: String, command: String,
} }
lazy_static! { #[actix_web::main]
static ref OPT: Opt = Opt::from_args(); async fn main() -> std::io::Result<()> {
} env_logger::init();
fn main() { let opt = Opt::parse();
pretty_env_logger::init();
// Normalize common hostnames that sometimes resolve to IPv6-only addresses // Normalize common hostnames that sometimes resolve to IPv6-only addresses
// which can cause platform-specific bind failures. Mapping `localhost` to // which can cause platform-specific bind failures. Mapping `localhost` to
// 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise // 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise
// be selected. // be selected.
let host = if OPT.host == "localhost" { let host = if opt.host == "localhost" {
"127.0.0.1".to_string() "127.0.0.1".to_string()
} else { } else {
OPT.host.clone() opt.host.clone()
}; };
let bind_addr = format!("{}:{}", host, OPT.port); let bind_addr = format!("{}:{}", host, opt.port);
println!("Starting webterm server on http://{}", bind_addr); println!("Starting webterm server on http://{}", bind_addr);
// Single factory closure variable that we reuse for HttpServer::new. let command = opt.command.clone();
// 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. HttpServer::new(move || {
let factory = || { let cmd = command.clone();
App::new() App::new()
.service(actix_files::Files::new("/assets", "./static")) .service(actix_files::Files::new("/assets", "./static"))
.service(actix_files::Files::new("/static", "./node_modules")) .service(actix_files::Files::new("/static", "./node_modules"))
.webterm_socket("/websocket", |_req| { .webterm_socket("/websocket", move |_req| {
// Use the static OPT inside the handler; this does not make the let mut command = Command::new(&cmd);
// outer `factory` closure capture stack variables, so factory command.env("TERM", "xterm");
// remains a zero-capture closure (a function item/type). command
let mut cmd = Command::new(OPT.command.clone());
cmd.env("TERM", "xterm");
cmd
}) })
.webterm_ui("/", "/websocket", "/static") .webterm_ui("/", "/websocket", "/static")
}; })
.bind(&bind_addr)?
// Bind a std::net::TcpListener ourselves and hand it to actix via `listen`. .run()
// This avoids actix's address parser producing EINVAL on some platforms. .await
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);
}
} }

View File

@ -1,4 +1,5 @@
use actix::Message; use actix::Message;
use log::error;
use libc::c_ushort; use libc::c_ushort;
@ -6,7 +7,6 @@ use std::convert::TryFrom;
use serde::ser::SerializeSeq; use serde::ser::SerializeSeq;
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use serde_json;
use crate::event::IO; use crate::event::IO;

View File

@ -44,23 +44,23 @@
/> />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="shortcut icon" type="image/png" href="/static/favicon.png" /> <link rel="shortcut icon" type="image/png" href="/assets/favicon.png" />
<!-- External Stylesheets --> <!-- External Stylesheets -->
<link <link
rel="stylesheet" rel="stylesheet"
href="{{ static_path }}/@xterm/xterm/css/xterm.css" href="{{ static_path }}/@xterm/xterm/css/xterm.css"
/> />
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/assets/styles.css" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/> />
<!-- Preload critical resources --> <!-- Preload critical resources -->
<link rel="preload" href="/static/styles.css" as="style" /> <link rel="preload" href="/assets/styles.css" as="style" />
<link rel="preload" href="/static/terminal.js" as="script" /> <link rel="preload" href="/assets/terminal.js" as="script" />
<!-- DNS Prefetch for external resources --> <!-- DNS Prefetch for external resources -->
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com" /> <link rel="dns-prefetch" href="https://cdnjs.cloudflare.com" />
@ -184,6 +184,6 @@
</script> </script>
<!-- Initialize Terminal --> <!-- Initialize Terminal -->
<script src="/static/terminal.js"></script> <script src="/assets/terminal.js"></script>
</body> </body>
</html> </html>