diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6b035eb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## 1.60.0 — unreleased + +Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.0 together. + +### Security + +- **Certificate pinning is now real.** With `--verify-hostname` off (the default), the client previously accepted *any* server certificate — the `--tls-ca` file was never consulted. The presented certificate must now be byte-identical to one in the pinned PEM (multi-cert files supported for rotation). If you use TLS, update the client: earlier versions are MITM-able despite the pinning documentation. (housekeeping-p2) +- `key.pem` is created with mode 0600 (was world-readable 0644); agents also tighten existing keys on startup. (housekeeping-p2) +- The agent's per-PID caches now evict (60s age / 64 entries); previously they grew without bound. (housekeeping-p2) + +### Performance + +- Agent CPU on GPU machines cut ~6× (measured 23.5 → 4.0 ms/s at default polling): GPU collection moved to a dedicated worker thread that keeps the NVML session open instead of re-initializing it every 1.5 s on the async runtime. (housekeeping-p2) +- `journalctl` no longer blocks the agent's async workers. (housekeeping-p2) +- Cached "no temp sensor / no GPU" results count as fresh — no more per-request rescans on hosts without them. (housekeeping-p2) +- Nagle disabled on all connection paths (small request/response frames). (housekeeping-p2) + +### TUI + +- **Compact layout for small windows**: when the window is too short for the Disks pane, Disks is dropped, Memory/Swap go side by side, GPU collapses to one line (omitted if absent), and the reclaimed rows keep the CPU graph and per-core bars visible. `--compact` pins it. (#37) +- **Width-aware text**: header, CPU title, and process table shed detail by priority as the terminal narrows instead of overwriting each other; process Name column is now the last to go, not the first. Fixed sort-header clicks landing up to 4 columns off. (#38) +- **Responsive input**: keys and mouse are handled within ~30 ms instead of queueing for a full metrics interval. (housekeeping-p2) +- **No more freezes**: all requests carry a 5 s timeout; a dead connection shows the reconnect modal (with working `q`) instead of hanging the UI. Consecutive timeouts surface a persistent "agent not responding" error. (housekeeping-p2) +- Old agents without the per-process endpoints once again show "Agent Update Required" instead of a reconnect loop. (housekeeping-p2) +- Journal pane distinguishes "no entries" from "no journal access" (e.g. user-run/demo agents) and shows journalctl's hint plus the fix. (housekeeping-p2) +- Scatter-plot axes align correctly for large CPU-time values. (housekeeping-p2) +- Demo mode explains how to install `socktop_agent` when the binary is missing. (#36) + +### Correctness + +- Process/child CPU times were sent as ms but displayed as µs — values rendered 1000× too small in the details modal. (housekeeping-p2) +- Non-Linux per-process CPU% no longer truncates multi-core usage (clamp after divide). (housekeeping-p2) +- Journal timestamps are real RFC 3339 UTC with numeric sorting (additive `timestamp_us`). (housekeeping-p2) +- Partition detection uses `/sys/block` on Linux — whole-disk filesystems (`nvme0n1`, `zram1`) are no longer misclassified as partitions. (housekeeping-p2) +- Network rates use agent-side sample timestamps (additive `sampled_at_ms`), eliminating rate sawtooth from TTL-cached snapshots; falls back to the client clock with older agents. (housekeeping-p2) +- The details modal's Command/exe/cwd fields are populated again (dropped by an earlier refresh optimization). (housekeeping-p2) +- Non-ASCII device names no longer panic the disk pane. (housekeeping-p2) + +### Wire format (additive only — old/new client-agent pairs keep working) + +- `Metrics.sampled_at_ms` (epoch ms of actual collection) +- `JournalEntry.timestamp_us` (epoch µs), `JournalEntry.timestamp` now RFC 3339 +- `JournalResponse.notice` (journal-access hint) + +### Internal / packaging + +- ratatui 0.28 → 0.30 (#33); aws-lc-rs advisories patched (#34); Debian packaging for the agent (#25); assorted dependabot bumps. +- ~3,100 lines of dead code removed, including an orphaned pre-refactor copy of the connector. +- `socktop` consumes `socktop_connector` via a path+version dep — connector changes are testable in-repo before publishing. +- wasm examples build against the in-repo connector; note `zellij_socktop_plugin` has pre-existing compile errors and needs its own rework. + +### Upgrade notes + +- **Release/publish order**: `socktop_connector` → `socktop` → agent packages. +- Clients older than 1.60 work against 1.60 agents and vice versa; the security fix is client-side, so prioritize client updates where TLS is used. diff --git a/Cargo.lock b/Cargo.lock index ca73e7d..a96b308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2412,7 +2412,7 @@ dependencies = [ [[package]] name = "socktop" -version = "1.51.0" +version = "1.60.0" dependencies = [ "anyhow", "assert_cmd", @@ -2431,7 +2431,7 @@ dependencies = [ [[package]] name = "socktop_agent" -version = "1.51.0" +version = "1.60.0" dependencies = [ "anyhow", "assert_cmd", @@ -2462,7 +2462,7 @@ dependencies = [ [[package]] name = "socktop_connector" -version = "1.51.0" +version = "1.60.0" dependencies = [ "flate2", "futures-util", diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..0b55f14 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Build socktop + socktop_agent from source and install them. +# +# Works on Linux (x86_64, arm64/armv7, riscv64) and macOS. Handles fresh +# installs and upgrades; if a systemd socktop-agent service is present, its +# binary is replaced in place and the service restarted. +# +# ./scripts/install.sh # build HEAD of the repo you're in +# ./scripts/install.sh --ref v1.60.0 # build a tag/branch (clones if needed) +# ./scripts/install.sh --ref housekeeping-p2 +# ./scripts/install.sh --prefix ~/.local/bin --no-service +# +set -euo pipefail + +REPO_URL="https://github.com/jasonwitty/socktop.git" +REF="" +PREFIX="" +NO_SERVICE=0 +SRC_DIR="${SOCKTOP_SRC_DIR:-$HOME/.cache/socktop-src}" + +while [ $# -gt 0 ]; do + case "$1" in + --ref) REF="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --no-service) NO_SERVICE=1; shift ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +OS="$(uname -s)" +ARCH="$(uname -m)" + +# ---------- toolchain ---------- +command -v git >/dev/null || die "git is required" +if ! command -v cargo >/dev/null; then + # rustup may be installed but not on PATH in this shell + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" +fi +if ! command -v cargo >/dev/null; then + say "Rust toolchain not found — installing via rustup (stable, default profile)" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + . "$HOME/.cargo/env" +fi +command -v cc >/dev/null || warn "no C compiler found (apt: build-essential / brew: xcode-select --install) — the build may fail" +case "$ARCH" in + riscv64*) + # protoc-bin-vendored ships no riscv64 binary; the build falls back to + # the system protoc (see build.rs). + command -v protoc >/dev/null || die "riscv64 needs a system protoc: sudo apt install protobuf-compiler" + ;; +esac + +# ---------- source ---------- +# If run from inside a socktop checkout and no --ref given, build that tree +# as-is (whatever is checked out, including local changes). +if [ -z "$REF" ] && git rev-parse --show-toplevel >/dev/null 2>&1 \ + && grep -qs '^name = "socktop"' "$(git rev-parse --show-toplevel)/socktop/Cargo.toml" 2>/dev/null; then + SRC_DIR="$(git rev-parse --show-toplevel)" + say "Building the current checkout: $SRC_DIR ($(git -C "$SRC_DIR" describe --always --dirty 2>/dev/null))" +else + REF="${REF:-master}" + if [ ! -d "$SRC_DIR/.git" ]; then + say "Cloning $REPO_URL -> $SRC_DIR" + git clone "$REPO_URL" "$SRC_DIR" + fi + say "Checking out $REF" + git -C "$SRC_DIR" fetch --tags origin + git -C "$SRC_DIR" checkout -q "$REF" + # fast-forward when REF is a branch + git -C "$SRC_DIR" merge --ff-only "origin/$REF" >/dev/null 2>&1 || true +fi + +# ---------- build ---------- +say "Building release binaries (this can take a while on SBCs)" +( cd "$SRC_DIR" && cargo build --release -p socktop -p socktop_agent ) +CLIENT="$SRC_DIR/target/release/socktop" +AGENT="$SRC_DIR/target/release/socktop_agent" + +# ---------- install ---------- +if [ -z "$PREFIX" ]; then + PREFIX="/usr/local/bin" +fi +SUDO="" +if [ ! -w "$PREFIX" ]; then + if command -v sudo >/dev/null; then SUDO="sudo"; else + PREFIX="$HOME/.local/bin"; mkdir -p "$PREFIX" + warn "no sudo — installing to $PREFIX (ensure it is on your PATH)" + fi +fi +say "Installing to $PREFIX" +$SUDO install -m 755 "$CLIENT" "$PREFIX/socktop" +$SUDO install -m 755 "$AGENT" "$PREFIX/socktop_agent" + +# ---------- systemd service (Linux only) ---------- +if [ "$OS" = "Linux" ] && [ "$NO_SERVICE" -eq 0 ] \ + && command -v systemctl >/dev/null \ + && systemctl list-unit-files 2>/dev/null | grep -q '^socktop-agent\.service'; then + # The deb package's unit points at its own binary path; replace it in place + # so the running service picks up this build. + UNIT_BIN="$(systemctl show -p ExecStart socktop-agent.service 2>/dev/null \ + | sed -n 's/.*path=\([^ ;]*\).*/\1/p' | head -1)" + if [ -n "$UNIT_BIN" ] && [ "$UNIT_BIN" != "$PREFIX/socktop_agent" ]; then + say "Refreshing systemd service binary at $UNIT_BIN" + $SUDO systemctl stop socktop-agent.service + $SUDO install -m 755 "$AGENT" "$UNIT_BIN" + $SUDO systemctl start socktop-agent.service + else + say "Restarting socktop-agent.service" + $SUDO systemctl restart socktop-agent.service + fi + sleep 1 + systemctl --no-pager -l status socktop-agent.service | head -5 || true +fi + +say "Installed:" +"$PREFIX/socktop" --version +"$PREFIX/socktop_agent" --version diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 4414ac6..9f7ce20 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop" -version = "1.51.0" +version = "1.60.0" authors = ["Jason Witty "] description = "Remote system monitor over WebSocket, TUI like top" edition = "2024" @@ -11,7 +11,7 @@ repository = "https://github.com/jasonwitty/socktop" [dependencies] # socktop connector for agent communication -socktop_connector = { version = "1.51.0", path = "../socktop_connector" } +socktop_connector = { version = "1.60.0", path = "../socktop_connector" } tokio = { workspace = true } futures-util = { workspace = true } diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 51588f6..47e09b9 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -92,7 +92,7 @@ pub struct App { // Network totals snapshot + histories of KB/s last_net_totals: Option<(u64, u64, Instant)>, - // Agent-side sample timestamp of the previous snapshot (1.51+ agents). + // Agent-side sample timestamp of the previous snapshot (1.60+ agents). last_net_sampled_at_ms: Option, // Consecutive metrics-request timeouts. One timeout gets a silent stream @@ -1428,7 +1428,7 @@ impl App { // NET: sum across all ifaces, compute KB/s. Prefer the agent's sample // timestamps (the agent serves TTL-cached snapshots, so client receive // time overstates dt on a cache hit and produces a 0-then-2x sawtooth); - // fall back to the client clock against pre-1.51 agents. + // fall back to the client clock against pre-1.60 agents. let now = Instant::now(); let rx_total = m.networks.iter().map(|n| n.received).sum::(); let tx_total = m.networks.iter().map(|n| n.transmitted).sum::(); diff --git a/socktop/src/ui/modal_process.rs b/socktop/src/ui/modal_process.rs index 49c75aa..a3f91e5 100644 --- a/socktop/src/ui/modal_process.rs +++ b/socktop/src/ui/modal_process.rs @@ -472,13 +472,28 @@ impl ModalManager { .borders(Borders::ALL); let content_lines: Vec = if journal.entries.is_empty() { - vec![ + let mut lines = vec![ Line::from(""), Line::from(Span::styled( "No journal entries found for this process", Style::default().add_modifier(Modifier::DIM), )), - ] + ]; + // Access limits, not absence of logs: show journalctl's own hint + // (typical when the agent runs as an unprivileged user, e.g. demo + // mode) plus the practical fix. + if let Some(notice) = &journal.notice { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("⚠ {notice}"), + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from(Span::styled( + " Run the agent as a service (or a user in the systemd-journal group) for full journal access.", + Style::default().add_modifier(Modifier::DIM), + ))); + } + lines } else { journal .entries diff --git a/socktop_agent/Cargo.toml b/socktop_agent/Cargo.toml index e40a28c..bec254c 100644 --- a/socktop_agent/Cargo.toml +++ b/socktop_agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop_agent" -version = "1.51.0" +version = "1.60.0" authors = ["Jason Witty "] description = "Socktop agent daemon. Serves host metrics over WebSocket." edition = "2024" diff --git a/socktop_agent/build.rs b/socktop_agent/build.rs index cb34d8a..ecfb2fa 100644 --- a/socktop_agent/build.rs +++ b/socktop_agent/build.rs @@ -1,13 +1,15 @@ fn main() { - // Vendored protoc for reproducible builds - let protoc = protoc_bin_vendored::protoc_bin_path().expect("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()); - cfg.protoc_executable(protoc); // Use the vendored protoc directly + // Vendored protoc for reproducible builds where available. It ships no + // riscv64 binary, so on such hosts fall through to $PROTOC / PATH + // (prost-build's default lookup) — apt: protobuf-compiler. + if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() { + cfg.protoc_executable(protoc); + } // 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"); diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 83a1532..ea1ad71 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -1412,6 +1412,25 @@ pub async fn collect_journal_entries(pid: u32) -> Result>() + .join(" "); + if hint.is_empty() { None } else { Some(hint) } + } else { + None + }; + let response_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| format!("Time error: {e}"))? @@ -1424,6 +1443,7 @@ pub async fn collect_journal_entries(pid: u32) -> Result (PathBuf, PathBuf) { pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> { let (cert_path, key_path) = cert_paths(); if cert_path.exists() && key_path.exists() { - // Keys generated by agents older than 1.51 were written with the + // Keys generated by agents older than 1.60 were written with the // default umask (typically 0644): tighten them on startup. #[cfg(unix)] { diff --git a/socktop_agent/src/types.rs b/socktop_agent/src/types.rs index 8efec08..dce3f49 100644 --- a/socktop_agent/src/types.rs +++ b/socktop_agent/src/types.rs @@ -126,5 +126,9 @@ pub struct JournalResponse { pub entries: Vec, pub total_count: u32, pub truncated: bool, + /// journalctl's own explanation when the result is empty because of + /// journal ACCESS (not absence of logs) — e.g. a user-run agent asking + /// about a system service. None when entries exist or nothing to say. + pub notice: Option, pub cached_at: u64, // Unix timestamp when this data was cached } diff --git a/socktop_connector/Cargo.toml b/socktop_connector/Cargo.toml index 240cdc6..63ddff7 100644 --- a/socktop_connector/Cargo.toml +++ b/socktop_connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop_connector" -version = "1.51.0" +version = "1.60.0" edition = "2024" license = "MIT" description = "WebSocket connector library for socktop agent communication" diff --git a/socktop_connector/build.rs b/socktop_connector/build.rs index 9390bf5..5b873e9 100644 --- a/socktop_connector/build.rs +++ b/socktop_connector/build.rs @@ -1,8 +1,12 @@ fn main() -> Result<(), Box> { - // Set the protoc binary path to use the vendored version for CI compatibility - // SAFETY: We're only setting PROTOC in a build script environment, which is safe - unsafe { - std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); + // Vendored protoc for reproducible builds where available. It ships no + // riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to + // prost-build (apt: protobuf-compiler). + // SAFETY: We're only setting PROTOC in a build script environment. + if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() { + unsafe { + std::env::set_var("PROTOC", protoc); + } } prost_build::compile_protos(&["processes.proto"], &["."])?; diff --git a/socktop_connector/src/types.rs b/socktop_connector/src/types.rs index c2ecff5..e93601d 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -55,7 +55,7 @@ pub struct GpuInfo { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Metrics { /// Epoch ms when the agent actually collected this snapshot (agents may - /// serve TTL-cached data). Absent on agents older than 1.51. + /// serve TTL-cached data). Absent on agents older than 1.60. #[serde(default)] pub sampled_at_ms: Option, pub cpu_total: f32, @@ -151,6 +151,10 @@ pub struct JournalResponse { pub entries: Vec, pub total_count: u32, pub truncated: bool, + /// Agent-side explanation for an empty result (journal access limits). + /// Absent on agents older than 1.60. + #[serde(default)] + pub notice: Option, pub cached_at: u64, // Unix timestamp when this data was cached }