Compare commits

..

31 Commits

Author SHA1 Message Date
jason e53d0ab98d Add TLS / Token, polling interval indicators.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-21 17:38:26 -07:00
jason 2ca51adc61 tui: refine header icons (crossed TLS when disabled, spacing fix) 2025-08-21 17:28:21 -07:00
jason 67ecf36883 feat(tui): header shows TLS/token status and polling intervals 2025-08-21 17:24:41 -07:00
jason 9a35306340 cargo fmt 2025-08-21 16:19:49 -07:00
jason a4bb6f170a feat(client): configurable metrics/process intervals with profile persistence; docs updated 2025-08-21 16:18:41 -07:00
jason f9114426cc add unit tests for profile creation and update readme 2025-08-21 14:42:15 -07:00
jason 8ee2a03a2c chore(client): clean up demo mode integration and add stop log line 2025-08-21 13:55:02 -07:00
jason 0275b1871d cargo fmt 2025-08-21 13:49:36 -07:00
jason 9491dc50a8 feat(client): demo mode (--demo or select demo) auto-spawns local agent on 3231 2025-08-21 13:47:28 -07:00
jason e7eb3e6557 cargo fmt 2025-08-21 13:18:36 -07:00
jason a596acfb72 chore(client): refactor profile overwrite logic to satisfy clippy 2025-08-21 13:17:53 -07:00
jason b727e54589 feat(client): prompt for URL/CA when specifying a new profile name 2025-08-21 12:56:11 -07:00
jason 2af08c455a fix(client): correct profile overwrite prompt logic (only save on confirm or --save) 2025-08-21 12:48:53 -07:00
jason d049846564 docs: add connection profiles section to README 2025-08-21 12:41:46 -07:00
jason 97308f9d15 feat(client): connection profiles (--profile/-P, --save) with JSON persistence 2025-08-21 12:39:21 -07:00
jason 4cef273e57 Merge pull request #2 from jasonwitty/feature/protobuf-processes
Feature/protobuf processes
2025-08-21 11:50:27 -07:00
jason 660474a6ce ci cleanup
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-20 20:36:49 -07:00
jason 93dd14967d try/fix windows again ! 2025-08-20 16:32:51 -07:00
jason 923a3872fe add logging to help debug windows problems 2025-08-20 15:47:28 -07:00
jason 5f10e34341 windows try/fix 2025-08-20 15:20:07 -07:00
jason b80d322650 cargo fmt 2025-08-20 11:29:22 -07:00
jason fff386f9d5 fixing windows build problems. i hate windows !
Agent:
Added GET /healthz that returns 200 immediately.
File: main.rs (router now includes /healthz).
CI workflow:
Start agent from target/release on both OSes.
Set SOCKTOP_ENABLE_SSL=0 explicitly.
Ubuntu: wait on curl http://127.0.0.1:3000/healthz (60s), log tail and ss/netstat on failure.
Windows: wait on Invoke-WebRequest to /healthz (60s), capture stdout/stderr, print netstat on failure.
File: .github/workflows/ci.yml.
2025-08-20 11:26:09 -07:00
jason 93f4e1feea fix windows build after ssl feature and optimize build 2025-08-20 10:24:24 -07:00
jason 97255b42fb fix windows build 2025-08-20 00:14:21 -07:00
jason 554a2c349f protobuff Process list
BREAKING: Process list over WS is now Protocol Buffers; client required.
Agent: returns all processes (no server-side top-k); large payloads gzip-compressed.
Client: decodes protobuf (gz/raw), moves sorting/pagination to TUI.
Build: add prost/prost-build with vendored protoc; enable thin LTO, panic=abort, strip symbols.
Cleanup: cfg-gate Linux-only code; fix Clippy across platforms; tests updated (ws probe TLS CA).
2025-08-19 23:24:36 -07:00
jason 10501168c5 clippy fixes. 2025-08-19 15:52:30 -07:00
jason d346c61c28 Merge pull request #1 from jasonwitty/feature/wss-selfsigned
SSL Support
2025-08-19 15:33:50 -07:00
jason 7652095109 cargo fmt
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-19 15:33:11 -07:00
jason 6b58ac67f6 Merge branch 'master' into feature/wss-selfsigned 2025-08-19 15:31:10 -07:00
jason 36e73fd9ed cargo fmt 2025-08-16 01:25:03 -07:00
jason 3d14e4a370 SSL Support
Add WSS/TLS (self‑signed) with client cert pinning; auto ws→wss on --tls-ca/-t; add -p/-t flags; harden TLS test; fix clippy; update README.

feat: WSS/TLS support (self‑signed + pinning), auto ws→wss when CA provided, new -p/-t flags; tests + clippy cleanup; docs updated.

Add TLS: self‑signed certs on agent, client pin via --tls-ca/-t (auto‑upgrade to wss), CLI/tests/README updates, clippy fixes.

