Compare commits

...

66 Commits

Author SHA1 Message Date
jason b6e656738b chore(release): bump to 1.40.0
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-24 18:56:40 -07:00
jason f83cb07d57 Release candidate 1.4
increment version, support version flag on socktop
2025-08-24 18:03:45 -07:00
jason 7697c7dc2b docs: add per-crate README.md and link via Cargo.toml readme field 2025-08-24 17:56:22 -07:00
jason 1043fffc8d Merge pull request #5 from jasonwitty/feature/housekeeping
Feature/housekeeping
2025-08-24 17:46:26 -07:00
jason ce59dd9dfe chore(agent): fix clippy needless_return for non-linux process collection
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-24 12:52:56 -07:00
jason 8d48fa4c3b increment version 2025-08-24 12:47:49 -07:00
jason 51e702368e cargo fmt 2025-08-24 12:40:35 -07:00
jason 85f9a44e46 perf(agent): add hostname + TTL caches (metrics/disks/processes) and reuse sys for processes 2025-08-24 12:38:32 -07:00
jason b2468a5936 refactor(agent): remove unused background sampler infrastructure (request-driven only) 2025-08-24 12:29:23 -07:00
jason 8de5943f34 test(agent): move inline port parsing test to tests/port_parse.rs 2025-08-24 12:15:32 -07:00
jason e624751f56 chore: align sysinfo to 0.37 across workspace 2025-08-24 12:11:38 -07:00
jason 8bd1af7a27 chore: remove unused deps (thiserror, chrono, futures, nvml-wrapper, tungstenite, bytes, prost-types) 2025-08-24 12:08:52 -07:00
jason 5c32d15156 first run messaging.
display welcome message on first run when a profile file is not created and remote server not specified.
2025-08-24 12:01:17 -07:00
jason 471d547b5d cargo fmt and increment version 2025-08-23 02:18:09 -07:00
jason d3aff590bc client: fully disable hostname verification by custom ServerCertVerifier unless --verify-hostname used 2025-08-23 02:15:45 -07:00
jason 47910725a8 increment version 2025-08-22 22:48:41 -07:00
jason a8e3f4ef26 docs: describe --verify-hostname flag and default relaxed SAN behavior 2025-08-22 22:39:18 -07:00
jason fab1e5a104 client: default skip hostname verification; add --verify-hostname to enable 2025-08-22 22:39:06 -07:00
jason d0455611d5 cargo fmt 2025-08-22 14:09:02 -07:00
jason 4c45b85c98 fix san and increment version 2025-08-22 14:07:41 -07:00
jason d9fdc31e8f docs: document SOCKTOP_AGENT_EXTRA_SANS for additional certificate SANs 2025-08-22 13:48:07 -07:00
jason dc9aa4c026 agent: support extra SANs via SOCKTOP_AGENT_EXTRA_SANS env var 2025-08-22 13:47:51 -07:00
jason c2e91bd20c docs: document TLS cert expiry and manual regeneration procedure 2025-08-22 12:57:48 -07:00
jason 25229d6b03 cargo fmt 2025-08-22 12:49:12 -07:00
jason 290e2a8fb2 fix for expired certificate 2025-08-22 12:46:01 -07:00
jason 30d263c71e agent: dynamic self-signed cert validity (~397d from now) to avoid immediate expiry 2025-08-22 12:43:48 -07:00
jason 9b177f3206 cargo fmt 2025-08-22 11:55:41 -07:00
jason 8a6ae3fcd7 increment version 2025-08-22 11:53:27 -07:00
jason 5b8ec7efc1 agent: add --version / -V flag 2025-08-22 11:52:51 -07:00
jason 155c420a1a cargo fmt all check ins 2025-08-22 11:10:11 -07:00
jason d3fa55e572 chore: ignore .vscode and remove from repo 2025-08-22 11:09:33 -07:00
jason faf2861b29 cargo fmt 2025-08-22 10:53:47 -07:00
jason 59432ab1d3 agent: fix rcgen usage for self-signed cert generation 2025-08-22 10:48:01 -07:00
jason d1c8a64418 agent: replace openssl self-signed cert generation with rcgen (pure Rust) 2025-08-22 10:46:29 -07:00
jason 8def4b2d06 Publish: include proto in each crate and fix build.rs paths 2025-08-22 10:44:56 -07:00
jason a42ca71a9f update version prior to cargo publish 2025-08-22 10:41:17 -07:00
jason 9f675fa804 Merge pull request #4 from jasonwitty/feature/connection-profiles
Feature/connection profiles
2025-08-22 09:31:28 -07:00
jason 3ac03c07ba enable GPU polling only when GPU is present 2025-08-22 09:27:05 -07:00
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 384953d5d5 Merge pull request #3 from jasonwitty/feature/connection-profiles
Feature/connection profiles
2025-08-21 16:17:49 -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
33 changed files with 1870 additions and 706 deletions
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[pre-commit] Running cargo fmt --all" >&2
if ! command -v cargo >/dev/null 2>&1; then
echo "[pre-commit] cargo not found in PATH" >&2
exit 1
fi
cargo fmt --all
# Stage any Rust files that were reformatted
changed=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
if [ -n "$changed" ]; then
echo "$changed" | xargs git add
echo "[pre-commit] Added formatted files" >&2
fi
# Fail if further diffs remain (shouldn't happen normally)
unfmt=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
if [ -n "$unfmt" ]; then
echo "[pre-commit] Some Rust files still differ after formatting:" >&2
echo "$unfmt" >&2
exit 1
fi
exit 0
+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
+1
View File
@@ -1 +1,2 @@
/target
.vscode/
-83
View File
@@ -1,83 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'socktop'",
"cargo": {
"args": [
"build",
"--bin=socktop",
"--package=socktop"
],
"filter": {
"name": "socktop",
"kind": "bin"
}
},
"args": ["ws://127.0.0.1:3000/ws"],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'socktop'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=socktop",
"--package=socktop"
],
"filter": {
"name": "socktop",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'socktop_agent'",
"cargo": {
"args": [
"build",
"--bin=socktop_agent",
"--package=socktop_agent"
],
"filter": {
"name": "socktop_agent",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'socktop_agent'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=socktop_agent",
"--package=socktop_agent"
],
"filter": {
"name": "socktop_agent",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}
Generated
+316 -157
View File
@@ -357,10 +357,8 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -524,6 +522,15 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]]
name = "difflib"
version = "0.4.0"
@@ -540,6 +547,27 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -591,6 +619,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.2"
@@ -613,21 +647,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -1198,6 +1217,16 @@ dependencies = [
"windows-targets 0.53.3",
]
[[package]]
name = "libredox"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -1322,6 +1351,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "nom"
version = "7.1.3"
@@ -1351,6 +1386,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1417,54 +1458,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-src"
version = "300.5.2+3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "overload"
version = "0.1.1"
@@ -1500,12 +1493,32 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
dependencies = [
"base64",
"serde",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -1538,12 +1551,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -1553,6 +1560,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1608,6 +1621,122 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools 0.13.0",
"log",
"multimap",
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.13.0",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "quote"
version = "1.0.40"
@@ -1630,18 +1759,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
"rand_chacha",
"rand_core",
]
[[package]]
@@ -1651,17 +1770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
"rand_core",
]
[[package]]
@@ -1673,15 +1782,6 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "ratatui"
version = "0.28.1"
@@ -1703,6 +1803,19 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.17"
@@ -1712,6 +1825,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
version = "1.11.1"
@@ -2038,51 +2162,57 @@ dependencies = [
[[package]]
name = "socktop"
version = "0.1.11"
version = "1.40.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"crossterm 0.27.0",
"dirs-next",
"flate2",
"futures",
"futures-util",
"prost",
"prost-build",
"protoc-bin-vendored",
"ratatui",
"rustls 0.23.31",
"rustls-pemfile",
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"url",
]
[[package]]
name = "socktop_agent"
version = "0.1.11"
dependencies = [
"anyhow",
"assert_cmd",
"axum",
"axum-server",
"flate2",
"futures",
"futures-util",
"gfxinfo",
"hostname",
"nvml-wrapper",
"once_cell",
"openssl",
"rustls 0.23.31",
"rustls-pemfile",
"serde",
"serde_json",
"sysinfo",
"tempfile",
"tokio",
"tokio-tungstenite",
"url",
]
[[package]]
name = "socktop_agent"
version = "1.40.0"
dependencies = [
"anyhow",
"assert_cmd",
"axum",
"axum-server",
"flate2",
"futures-util",
"gfxinfo",
"hostname",
"once_cell",
"prost",
"prost-build",
"protoc-bin-vendored",
"rcgen",
"rustls 0.23.31",
"rustls-pemfile",
"serde",
"serde_json",
"sysinfo",
"tempfile",
"time",
"tokio",
"tonic-build",
"tracing",
"tracing-subscriber",
"tungstenite 0.27.0",
]
[[package]]
@@ -2241,6 +2371,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -2314,7 +2475,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.2",
"tungstenite 0.24.0",
"tungstenite",
]
[[package]]
@@ -2330,6 +2491,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tonic-build"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.4.13"
@@ -2447,7 +2620,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand 0.8.5",
"rand",
"rustls 0.23.31",
"rustls-pki-types",
"sha1",
@@ -2455,23 +2628,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.18.0"
@@ -2542,12 +2698,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3120,6 +3270,15 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.0"
+14 -7
View File
@@ -8,29 +8,36 @@ members = [
[workspace.dependencies]
# async + streams
tokio = { version = "1", features = ["full"] }
futures = "0.3"
futures-util = "0.3"
anyhow = "1.0"
# websocket
tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] }
tungstenite = "0.24"
url = "2.5"
# JSON + error handling
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
# system stats
sysinfo = "0.32"
# system stats (align across crates)
sysinfo = "0.37"
# CLI UI
ratatui = "0.28"
crossterm = "0.27"
# date/time
chrono = { version = "0.4", features = ["serde"] }
# web server (remote-agent)
axum = { version = "0.7", features = ["ws"] }
# protobuf
prost = "0.13"
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"
+156 -21
View File
@@ -66,7 +66,7 @@ sudo apt-get install libdrm-dev libdrm-amdgpu1
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.
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.
@@ -94,31 +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)
TLS quick start (optional, recommended on untrusted networks):
- Start the agent with TLS enabled (default TLS port 8443). On first run it will generate a selfsigned certificate and key under your config directory.
Spin up a temporary local agent on port 3231 and connect automatically:
```bash
./target/release/socktop_agent --enableSSL --port 8443 # or: -p 8443
# First run prints the cert and key paths, e.g.:
# socktop_agent: generated self-signed TLS certificate at /home/you/.config/socktop_agent/tls/cert.pem
# socktop_agent: private key at /home/you/.config/socktop_agent/tls/key.pem
socktop --demo
```
- Copy the certificate file to the client machine (keep the key private on the server):
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:
```bash
scp /home/you/.config/socktop_agent/tls/cert.pem you@client:/tmp/socktop-agent-ca.pem
```
- Connect with the TUI, pinning the server cert:
```bash
./target/release/socktop --tls-ca /tmp/socktop-agent-ca.pem wss://REMOTE_HOST:8443/ws
# Note: if you pass --tls-ca but use ws://, the client auto-upgrades to wss://
```
- 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
---
@@ -139,7 +126,8 @@ 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.
Option B: System-wide agent (Linux)
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
@@ -150,6 +138,36 @@ 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
@@ -172,6 +190,8 @@ socktop ws://HOST:3000/ws
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
@@ -186,6 +206,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):
@@ -227,6 +337,18 @@ Tip: If only the binary changed, restart is enough. If the unit file changed, ru
- 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
@@ -308,6 +430,7 @@ 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 (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking.
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
---
@@ -383,6 +506,18 @@ cargo run -p socktop -- ws://127.0.0.1:3000/ws
cargo run -p socktop_agent -- --enableSSL --port 8443
```
### Auto-format on commit
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
+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
}
+11 -4
View File
@@ -1,26 +1,33 @@
[package]
name = "socktop"
version = "0.1.11"
version = "1.40.0"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top"
edition = "2021"
license = "MIT"
readme = "README.md"
[dependencies]
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
dirs-next = { workspace = true }
sysinfo = { workspace = true }
rustls = "0.23"
rustls-pemfile = "2.1"
prost = { workspace = true }
[dev-dependencies]
assert_cmd = "2.0"
assert_cmd = "2.0"
tempfile = "3"
[build-dependencies]
prost-build = "0.13"
protoc-bin-vendored = "3"
+26
View File
@@ -0,0 +1,26 @@
# socktop (client)
Minimal TUI client for the socktop remote monitoring agent.
Features:
- Connects to a socktop_agent over WebSocket / secure WebSocket
- Displays CPU, memory, swap, disks, network, processes, (optional) GPU metrics
- Selfsigned TLS cert pinning via --tls-ca
- Profile management with saved intervals
- Low CPU usage (request-driven updates)
Quick start:
```
cargo install socktop
socktop ws://HOST:3000/ws
```
With TLS (copy agent cert first):
```
socktop --tls-ca cert.pem wss://HOST:8443/ws
```
Demo mode (spawns a local agent automatically on first run prompt):
```
socktop --demo
```
Full documentation, screenshots, and advanced usage:
https://github.com/jasonwitty/socktop
+14
View File
@@ -0,0 +1,14 @@
fn main() {
// Vendored protoc for reproducible builds (works on crates.io build machines)
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
std::env::set_var("PROTOC", &protoc);
// Tell Cargo when to re-run
println!("cargo:rerun-if-changed=proto/processes.proto");
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
// Use in-crate relative path so `cargo package` includes the file
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // paths relative to CARGO_MANIFEST_DIR
.expect("compile protos");
}
+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
}
+36 -2
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,10 +98,29 @@ 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 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,
@@ -284,7 +307,7 @@ impl App {
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,
}
}
}
+380 -18
View File
@@ -2,29 +2,73 @@
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};
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option<String>), String> {
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>,
verify_hostname: bool,
}
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;
let mut verify_hostname = 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] ws://HOST:PORT/ws"
));
return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--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();
}
"--verify-hostname" => {
// opt-in hostname (SAN) verification
// default behavior is to skip it for easier home network usage
// (still pins the provided certificate)
verify_hostname = true;
}
"--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() {
@@ -32,30 +76,48 @@ fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option
}
}
}
_ 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] ws://HOST:PORT/ws"
));
return Err(format!("Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"));
}
}
}
}
match url {
Some(u) => Ok((u, tls_ca)),
None => Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
)),
}
Ok(ParsedArgs {
url,
tls_ca,
profile,
save,
demo,
dry_run,
metrics_interval_ms,
processes_interval_ms,
verify_hostname,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Reuse the same parsing logic for testability
let (url, tls_ca) = match parse_args(env::args()) {
let parsed = match parse_args(env::args()) {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
@@ -63,6 +125,306 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
};
let mut app = App::new();
//support version flag (print and exit)
if env::args().any(|a| a == "--version" || a == "-V") {
println!("socktop {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref()).await;
}
if parsed.verify_hostname {
// Set env var consumed by ws::connect logic
std::env::set_var("SOCKTOP_VERIFY_NAME", "1");
}
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: &String| 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.");
//first run, no args, no profiles: show welcome message and offer demo mode
if profiles_mut.profiles.is_empty() && parsed.url.is_none() {
eprintln!("Welcome to socktop!");
eprintln!("It looks like this is your first time running the application.");
eprintln!("You can connect to a socktop_agent instance to monitor system metrics and processes.");
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()).await;
} else {
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(());
}
}
return Err("No URL provided and no profiles to select.".into());
}
};
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);
}
+99 -95
View File
@@ -2,20 +2,27 @@
use flate2::bufread::GzDecoder;
use futures_util::{SinkExt, StreamExt};
use prost::Message as _;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, RootCertStore};
use rustls::{DigitallySignedStruct, SignatureScheme};
use rustls_pemfile::Item;
use std::io::{Cursor, Read};
use std::sync::OnceLock;
use std::io::Read;
use std::{fs::File, io::BufReader, sync::Arc};
use tokio::net::TcpStream;
use tokio::time::{interval, timeout, Duration};
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>>;
@@ -46,27 +53,61 @@ async fn connect_with_ca(url: &str, ca_path: &str) -> Result<WsStream, Box<dyn s
}
root.add_parsable_certificates(der_certs);
let cfg = ClientConfig::builder()
let mut cfg = ClientConfig::builder()
.with_root_certificates(root)
.with_no_client_auth();
let cfg = Arc::new(cfg);
let req = url.into_client_request()?;
let verify_domain = std::env::var("SOCKTOP_VERIFY_NAME").ok().as_deref() == Some("1");
if !verify_domain {
#[derive(Debug)]
struct NoVerify;
impl ServerCertVerifier for NoVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
// Provide common schemes; not strictly needed for skipping but keeps API happy
vec![
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
eprintln!("socktop: hostname verification disabled (default). Use --verify-hostname to enable strict SAN checking.");
}
let cfg = Arc::new(cfg);
let (ws, _) =
connect_async_tls_with_config(req, None, true, Some(Connector::Rustls(cfg))).await?;
connect_async_tls_with_config(req, None, verify_domain, Some(Connector::Rustls(cfg)))
.await?;
Ok(ws)
}
#[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)
})
}
// 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() {
@@ -89,6 +130,16 @@ fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
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 {
@@ -97,23 +148,6 @@ pub enum Payload {
Processes(ProcessesPayload),
}
#[allow(dead_code)]
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_disks" request and await a JSON Vec<DiskInfo>
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
if ws.send(Message::Text("get_disks".into())).await.is_err() {
@@ -128,7 +162,7 @@ pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
}
}
// 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 ws
.send(Message::Text("get_processes".into()))
@@ -139,68 +173,38 @@ pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<ProcessesPayload>(&s).ok())
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,
})
}
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,
}
}
}
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
_ => None,
}
}
#[allow(dead_code)]
pub async fn start_ws_polling(mut ws: WsStream) {
let mut t_fast = interval(Duration::from_millis(500));
let mut t_procs = interval(Duration::from_secs(2));
let mut t_disks = interval(Duration::from_secs(5));
let _ = ws.send(Message::Text("get_metrics".into())).await;
let _ = ws.send(Message::Text("get_processes".into())).await;
let _ = ws.send(Message::Text("get_disks".into())).await;
loop {
tokio::select! {
_ = t_fast.tick() => {
let _ = ws.send(Message::Text("get_metrics".into())).await;
}
_ = t_procs.tick() => {
let _ = ws.send(Message::Text("get_processes".into())).await;
}
_ = t_disks.tick() => {
let _ = ws.send(Message::Text("get_disks".into())).await;
}
maybe = ws.next() => {
let Some(result) = maybe else { break; };
let Ok(msg) = result else { break; };
match msg {
Message::Binary(b) => {
if let Some(json) = gunzip_to_string(&b) {
if let Ok(payload) = parse_any_payload(&json) {
match payload {
Payload::Metrics(_m) => {
// update your app state with fast metrics
}
Payload::Disks(_d) => {
// update your app state with disks
}
Payload::Processes(_p) => {
// update your app state with processes
}
}
}
}
}
Message::Text(s) => {
if let Ok(payload) = parse_any_payload(&s) {
match payload {
Payload::Metrics(_m) => {}
Payload::Disks(_d) => {}
Payload::Processes(_p) => {}
}
}
}
Message::Close(_) => break,
_ => {}
}
}
}
}
}
+21 -2
View File
@@ -17,8 +17,11 @@ fn test_help_mentions_short_and_long_flags() {
String::from_utf8_lossy(&output.stderr)
);
assert!(
text.contains("--tls-ca") && text.contains("-t"),
"help text missing --tls-ca/-t\n{text}"
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}"
);
}
@@ -53,4 +56,20 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
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;
+11 -5
View File
@@ -1,10 +1,11 @@
[package]
name = "socktop_agent"
version = "0.1.11"
version = "1.40.0"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top"
edition = "2021"
license = "MIT"
readme = "README.md"
[dependencies]
tokio = { version = "1", features = ["full"] }
@@ -13,20 +14,25 @@ sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
futures = "0.3"
futures-util = "0.3.31"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nvml-wrapper = "0.10"
# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now)
gfxinfo = "0.1.2"
tungstenite = "0.27.0"
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
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build)
anyhow = "1"
hostname = "0.3"
prost = { workspace = true }
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
[build-dependencies]
prost-build = "0.13"
tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"
+32
View File
@@ -0,0 +1,32 @@
# socktop_agent (server)
Lightweight ondemand metrics WebSocket server for the socktop TUI.
Highlights:
- Collects system metrics only when requested (keeps idle CPU <1%)
- Optional TLS (selfsigned cert autogenerated & pinned by client)
- JSON for fast metrics / disks; protobuf (optionally gzipped) for processes
- Accurate perprocess CPU% on Linux via /proc jiffies delta
- Optional GPU & temperature metrics (disable via env vars)
- Simple token auth (?token=...) support
Run (no TLS):
```
cargo install socktop_agent
socktop_agent --port 3000
```
Enable TLS:
```
SOCKTOP_ENABLE_SSL=1 socktop_agent --port 8443
# cert/key stored under $XDG_DATA_HOME/socktop_agent/tls
```
Environment toggles:
- SOCKTOP_AGENT_GPU=0 (disable GPU collection)
- SOCKTOP_AGENT_TEMP=0 (disable temperature)
- SOCKTOP_TOKEN=secret (require token param from client)
- SOCKTOP_AGENT_METRICS_TTL_MS=250 (cache fast metrics window)
- SOCKTOP_AGENT_PROCESSES_TTL_MS=1000
- SOCKTOP_AGENT_DISKS_TTL_MS=1000
Systemd unit example & full docs:
https://github.com/jasonwitty/socktop
+14
View File
@@ -0,0 +1,14 @@
fn main() {
// Vendored protoc for reproducible builds
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
std::env::set_var("PROTOC", &protoc);
println!("cargo:rerun-if-changed=proto/processes.proto");
// Compile protobuf definitions for processes
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
// Use local path (ensures file is inside published crate tarball)
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
.expect("compile protos");
}
+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
}
+17 -54
View File
@@ -1,20 +1,19 @@
//! socktop agent entrypoint: sets up sysinfo handles, launches a sampler,
//! and serves a WebSocket endpoint at /ws.
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
mod gpu;
mod metrics;
mod sampler;
mod proto;
// sampler module removed (metrics now purely request-driven)
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;
fn arg_flag(name: &str) -> bool {
@@ -36,19 +35,24 @@ fn arg_value(name: &str) -> Option<String> {
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
// Version flag (print and exit). Keep before heavy initialization.
if arg_flag("--version") || arg_flag("-V") {
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let state = AppState::new();
// Start background sampler (adjust cadence as needed)
// 500ms fast metrics
let _h_fast = spawn_sampler(state.clone(), std::time::Duration::from_millis(500));
// 2s processes (top 50)
let _h_procs = spawn_process_sampler(state.clone(), std::time::Duration::from_secs(2), 50);
// 5s disks
let _h_disks = spawn_disks_sampler(state.clone(), std::time::Duration::from_secs(5));
// No background samplers: metrics collected on-demand per websocket request.
// 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::ws_handler))
.route("/healthz", get(healthz))
.with_state(state.clone());
let enable_ssl =
@@ -86,45 +90,4 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
#[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)
}
#[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);
}
}
// Unit tests for CLI parsing moved to `tests/port_parse.rs`.
+156 -80
View File
@@ -11,8 +11,9 @@ use std::fs;
#[cfg(target_os = "linux")]
use std::io;
use std::sync::Mutex;
use std::time::Duration as StdDuration;
use std::time::{Duration, Instant};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
use tracing::warn;
// Runtime toggles (read once)
@@ -97,6 +98,20 @@ fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
// TTL (ms) overridable via env, default 250ms
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(250);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_metrics.lock().await;
if cache.is_fresh(ttl) {
if let Some(c) = cache.take_clone() {
return c;
}
}
}
let mut sys = state.sys.lock().await;
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sys.refresh_cpu_usage();
@@ -105,7 +120,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
warn!("sysinfo selective refresh panicked: {e:?}");
}
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
let hostname = state.hostname.clone();
let cpu_total = sys.global_cpu_usage();
let cpu_per_core: Vec<f32> = sys.cpus().iter().map(|c| c.cpu_usage()).collect();
let mem_total = sys.total_memory();
@@ -153,25 +168,46 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
.collect()
};
// GPUs: refresh only when cache is stale
let gpus = if cached_gpus().is_some() {
cached_gpus()
} else if gpu_enabled() {
let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None,
Err(e) => {
warn!("gpu collection failed: {e}");
None
// GPUs: if we already determined none exist, short-circuit (no repeated probing)
let gpus = if gpu_enabled() {
if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)
{
None
} else if cached_gpus().is_some() {
cached_gpus()
} else {
let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None,
Err(e) => {
warn!("gpu collection failed: {e}");
None
}
};
// First probe records presence; subsequent calls rely on cache flags.
if !state
.gpu_checked
.swap(true, std::sync::atomic::Ordering::AcqRel)
{
if v.is_some() {
state
.gpu_present
.store(true, std::sync::atomic::Ordering::Release);
} else {
state
.gpu_present
.store(false, std::sync::atomic::Ordering::Release);
}
}
};
set_gpus(v.clone());
v
set_gpus(v.clone());
v
}
} else {
None
};
Metrics {
let metrics = Metrics {
cpu_total,
cpu_per_core,
mem_total,
@@ -184,21 +220,44 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
networks,
top_processes: Vec::new(),
gpus,
};
{
let mut cache = state.cache_metrics.lock().await;
cache.set(metrics.clone());
}
metrics
}
// Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_disks.lock().await;
if cache.is_fresh(ttl) {
if let Some(v) = cache.take_clone() {
return v;
}
}
}
let mut disks_list = state.disks.lock().await;
disks_list.refresh(false); // don't drop missing disks
disks_list
let disks: Vec<DiskInfo> = disks_list
.iter()
.map(|d| DiskInfo {
name: d.name().to_string_lossy().into_owned(),
total: d.total_space(),
available: d.available_space(),
})
.collect()
.collect();
{
let mut cache = state.cache_disks.lock().await;
cache.set(disks.clone());
}
disks
}
// Linux-only helpers and implementation using /proc deltas for accurate CPU%.
@@ -236,11 +295,25 @@ 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 {
// Fresh view to avoid lingering entries and select "no tasks" (no per-thread rows).
let mut sys = System::new();
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_processes.lock().await;
if cache.is_fresh(ttl) {
if let Some(v) = cache.take_clone() {
return v;
}
}
}
// Reuse shared System to avoid reallocation; refresh processes fully.
let mut sys_guard = state.sys.lock().await;
let sys = &mut *sys_guard;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
@@ -291,7 +364,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,
};
}
@@ -315,67 +388,70 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
})
.collect();
ProcessesPayload {
process_count: total_count,
top_processes: top_k_sorted(procs, k),
}
}
/// Collect top 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 {
use tokio::time::sleep;
let mut sys = state.sys.lock().await;
// First refresh to set baseline
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
// Small delay so sysinfo can compute CPU deltas on next refresh
sleep(Duration::from_millis(250)).await;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
let total_count = sys.processes().len();
let mut procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| ProcessInfo {
pid: p.pid().as_u32(),
name: p.name().to_string_lossy().into_owned(),
cpu_usage: p.cpu_usage(),
mem_bytes: p.memory(),
})
.collect();
procs = top_k_sorted(procs, k);
ProcessesPayload {
let payload = ProcessesPayload {
process_count: total_count,
top_processes: procs,
};
{
let mut cache = state.cache_processes.lock().await;
cache.set(payload.clone());
}
payload
}
// 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);
/// 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_all(state: &AppState) -> ProcessesPayload {
use tokio::time::sleep;
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_processes.lock().await;
if cache.is_fresh(ttl) {
if let Some(v) = cache.take_clone() {
return v;
}
}
}
{
let mut sys = state.sys.lock().await;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
}
// Release lock during sleep interval
sleep(Duration::from_millis(250)).await;
{
let mut sys = state.sys.lock().await;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
let total_count = sys.processes().len();
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| ProcessInfo {
pid: p.pid().as_u32(),
name: p.name().to_string_lossy().into_owned(),
cpu_usage: p.cpu_usage(),
mem_bytes: p.memory(),
})
.collect();
let payload = ProcessesPayload {
process_count: total_count,
top_processes: procs,
};
{
let mut cache = state.cache_processes.lock().await;
cache.set(payload.clone());
}
payload
}
v.sort_by(|a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
v
}
+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,
}
-34
View File
@@ -1,34 +0,0 @@
//! Background sampler: periodically collects metrics and updates precompressed caches,
//! so WS replies just read and send cached bytes.
use crate::state::AppState;
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};
// 500ms: fast path (cpu/mem/net/temp/gpu)
pub fn spawn_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
// no-op background sampler (request-driven collection elsewhere)
loop {
sleep(Duration::from_secs(3600)).await;
}
})
}
// 2s: processes top-k
pub fn spawn_process_sampler(_state: AppState, _period: Duration, _top_k: usize) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(3600)).await;
}
})
}
// 5s: disks
pub fn spawn_disks_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(3600)).await;
}
})
}
+45 -1
View File
@@ -2,8 +2,9 @@
#[cfg(target_os = "linux")]
use std::collections::HashMap;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use sysinfo::{Components, Disks, Networks, System};
use tokio::sync::Mutex;
@@ -25,6 +26,7 @@ pub struct AppState {
pub components: SharedComponents,
pub disks: SharedDisks,
pub networks: SharedNetworks,
pub hostname: String,
// For correct per-process CPU% using /proc deltas (Linux only path uses this tracker)
#[cfg(target_os = "linux")]
@@ -34,6 +36,42 @@ pub struct AppState {
pub client_count: Arc<AtomicUsize>,
pub auth_token: Option<String>,
// GPU negative cache (probe once). gpu_checked=true after first attempt; gpu_present reflects result.
pub gpu_checked: Arc<AtomicBool>,
pub gpu_present: Arc<AtomicBool>,
// Lightweight on-demand caches (TTL based) to cap CPU under bursty polling.
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
}
#[derive(Clone, Debug)]
pub struct CacheEntry<T> {
pub at: Option<Instant>,
pub value: Option<T>,
}
impl<T> CacheEntry<T> {
pub fn new() -> Self {
Self {
at: None,
value: None,
}
}
pub fn is_fresh(&self, ttl: Duration) -> bool {
self.at.is_some_and(|t| t.elapsed() < ttl) && self.value.is_some()
}
pub fn set(&mut self, v: T) {
self.value = Some(v);
self.at = Some(Instant::now());
}
pub fn take_clone(&self) -> Option<T>
where
T: Clone,
{
self.value.clone()
}
}
impl AppState {
@@ -48,12 +86,18 @@ impl AppState {
components: Arc::new(Mutex::new(components)),
disks: Arc::new(Mutex::new(disks)),
networks: Arc::new(Mutex::new(networks)),
hostname: System::host_name().unwrap_or_else(|| "unknown".into()),
#[cfg(target_os = "linux")]
proc_cpu: Arc::new(Mutex::new(ProcCpuTracker::default())),
client_count: Arc::new(AtomicUsize::new(0)),
auth_token: std::env::var("SOCKTOP_TOKEN")
.ok()
.filter(|s| !s.is_empty()),
gpu_checked: Arc::new(AtomicBool::new(false)),
gpu_present: Arc::new(AtomicBool::new(false)),
cache_metrics: Arc::new(Mutex::new(CacheEntry::new())),
cache_disks: Arc::new(Mutex::new(CacheEntry::new())),
cache_processes: Arc::new(Mutex::new(CacheEntry::new())),
}
}
}
+44 -49
View File
@@ -1,18 +1,11 @@
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 rcgen::{CertificateParams, DistinguishedName, DnType, IsCa, SanType};
use std::{
fs,
io::Write,
net::{IpAddr, Ipv4Addr},
path::{Path, PathBuf},
};
use time::{Duration, OffsetDateTime};
fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
@@ -35,57 +28,59 @@ pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
}
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)?;
let mut params = CertificateParams::new(vec![hostname.clone(), "localhost".into()])?;
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V6(
::std::net::Ipv6Addr::LOCALHOST,
)));
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
builder.set_not_before(Asn1Time::days_from_now(0)?.as_ref())?;
builder.set_not_after(Asn1Time::days_from_now(397)?.as_ref())?;
// Allow operator to provide extra SANs (comma-separated), e.g. IPs or DNS names
if let Ok(extra) = std::env::var("SOCKTOP_AGENT_EXTRA_SANS") {
for raw in extra.split(',') {
let s = raw.trim();
if s.is_empty() {
continue;
}
if let Ok(ip) = s.parse::<IpAddr>() {
params.subject_alt_names.push(SanType::IpAddress(ip));
} else {
match s.to_string().try_into() {
Ok(dns) => params.subject_alt_names.push(SanType::DnsName(dns)),
Err(_) => eprintln!("socktop_agent: ignoring invalid SAN entry: {s}"),
}
}
}
}
// 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)?;
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, hostname.clone());
params.distinguished_name = dn;
params.is_ca = IsCa::NoCa;
// Dynamic validity: start slightly in the past to avoid clock skew issues, end ~397 days later
let now = OffsetDateTime::now_utc();
params.not_before = now - Duration::minutes(5);
params.not_after = now + Duration::days(397);
builder.sign(&pkey, MessageDigest::sha256())?;
let cert: X509 = builder.build();
// Generate key pair (default is ECDSA P256 SHA256)
let key_pair = rcgen::KeyPair::generate()?; // defaults to ECDSA P256 SHA256
let cert = params.self_signed(&key_pair)?;
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
let mut f = fs::File::create(&cert_path)?;
f.write_all(&cert.to_pem()?)?;
f.write_all(cert_pem.as_bytes())?;
let mut k = fs::File::create(&key_path)?;
k.write_all(&pkey.private_key_to_pem_pkcs8()?)?;
k.write_all(key_pem.as_bytes())?;
println!(
"socktop_agent: generated self-signed TLS certificate at {}",
+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,
_ => {}
+40
View File
@@ -0,0 +1,40 @@
//! Unit test for port parsing logic moved out of `main.rs`.
fn parse_port<I: IntoIterator<Item = String>>(args: I, default_port: u16) -> u16 {
let mut it = args.into_iter();
let _ = it.next(); // program name
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)
}
#[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);
}