Compare commits

..

11 Commits

Author SHA1 Message Date
jasonwitty 56a2dc372a Reject unknown options in parse_args instead of treating them as the URL
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
An older socktop handed a newer flag (webterm 0.3.9's restricted shell
passing --no-kill to 1.60.1) silently parsed the flag as the positional
websocket URL and offered to overwrite the named profile's URL with the
literal flag text. Unknown options now fail with 'Unknown option' and
exit code 2 (help remains exit 0), with a regression test covering the
exact incident shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:46:20 -07:00
jasonwitty 59320c3bc0 fix formatting 2026-08-24 07:26:16 -07:00
jasonwitty a6518a79a9 minor bump to cargo version
Build Debian Packages / Build .deb for x86_64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for aarch64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for armv7-unknown-linux-gnueabihf (push) Has been cancelled
Build Debian Packages / Build .deb for riscv64gc-unknown-linux-gnu (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2026-08-24 07:22:51 -07:00
jasonwitty bedbe0a2ec Add flag to override logic and supress terminate option. (--no-kill)
Flag specifically used to block feature on socktop.io. Will remain
undocumented for standard usage.
2026-08-24 07:19:37 -07:00
jasonwitty a9cb4b732d Update socktop preview image to version 1.60 2026-08-23 20:47:41 -07:00
jason 40a0133aeb Update copyright year in LICENSE file
Updated copyright year from 2025 to 2026.
2026-08-23 18:33:08 -07:00
jason 407532ea1c Fix grammar and update LICENSE reference in README
Corrected grammatical errors and updated license reference format.
2026-08-23 18:32:37 -07:00
jason b9d10d2c90 Refactor README for improved clarity and formatting
Updated README to improve formatting and clarity, including adjustments to the features and platform support sections.
2026-08-23 18:24:57 -07:00
jason db34a142a3 Update README with resource links and modify intro
Removed the phrase 'inspired by top/btop' from the introduction and added new resource links for Auth Setup, TLS Setup, and Monitoring Multiple Hosts.
2026-08-23 17:48:53 -07:00
jason f3f616b0a2 Enhance README with resource links and description
Updated README to enhance description and add resources table.
2026-08-23 17:37:02 -07:00
jason fe3ef7f25e fix(ci): pin deb builds to ubuntu-22.04 and enforce the fleet glibc floor (#41)
Build Debian Packages / Build .deb for x86_64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for aarch64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for armv7-unknown-linux-gnueabihf (push) Has been cancelled
Build Debian Packages / Build .deb for riscv64gc-unknown-linux-gnu (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
* fix(ci): pin deb builds to ubuntu-22.04 and enforce the fleet glibc floor

The v1.60.0 debs failed to install on Raspberry Pi OS bookworm:

  socktop : Depends: libc6 (>= 2.39) but 2.36-9+rpt2+deb12u14 is to be installed

Cross-compiled binaries link against the RUNNER's (multiarch) glibc, so
the runner picks the minimum glibc the packages demand. ubuntu-latest
migrated from 22.04 (glibc 2.35) to 24.04 (glibc 2.39) between the
1.50.x releases and now, silently raising the requirement past the
Debian-12 fleet.

Pin the build job to ubuntu-22.04 (2.35 — satisfied by bookworm's 2.36)
and add a post-build gate that reads each .deb's computed libc6
requirement and fails the run if it exceeds the fleet floor, so the next
runner migration turns into a red build instead of a fleet-wide apt
error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): extract the libc6 version, not the 6 in 'libc6'

The floor gate's second grep matched the trailing digit of the package
name before the version ('libc6 (>= 2.34)' -> '6'), failing every
target. sed capture group instead; verified against realistic Depends
strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump all crates to 1.60.1

The 1.60.0 debs were built against glibc 2.39 and never installed on the
bookworm fleet; rather than force-moving the tag, the rebuilt release
ships as 1.60.1. Nothing was published to crates.io at 1.60.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 09:42:39 -07:00
15 changed files with 233 additions and 603 deletions
+25 -1
View File
@@ -18,7 +18,15 @@ env:
jobs:
build-deb:
name: Build .deb for ${{ matrix.target }}
runs-on: ubuntu-latest
# PINNED, not ubuntu-latest: the binaries link against this runner's
# (multiarch) glibc, so the runner sets the MINIMUM glibc the .debs demand
# at install time. ubuntu-latest moved to 24.04/glibc 2.39 and the packages
# stopped installing on Debian 12/RPi OS bookworm (glibc 2.36). 22.04 links
# 2.35, which bookworm satisfies. The "enforce glibc floor" step below
# turns any future violation into a red build instead of a fleet-wide apt
# failure — if this pin ever has to move past bookworm's glibc, that step
# is the contract to renegotiate first.
runs-on: ubuntu-22.04
strategy:
matrix:
include:
@@ -159,6 +167,22 @@ jobs:
mkdir -p debs
cp target/${{ matrix.target }}/debian/*.deb debs/
- name: Enforce glibc floor (Debian 12 / RPi OS bookworm fleet)
run: |
# The fleet's oldest supported glibc. A .deb that demands newer libc6
# than this will not install on the Pis — fail HERE, not at apt time.
FLOOR="2.36"
fail=0
for deb in debs/*.deb; do
req=$(dpkg-deb -f "$deb" Depends | sed -n 's/.*libc6 (>= \([0-9.]*\)).*/\1/p' | head -1)
echo "$deb -> libc6 >= ${req:-none}"
if [ -n "$req" ] && [ "$(printf '%s\n' "$req" "$FLOOR" | sort -V | tail -1)" != "$FLOOR" ]; then
echo "::error::$deb requires libc6 >= $req, exceeding the fleet floor $FLOOR (bookworm). The build runner's glibc is too new — see the runs-on pin comment."
fail=1
fi
done
exit $fail
- name: List generated packages
run: ls -lh debs/
+20 -2
View File
@@ -1,8 +1,26 @@
# Changelog
## 1.60.0 — unreleased
## Unreleased
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.0 together.
### TUI
- **`--no-kill` flag and `SOCKTOP_NO_KILL` env var** disable the local
process-kill feature regardless of agent locality, for shared terminals and
public demos (e.g. the socktop.io webterm). Either one forces the feature
off and suppresses the `t` kill hints; the env var covers every socktop
invocation under a deployment without touching command lines. `App`'s
builder renamed `with_local``with_kill_enabled` to match what it now
means (locality fact AND policy).
## 1.60.1 — unreleased
Identical to 1.60.0 plus rebuilt Debian packages: the 1.60.0 debs were linked
against glibc 2.39 (a GitHub runner migration) and would not install on
Debian 12 / Raspberry Pi OS bookworm. CI now pins the build environment and
gates every package against the fleet's glibc floor. 1.60.0 was never
published to crates.io.
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.1 together.
### Security
Generated
+3 -3
View File
@@ -2412,7 +2412,7 @@ dependencies = [
[[package]]
name = "socktop"
version = "1.60.0"
version = "1.60.2"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2432,7 +2432,7 @@ dependencies = [
[[package]]
name = "socktop_agent"
version = "1.60.0"
version = "1.60.2"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2464,7 +2464,7 @@ dependencies = [
[[package]]
name = "socktop_connector"
version = "1.60.0"
version = "1.60.2"
dependencies = [
"flate2",
"futures-util",
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Witty One Off
Copyright (c) 2026 Witty One Off
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+20 -556
View File
@@ -1,548 +1,36 @@
# socktop
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
_socktop_ is a remote system monitor with a rich TUI, talking to an ultra lightweight agent over WebSockets.
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
<img src="./docs/socktop_demo_1_60.apng" width="100%">
[socktop.io](https://www.socktop.io)
## Resources
<img src="./docs/socktop_demo.apng" width="100%">
| Resource | Location |
| -------- | -------- |
| Website and online demo (yes it's real) | [socktop.io](https://www.socktop.io) |
| Quick Start guide | [https://socktop.io/assets/docs/installation/quick-start.html](https://socktop.io/assets/docs/installation/quick-start.html) |
| Prereqs | [https://socktop.io/assets/docs/installation/prerequisites.html](https://socktop.io/assets/docs/installation/prerequisites.html) |
| APT Install | [https://socktop.io/assets/docs/installation/apt.html](https://socktop.io/assets/docs/installation/apt.html) |
| Cargo Install | [https://socktop.io/assets/docs/installation/cargo.html](https://socktop.io/assets/docs/installation/cargo.html)
| Usage | [https://socktop.io/assets/docs/usage/general.html](https://socktop.io/assets/docs/usage/general.html)
| Auth Setup | [https://socktop.io/assets/docs/security/token.html](https://socktop.io/assets/docs/security/token.html) |
| TLS Setup | [https://socktop.io/assets/docs/security/tls.html](https://socktop.io/assets/docs/security/tls.html) |
| Monitoring Multiple Hosts | [tmux](https://socktop.io/assets/docs/advanced/tmux.html) / [zellij](https://socktop.io/assets/docs/advanced/zellij.html) |
---
## Features
## Platform Support
- Remote monitoring via WebSocket (JSON over WS)
- Optional WSS (TLS): agent autogenerates a selfsigned cert on first run; client pins the cert via --tls-ca/-t
- TUI built with ratatui
- CPU
- Overall sparkline + per-core mini bars
- Accurate per-process CPU% (Linux /proc deltas), normalized to 0100%
- Memory/Swap gauges with human units
- Disks: per-device usage
- Network: per-interface throughput with sparklines and peak markers
- Temperatures: CPU (optional)
- Top processes (top 50)
- PID, name, CPU%, memory, and memory%
- Click-to-sort by CPU% or Mem (descending)
- Scrollbar and mouse/keyboard scrolling
- Total process count shown in the header
- Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled)
- Optional auth token for the agent
- Compact layout for small windows: automatically drops the panes that no longer fit so
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
Linux (all flavors), ARM/Raspberry Pi (32b/64b), MacOS, Windows, RISC-V (experimental)
---
## Prerequisites: Install Rust (rustup)
## Contributing
Rust is fast, safe, and crossplatform. Installing it will make your machine better. Consider yourself privileged.
Contributions are welcome and you have the freedom to use whatever development tools you would like, as long as there is a human in the loop and all the clippy and unit tests pass you are good to submit a PR. Defects / Bugs just go ahead and fix and file a PR. New features, please create a issue in advance and let me know you are offering to build it. I don't want to be in a position where you worked for a couple of weeks on something and I don't want to merge it.
Linux/macOS:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# load cargo for this shell
source "$HOME/.cargo/env"
# ensure stable is up to date
rustup update stable
rustc --version
cargo --version
# after install you may need to reload your shell, e.g.:
exec bash # or: exec zsh / exec fish
```
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, youll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
### Raspberry Pi / Ubuntu / PopOS (required for GPU support)
**Note:** GPU monitoring is only supported on x86_64 and aarch64 (64-bit ARM) platforms. ARMv7 (32-bit) and RISC-V builds do not include GPU support.
For 64-bit systems with GPU support:
```bash
sudo apt-get update
sudo apt-get install libdrm-dev libdrm-amdgpu1
```
For ARMv7 (32-bit Raspberry Pi), build with `--no-default-features` to disable GPU support:
```bash
cargo build --release -p socktop_agent --no-default-features
```
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
---
## Architecture
Two components:
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
---
## Quick start
- Build both binaries:
```bash
git clone https://github.com/jasonwitty/socktop.git
cd socktop
cargo build --release
```
- Start the agent on the target machine (default port 3000):
```bash
./target/release/socktop_agent --port 3000
```
- Connect with the TUI from your local machine:
```bash
./target/release/socktop ws://REMOTE_HOST:3000/ws
```
### Cross-compiling for Raspberry Pi
For Raspberry Pi and other ARM devices, you can cross-compile the agent from a more powerful machine:
- [Cross-compilation guide](./docs/cross-compiling.md) - Instructions for cross-compiling from Linux, macOS, or Windows hosts
### Quick demo (no agent setup)
Spin up a temporary local agent on port 3231 and connect automatically:
```bash
socktop --demo
```
Or just run `socktop` with no arguments and pick the builtin `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
- Runs locally (`ws://127.0.0.1:3231/ws`)
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
---
## Install (from crates.io)
You dont need to clone this repo to use socktop. Install the published binaries with cargo:
```bash
# TUI (client)
cargo install socktop
# Agent (server)
cargo install socktop_agent
```
This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
Notes:
- After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
- Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldnt. Be brave.
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
```
```bash
# Enable SSL
# Stop service
sudo systemctl stop socktop-agent
# Edit service to append SSL option and port
sudo micro /etc/systemd/system/socktop-agent.service
--
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
--
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
--
```
---
## Usage
Agent (server):
```bash
socktop_agent --port 3000
# or env: SOCKTOP_PORT=3000 socktop_agent
# optional auth: SOCKTOP_TOKEN=changeme socktop_agent
# enable TLS (selfsigned cert, default port 8443; you can also use -p):
socktop_agent --enableSSL --port 8443
```
Client (TUI):
```bash
socktop ws://HOST:3000/ws
# with token:
socktop "ws://HOST:3000/ws?token=changeme"
# TLS with pinned server certificate (recommended over the internet):
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# (By default hostname/SAN verification is skipped for ease on home networks. To enforce it add --verify-hostname)
socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
# force the small-window layout at any terminal size (normally automatic):
socktop --compact ws://HOST:3000/ws
```
Intervals (client-driven):
- Fast metrics: ~500 ms
- Processes: ~2 s
- Disks: ~5 s
The agent stays idle unless queried. When queried, it collects just whats needed.
---
## Compact mode
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
are the first things to collapse — exactly the panes you are most likely watching. Once
the window is too short for the Disks pane to show even one disk, socktop switches to a
compact layout:
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
- **Memory and Swap move side by side** into the row Disks vacated.
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
with no GPU the pane disappears entirely.
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
below the size where they used to vanish.
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
layout at any window size:
```bash
socktop --compact ws://HOST:3000/ws
```
---
## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
Config file location:
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
### Creating a profile
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
```bash
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
You can also set custom intervals (milliseconds):
```bash
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
```
```
If a profile already exists you will be prompted before overwriting:
```
$ socktop --profile prod ws://new-host:3000/ws
Overwrite existing profile 'prod'? [y/N]: y
```
To overwrite without an interactive prompt pass `--save`:
```bash
socktop --profile prod --save ws://new-host:3000/ws
```
### Using a saved profile
Just pass the profile name (no URL needed):
```bash
socktop --profile prod
socktop -P prod-tls # short flag
```
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
### Interactive selection (no args)
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
```
$ socktop
Select profile:
1. prod
2. prod-tls
Enter number (or blank to abort): 2
```
Choosing a number starts the TUI with that profile. A builtin `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
### JSON format
An example `profiles.json` (prettyprinted):
```json
{
"profiles": {
"prod": { "url": "ws://prod-host:3000/ws" },
"prod-tls": {
"url": "wss://prod-host:8443/ws",
"tls_ca": "/home/user/certs/prod-cert.pem",
"metrics_interval_ms": 500,
"processes_interval_ms": 2000
}
},
"version": 0
}
```
Notes:
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
- Profiles are client-side convenience only; they do not affect the agent.
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
---
## Updating
Update the agent (systemd):
```bash
# 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
```
Update the TUI (client):
```bash
cargo install socktop --force
socktop ws://HOST:3000/ws
```
Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
---
## Configuration (agent)
- Port:
- Flag: --port 8080 or -p 8080
- Positional: socktop_agent 8080
- Env: SOCKTOP_PORT=8080
- TLS (selfsigned):
- 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.
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
- Additional SANs: set `SOCKTOP_AGENT_EXTRA_SANS` (commaseparated) before first TLS start to include extra IPs/DNS names in the cert. Example:
```bash
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL
```
This prevents client errors like `NotValidForName` when connecting via an IP not present in the default cert SAN list.
- Expiry / rotation: the generated cert is valid for ~397 days from creation. If the agent fails to start with an "ExpiredCertificate" error (or your client reports expiry), simply delete the existing cert and key:
```bash
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
# (adjust path if XDG_CONFIG_HOME is set or different user)
systemctl restart socktop-agent # if running under systemd
```
On next TLS start the agent will generate a fresh pair. Only distribute the new cert.pem to clients (never the key).
- Auth token (optional): SOCKTOP_TOKEN=changeme
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
---
## Keyboard & Mouse
- Quit: q or Esc
- 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
---
## Example agent JSON
```json
{
"sampled_at_ms": 1786752000123,
"cpu_total": 12.4,
"cpu_per_core": [11.2, 15.7],
"mem_total": 33554432,
"mem_used": 18321408,
"swap_total": 0,
"swap_used": 0,
"process_count": 127,
"hostname": "myserver",
"cpu_temp_c": 42.5,
"disks": [{"name":"nvme0n1p2","total":512000000000,"available":320000000000}],
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
"top_processes": [
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
],
"gpus": null
}
```
Notes:
- process_count is merged into the main metrics on the client when processes are polled.
- top_processes are the current top 50 (sorting in the TUI is client-side).
---
## Security
Set a token on the agent and pass it as a query param from the client:
Server:
```bash
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
```
Client:
```bash
socktop "ws://HOST:3000/ws?token=changeme"
```
### TLS / WSS
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
Server (generates selfsigned cert and key on first run):
```bash
socktop_agent --enableSSL --port 8443
```
Client (trust/pin the server cert; copy cert.pem from the agent):
```bash
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
```
Notes:
- Do not copy the private key off the server; only the cert.pem is needed by clients.
- When --tls-ca/-t is supplied, the client autoupgrades ws:// to wss:// to avoid protocol mismatch.
- Hostname (SAN) verification is DISABLED by default; instead the client PINS the certificate: the agent must present a cert byte-identical to one in your `--tls-ca` file (expiry is ignored in this mode — you pinned that exact cert). Use `--verify-hostname` to switch to strict chain + SAN validation instead.
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
---
## Using tmux to monitor multiple hosts
You can use tmux to show multiple socktop instances in a single terminal.
![socktop screenshot](./docs/tmux_4_rpis_v3.jpg)
monitoring 4 Raspberry Pis using Tmux
Prerequisites:
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
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
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 (top-left, top-right, bottom-left, bottom-right)
- 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`
---
## Platform notes
- Linux: fully supported (agent and client).
- Raspberry Pi:
- 64-bit: aarch64-unknown-linux-gnu
- 32-bit: armv7-unknown-linux-gnueabihf
- Windows:
- TUI + agent can build with stable Rust; bring your own MSVC. Youre on Windows; you know the drill.
- CPU temperature may be unavailable.
- binary exe for both available in build artifacts under actions.
- macOS:
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
---
## Development
### Development
```bash
cargo fmt
@@ -557,35 +45,11 @@ cargo run -p socktop_agent -- --enableSSL --port 8443
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
Enable it (one-time):
```bash
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
```
Every commit will then format Rust sources and restage them automatically.
---
## Roadmap
- [x] Agent authentication (token)
- [x] Hide per-thread entries; only show processes
- [x] Sort top processes in the TUI
- [x] Configurable refresh intervals (client)
- [ ] Export metrics to file
- [x] TLS / WSS support (selfsigned server cert + client pinning)
- [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
- [ ] Outage notifications and reconnect.
- [ ] Per process detailed statistics pane
- [ ] cleanup of Disks section, properly display physical disks / partitions, remove duplicate entries
---
## License
MIT — see LICENSE.
---
MIT — see [LICENSE](LICENSE).
## Acknowledgements
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 MiB

+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop"
version = "1.60.0"
version = "1.60.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top"
edition = "2024"
@@ -11,7 +11,7 @@ repository = "https://github.com/jasonwitty/socktop"
[dependencies]
# socktop connector for agent communication
socktop_connector = { version = "1.60.0", path = "../socktop_connector" }
socktop_connector = { version = "1.60.1", path = "../socktop_connector" }
tokio = { workspace = true }
futures-util = { workspace = true }
+15 -13
View File
@@ -194,9 +194,10 @@ pub struct App {
// Security / status flags
pub is_tls: bool,
pub has_token: bool,
// Whether the connected agent is on this machine. Gates the local
// process-kill feature (t = SIGTERM, k = SIGKILL).
pub is_local: bool,
// Whether the local process-kill feature (t = SIGTERM, k = SIGKILL) is
// available: the connected agent is on this machine AND no policy override
// (--no-kill / SOCKTOP_NO_KILL) has disabled it.
pub kill_enabled: bool,
// Pending kill awaiting confirmation: (pid, process name). Which signal is
// sent depends on the button chosen in the confirmation modal, so it isn't
// decided until then.
@@ -295,7 +296,7 @@ impl App {
verify_hostname: false,
is_tls: false,
has_token: false,
is_local: false,
kill_enabled: false,
pending_kill: None,
force_compact: false,
header_title: String::new(),
@@ -349,9 +350,10 @@ impl App {
}
/// Enable the local process-kill feature. Only set true when the agent has
/// been verified to be on this machine (see [`crate::local`]).
pub fn with_local(mut self, is_local: bool) -> Self {
self.is_local = is_local;
/// been verified to be on this machine (see [`crate::local`]) and no
/// policy override (`--no-kill`, `SOCKTOP_NO_KILL`) forbids it.
pub fn with_kill_enabled(mut self, kill_enabled: bool) -> Self {
self.kill_enabled = kill_enabled;
self
}
@@ -375,11 +377,11 @@ impl App {
.map(|p| p.name.clone())
}
/// Raise the kill confirmation for `pid`. No-op unless the agent is on this
/// machine — the same gate the keybinding uses, repeated here because this
/// Raise the kill confirmation for `pid`. No-op unless the kill feature is
/// enabled — the same gate the keybinding uses, repeated here because this
/// is also reachable from the details modal.
fn prompt_kill(&mut self, pid: u32) {
if !self.is_local {
if !self.kill_enabled {
return;
}
let name = self
@@ -1182,7 +1184,7 @@ impl App {
// the details modal so it could not be reused there, and one
// key for both entry points is one thing to remember.
// SIGTERM vs SIGKILL is chosen in the confirmation modal.
if self.is_local
if self.kill_enabled
&& !self.modal_manager.is_active()
&& matches!(k.code, KeyCode::Char('t') | KeyCode::Char('T'))
&& let Some(pid) = self.selected_process_pid
@@ -1948,7 +1950,7 @@ impl App {
filtered_indices: &self.procs_filtered,
cached_rows: &self.procs_row_cache,
peak_cpu: self.procs_row_peak_cpu,
is_local: self.is_local,
kill_enabled: self.kill_enabled,
},
);
@@ -1969,7 +1971,7 @@ impl App {
},
max_mem_bytes: self.max_process_mem_bytes,
unsupported: self.process_details_unsupported,
is_local: self.is_local,
kill_enabled: self.kill_enabled,
},
);
}
+55 -11
View File
@@ -25,6 +25,18 @@ pub(crate) struct ParsedArgs {
processes_interval_ms: Option<u64>,
verify_hostname: bool,
compact: bool,
no_kill: bool,
}
/// True when the `SOCKTOP_NO_KILL` environment variable disables the process-kill
/// feature. Any value other than empty, `0`, or `false` (case-insensitive) counts
/// as set, so a deployment can export `SOCKTOP_NO_KILL=1` once and every socktop
/// launched under it — whatever its command line — has the feature off.
pub(crate) fn no_kill_from_env() -> bool {
match env::var("SOCKTOP_NO_KILL") {
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
Err(_) => false,
}
}
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
@@ -40,11 +52,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false;
let mut compact = false;
let mut no_kill = false;
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
return Err(format!(
"Usage: {prog} [--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]\n"
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
));
}
"--tls-ca" | "-t" => {
@@ -70,6 +83,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
// layout switches on its own once the window gets too short.
compact = true;
}
"--no-kill" => {
// Disable the local process-kill feature even when the agent is
// local. For shared/kiosk deployments; SOCKTOP_NO_KILL=1 in the
// environment does the same without touching the command line.
no_kill = true;
}
"--dry-run" => {
// intentionally undocumented
dry_run = true;
@@ -105,11 +124,20 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
}
}
_ => {
// An unrecognized option must never fall through to the
// positional URL slot: an older binary handed a newer flag
// would otherwise "connect" to the flag text — and offer to
// save it over a named profile's URL.
if arg.starts_with('-') {
return Err(format!(
"Unknown option '{arg}'. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
));
}
if url.is_none() {
url = Some(arg);
} else {
return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
));
}
}
@@ -126,6 +154,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
processes_interval_ms,
verify_hostname,
compact,
no_kill,
})
}
@@ -135,6 +164,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
// --help produces the bare usage text and exits cleanly; real
// parse errors must be visible to scripts and CI as a failure.
if !msg.starts_with("Usage:") {
std::process::exit(2);
}
return Ok(());
}
};
@@ -146,7 +180,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill).await;
}
let profiles_file = load_profiles();
@@ -251,7 +285,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if (1..=names.len()).contains(&idx) {
let name = &names[idx - 1];
if name == "demo" {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
return run_demo_mode(
parsed.tls_ca.as_deref(),
parsed.compact,
parsed.no_kill,
)
.await;
}
if let Some(entry) = profiles_mut.profiles.get(name) {
(
@@ -311,7 +350,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
);
eprintln!("If you don't have an agent running, you can try the demo mode.");
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill)
.await;
} else {
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(());
@@ -324,14 +364,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let is_tls = url.starts_with("wss://");
let has_token = url.contains("token=");
// Only enable local process-kill when the agent is verified to be on this
// machine; otherwise on-screen PIDs refer to a remote host and acting on
// them locally would signal the wrong process. See local::agent_is_local.
let is_local = local::agent_is_local(&url);
// machine otherwise on-screen PIDs refer to a remote host and acting on
// them locally would signal the wrong process (see local::agent_is_local) —
// AND neither --no-kill nor SOCKTOP_NO_KILL disables it as a matter of
// policy (shared terminals, public demos).
let kill_enabled = local::agent_is_local(&url) && !parsed.no_kill && !no_kill_from_env();
let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms)
.with_status(is_tls, has_token)
.with_compact(parsed.compact)
.with_local(is_local);
.with_kill_enabled(kill_enabled);
if parsed.dry_run {
return Ok(());
}
@@ -398,6 +440,7 @@ fn gather_intervals(
async fn run_demo_mode(
_tls_ca: Option<&str>,
compact: bool,
no_kill: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws");
@@ -413,10 +456,11 @@ async fn run_demo_mode(
};
// Demo mode runs the real agent on loopback, so its PIDs are real local
// processes — enable the local process-kill feature, gated the same way as
// the normal connect path (loopback resolves local).
// the normal connect path (loopback resolves local, --no-kill and
// SOCKTOP_NO_KILL still override).
let mut app = App::new()
.with_compact(compact)
.with_local(local::agent_is_local(&url));
.with_kill_enabled(local::agent_is_local(&url) && !no_kill && !no_kill_from_env());
// Demo mode connects to localhost, so disable hostname verification
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
}
+2 -2
View File
@@ -130,8 +130,8 @@ impl ModalManager {
])];
// Kill from here too — same key as the processes pane, and only shown
// when the agent is local, since that is the only case where it works.
if data.is_local
// when the kill feature is enabled (agent local, no policy override).
if data.kill_enabled
&& let Some(line) = help_text.first_mut()
{
line.spans.push(Span::styled(
+4 -3
View File
@@ -19,9 +19,10 @@ pub struct ProcessModalData<'a> {
pub history: ProcessHistoryData<'a>,
pub max_mem_bytes: u64,
pub unsupported: bool,
/// Whether the agent is on this machine. Only used to decide whether the
/// `t` kill hint is shown — the kill itself is gated in `App`.
pub is_local: bool,
/// Whether the process-kill feature is available (agent local, no policy
/// override). Only used to decide whether the `t` kill hint is shown —
/// the kill itself is gated in `App`.
pub kill_enabled: bool,
}
/// Parameters for rendering scatter plot
+8 -7
View File
@@ -87,9 +87,10 @@ pub struct ProcessDisplayParams<'a> {
/// Peak cpu_usage from the most recent cache build; used to bold the
/// busiest process. -1.0 if no cache.
pub peak_cpu: f32,
/// Agent is on this machine, so the `t` kill hint applies. Without it the
/// hint would advertise a key that deliberately does nothing.
pub is_local: bool,
/// The process-kill feature is available (agent local, no policy
/// override), so the `t` kill hint applies. Without it the hint would
/// advertise a key that deliberately does nothing.
pub kill_enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -464,7 +465,7 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
Span::styled(" details", label),
Span::styled(" · ", label),
];
if params.is_local {
if params.kill_enabled {
hints.push(Span::styled("t", key));
hints.push(Span::styled(" kill", label));
hints.push(Span::styled(" · ", label));
@@ -952,7 +953,7 @@ mod click_tests {
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
is_local: false,
kill_enabled: false,
},
)
})
@@ -1066,7 +1067,7 @@ mod tooltip_tests {
}
/// Render the pane with a selection and return the whole buffer as text.
fn rendered(name: &str, width: u16, is_local: bool) -> String {
fn rendered(name: &str, width: u16, kill_enabled: bool) -> String {
let m = metrics(name);
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
@@ -1088,7 +1089,7 @@ mod tooltip_tests {
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
is_local,
kill_enabled,
},
)
})
+76
View File
@@ -106,3 +106,79 @@ fn test_compact_flag_documented_and_accepted() {
String::from_utf8_lossy(&out2.stderr)
);
}
#[test]
fn test_no_kill_flag_documented_and_accepted() {
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--no-kill", "--help"])
.output()
.expect("run socktop --no-kill --help");
assert!(
out.status.success(),
"socktop --no-kill --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("--no-kill"),
"help text missing --no-kill\n{text}"
);
// The flag must not be mistaken for the positional URL argument.
let out2 = Command::new(exe)
.args(["--no-kill", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop --no-kill --dry-run");
assert!(
out2.status.success(),
"socktop --no-kill with a URL was rejected: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
#[test]
fn test_no_kill_env_var_accepted() {
// SOCKTOP_NO_KILL must not break startup — the env-only path is how the
// webterm deployment disables the kill feature for every invocation.
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.env("SOCKTOP_NO_KILL", "1")
.args(["--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop with SOCKTOP_NO_KILL=1");
assert!(
out.status.success(),
"socktop with SOCKTOP_NO_KILL=1 did not succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn test_unknown_option_rejected_not_treated_as_url() {
// Regression guard for the socktop.io incident (Aug 2026): socktop 1.60.1
// parsed the then-unknown --no-kill flag as the positional websocket URL,
// which made it prompt to overwrite the 'local' profile's URL with the
// literal string "--no-kill". Unknown options must fail loudly instead of
// falling through to the URL slot.
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--not-a-real-flag", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop with unknown flag");
assert_eq!(
out.status.code(),
Some(2),
"unknown option should exit 2, got: {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("Unknown option '--not-a-real-flag'"),
"stderr should name the rejected option\n{err}"
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop_agent"
version = "1.60.0"
version = "1.60.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop_connector"
version = "1.60.0"
version = "1.60.2"
edition = "2024"
license = "MIT"
description = "WebSocket connector library for socktop agent communication"