12 files changed
Cargo.toml
README.md
Cargo.tomlsocktop_agent
main.rssocktop_agent/src
tls.rssocktop_agent/src
cli_args.rssocktop_agent/tests
Add Context...
README.md
2025-08-16 01:23:20 -07:00
25 changed files with 2345 additions and 415 deletions
+67 -52
View File
@@ -5,16 +5,16 @@ on:
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install system dependencies
- name: Install system dependencies (Linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y libdrm-dev libdrm-amdgpu1
- name: Cargo fmt
@@ -23,75 +23,89 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build (release)
run: cargo build --release --workspace
- name: Start agent (Ubuntu)
- name: "Linux: start agent and run WS probe"
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
# Use debug build for faster startup in CI
RUST_LOG=info cargo run -p socktop_agent -- -p 3000 &
RUST_LOG=info SOCKTOP_ENABLE_SSL=0 SOCKTOP_AGENT_GPU=0 SOCKTOP_AGENT_TEMP=0 ./target/release/socktop_agent -p 3000 > agent.log 2>&1 &
AGENT_PID=$!
echo "AGENT_PID=$AGENT_PID" >> $GITHUB_ENV
# Wait for port 3000 to accept connections (30s max)
for i in {1..60}; do
if bash -lc "</dev/tcp/127.0.0.1/3000" &>/dev/null; then
echo "agent is ready"
break
fi
sleep 0.5
if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then break; fi
sleep 1
done
- name: Run WS probe test (Ubuntu)
if: matrix.os == 'ubuntu-latest'
shell: bash
env:
SOCKTOP_WS: ws://127.0.0.1:3000/ws
run: |
set -euo pipefail
cargo test -p socktop --test ws_probe -- --nocapture
- name: Stop agent (Ubuntu)
if: always() && matrix.os == 'ubuntu-latest'
shell: bash
run: |
if [ -n "${AGENT_PID:-}" ]; then kill $AGENT_PID || true; fi
- name: Start agent (Windows)
if ! curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
echo "--- agent.log (tail) ---"
tail -n 200 agent.log || true
(command -v ss >/dev/null && ss -ltnp || netstat -ltnp) || true
kill $AGENT_PID || true
exit 1
fi
SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop --test ws_probe -- --nocapture
kill $AGENT_PID || true
- name: "Windows: start agent and run WS probe"
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$p = Start-Process -FilePath "cargo" -ArgumentList "run -p socktop_agent -- -p 3000" -PassThru
echo "AGENT_PID=$($p.Id)" | Out-File -FilePath $env:GITHUB_ENV -Append
$env:SOCKTOP_ENABLE_SSL = "0"
$env:SOCKTOP_AGENT_GPU = "0"
$env:SOCKTOP_AGENT_TEMP = "0"
$out = Join-Path $PWD "agent.out.txt"
$err = Join-Path $PWD "agent.err.txt"
$p = Start-Process -FilePath "${PWD}\target\release\socktop_agent.exe" -ArgumentList "-p 3000" -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -NoNewWindow
$ready = $false
for ($i = 0; $i -lt 60; $i++) {
if (Test-NetConnection -ComputerName 127.0.0.1 -Port 3000 -InformationLevel Quiet) { $ready = $true; break }
Start-Sleep -Milliseconds 500
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = "curl.exe"
$pinfo.Arguments = "-fsS http://127.0.0.1:3000/healthz"
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$proc = [System.Diagnostics.Process]::Start($pinfo)
$proc.WaitForExit()
if ($proc.ExitCode -eq 0) { $ready = $true; break }
Start-Sleep -Seconds 1
}
if (-not $ready) {
Write-Warning "TCP connect to (127.0.0.1 : 3000) failed"
if (Test-Path $out) { Write-Host "--- agent.out (full) ---"; Get-Content $out }
if (Test-Path $err) { Write-Host "--- agent.err (full) ---"; Get-Content $err }
Write-Host "--- netstat ---"
netstat -ano | Select-String ":3000" | ForEach-Object { $_.Line }
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
throw "agent did not become ready"
}
if (-not $ready) { Write-Error "agent did not become ready" }
- name: Run WS probe test (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
cargo test -p socktop --test ws_probe -- --nocapture
- name: Stop agent (Windows)
if: always() && matrix.os == 'windows-latest'
shell: pwsh
run: |
if ($env:AGENT_PID) { Stop-Process -Id $env:AGENT_PID -Force -ErrorAction SilentlyContinue }
try {
cargo test -p socktop --test ws_probe -- --nocapture
} finally {
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
}
- name: Smoke test (client --help)
run: cargo run -p socktop -- --help
- name: Package artifacts
- name: Package artifacts (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -e
mkdir dist
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp target/release/socktop.exe dist/
cp target/release/socktop_agent.exe dist/
7z a socktop-${{ matrix.os }}.zip dist/*
else
cp target/release/socktop dist/
cp target/release/socktop_agent dist/
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
fi
mkdir -p dist
cp target/release/socktop dist/
cp target/release/socktop_agent dist/
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
- name: Package artifacts (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item target\release\socktop.exe dist\
Copy-Item target\release\socktop_agent.exe dist\
Compress-Archive -Path dist\* -DestinationPath socktop-${{ matrix.os }}.zip -Force
- name: Upload build artifacts (ephemeral)
uses: actions/upload-artifact@v4
with:
@@ -99,6 +113,7 @@ jobs:
path: |
*.tar.gz
*.zip
- name: Upload to rolling GitHub Release (main only)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: softprops/action-gh-release@v2
Generated
+866 -6
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -13,7 +13,7 @@ futures-util = "0.3"
anyhow = "1.0"
# websocket
tokio-tungstenite = "0.24"
tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] }
tungstenite = "0.24"
url = "2.5"
@@ -34,3 +34,17 @@ chrono = { version = "0.4", features = ["serde"] }
# web server (remote-agent)
axum = { version = "0.7", features = ["ws"] }
# protobuf
prost = "0.13"
prost-types = "0.13"
bytes = "1"
dirs-next = "2"
[profile.release]
# Favor smaller, simpler binaries with good runtime perf
lto = "thin"
codegen-units = 1
panic = "abort"
opt-level = 3
strip = "symbols"
+142 -4
View File
@@ -12,6 +12,7 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
## Features
- 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
@@ -67,7 +68,7 @@ Two components:
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects on demand when the client asks (fast metrics ~500 ms, processes ~2 s, disks ~5 s). No background loop when nobody is connected.
2) Client (local): TUI that connects to ws://HOST:PORT/ws and renders updates.
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
---
@@ -93,7 +94,18 @@ cargo build --release
./target/release/socktop ws://REMOTE_HOST:3000/ws
```
Tip: Add ?token=... if you enable auth (see Security).
### 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
---
@@ -135,6 +147,8 @@ Agent (server):
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):
@@ -143,6 +157,11 @@ Client (TUI):
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
# shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
```
Intervals (client-driven):
@@ -154,6 +173,96 @@ The agent stays idle unless queried. When queried, it collects just whats nee
---
## 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):
@@ -188,6 +297,13 @@ Tip: If only the binary changed, restart is enough. If the unit file changed, ru
- 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.
- Auth token (optional): SOCKTOP_TOKEN=changeme
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
@@ -250,6 +366,27 @@ Client:
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.
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
---
## Using tmux to monitor multiple hosts
@@ -319,7 +456,8 @@ Tips:
cargo fmt
cargo clippy --all-targets --all-features
cargo run -p socktop -- ws://127.0.0.1:3000/ws
cargo run -p socktop_agent -- --port 3000
# TLS (dev): first run will create certs under ~/.config/socktop_agent/tls/
cargo run -p socktop_agent -- --enableSSL --port 8443
```
---
@@ -331,7 +469,7 @@ cargo run -p socktop_agent -- --port 3000
- [x] Sort top processes in the TUI
- [ ] Configurable refresh intervals (client)
- [ ] Export metrics to file
- [ ] TLS / WSS support
- [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)
---
+8
View File
@@ -0,0 +1,8 @@
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
Error: Address already in use (os error 98)
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
Error: Address already in use (os error 98)
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
package socktop;
// All running processes. Sorting is done client-side.
message Processes {
uint64 process_count = 1; // total processes in the system
repeated Process rows = 2; // all processes
}
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3; // 0..100
uint64 mem_bytes = 4; // RSS bytes
}
+13 -1
View File
@@ -19,4 +19,16 @@ crossterm = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
tungstenite = "0.27.0"
dirs-next = { workspace = true }
rustls = "0.23"
rustls-pemfile = "2.1"
prost = { workspace = true }
bytes = { workspace = true }
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3"
[build-dependencies]
prost-build = "0.13"
protoc-bin-vendored = "3"
+8
View File
@@ -0,0 +1,8 @@
fn main() {
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
std::env::set_var("PROTOC", protoc);
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
cfg.compile_protos(&["../proto/processes.proto"], &["../proto"])
.expect("compile protos");
}
+47 -13
View File
@@ -63,9 +63,13 @@ pub struct App {
last_disks_poll: Instant,
procs_interval: Duration,
disks_interval: Duration,
metrics_interval: Duration,
// For reconnects
ws_url: String,
// Security / status flags
pub is_tls: bool,
pub has_token: bool,
}
impl App {
@@ -94,14 +98,38 @@ impl App {
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
ws_url: String::new(),
is_tls: false,
has_token: false,
}
}
pub async fn run(&mut self, url: &str) -> Result<(), Box<dyn std::error::Error>> {
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
if let Some(m) = metrics_ms {
self.metrics_interval = Duration::from_millis(m.max(100));
}
if let Some(p) = procs_ms {
self.procs_interval = Duration::from_millis(p.max(200));
}
self
}
pub fn with_status(mut self, is_tls: bool, has_token: bool) -> Self {
self.is_tls = is_tls;
self.has_token = has_token;
self
}
pub async fn run(
&mut self,
url: &str,
tls_ca: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
// Connect to agent
//let mut ws = connect(url, tls_ca).await?;
self.ws_url = url.to_string();
let mut ws = connect(url).await?;
let mut ws = connect(url, tls_ca).await?;
// Terminal setup
enable_raw_mode()?;
@@ -249,10 +277,7 @@ impl App {
break;
}
// Draw current frame first so the UI never feels blocked
terminal.draw(|f| self.draw(f))?;
// Then fetch and update
// Fetch and update
if let Some(m) = request_metrics(ws).await {
self.update_with_metrics(m);
@@ -276,15 +301,13 @@ impl App {
}
self.last_disks_poll = Instant::now();
}
} else {
// If we couldn't get metrics, try to reconnect once
if let Ok(new_ws) = connect(&self.ws_url).await {
*ws = new_ws;
}
}
// Draw
terminal.draw(|f| self.draw(f))?;
// Tick rate
sleep(Duration::from_millis(500)).await;
sleep(self.metrics_interval).await;
}
Ok(())
@@ -351,7 +374,15 @@ impl App {
.split(area);
// Header
draw_header(f, rows[0], self.last_metrics.as_ref());
draw_header(
f,
rows[0],
self.last_metrics.as_ref(),
self.is_tls,
self.has_token,
self.metrics_interval,
self.procs_interval,
);
// Top row: left CPU avg, right Per-core (full top-right)
let top_lr = ratatui::layout::Layout::default()
@@ -471,7 +502,10 @@ impl Default for App {
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
ws_url: String::new(),
is_tls: false,
has_token: false,
}
}
}
+374 -12
View File
@@ -2,29 +2,391 @@
mod app;
mod history;
mod profiles;
mod types;
mod ui;
mod ws;
use app::App;
use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, ResolveProfile};
use std::env;
use std::io::{self, Write};
pub(crate) struct ParsedArgs {
url: Option<String>,
tls_ca: Option<String>,
profile: Option<String>,
save: bool,
demo: bool,
dry_run: bool, // hidden test helper: skip connecting
metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>,
}
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
let mut it = args.into_iter();
let prog = it.next().unwrap_or_else(|| "socktop".into());
let mut url: Option<String> = None;
let mut tls_ca: Option<String> = None;
let mut profile: Option<String> = None;
let mut save = false;
let mut demo = false;
let mut dry_run = false;
let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None;
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"));
}
"--tls-ca" | "-t" => {
tls_ca = it.next();
}
"--profile" | "-P" => {
profile = it.next();
}
"--save" => {
save = true;
}
"--demo" => {
demo = true;
}
"--dry-run" => {
// intentionally undocumented
dry_run = true;
}
"--metrics-interval-ms" => {
metrics_interval_ms = it.next().and_then(|v| v.parse().ok());
}
"--processes-interval-ms" => {
processes_interval_ms = it.next().and_then(|v| v.parse().ok());
}
_ if arg.starts_with("--tls-ca=") => {
if let Some((_, v)) = arg.split_once('=') {
if !v.is_empty() {
tls_ca = Some(v.to_string());
}
}
}
_ if arg.starts_with("--profile=") => {
if let Some((_, v)) = arg.split_once('=') {
if !v.is_empty() {
profile = Some(v.to_string());
}
}
}
_ if arg.starts_with("--metrics-interval-ms=") => {
if let Some((_, v)) = arg.split_once('=') {
metrics_interval_ms = v.parse().ok();
}
}
_ if arg.starts_with("--processes-interval-ms=") => {
if let Some((_, v)) = arg.split_once('=') {
processes_interval_ms = v.parse().ok();
}
}
_ => {
if url.is_none() {
url = Some(arg);
} else {
return Err(format!("Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"));
}
}
}
}
Ok(ParsedArgs {
url,
tls_ca,
profile,
save,
demo,
dry_run,
metrics_interval_ms,
processes_interval_ms,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = env::args();
let prog = args.next().unwrap_or_else(|| "socktop".into());
let url = match args.next() {
Some(flag) if flag == "-h" || flag == "--help" => {
println!("Usage: {prog} ws://HOST:PORT/ws");
let parsed = match parse_args(env::args()) {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
return Ok(());
}
Some(url) => url,
None => {
eprintln!("Usage: {prog} ws://HOST:PORT/ws");
std::process::exit(1);
};
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref()).await;
}
let profiles_file = load_profiles();
let req = ProfileRequest {
profile_name: parsed.profile.clone(),
url: parsed.url.clone(),
tls_ca: parsed.tls_ca.clone(),
};
let resolved = req.resolve(&profiles_file);
let mut profiles_mut = profiles_file.clone();
let (url, tls_ca, metrics_interval_ms, processes_interval_ms): (
String,
Option<String>,
Option<u64>,
Option<u64>,
) = match resolved {
ResolveProfile::Direct(u, t) => {
if let Some(name) = parsed.profile.as_ref() {
let existing = profiles_mut.profiles.get(name);
match existing {
None => {
let (mi, pi) = gather_intervals(
parsed.metrics_interval_ms,
parsed.processes_interval_ms,
)?;
profiles_mut.profiles.insert(
name.clone(),
ProfileEntry {
url: u.clone(),
tls_ca: t.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
},
);
let _ = save_profiles(&profiles_mut);
(u, t, mi, pi)
}
Some(entry) => {
let changed = entry.url != u || entry.tls_ca != t;
if changed {
let overwrite = if parsed.save {
true
} else {
prompt_yes_no(&format!(
"Overwrite existing profile '{name}'? [y/N]: "
))
};
if overwrite {
let (mi, pi) = gather_intervals(
parsed.metrics_interval_ms,
parsed.processes_interval_ms,
)?;
profiles_mut.profiles.insert(
name.clone(),
ProfileEntry {
url: u.clone(),
tls_ca: t.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
},
);
let _ = save_profiles(&profiles_mut);
(u, t, mi, pi)
} else {
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
}
} else {
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
}
}
}
} else {
(
u,
t,
parsed.metrics_interval_ms,
parsed.processes_interval_ms,
)
}
}
ResolveProfile::Loaded(u, t) => {
let entry = profiles_mut
.profiles
.get(parsed.profile.as_ref().unwrap())
.unwrap();
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
}
ResolveProfile::PromptSelect(mut names) => {
if !names.iter().any(|n| n == "demo") {
names.push("demo".into());
}
eprintln!("Select profile:");
for (i, n) in names.iter().enumerate() {
eprintln!(" {}. {}", i + 1, n);
}
eprint!("Enter number (or blank to abort): ");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_ok() {
if let Ok(idx) = line.trim().parse::<usize>() {
if idx >= 1 && idx <= names.len() {
let name = &names[idx - 1];
if name == "demo" {
return run_demo_mode(parsed.tls_ca.as_deref()).await;
}
if let Some(entry) = profiles_mut.profiles.get(name) {
(
entry.url.clone(),
entry.tls_ca.clone(),
entry.metrics_interval_ms,
entry.processes_interval_ms,
)
} else {
return Ok(());
}
} else {
return Ok(());
}
} else {
return Ok(());
}
} else {
return Ok(());
}
}
ResolveProfile::PromptCreate(name) => {
eprintln!("Profile '{name}' does not exist yet.");
let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?;
if url.trim().is_empty() {
return Ok(());
}
let ca = prompt_string("Enter TLS CA path (or leave blank): ")?;
let ca_opt = if ca.trim().is_empty() {
None
} else {
Some(ca.trim().to_string())
};
let (mi, pi) =
gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?;
profiles_mut.profiles.insert(
name.clone(),
ProfileEntry {
url: url.trim().to_string(),
tls_ca: ca_opt.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
},
);
let _ = save_profiles(&profiles_mut);
(url.trim().to_string(), ca_opt, mi, pi)
}
ResolveProfile::None => {
eprintln!("No URL provided and no profiles to select.");
return Ok(());
}
};
let mut app = App::new();
app.run(&url).await
let is_tls = url.starts_with("wss://");
let has_token = url.contains("token=");
let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms)
.with_status(is_tls, has_token);
if parsed.dry_run {
return Ok(());
}
app.run(&url, tls_ca.as_deref()).await
}
fn prompt_yes_no(prompt: &str) -> bool {
eprint!("{prompt}");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_ok() {
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
} else {
false
}
}
fn prompt_string(prompt: &str) -> io::Result<String> {
eprint!("{prompt}");
let _ = io::stderr().flush();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(line)
}
fn gather_intervals(
arg_metrics: Option<u64>,
arg_procs: Option<u64>,
) -> Result<(Option<u64>, Option<u64>), Box<dyn std::error::Error>> {
let default_metrics = 500u64;
let default_procs = 2000u64;
let metrics = match arg_metrics {
Some(v) => Some(v),
None => {
let inp = prompt_string(&format!(
"Metrics interval ms (default {default_metrics}, Enter for default): "
))?;
let t = inp.trim();
if t.is_empty() {
Some(default_metrics)
} else {
Some(t.parse()?)
}
}
};
let procs = match arg_procs {
Some(v) => Some(v),
None => {
let inp = prompt_string(&format!(
"Processes interval ms (default {default_procs}, Enter for default): "
))?;
let t = inp.trim();
if t.is_empty() {
Some(default_procs)
} else {
Some(t.parse()?)
}
}
};
Ok((metrics, procs))
}
// Demo mode implementation
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws");
let child = spawn_demo_agent(port)?;
let mut app = App::new();
tokio::select! { res=app.run(&url,None)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
}
struct DemoGuard {
port: u16,
child: std::sync::Arc<std::sync::Mutex<Option<std::process::Child>>>,
}
impl Drop for DemoGuard {
fn drop(&mut self) {
if let Some(mut ch) = self.child.lock().unwrap().take() {
let _ = ch.kill();
}
eprintln!("Stopped demo agent on port {}", self.port);
}
}
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
let candidate = find_agent_executable();
let mut cmd = std::process::Command::new(candidate);
cmd.arg("--port").arg(port.to_string());
cmd.env("SOCKTOP_ENABLE_SSL", "0");
//JW: do not disable GPU and TEMP in demo mode
//cmd.env("SOCKTOP_AGENT_GPU", "0");
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
let child = cmd.spawn()?;
std::thread::sleep(std::time::Duration::from_millis(300));
Ok(DemoGuard {
port,
child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))),
})
}
fn find_agent_executable() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
#[cfg(windows)]
let name = "socktop_agent.exe";
#[cfg(not(windows))]
let name = "socktop_agent";
let candidate = parent.join(name);
if candidate.exists() {
return candidate;
}
}
}
std::path::PathBuf::from("socktop_agent")
}
+102
View File
@@ -0,0 +1,102 @@
//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca }
//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json)
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileEntry {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls_ca: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_interval_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub processes_interval_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfilesFile {
#[serde(default)]
pub profiles: BTreeMap<String, ProfileEntry>,
#[serde(default)]
pub version: u32,
}
pub fn config_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("socktop")
} else {
dirs_next::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("socktop")
}
}
pub fn profiles_path() -> PathBuf {
config_dir().join("profiles.json")
}
pub fn load_profiles() -> ProfilesFile {
let path = profiles_path();
match fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ProfilesFile::default(),
}
}
pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> {
let path = profiles_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(p).expect("serialize profiles");
fs::write(path, data)
}
pub enum ResolveProfile {
/// Use the provided runtime inputs (not persisted). (url, tls_ca)
Direct(String, Option<String>),
/// Loaded from existing profile entry (url, tls_ca)
Loaded(String, Option<String>),
/// Should prompt user to select among profile names
PromptSelect(Vec<String>),
/// Should prompt user to create a new profile (name)
PromptCreate(String),
/// No profile could be resolved (e.g., missing arguments)
None,
}
pub struct ProfileRequest {
pub profile_name: Option<String>,
pub url: Option<String>,
pub tls_ca: Option<String>,
}
impl ProfileRequest {
pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile {
// Case: only profile name given -> try load
if self.url.is_none() && self.profile_name.is_some() {
let name = self.profile_name.unwrap();
if let Some(entry) = pf.profiles.get(&name) {
return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone());
} else {
return ResolveProfile::PromptCreate(name);
}
}
// Both provided -> direct (maybe later saved by caller)
if let Some(u) = self.url {
return ResolveProfile::Direct(u, self.tls_ca);
}
// Nothing provided -> maybe prompt select if profiles exist
if self.url.is_none() && self.profile_name.is_none() {
if pf.profiles.is_empty() {
ResolveProfile::None
} else {
ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect())
}
} else {
ResolveProfile::None
}
}
}
+27 -7
View File
@@ -5,9 +5,18 @@ use ratatui::{
layout::Rect,
widgets::{Block, Borders},
};
use std::time::Duration;
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let title = if let Some(mm) = m {
pub fn draw_header(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
is_tls: bool,
has_token: bool,
metrics_interval: Duration,
procs_interval: Duration,
) {
let base = if let Some(mm) = m {
let temp = mm
.cpu_temp_c
.map(|t| {
@@ -21,12 +30,23 @@ pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>)
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into());
format!(
"socktop — host: {} | {} (press 'q' to quit)",
mm.hostname, temp
)
format!("socktop — host: {} | {}", mm.hostname, temp)
} else {
"socktop — connecting... (press 'q' to quit)".into()
"socktop — connecting...".into()
};
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
// Token indicator
let tok_txt = if has_token { "🔑 token" } else { "" };
let mi = metrics_interval.as_millis();
let pi = procs_interval.as_millis();
let intervals = format!("{mi}ms metrics | {pi}ms procs");
let mut parts = vec![base, tls_txt.into()];
if !tok_txt.is_empty() {
parts.push(tok_txt.into());
}
parts.push(intervals);
parts.push("(q to quit)".into());
let title = parts.join(" | ");
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
}
+110 -199
View File
@@ -1,82 +1,98 @@
//! Minimal WebSocket client helpers for requesting metrics from the agent.
use flate2::read::GzDecoder;
use flate2::bufread::GzDecoder;
use futures_util::{SinkExt, StreamExt};
use std::io::{Cursor, Read};
use std::sync::OnceLock;
use prost::Message as _;
use rustls::{ClientConfig, RootCertStore};
use rustls_pemfile::Item;
use std::io::Read;
use std::{fs::File, io::BufReader, sync::Arc};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{
connect_async, connect_async_tls_with_config, tungstenite::client::IntoClientRequest,
tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream,
};
use url::Url;
use crate::types::{DiskInfo, Metrics, ProcessesPayload};
use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
mod pb {
// generated by build.rs
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
}
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[inline]
fn debug_on() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
std::env::var("SOCKTOP_DEBUG")
.map(|v| v != "0")
.unwrap_or(false)
})
}
fn log_msg(msg: &Message) {
match msg {
Message::Binary(b) => eprintln!("ws: Binary {} bytes", b.len()),
Message::Text(s) => eprintln!("ws: Text {} bytes", s.len()),
Message::Close(_) => eprintln!("ws: Close"),
_ => eprintln!("ws: Other frame"),
}
}
// Connect to the agent and return the WS stream
pub async fn connect(url: &str) -> Result<WsStream, Box<dyn std::error::Error>> {
if debug_on() {
eprintln!("ws: connecting to {url}");
}
let (ws, _) = connect_async(url).await?;
if debug_on() {
eprintln!("ws: connected");
pub async fn connect(
url: &str,
tls_ca: Option<&str>,
) -> Result<WsStream, Box<dyn std::error::Error>> {
let mut u = Url::parse(url)?;
if let Some(ca_path) = tls_ca {
if u.scheme() == "ws" {
let _ = u.set_scheme("wss");
}
return connect_with_ca(u.as_str(), ca_path).await;
}
let (ws, _) = connect_async(u.as_str()).await?;
Ok(ws)
}
// Decompress a gzip-compressed binary frame into a String.
fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
let cursor = Cursor::new(bytes);
let mut dec = GzDecoder::new(cursor);
let mut out = String::new();
dec.read_to_string(&mut out).ok()?;
if debug_on() {
eprintln!("ws: gunzip decoded {} bytes", out.len());
async fn connect_with_ca(url: &str, ca_path: &str) -> Result<WsStream, Box<dyn std::error::Error>> {
let mut root = RootCertStore::empty();
let mut reader = BufReader::new(File::open(ca_path)?);
let mut der_certs = Vec::new();
while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) {
if let Item::X509Certificate(der) = item {
der_certs.push(der);
}
}
Some(out)
root.add_parsable_certificates(der_certs);
let cfg = ClientConfig::builder()
.with_root_certificates(root)
.with_no_client_auth();
let cfg = Arc::new(cfg);
let req = url.into_client_request()?;
let (ws, _) =
connect_async_tls_with_config(req, None, true, Some(Connector::Rustls(cfg))).await?;
Ok(ws)
}
fn message_to_json(msg: &Message) -> Option<String> {
match msg {
Message::Binary(b) => {
if debug_on() {
eprintln!("ws: <- Binary frame {} bytes", b.len());
}
if let Some(s) = gunzip_to_string(b) {
return Some(s);
}
// Fallback: try interpreting as UTF-8 JSON in a binary frame
String::from_utf8(b.clone()).ok()
}
Message::Text(s) => {
if debug_on() {
eprintln!("ws: <- Text frame {} bytes", s.len());
}
Some(s.clone())
// Send a "get_metrics" request and await a single JSON reply
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
if ws.send(Message::Text("get_metrics".into())).await.is_err() {
return None;
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Metrics>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
_ => None,
}
}
// Decompress a gzip-compressed binary frame into a String.
fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
let mut dec = GzDecoder::new(bytes);
let mut out = String::new();
dec.read_to_string(&mut out).ok()?;
Some(out)
}
fn gunzip_to_vec(bytes: &[u8]) -> Option<Vec<u8>> {
let mut dec = GzDecoder::new(bytes);
let mut out = Vec::new();
dec.read_to_end(&mut out).ok()?;
Some(out)
}
fn is_gzip(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
}
// Suppress dead_code until these are wired into the app
#[allow(dead_code)]
pub enum Payload {
@@ -85,124 +101,22 @@ pub enum Payload {
Processes(ProcessesPayload),
}
fn parse_any_payload(json: &str) -> Result<Payload, serde_json::Error> {
if let Ok(m) = serde_json::from_str::<Metrics>(json) {
return Ok(Payload::Metrics(m));
}
if let Ok(d) = serde_json::from_str::<Vec<DiskInfo>>(json) {
return Ok(Payload::Disks(d));
}
if let Ok(p) = serde_json::from_str::<ProcessesPayload>(json) {
return Ok(Payload::Processes(p));
}
Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unknown payload",
)))
}
// Send a "get_metrics" request and await a single JSON reply
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
if debug_on() {
eprintln!("ws: -> get_metrics");
}
if ws.send(Message::Text("get_metrics".into())).await.is_err() {
return None;
}
// Drain a few messages until we find Metrics (handle out-of-order replies)
for _ in 0..8 {
match timeout(Duration::from_millis(800), ws.next()).await {
Ok(Some(Ok(msg))) => {
if debug_on() {
log_msg(&msg);
}
if let Some(json) = message_to_json(&msg) {
match parse_any_payload(&json) {
Ok(Payload::Metrics(m)) => return Some(m),
Ok(Payload::Disks(_)) => {
if debug_on() {
eprintln!("ws: got Disks while waiting for Metrics");
}
}
Ok(Payload::Processes(_)) => {
if debug_on() {
eprintln!("ws: got Processes while waiting for Metrics");
}
}
Err(_e) => {
if debug_on() {
eprintln!(
"ws: unknown payload while waiting for Metrics (len={})",
json.len()
);
}
}
}
} else if debug_on() {
eprintln!("ws: non-json frame while waiting for Metrics");
}
}
Ok(Some(Err(_e))) => continue,
Ok(None) => return None,
Err(_elapsed) => continue,
}
}
None
}
// Send a "get_disks" request and await a JSON Vec<DiskInfo>
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
if debug_on() {
eprintln!("ws: -> get_disks");
}
if ws.send(Message::Text("get_disks".into())).await.is_err() {
return None;
}
for _ in 0..8 {
match timeout(Duration::from_millis(800), ws.next()).await {
Ok(Some(Ok(msg))) => {
if debug_on() {
log_msg(&msg);
}
if let Some(json) = message_to_json(&msg) {
match parse_any_payload(&json) {
Ok(Payload::Disks(d)) => return Some(d),
Ok(Payload::Metrics(_)) => {
if debug_on() {
eprintln!("ws: got Metrics while waiting for Disks");
}
}
Ok(Payload::Processes(_)) => {
if debug_on() {
eprintln!("ws: got Processes while waiting for Disks");
}
}
Err(_e) => {
if debug_on() {
eprintln!(
"ws: unknown payload while waiting for Disks (len={})",
json.len()
);
}
}
}
} else if debug_on() {
eprintln!("ws: non-json frame while waiting for Disks");
}
}
Ok(Some(Err(_e))) => continue,
Ok(None) => return None,
Err(_elapsed) => continue,
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Vec<DiskInfo>>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<Vec<DiskInfo>>(&json).ok(),
_ => None,
}
None
}
// Send a "get_processes" request and await a JSON ProcessesPayload
// Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped)
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
if debug_on() {
eprintln!("ws: -> get_processes");
}
if ws
.send(Message::Text("get_processes".into()))
.await
@@ -210,43 +124,40 @@ pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
{
return None;
}
for _ in 0..16 {
// allow a few more cycles due to gzip size
match timeout(Duration::from_millis(1200), ws.next()).await {
Ok(Some(Ok(msg))) => {
if debug_on() {
log_msg(&msg);
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
let gz = is_gzip(&b);
let data = if gz { gunzip_to_vec(&b)? } else { b };
match pb::Processes::decode(data.as_slice()) {
Ok(pb) => {
let rows: Vec<ProcessInfo> = pb
.rows
.into_iter()
.map(|p: pb::Process| ProcessInfo {
pid: p.pid,
name: p.name,
cpu_usage: p.cpu_usage,
mem_bytes: p.mem_bytes,
})
.collect();
Some(ProcessesPayload {
process_count: pb.process_count as usize,
top_processes: rows,
})
}
if let Some(json) = message_to_json(&msg) {
match parse_any_payload(&json) {
Ok(Payload::Processes(p)) => return Some(p),
Ok(Payload::Metrics(_)) => {
if debug_on() {
eprintln!("ws: got Metrics while waiting for Processes");
}
}
Ok(Payload::Disks(_)) => {
if debug_on() {
eprintln!("ws: got Disks while waiting for Processes");
}
}
Err(_e) => {
if debug_on() {
eprintln!(
"ws: unknown payload while waiting for Processes (len={})",
json.len()
);
}
}
Err(e) => {
if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") {
eprintln!("protobuf decode failed: {e}");
}
// Fallback: maybe it's JSON (bytes already decompressed if gz)
match String::from_utf8(data) {
Ok(s) => serde_json::from_str::<ProcessesPayload>(&s).ok(),
Err(_) => None,
}
} else if debug_on() {
eprintln!("ws: non-json frame while waiting for Processes");
}
}
Ok(Some(Err(_e))) => continue,
Ok(None) => return None,
Err(_elapsed) => continue,
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
_ => None,
}
None
}
+75
View File
@@ -0,0 +1,75 @@
//! CLI arg parsing tests for socktop (client)
use std::process::Command;
// We test the parsing by invoking the binary with --help and ensuring the help mentions short and long flags.
// Also directly test the parse_args function via a tiny helper in a doctest-like fashion using a small
// reimplementation here kept in sync with main (compile-time test).
#[test]
fn test_help_mentions_short_and_long_flags() {
let output = Command::new(env!("CARGO_BIN_EXE_socktop"))
.arg("--help")
.output()
.expect("run socktop --help");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
text.contains("--tls-ca")
&& text.contains("-t")
&& text.contains("--profile")
&& text.contains("-P"),
"help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}"
);
}
#[test]
fn test_tlc_ca_arg_long_and_short_parsed() {
// Use --help combined with flags to avoid network and still exercise arg acceptance
let exe = env!("CARGO_BIN_EXE_socktop");
// Long form with help
let out = Command::new(exe)
.args(["--tls-ca", "/tmp/cert.pem", "--help"])
.output()
.expect("run socktop");
assert!(
out.status.success(),
"socktop --tls-ca … --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(text.contains("Usage:"));
// Short form with help
let out2 = Command::new(exe)
.args(["-t", "/tmp/cert.pem", "--help"])
.output()
.expect("run socktop");
assert!(out2.status.success(), "socktop -t … --help did not succeed");
let text2 = format!(
"{}{}",
String::from_utf8_lossy(&out2.stdout),
String::from_utf8_lossy(&out2.stderr)
);
assert!(text2.contains("Usage:"));
// Profile flags with help (should not error)
let out3 = Command::new(exe)
.args(["--profile", "dev", "--help"])
.output()
.expect("run socktop");
assert!(
out3.status.success(),
"socktop --profile dev --help did not succeed"
);
let text3 = format!(
"{}{}",
String::from_utf8_lossy(&out3.stdout),
String::from_utf8_lossy(&out3.stderr)
);
assert!(text3.contains("Usage:"));
}
+118
View File
@@ -0,0 +1,118 @@
//! Tests for profile load/save and resolution logic (non-interactive paths only)
use std::fs;
use std::sync::Mutex;
// Global lock to serialize tests that mutate process-wide environment variables.
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[allow(dead_code)] // touch crate
fn touch() {
let _ = socktop::types::Metrics {
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 0,
mem_used: 0,
swap_total: 0,
swap_used: 0,
process_count: None,
hostname: String::new(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus: None,
};
}
// We re-import internal modules by copying minimal logic here because profiles.rs isn't public.
// Instead of exposing internals, we simulate profile saving through CLI invocations.
use std::process::Command;
fn run_socktop(args: &[&str]) -> (bool, String) {
let exe = env!("CARGO_BIN_EXE_socktop");
let output = Command::new(exe).args(args).output().expect("run socktop");
let ok = output.status.success();
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
(ok, text)
}
fn config_dir() -> std::path::PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
std::path::PathBuf::from(xdg).join("socktop")
} else {
dirs_next::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("socktop")
}
}
fn profiles_path() -> std::path::PathBuf {
config_dir().join("profiles.json")
}
#[test]
fn test_profile_created_on_first_use() {
let _guard = ENV_LOCK.lock().unwrap();
// Isolate config in a temp dir
let td = tempfile::tempdir().unwrap();
std::env::set_var("XDG_CONFIG_HOME", td.path());
// Ensure directory exists fresh
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
let _ = fs::remove_file(profiles_path());
// Provide profile + url => should create profiles.json
let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]);
// We pass --help to exit early after parsing (no network attempt)
let data = fs::read_to_string(profiles_path()).expect("profiles.json created");
assert!(
data.contains("unittest"),
"profiles.json missing profile entry: {data}"
);
}
#[test]
fn test_profile_overwrite_only_when_changed() {
let _guard = ENV_LOCK.lock().unwrap();
let td = tempfile::tempdir().unwrap();
std::env::set_var("XDG_CONFIG_HOME", td.path());
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
let _ = fs::remove_file(profiles_path());
// Initial create
let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create
let first = fs::read_to_string(profiles_path()).unwrap();
// Re-run identical (should not duplicate or corrupt)
let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical
let second = fs::read_to_string(profiles_path()).unwrap();
assert_eq!(
first, second,
"Profile file changed despite identical input"
);
// Overwrite with different URL using --save (no prompt path)
let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]);
let third = fs::read_to_string(profiles_path()).unwrap();
assert!(third.contains("two"), "Updated URL not written: {third}");
}
#[test]
fn test_profile_tls_ca_persisted() {
let _guard = ENV_LOCK.lock().unwrap();
let td = tempfile::tempdir().unwrap();
std::env::set_var("XDG_CONFIG_HOME", td.path());
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
let _ = fs::remove_file(profiles_path());
let (_ok, _out) = run_socktop(&[
"--profile",
"secureX",
"--tls-ca",
"/tmp/cert.pem",
"wss://host/ws",
"--dry-run",
]);
let data = fs::read_to_string(profiles_path()).unwrap();
assert!(data.contains("secureX"));
assert!(data.contains("cert.pem"));
}
+3 -1
View File
@@ -15,7 +15,9 @@ async fn probe_ws_endpoints() {
}
};
let mut ws = connect(&url).await.expect("connect ws");
// Optional pinned CA for WSS/self-signed setups
let tls_ca = std::env::var("SOCKTOP_TLS_CA").ok();
let mut ws = connect(&url, tls_ca.as_deref()).await.expect("connect ws");
// Should get fast metrics quickly
let m = request_metrics(&mut ws).await;
+18 -1
View File
@@ -20,4 +20,21 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nvml-wrapper = "0.10"
gfxinfo = "0.1.2"
tungstenite = "0.27.0"
once_cell = "1.19"
once_cell = "1.19"
axum-server = { version = "0.6", features = ["tls-rustls"] }
rustls = "0.23"
rustls-pemfile = "2.1"
openssl = { version = "0.10", features = ["vendored"] } # for crossplatform selfsigned generation
anyhow = "1"
hostname = "0.3"
bytes = { workspace = true }
prost = { workspace = true }
[build-dependencies]
prost-build = "0.13"
prost-types = { workspace = true }
tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"
+11
View File
@@ -0,0 +1,11 @@
fn main() {
// Ensure protoc exists (vendored for reproducible builds)
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
std::env::set_var("PROTOC", protoc);
// Compile protobuf definitions for processes
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
cfg.compile_protos(&["../proto/processes.proto"], &["../proto"])
.expect("compile protos");
}
+96 -59
View File
@@ -3,20 +3,38 @@
mod gpu;
mod metrics;
mod proto;
mod sampler;
mod state;
mod types;
mod ws;
use axum::{routing::get, Router};
use axum::{http::StatusCode, routing::get, Router};
use std::net::SocketAddr;
use std::str::FromStr;
mod tls;
use crate::sampler::{spawn_disks_sampler, spawn_process_sampler, spawn_sampler};
use state::AppState;
use ws::ws_handler;
fn arg_flag(name: &str) -> bool {
std::env::args().any(|a| a == name)
}
fn arg_value(name: &str) -> Option<String> {
let mut it = std::env::args();
while let Some(a) = it.next() {
if a == name {
return it.next();
}
}
None
}
// (tests moved to end of file to satisfy clippy::items_after_test_module)
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let state = AppState::new();
@@ -29,71 +47,90 @@ async fn main() {
// 5s disks
let _h_disks = spawn_disks_sampler(state.clone(), std::time::Duration::from_secs(5));
// Web app
let port = resolve_port();
// Web app: route /ws to the websocket handler
async fn healthz() -> StatusCode {
println!("/healthz request");
StatusCode::OK
}
let app = Router::new()
.route("/ws", get(ws_handler))
.with_state(state);
.route("/ws", get(ws::ws_handler))
.route("/healthz", get(healthz))
.with_state(state.clone());
let enable_ssl =
arg_flag("--enableSSL") || std::env::var("SOCKTOP_ENABLE_SSL").ok().as_deref() == Some("1");
if enable_ssl {
// Port can be overridden by --port or SOCKTOP_PORT; default to 8443 when SSL
let port = arg_value("--port")
.or_else(|| arg_value("-p"))
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(8443);
let (cert_path, key_path) = tls::ensure_self_signed_cert()?;
let cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
let addr = SocketAddr::from_str(&format!("0.0.0.0:{port}"))?;
println!("socktop_agent: TLS enabled. Listening on wss://{addr}/ws");
axum_server::bind_rustls(addr, cfg)
.serve(app.into_make_service())
.await?;
return Ok(());
}
// Non-TLS HTTP/WS path
let port = arg_value("--port")
.or_else(|| arg_value("-p"))
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(3000);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
//output to console
println!("Remote agent running at http://{addr}");
println!("WebSocket endpoint: ws://{addr}/ws");
//trace logging
tracing::info!("Remote agent running at http://{} (ws at /ws)", addr);
tracing::info!("WebSocket endpoint: ws://{}/ws", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
println!("socktop_agent: Listening on ws://{addr}/ws");
axum_server::bind(addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
// Resolve the listening port from CLI args/env with a 3000 default.
// Supports: --port <PORT>, -p <PORT>, a bare numeric positional arg, or SOCKTOP_PORT.
fn resolve_port() -> u16 {
const DEFAULT: u16 = 3000;
// Env takes precedence over positional, but is overridden by explicit flags if present.
if let Ok(s) = std::env::var("SOCKTOP_PORT") {
if let Ok(p) = s.parse::<u16>() {
if p != 0 {
return p;
}
}
eprintln!("Warning: invalid SOCKTOP_PORT='{s}'; using default {DEFAULT}");
}
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--port" | "-p" => {
if let Some(v) = args.next() {
match v.parse::<u16>() {
Ok(p) if p != 0 => return p,
_ => {
eprintln!("Invalid port '{v}'; using default {DEFAULT}");
return DEFAULT;
}
}
} else {
eprintln!("Missing value for {arg} ; using default {DEFAULT}");
return DEFAULT;
}
}
"--help" | "-h" => {
println!("Usage: socktop_agent [--port <PORT>] [PORT]\n SOCKTOP_PORT=<PORT> socktop_agent");
std::process::exit(0);
}
s => {
if let Ok(p) = s.parse::<u16>() {
if p != 0 {
return p;
#[cfg(test)]
mod tests_cli_agent {
// Local helper for testing port parsing
fn parse_port<I: IntoIterator<Item = String>>(args: I, default_port: u16) -> u16 {
let mut it = args.into_iter();
let _ = it.next(); // prog
let mut long: Option<String> = None;
let mut short: Option<String> = None;
while let Some(a) = it.next() {
match a.as_str() {
"--port" => long = it.next(),
"-p" => short = it.next(),
_ if a.starts_with("--port=") => {
if let Some((_, v)) = a.split_once('=') {
long = Some(v.to_string());
}
}
_ => {}
}
}
long.or(short)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(default_port)
}
DEFAULT
#[test]
fn port_long_short_and_assign() {
assert_eq!(
parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443),
9001
);
assert_eq!(
parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443),
9002
);
assert_eq!(
parse_port(vec!["agent".into(), "--port=9003".into()], 8443),
9003
);
assert_eq!(parse_port(vec!["agent".into()], 8443), 8443);
}
}
+8 -25
View File
@@ -236,9 +236,9 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
Some(utime.saturating_add(stime))
}
/// Collect top processes (Linux variant): compute CPU% via /proc jiffies delta.
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
#[cfg(target_os = "linux")]
pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPayload {
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// Fresh view to avoid lingering entries and select "no tasks" (no per-thread rows).
let mut sys = System::new();
sys.refresh_processes_specifics(
@@ -291,7 +291,7 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
.collect();
return ProcessesPayload {
process_count: total_count,
top_processes: top_k_sorted(procs, k),
top_processes: procs,
};
}
@@ -317,13 +317,13 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
ProcessesPayload {
process_count: total_count,
top_processes: top_k_sorted(procs, k),
top_processes: procs,
}
}
/// Collect top processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
/// Collect all processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
#[cfg(not(target_os = "linux"))]
pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPayload {
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
use tokio::time::sleep;
let mut sys = state.sys.lock().await;
@@ -344,7 +344,7 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
let total_count = sys.processes().len();
let mut procs: Vec<ProcessInfo> = sys
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| ProcessInfo {
@@ -354,8 +354,6 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
mem_bytes: p.memory(),
})
.collect();
procs = top_k_sorted(procs, k);
ProcessesPayload {
process_count: total_count,
top_processes: procs,
@@ -363,19 +361,4 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
}
// Small helper to select and sort top-k by cpu
fn top_k_sorted(mut v: Vec<ProcessInfo>, k: usize) -> Vec<ProcessInfo> {
if v.len() > k {
v.select_nth_unstable_by(k, |a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
v.truncate(k);
}
v.sort_by(|a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
v
}
// Client now handles sorting/pagination.
+4 -31
View File
@@ -1,32 +1,5 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metrics {
pub ts_unix_ms: i64,
pub host: String,
pub uptime_secs: u64,
pub cpu_overall: f32,
pub cpu_per_core: Vec<f32>,
pub load_avg: (f64, f64, f64),
pub mem_total_mb: u64,
pub mem_used_mb: u64,
pub swap_total_mb: u64,
pub swap_used_mb: u64,
pub net_aggregate: NetTotals,
pub top_processes: Vec<Proc>,
// Generated protobuf modules live under OUT_DIR; include them here.
// This module will expose socktop::Processes and socktop::Process types.
pub mod pb {
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetTotals {
pub rx_bytes: u64,
pub tx_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proc {
pub pid: i32,
pub name: String,
pub cpu: f32,
pub mem_mb: u64,
pub status: String,
}
+96
View File
@@ -0,0 +1,96 @@
use openssl::asn1::Asn1Time;
use openssl::hash::MessageDigest;
use openssl::nid::Nid;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::x509::extension::{
BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName,
};
use openssl::x509::{X509NameBuilder, X509};
use std::{
fs,
io::Write,
net::{IpAddr, Ipv4Addr},
path::{Path, PathBuf},
};
fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("socktop_agent")
.join("tls")
}
pub fn cert_paths() -> (PathBuf, PathBuf) {
let dir = config_dir();
(dir.join("cert.pem"), dir.join("key.pem"))
}
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
let (cert_path, key_path) = cert_paths();
if cert_path.exists() && key_path.exists() {
return Ok((cert_path, key_path));
}
fs::create_dir_all(cert_path.parent().unwrap())?;
// Key
let rsa = Rsa::generate(4096)?;
let pkey = PKey::from_rsa(rsa)?;
// Subject/issuer
let hostname = hostname::get()
.ok()
.and_then(|s| s.into_string().ok())
.unwrap_or_else(|| "localhost".to_string());
let mut name = X509NameBuilder::new()?;
name.append_entry_by_nid(Nid::COMMONNAME, &hostname)?;
let name = name.build();
// Cert builder
let mut builder = X509::builder()?;
builder.set_version(2)?;
builder.set_subject_name(&name)?;
builder.set_issuer_name(&name)?;
builder.set_pubkey(&pkey)?;
builder.set_not_before(Asn1Time::days_from_now(0)?.as_ref())?;
builder.set_not_after(Asn1Time::days_from_now(397)?.as_ref())?;
// SANs: hostname + localhost loopbacks
let mut san = SubjectAlternativeName::new();
san.dns(&hostname)
.dns("localhost")
.ip("127.0.0.1")
.ip("::1");
// Add a generic 0.0.0.0 for convenience; some TLS libs ignore this, but harmless.
let _ = san.ip(&IpAddr::V4(Ipv4Addr::UNSPECIFIED).to_string());
let san = san.build(&builder.x509v3_context(None, None))?;
// End-entity cert: not a CA
builder.append_extension(BasicConstraints::new().critical().build()?)?;
builder.append_extension(
KeyUsage::new()
.digital_signature()
.key_encipherment()
.build()?,
)?;
// TLS server usage
builder.append_extension(ExtendedKeyUsage::new().server_auth().build()?)?;
builder.append_extension(san)?;
builder.sign(&pkey, MessageDigest::sha256())?;
let cert: X509 = builder.build();
let mut f = fs::File::create(&cert_path)?;
f.write_all(&cert.to_pem()?)?;
let mut k = fs::File::create(&key_path)?;
k.write_all(&pkey.private_key_to_pem_pkcs8()?)?;
println!(
"socktop_agent: generated self-signed TLS certificate at {}",
cert_path.display()
);
println!("socktop_agent: private key at {}", key_path.display());
Ok((cert_path, key_path))
}
+35 -3
View File
@@ -10,7 +10,8 @@ use futures_util::StreamExt;
use std::collections::HashMap;
use std::io::Write;
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_top_k};
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all};
use crate::proto::pb;
use crate::state::AppState;
pub async fn ws_handler(
@@ -44,8 +45,39 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
let _ = send_json(&mut socket, &d).await;
}
Message::Text(ref text) if text == "get_processes" => {
let p = collect_processes_top_k(&state, 50).await;
let _ = send_json(&mut socket, &p).await;
let payload = collect_processes_all(&state).await;
// Map to protobuf message
let rows: Vec<pb::Process> = payload
.top_processes
.into_iter()
.map(|p| pb::Process {
pid: p.pid,
name: p.name,
cpu_usage: p.cpu_usage,
mem_bytes: p.mem_bytes,
})
.collect();
let pb = pb::Processes {
process_count: payload.process_count as u64,
rows,
};
let mut buf = Vec::with_capacity(8 * 1024);
if prost::Message::encode(&pb, &mut buf).is_err() {
let _ = socket.send(Message::Close(None)).await;
} else {
// compress if large
if buf.len() <= 768 {
let _ = socket.send(Message::Binary(buf)).await;
} else {
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
if enc.write_all(&buf).is_ok() {
let bin = enc.finish().unwrap_or(buf);
let _ = socket.send(Message::Binary(bin)).await;
} else {
let _ = socket.send(Message::Binary(buf)).await;
}
}
}
}
Message::Close(_) => break,
_ => {}
+28
View File
@@ -0,0 +1,28 @@
//! CLI arg parsing tests for socktop_agent (server)
use std::process::Command;
#[test]
fn test_help_and_port_short_long() {
// We verify port flags are accepted by ensuring the process starts (then we kill quickly).
// Use an unlikely port to avoid conflicts.
let exe = env!("CARGO_BIN_EXE_socktop_agent");
// TLS enabled with long --port
let mut child = Command::new(exe)
.args(["--enableSSL", "--port", "9555"])
.spawn()
.expect("spawn agent");
// Give it a moment to bind
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = child.kill();
let _ = child.wait();
// TLS enabled with short -p
let mut child2 = Command::new(exe)
.args(["--enableSSL", "-p", "9556"])
.spawn()
.expect("spawn agent");
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = child2.kill();
let _ = child2.wait();
}
+59
View File
@@ -0,0 +1,59 @@
use assert_cmd::prelude::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
fn expected_paths(config_home: &std::path::Path) -> (PathBuf, PathBuf) {
let base = config_home.join("socktop_agent").join("tls");
(base.join("cert.pem"), base.join("key.pem"))
}
#[test]
fn generates_self_signed_cert_and_key_in_xdg_path() {
// Create an isolated fake XDG_CONFIG_HOME
let tmpdir = tempfile::tempdir().expect("tempdir");
let xdg = tmpdir.path().to_path_buf();
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
cmd.env("XDG_CONFIG_HOME", &xdg)
.arg("--enableSSL")
.arg("-p")
.arg("0");
// Spawn the process and poll for cert generation
let mut child = cmd.spawn().expect("spawn agent");
// Poll up to ~3s for files to appear to avoid timing flakes
let (cert_path, key_path) = expected_paths(&xdg);
let start = Instant::now();
let timeout = Duration::from_millis(3000);
let interval = Duration::from_millis(50);
while start.elapsed() < timeout {
if cert_path.exists() && key_path.exists() {
break;
}
std::thread::sleep(interval);
}
// Terminate the process regardless
let _ = child.kill();
let _ = child.wait();
// Verify files exist at expected paths
assert!(
cert_path.exists(),
"cert not found at {}",
cert_path.display()
);
assert!(key_path.exists(), "key not found at {}", key_path.display());
// Also ensure they are non-empty
let cert_md = fs::metadata(&cert_path).expect("cert metadata");
let key_md = fs::metadata(&key_path).expect("key metadata");
assert!(cert_md.len() > 0, "cert is empty");
assert!(key_md.len() > 0, "key is empty");
}