Compare commits
2 Commits
b1030154ea
...
12f2d6e6af
| Author | SHA1 | Date | |
|---|---|---|---|
| 12f2d6e6af | |||
| b365ac38e3 |
@ -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
1
.gitignore
vendored
@ -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
3104
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
45
Cargo.toml
45
Cargo.toml
@ -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
222
README.md
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# 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
54
build.rs
Normal 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
BIN
screenshots/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
53
src/event.rs
53
src/event.rs
@ -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 {
|
||||||
|
|||||||
337
src/lib.rs
337
src/lib.rs
@ -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,
|
|
||||||
None => {
|
|
||||||
error!("Write half of PTY died, stopping Terminal.");
|
|
||||||
ctx.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
trace!("Websocket -> Terminal : {:?}", msg);
|
|
||||||
match msg {
|
match msg {
|
||||||
event::TerminadoMessage::Stdin(io) => {
|
TerminadoMessage::Stdin(io) => {
|
||||||
// Reset idle timer on user input
|
// Reset idle timer on user input
|
||||||
self.last_activity = Instant::now();
|
self.last_activity = Instant::now();
|
||||||
|
|
||||||
if let Err(e) = pty.write(io.as_ref()) {
|
let writer = match &mut self.pty_writer {
|
||||||
error!("Could not write to PTY: {}", e);
|
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();
|
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)
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user