diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3b12194 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,106 @@ +name: Bug report +description: Report a reproducible vminfo failure or regression +title: "[Bug]: " +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Remove tokens, public IP addresses, hostnames, usernames, process arguments, and other sensitive data before submitting. + + - type: dropdown + id: area + attributes: + label: Affected area + options: + - CLI commands + - Terminal UI + - Web dashboard or API + - Go library + - Installer or updater + - Other + validations: + required: true + + - type: input + id: version + attributes: + label: vminfo version + description: Paste the output of `vminfo version` or provide the commit SHA. + placeholder: vX.Y.Z or commit SHA + validations: + required: true + + - type: dropdown + id: operating-system + attributes: + label: Operating system + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + + - type: input + id: platform-details + attributes: + label: Platform details + description: Include distribution or OS version, architecture, and terminal name/size when relevant. + placeholder: Debian 12, amd64, tmux 3.3a, 120x35 + validations: + required: true + + - type: input + id: command + attributes: + label: Command + description: Enter the exact command and flags that triggered the problem. + placeholder: vminfo summary --json + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: Provide the smallest reliable sequence that reproduces the issue. + placeholder: | + 1. Install ... + 2. Run ... + 3. Observe ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or sanitized output + description: Paste only the output needed to diagnose the issue. + render: shell + + - type: checkboxes + id: checks + attributes: + label: Submission checks + options: + - label: I searched existing issues for the same problem. + required: true + - label: I removed secrets and sensitive host or process data. + required: true + - label: This is not a security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8ea8492 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://vminfo.bestcheapvps.org + about: Read installation, command, web dashboard, API, and library documentation. + - name: Usage questions and community support + url: https://t.me/VMPulse + about: Ask installation and usage questions in the VMPulse Telegram group. + - name: Report a security vulnerability + url: https://github.com/cloudapp3/vminfo/security/advisories/new + about: Send sensitive vulnerability details privately. Do not open a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..b9161fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,77 @@ +name: Feature request +description: Propose a focused improvement to vminfo +title: "[Feature]: " +body: + - type: markdown + attributes: + value: | + Describe the operational problem first. Non-trivial changes should preserve cross-platform behavior, CLI JSON compatibility, and the public Go library unless a breaking change is explicitly agreed. + + - type: dropdown + id: area + attributes: + label: Target area + options: + - CLI commands + - Terminal UI + - Web dashboard or API + - Go library + - Installer or updater + - Documentation + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem to solve + description: Explain the workflow, limitation, or user need without assuming an implementation. + validations: + required: true + + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe the observable behavior you want. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: List current workarounds or other tools you evaluated. + + - type: checkboxes + id: platforms + attributes: + label: Relevant platforms + options: + - label: Linux + - label: macOS + - label: Windows + - label: Platform-independent + + - type: textarea + id: compatibility + attributes: + label: Compatibility considerations + description: Note any expected CLI, JSON, API, package, or platform behavior changes. + + - type: textarea + id: context + attributes: + label: Additional context + description: Add sanitized examples, mockups, or links that clarify the request. + + - type: checkboxes + id: checks + attributes: + label: Submission checks + options: + - label: I searched existing issues for a similar request. + required: true + - label: I described the problem and desired outcome, not only an implementation. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..87a8504 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## Summary + +Describe the problem and the focused change that solves it. + +## Affected surface + +- [ ] CLI commands or flags +- [ ] Terminal UI +- [ ] Web dashboard, REST API, or WebSocket +- [ ] Public Go library +- [ ] Collector or platform behavior +- [ ] Installer, updater, CI, or release packaging +- [ ] Documentation only + +## Validation + +List the commands you ran and the platforms you tested. + +```text +go test ./... +go test -race ./... +go vet ./... +``` + +## Compatibility and security + +- [ ] Existing CLI JSON output remains compatible, or the intended change is documented. +- [ ] Public Go APIs remain compatible, or the intended breaking change is documented. +- [ ] Cross-platform commands retain non-Linux support; `ps` / `kill` retain unsupported stubs where required. +- [ ] Network, file I/O, token, and user-input paths include appropriate timeout and error handling. +- [ ] Logs, screenshots, fixtures, and examples contain no secrets or sensitive host data. + +## Documentation and visuals + +- [ ] README, documentation, tests, and examples were updated when behavior changed. +- [ ] TUI or web changes include a screenshot or terminal capture when useful. +- [ ] No unrelated files or user changes were reverted. + +Related issue: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc96247..013eb20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Run tests run: go test ./... + - name: Run race detector + run: go test -race ./... + - name: Run vet run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c521d6a..0d5a053 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,11 +6,56 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + run: | + unformatted=$(gofmt -l $(git ls-files '*.go')) + if [ -n "$unformatted" ]; then + echo "The following files are not gofmt formatted:" + echo "$unformatted" + exit 1 + fi + + - name: Check module tidiness + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Run tests + run: go test ./... + + - name: Run race detector + run: go test -race ./... + + - name: Run vet + run: go vet ./... + + - name: Smoke checks + run: | + go run ./cmd/vminfo version + go run ./cmd/vminfo summary --json + go run ./cmd/vminfo watch --count 1 + go run ./cmd/vminfo ps + release: + needs: verify runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.goreleaser.yml b/.goreleaser.yml index 6f665f2..43ae194 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -63,13 +63,35 @@ release: draft: false prerelease: auto mode: replace + header: | + {{ .ProjectName }} {{ .Tag }} is a cross-platform terminal system monitor with JSON output, a web dashboard, network diagnostics, and embeddable Go APIs. + + ## Changes + footer: | + ## Install or upgrade + + Linux and macOS: + + ```bash + curl -fsSL https://raw.githubusercontent.com/cloudapp3/vminfo/main/install.sh | sudo bash -s -- --dir /usr/local/bin + ``` + + Existing Linux and macOS release builds can upgrade with: + + ```bash + vminfo update --version {{ .Tag }} + ``` + + Download archives and Linux packages from the assets below. Verify downloads against `checksums.txt` before installation. + + [Documentation](https://vminfo.bestcheapvps.org) · [Quick start](https://vminfo.bestcheapvps.org/guide/quick-start) · [Command reference](https://vminfo.bestcheapvps.org/commands/) · [Compare releases](https://github.com/cloudapp3/vminfo/releases) nfpms: - id: packages package_name: vminfo maintainer: cloudapp3 - description: Host runtime information toolkit - homepage: https://github.com/cloudapp3/vminfo + description: Terminal system monitor with JSON output and a web dashboard + homepage: https://vminfo.bestcheapvps.org license: MIT formats: - deb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceabf8e..bddaee5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,9 @@ Thanks for helping improve vminfo. Bug reports, feature ideas, documentation imp git clone https://github.com/cloudapp3/vminfo.git cd vminfo go test ./... +go test -race ./... go vet ./... -go run ./cmd/vminfo version --json +go run ./cmd/vminfo version go run ./cmd/vminfo summary --json go run ./cmd/vminfo watch --count 1 go run ./cmd/vminfo ps # Linux-only @@ -58,6 +59,7 @@ Notes: - [ ] The change is focused and clearly described - [ ] Modified Go files have been formatted with `gofmt -w` - [ ] `go test ./...` passes +- [ ] `go test -race ./...` passes - [ ] `go vet ./...` passes - [ ] README / docs / tests were updated when behavior changed - [ ] No unrelated files were reverted diff --git a/README.md b/README.md index 4cc87e2..c091337 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,61 @@ -# vminfo — terminal system monitor, web dashboard, and Go library +# vminfo - cross-platform terminal system monitor, web dashboard, and Go library -> Cross-platform system monitoring for Linux, macOS, and Windows. Inspect CPU, memory, disk, network, load, and processes in a polished terminal UI, JSON output, or browser dashboard. +> A single-binary system monitoring toolkit for Linux, macOS, and Windows. Inspect CPU, memory, disk, network, and load in a live terminal UI, export JSON for automation, open a browser dashboard, or embed host metrics in Go. No background agent or configuration is required for local monitoring. [![CI](https://github.com/cloudapp3/vminfo/actions/workflows/ci.yml/badge.svg)](https://github.com/cloudapp3/vminfo/actions/workflows/ci.yml) +[![Latest release](https://img.shields.io/github/v/release/cloudapp3/vminfo?display_name=tag)](https://github.com/cloudapp3/vminfo/releases/latest) +[![GitHub Downloads](https://img.shields.io/github/downloads/cloudapp3/vminfo/total.svg)](https://github.com/cloudapp3/vminfo/releases) [![Go Reference](https://pkg.go.dev/badge/github.com/cloudapp3/vminfo.svg)](https://pkg.go.dev/github.com/cloudapp3/vminfo) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Documentation: [Website](https://vminfo.bestcheapvps.org) · [中文说明](https://vminfo.bestcheapvps.org/zh/) · [HTTP API](https://vminfo.bestcheapvps.org/api) · [Docs source](https://github.com/cloudapp3/vmdocs) +Documentation: [vminfo documentation](https://vminfo.bestcheapvps.org) · [中文说明](https://vminfo.bestcheapvps.org/zh/) · [HTTP API reference](https://vminfo.bestcheapvps.org/api) · [Docs source](https://github.com/cloudapp3/vmdocs) -[Quick start](#quick-start) · [Preview](#preview) · [Join Telegram](https://t.me/VMPulse) · [Open an issue](https://github.com/cloudapp3/vminfo/issues/new) · [Contributing](#contributing) +[Preview](#preview) · [Quick start](#quick-start) · [Why vminfo](#why-vminfo) · [Commands](#commands) · [Platform support](#platform-support) · [FAQ](#faq) · [Contributing](#contributing) + +## Preview + +![vminfo terminal system monitor demo](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/tui-demo.gif) + +> The demo cycles through the overview, Linux process view, and help. Screens may vary slightly by terminal width, font, and theme. + +| Web dashboard | Linux process view | +| --- | --- | +| ![vminfo browser system monitoring dashboard](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/web-dashboard.png) | ![vminfo Linux process monitor](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/tui-processes.png) | ## Quick start ```bash -# 1. Install — one-line script (Linux/macOS) +# 1. Install - one-line script (Linux/macOS) curl -fsSL https://raw.githubusercontent.com/cloudapp3/vminfo/main/install.sh | sudo bash -s -- --dir /usr/local/bin # Or install without sudo to an auto-detected user directory curl -fsSL https://raw.githubusercontent.com/cloudapp3/vminfo/main/install.sh | bash -# 2. Run — interactive TUI +# 2. Run - interactive TUI vminfo # 3. Or get a JSON snapshot for scripting vminfo summary --json ``` -That's it. No config files, no daemons, no setup. +That's it. No background agent, config file, or setup is required for local monitoring. The installer downloads the matching GitHub Release and verifies its SHA-256 checksum by default. The install script auto-selects a directory when `--dir` is not set: `/usr/local/bin` → `~/.local/bin` → `~/bin`. -Other install options: +### Other installation methods + +| Method | Platforms | Instructions | +| --- | --- | --- | +| Release archive | Linux, macOS, Windows | Download the matching `.tar.gz` or `.zip` from [GitHub Releases](https://github.com/cloudapp3/vminfo/releases/latest). | +| Linux package | Debian/Ubuntu, Fedora/RHEL | Download the generated `.deb` or `.rpm` from [GitHub Releases](https://github.com/cloudapp3/vminfo/releases/latest). | +| Go toolchain | Linux, macOS, Windows | Run `go install github.com/cloudapp3/vminfo/cmd/vminfo@latest`. | + +Windows release builds are available for `amd64`. Extract `vminfo.exe` from the release ZIP and place it in a directory on your `PATH`. + +Custom Linux/macOS install directory: ```bash -# Custom directory curl -fsSL https://raw.githubusercontent.com/cloudapp3/vminfo/main/install.sh | bash -s -- --dir /opt/bin - -# Go source build -go install github.com/cloudapp3/vminfo/cmd/vminfo@latest ``` If you install to a custom directory such as `/opt/bin`, make sure that directory is in your `PATH`, or symlink the binary into `/usr/local/bin`: @@ -46,65 +64,73 @@ If you install to a custom directory such as `/opt/bin`, make sure that director sudo ln -sf /opt/bin/vminfo /usr/local/bin/vminfo ``` -Need help, want to share feedback, or request a feature? Join the [VMPulse Telegram group](https://t.me/VMPulse) or [open an issue](https://github.com/cloudapp3/vminfo/issues/new). - ## Why vminfo -vminfo is built for developers, SREs, DevOps engineers, and server operators who want fast, low-friction visibility into host metrics. +vminfo is built for developers, SREs, DevOps engineers, and server operators who want fast, low-friction visibility into host metrics without installing a monitoring stack. Use vminfo when you need to: -- monitor CPU, memory, disk, network, and load from the terminal -- inspect Linux processes quickly without switching tools -- export machine-readable JSON for scripts, CI, or automation -- open a lightweight browser dashboard on a server with `vminfo --web` -- embed host metrics collection into your own Go tools +- monitor CPU, memory, disk, network, and load from a live terminal dashboard +- inspect and manage Linux processes without switching tools +- export stable JSON snapshots or JSON Lines for scripts, CI, and automation +- open a lightweight browser dashboard with `vminfo --web` +- embed host metrics collection or the TUI into your own Go tools -## Preview +The same binary provides four interfaces: -![vminfo preview](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/tui-overview-refreshed.png) +- **Terminal UI** - full-screen, live-updating overview and process views +- **JSON and text CLI** - one-shot or streaming output for automation +- **Web dashboard** - browser UI with REST and WebSocket endpoints +- **Go library** - public collection APIs plus an embeddable TUI package -> Screens may vary slightly by terminal width, font, and theme. +Collected metrics include CPU per core, memory, swap, disk, disk I/O, network, load, TCP/UDP counts, TCP state distribution, conntrack usage, interface rates, processes, temperatures, uptime, and host metadata. -| TUI overview | Web dashboard | -| --- | --- | -| ![vminfo overview refreshed](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/tui-overview-refreshed.png) | ![vminfo web dashboard](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/web-dashboard.png) | +If vminfo is useful on your machines, [star the repository](https://github.com/cloudapp3/vminfo) to help other operators discover it. -| Processes | Help | -| --- | --- | -| ![vminfo processes](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/tui-processes.png) | ![vminfo help](https://raw.githubusercontent.com/cloudapp3/vmdocs/main/sites/vminfo/docs/assets/tui-help.png) | +## vminfo compared with other system monitors -## What it does +| Project | Primary use | Where vminfo differs | +| --- | --- | --- | +| htop / btop | Interactive local process and resource monitoring | vminfo also provides scriptable JSON, a browser dashboard, and embeddable Go APIs from one binary. | +| Glances | Python-based terminal, export, API, and web monitoring | vminfo focuses on a self-contained Go binary and public Go packages. | +| Netdata | Always-on monitoring agent and web observability platform | vminfo can run on demand without installing a background service. | +| gopsutil | Go library for host and process metrics | vminfo adds a ready-to-run CLI, TUI, web dashboard, and opinionated output contracts. | -vminfo gives you instant visibility into any host: +These tools serve different operational needs. See the [detailed comparison guide](https://vminfo.bestcheapvps.org/compare/) before choosing for production use. -- **TUI** — full-screen, live-updating terminal dashboard with overview and process views -- **JSON** — machine-readable output for scripts, CI, monitoring pipelines -- **Web dashboard** — browser-based UI with REST and WebSocket endpoints (`vminfo --web`) -- **Go library** — import `github.com/cloudapp3/vminfo` for collection, or `github.com/cloudapp3/vminfo/tui` to embed the interactive terminal UI +## Cross-platform monitoring features -Collected metrics: CPU (per-core), memory, swap, disk, disk I/O, network, load, TCP/UDP counts, TCP state distribution (ESTABLISHED / TIME_WAIT / …), conntrack usage, network interface totals with per-interface error/drop rates, process list, temperatures, uptime, and host metadata. +- **Host resources** - CPU, memory, swap, disk, disk I/O, temperature, uptime, and metadata +- **Network visibility** - total throughput, per-interface rates, IP addresses, TCP/UDP counts, TCP states, and Linux conntrack usage +- **Host health** - resource score and focused warnings exposed in both the dashboard and `/api/v1/health` +- **Network diagnostics** - DNS, TCP port, TCP/ICMP ping, and public IP/ASN/geo lookup commands +- **International UI** - built-in English, Chinese, German, Spanish, French, Japanese, Korean, Portuguese, and Russian translations -## Network & Load panel +
+Network and host-health details -- **Load-aware coloring** — 1m / 5m / 15m load values are colored by `load / CPU cores`, with mini bars in wide layouts -- **Traffic split** — total throughput is separated from the per-interface table for faster scanning -- **Interface prioritization** — active interfaces sort before idle bridges / veth devices -- **Noise reduction** — idle interfaces fold on narrow layouts, while public/private IPs stay visually distinct -- **Web parity** — the web dashboard mirrors the same network semantics: totals, sorting, and IP styling -- **Connection states** — TCP sockets broken down by state (ESTABLISHED, TIME_WAIT, SYN_RECV, …) in both TUI and web -- **Conntrack usage** — current/max `nf_conntrack` entries with a saturation gauge (Linux) +### Network & Load panel -## Network health warnings +- **Load-aware coloring** - 1m / 5m / 15m load values are colored by `load / CPU cores`, with mini bars in wide layouts +- **Traffic split** - total throughput is separated from the per-interface table for faster scanning +- **Interface prioritization** - active interfaces sort before idle bridges / veth devices +- **Noise reduction** - idle interfaces fold on narrow layouts, while public/private IPs stay visually distinct +- **Web parity** - the web dashboard mirrors the same network semantics: totals, sorting, and IP styling +- **Connection states** - TCP sockets broken down by state (ESTABLISHED, TIME_WAIT, SYN_RECV, …) in both TUI and web +- **Conntrack usage** - current/max `nf_conntrack` entries with a saturation gauge (Linux) + +### Network health warnings The host health score (`/api/v1/health`, health panel) includes network signals, so a quietly degrading link is no longer invisible: -- `network_errors` — sustained per-interface error rate (events/s, not cumulative counters) -- `network_drops` — sustained packet-drop rate -- `tcpconn_high` — unusually high TCP socket count (≥5000 warn / ≥20000 critical) -- `conntrack_high` — conntrack table filling up (≥85% warn / ≥95% critical) +- `network_errors` - sustained per-interface error rate (events/s, not cumulative counters) +- `network_drops` - sustained packet-drop rate +- `tcpconn_high` - unusually high TCP socket count (≥5000 warn / ≥20000 critical) +- `conntrack_high` - conntrack table filling up (≥85% warn / ≥95% critical) + +Rates - not raw counters - gate `network_errors` / `network_drops`, so a long-lived total does not keep an otherwise-healthy host flagged. -Rates — not raw counters — gate `network_errors` / `network_drops`, so a long-lived total does not keep an otherwise-healthy host flagged. +
## Commands @@ -120,7 +146,7 @@ vminfo --web # web dashboard on 127.0.0.1:20021 vminfo --web --token # auto-generate a dashboard token vminfo --web --token secret-token vminfo --web --tui # web + TUI together -vminfo --web --bind 0.0.0.0 --port 8080 +vminfo --web --bind 0.0.0.0 --port 8080 --token vminfo ps # Linux-only process list vminfo ps nginx # filter by name, user, pid, or command vminfo ps --filter ssh # explicit process filter for scripts @@ -138,19 +164,37 @@ vminfo net ip # your public IP + ASN / geo vminfo net ip 8.8.8.8 # lookup a specific IP vminfo update # check + install the latest tagged release vminfo update --check # check without installing -vminfo update --version v0.1.0 +vminfo update --version vX.Y.Z vminfo --lang zh # switch UI language ``` Built-in languages: `en`, `zh`, `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`. +`net` subcommand flags may appear before or after the target, so both +`net ping --tcp-port 443 example.com` and `net ping example.com --tcp-port 443` +are accepted. CLI ping count is limited to 1-100 and probe timeouts must be +positive and no greater than 10 seconds. + +## Platform support + +| Capability | Linux | macOS | Windows | +| --- | --- | --- | --- | +| `summary` / `watch` | ✅ | ✅ | ✅ | +| TUI | ✅ | ✅ | ✅ | +| Web dashboard | ✅ | ✅ | ✅ | +| `ps` / `kill` | ✅ | ⚠️ stub | ⚠️ stub | +| `update --check` | ✅ | ✅ | ✅ | +| `update` install | ✅ | ✅ | ⚠️ check-only | + +TUI requires a real TTY. `ps` and `kill` are Linux-only by design. + ## Web dashboard ```bash vminfo --web # default: 127.0.0.1:20021 vminfo --web --token # auto-generate a token and print a ready-to-open URL vminfo --web --token my-token # use a fixed token -vminfo --web --bind 0.0.0.0 --port 8080 --interval 1s +vminfo --web --bind 0.0.0.0 --port 8080 --interval 1s --token ``` Add `--token` when you want to protect the dashboard in a browser: @@ -159,28 +203,36 @@ Add `--token` when you want to protect the dashboard in a browser: - bare `--token` auto-generates a URL-safe token - the first successful `/?token=...` visit sets a cookie, so later page/API/WebSocket requests can continue without keeping the token in the address bar -When binding to all interfaces, startup output now shows friendlier URLs instead of only `0.0.0.0`: +Loopback binds (`127.0.0.1`, `::1`, or `localhost`) may run without a token. +Any non-loopback bind, including `0.0.0.0`, requires an explicit `--token` or +bare `--token` request. An empty `--bind=` is rejected rather than interpreted +as all interfaces. -```text -Web dashboard: - Local http://127.0.0.1:20021 - Public http://203.0.113.10:20021 # when a public IPv4 is present - LAN http://192.168.1.23:20021 # fallback when only a LAN IPv4 is present -``` +The built-in server uses HTTP. A token controls access but does not encrypt the +connection. For remote access, put vminfo behind an HTTPS reverse proxy or use +an SSH tunnel; do not expose its HTTP port directly to an untrusted network. -When a token is enabled, the printed URLs include `?token=...` so you can copy/paste them directly: +When binding to all interfaces with the required token, startup output shows +friendlier, ready-to-open URLs instead of only `0.0.0.0`: ```text -Web dashboard: http://127.0.0.1:20021/?token=secret-token +Web dashboard: + Local http://127.0.0.1:20021/?token=example-token + Public http://203.0.113.10:20021/?token=example-token # when a public IPv4 is present + LAN http://192.168.1.23:20021/?token=example-token # fallback when only a LAN IPv4 is present ``` +Token-protected printed URLs always include `?token=...` so they can be opened directly. + Web mode keeps stdout quiet during normal browsing: routine HTTP request logs and WebSocket connect/disconnect logs are suppressed, while real startup and error messages are still shown. -With dashboard auth enabled, the web server also tightens browser access rules: +The web server always enforces browser same-origin access: -- dashboard pages, JSON APIs, and `/ws` require the token or the auth cookie -- permissive `Access-Control-Allow-Origin: *` is not exposed in token-protected mode -- WebSocket upgrades require the browser origin to match the dashboard host +- when a token is enabled, dashboard pages, JSON APIs, and `/ws` require the token or auth cookie +- permissive `Access-Control-Allow-Origin: *` is never exposed +- REST requests and WebSocket upgrades with an `Origin` header must match the dashboard host +- unauthenticated loopback mode only accepts `localhost` or loopback-IP Host headers, preventing DNS rebinding +- native clients without an `Origin` header remain supported Endpoints: @@ -188,49 +240,11 @@ Endpoints: - `GET /api/v1/processes` — process list with optional `filter` / `q`, `sort`, and `limit` query parameters - `GET /api/v1/health` — lightweight health score and resource warnings - `GET /ws` — live WebSocket stream -- `POST /api/v1/net/diag` — run a network diagnostic (`{"action":"dns|port|ping|ip","target":...}`); token-protected +- `POST /api/v1/net/diag` — run a same-origin network diagnostic (`{"action":"dns|port|ping|ip","target":...}`); ping count is limited to 10 and per-probe timeout to 3 seconds The dashboard ships with switchable themes (Auto / Neon / Light / Terminal / Synthwave) from the header; "Auto" follows the OS color scheme. JetBrains Mono is **embedded**, so the dashboard stays self-contained and works offline with no external font requests. -## Self-update - -Release builds can update themselves from GitHub Releases: - -```bash -vminfo update -vminfo update --check -vminfo update --version v0.1.0 -``` - -Recent web UI polish: - -- Larger overall type scale for easier browser reading -- Resource progress bars use grouped spacing and segmented tracks -- CPU right-side block is vertically centered in the Resources card -- Per-core CPU bars are larger and the extra `avg` footer has been removed - -## TUI controls - -| Key | Action | -| --- | --- | -| `q` / `ctrl+c` | Quit | -| `?` | Toggle help | -| `p` | Pause / resume | -| `+` / `-` | Adjust interval | -| `r` | Refresh now | -| `tab` | Switch overview / processes | -| `↑` / `↓` | Move selection | -| `s` | Cycle sort | -| `t` | Tree view | -| `/` | Filter processes | -| `k` | SIGTERM selected process | -| `K` | Show / hide Linux kernel threads | -| `enter` / `y` | Confirm kill | -| `esc` / `n` | Cancel | - -Status badges: `LIVE` · `PAUSED` · `LOADING` · `ERROR` · `STALE` - -## Library usage +## Go library Collect host metrics from your own Go program: @@ -276,45 +290,94 @@ func main() { `tui.Options` also accepts custom `Stdin` and `Stdout` streams for embedded CLIs and tests. -Public packages: `github.com/cloudapp3/vminfo` · `github.com/cloudapp3/vminfo/tui` +Public packages: [github.com/cloudapp3/vminfo](https://pkg.go.dev/github.com/cloudapp3/vminfo) · [github.com/cloudapp3/vminfo/tui](https://pkg.go.dev/github.com/cloudapp3/vminfo/tui) Exported collection types: `StaticInfo` · `RuntimeStats` · `ProcessInfo` · `Snapshot` · `AppMetadata` -## Platform support +## Self-update -| Capability | Linux | macOS | Windows | -| --- | --- | --- | --- | -| `summary` / `watch` | ✅ | ✅ | ✅ | -| TUI | ✅ | ✅ | ✅ | -| Web dashboard | ✅ | ✅ | ✅ | -| `ps` / `kill` | ✅ | ⚠️ stub | ⚠️ stub | -| `update --check` | ✅ | ✅ | ✅ | -| `update` install | ✅ | ✅ | ⚠️ check-only | +Release builds can update themselves from GitHub Releases: -TUI requires a real TTY. `ps` and `kill` are Linux-only by design. +```bash +vminfo update +vminfo update --check +vminfo update --version vX.Y.Z +``` -## Community & Support +See the [changelog](https://vminfo.bestcheapvps.org/changelog) for release-specific changes. + +## TUI controls + +| Key | Action | +| --- | --- | +| `q` / `ctrl+c` | Quit | +| `?` | Toggle help | +| `p` | Pause / resume | +| `+` / `-` | Adjust interval | +| `r` | Refresh now | +| `tab` | Switch overview / processes | +| `↑` / `↓` | Move selection | +| `s` | Cycle sort | +| `t` | Tree view | +| `/` | Filter processes | +| `k` | SIGTERM selected process | +| `K` | Show / hide Linux kernel threads | +| `enter` / `y` | Confirm kill | +| `esc` / `n` | Cancel | + +Status badges: `LIVE` · `PAUSED` · `LOADING` · `ERROR` · `STALE` + +## FAQ -- 💬 Join the Telegram group: [t.me/VMPulse](https://t.me/VMPulse) -- 🐛 Found a bug or want a feature? [Open an issue](https://github.com/cloudapp3/vminfo/issues/new) -- 📚 Prefer to start with docs? See [Documentation](#documentation) -- 🤝 Want to contribute? Start with [CONTRIBUTING.md](CONTRIBUTING.md) +### Does vminfo require a daemon or configuration file? + +No background service or configuration file is required for the TUI, `summary`, `watch`, or network diagnostics. Web mode starts a foreground HTTP server only when you request `vminfo --web`. + +### Does vminfo require root privileges? + +Normal monitoring commands do not require root. Installing into a protected directory, sending signals to other users' processes, and ICMP ping may require elevated OS permissions. + +### Which features work on Windows and macOS? + +The TUI, `summary`, `watch`, web dashboard, and update checks are cross-platform. `ps` and `kill` are Linux-only, and Windows self-update is currently check-only. See [Platform support](#platform-support). + +### Can I use vminfo in scripts and CI? + +Yes. Use `vminfo summary --json` for one snapshot or `vminfo watch --json` for a stream of JSON Lines. + +### How should I access the web dashboard remotely? + +Keep the default loopback bind when possible. Non-loopback binds require a token, but the built-in server is HTTP only; use an HTTPS reverse proxy or SSH tunnel for remote access. + +### Is vminfo a replacement for htop or btop? + +It overlaps with local resource and Linux process monitoring, but also targets JSON automation, browser access, and Go embedding. Choose based on the workflow summarized in [the comparison](#vminfo-compared-with-other-system-monitors). + +### How do I update vminfo? + +Run `vminfo update` from a tagged Linux or macOS release build. Use `vminfo update --check` to inspect available versions without installing. + +## Community & Support -Feedback, bug reports, and feature requests directly help shape the vminfo roadmap. +- Read the [documentation](https://vminfo.bestcheapvps.org) or [support guide](SUPPORT.md) +- Join the [VMPulse Telegram group](https://t.me/VMPulse) for usage questions and feedback +- [Open a structured issue](https://github.com/cloudapp3/vminfo/issues/new/choose) for a reproducible bug or feature request +- Follow [SECURITY.md](SECURITY.md) to report a vulnerability privately +- Browse [GitHub Releases](https://github.com/cloudapp3/vminfo/releases) for binaries, packages, checksums, and release notes ## Contributing -Contributions are welcome — bug reports, feature ideas, documentation improvements, tests, platform compatibility fixes, and pull requests. +Contributions are welcome - bug reports, feature ideas, documentation improvements, tests, platform compatibility fixes, and pull requests. If you want to help: -1. [Open an issue](https://github.com/cloudapp3/vminfo/issues/new) to discuss a bug, feature, or non-trivial change +1. [Open an issue](https://github.com/cloudapp3/vminfo/issues/new/choose) to discuss a bug, feature, or non-trivial change 2. Read [CONTRIBUTING.md](CONTRIBUTING.md) 3. Fork the repository and make a focused change -4. Run `go test ./...` and `go vet ./...` +4. Run `go test ./...`, `go test -race ./...`, and `go vet ./...` 5. Open a pull request -Questions before opening a PR? Join [Telegram](https://t.me/VMPulse) and say hi. +Questions before opening a PR? Join [Telegram](https://t.me/VMPulse). ## Build from source @@ -322,7 +385,7 @@ Questions before opening a PR? Join [Telegram](https://t.me/VMPulse) and say hi. git clone https://github.com/cloudapp3/vminfo.git cd vminfo go build -ldflags "\ - -X github.com/cloudapp3/vminfo.Version=v0.1.0 \ + -X github.com/cloudapp3/vminfo.Version=vX.Y.Z \ -X github.com/cloudapp3/vminfo.Commit=$(git rev-parse --short HEAD) \ -X github.com/cloudapp3/vminfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ -X github.com/cloudapp3/vminfo.Channel=stable" \ @@ -334,6 +397,7 @@ go build -ldflags "\ ```bash gofmt -w $(git ls-files '*.go') go test ./... +go test -race ./... go vet ./... go run ./cmd/vminfo summary --json ``` @@ -341,11 +405,11 @@ go run ./cmd/vminfo summary --json ## Documentation - [Documentation website](https://vminfo.bestcheapvps.org) -- [Chinese docs](https://vminfo.bestcheapvps.org/zh/) -- [HTTP API](https://vminfo.bestcheapvps.org/api) +- [Chinese documentation](https://vminfo.bestcheapvps.org/zh/) +- [HTTP API reference](https://vminfo.bestcheapvps.org/api) - [Changelog](https://vminfo.bestcheapvps.org/changelog) -- [Roadmap](https://vminfo.bestcheapvps.org/roadmap/feature-benchmark) -- [Docs source repository](https://github.com/cloudapp3/vmdocs) +- [Feature benchmark and roadmap](https://vminfo.bestcheapvps.org/roadmap/feature-benchmark) +- [Documentation source repository](https://github.com/cloudapp3/vmdocs) - [CONTRIBUTING.md](CONTRIBUTING.md) ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..22f3141 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest tagged release and the `main` branch. +Older releases may not receive backported fixes. + +| Version | Supported | +| --- | --- | +| Latest tagged release | Yes | +| `main` | Yes, for the next release | +| Older releases | No | + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. + +Use [GitHub Private Vulnerability Reporting](https://github.com/cloudapp3/vminfo/security/advisories/new) to send the maintainers: + +- the affected version, platform, and component +- a clear description of the impact +- reproduction steps or a proof of concept +- any known mitigations + +Please omit secrets, access tokens, private hostnames, and unrelated system data. The maintainers will confirm the report, investigate it, and coordinate disclosure and release timing with the reporter. + +## Web dashboard security + +The dashboard binds to loopback by default. Any non-loopback bind requires a token, but the built-in server uses HTTP and does not provide transport encryption. For remote access, use an HTTPS reverse proxy or SSH tunnel and do not expose the HTTP port directly to an untrusted network. + +## Scope + +Reports about the CLI, TUI, updater, installer, public Go packages, web dashboard, REST API, and WebSocket endpoint are in scope. General support questions and non-security bugs belong in the [issue tracker](https://github.com/cloudapp3/vminfo/issues/new/choose) or [SUPPORT.md](SUPPORT.md). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..82c1b0e --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,26 @@ +# Support + +## Documentation + +Start with the [vminfo documentation](https://vminfo.bestcheapvps.org), including the [quick start](https://vminfo.bestcheapvps.org/guide/quick-start), [command reference](https://vminfo.bestcheapvps.org/commands/), and [web dashboard guide](https://vminfo.bestcheapvps.org/guide/web-dashboard). + +## Usage questions + +For installation and usage questions, join the [VMPulse Telegram group](https://t.me/VMPulse). Include your operating system, architecture, vminfo version, and the command you ran. + +## Bugs and feature requests + +Use the structured [GitHub issue forms](https://github.com/cloudapp3/vminfo/issues/new/choose): + +- choose **Bug report** for reproducible failures or regressions +- choose **Feature request** for a new capability or behavior change + +Before posting logs or JSON output, remove tokens, public IP addresses, hostnames, usernames, process arguments, and other sensitive system data. + +## Security vulnerabilities + +Do not report vulnerabilities in public issues or group chats. Follow [SECURITY.md](SECURITY.md) and use GitHub Private Vulnerability Reporting. + +## Platform boundaries + +The TUI, `summary`, `watch`, and web dashboard are cross-platform. `ps` and `kill` are Linux-only, and Windows self-update is currently check-only. See the [platform support table](README.md#platform-support) for details. diff --git a/collect.go b/collect.go index 46f37ce..fe29d18 100644 --- a/collect.go +++ b/collect.go @@ -3,6 +3,7 @@ package vminfo import ( "context" "net" + "runtime" "strings" "sync" "time" @@ -271,8 +272,8 @@ func buildRuntimeStats(ctx context.Context, opts Options, base hostBase) (Runtim stats.Load15 = avg.Load15 } - stats.TCPCount, stats.TCPStates = readTCPStates() - stats.UDPCount = countUDPConnections() + stats.TCPCount, stats.TCPStates = readTCPStates(ctx) + stats.UDPCount = countUDPConnections(ctx) stats.ConntrackCount, stats.ConntrackMax = conntrackUsage() stats.ProcessCount = countProcesses(ctx) return stats, nil @@ -322,6 +323,10 @@ func readCPUCoreSamples(ctx context.Context) ([]cpuSample, error) { func parseCPUSample(stat cpu.TimesStat) cpuSample { idle := stat.Idle + stat.Iowait total := stat.User + stat.System + stat.Idle + stat.Nice + stat.Iowait + stat.Irq + stat.Softirq + stat.Steal + stat.Guest + stat.GuestNice + if runtime.GOOS == "linux" { + // Linux accounts guest and guest_nice time inside user and nice already. + total -= stat.Guest + stat.GuestNice + } return cpuSample{total: total, idle: idle} } diff --git a/collect_test.go b/collect_test.go index 5d50e2c..6dfdd1f 100644 --- a/collect_test.go +++ b/collect_test.go @@ -2,8 +2,11 @@ package vminfo import ( "context" + "runtime" "testing" "time" + + "github.com/shirou/gopsutil/v3/cpu" ) func TestCollectAllRefreshesDynamicUptimeWithStaticCache(t *testing.T) { @@ -60,3 +63,28 @@ func TestCalcIfaceSpeedsRates(t *testing.T) { t.Fatalf("tx drop rate = %v, want 30", iface.TxDropRate) } } + +func TestParseCPUSampleDoesNotDoubleCountGuestTime(t *testing.T) { + stat := cpu.TimesStat{ + User: 100, + System: 20, + Idle: 50, + Nice: 10, + Iowait: 5, + Irq: 2, + Softirq: 3, + Steal: 4, + Guest: 30, + GuestNice: 5, + } + + got := parseCPUSample(stat) + wantTotal := 229.0 + if runtime.GOOS == "linux" { + wantTotal = 194 + } + const wantIdle = 55 + if got.total != wantTotal || got.idle != wantIdle { + t.Fatalf("parseCPUSample() = %+v, want total=%v idle=%v", got, wantTotal, wantIdle) + } +} diff --git a/conncount_linux.go b/conncount_linux.go index 3d4ae3a..002d9c1 100644 --- a/conncount_linux.go +++ b/conncount_linux.go @@ -4,16 +4,20 @@ package vminfo import ( "bufio" + "context" "os" "strconv" "strings" ) -func countUDPConnections() uint32 { - return countConnsFromFile("/proc/net/udp") + countConnsFromFile("/proc/net/udp6") +func countUDPConnections(ctx context.Context) uint32 { + return countConnsFromFile(ctx, "/proc/net/udp") + countConnsFromFile(ctx, "/proc/net/udp6") } -func countConnsFromFile(path string) uint32 { +func countConnsFromFile(ctx context.Context, path string) uint32 { + if ctx.Err() != nil { + return 0 + } f, err := os.Open(path) if err != nil { return 0 @@ -23,8 +27,14 @@ func countConnsFromFile(path string) uint32 { var count uint32 scanner.Scan() // skip header line for scanner.Scan() { + if ctx.Err() != nil { + return count + } count++ } + // Best-effort counter: a read error ends the scan early, so return the + // partial count rather than failing the whole sample. + _ = scanner.Err() return count } @@ -32,10 +42,13 @@ func countConnsFromFile(path string) uint32 { // returning the total TCP socket count and a per-state distribution keyed by // state name (ESTABLISHED, TIME_WAIT, ...). countTCPConnections reuses this so // the kernel table files are read once per sample, not twice. -func readTCPStates() (uint32, map[string]uint32) { +func readTCPStates(ctx context.Context) (uint32, map[string]uint32) { var count uint32 states := make(map[string]uint32) for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + if ctx.Err() != nil { + return count, states + } f, err := os.Open(path) if err != nil { continue @@ -43,6 +56,10 @@ func readTCPStates() (uint32, map[string]uint32) { scanner := bufio.NewScanner(f) scanner.Scan() // skip header line for scanner.Scan() { + if ctx.Err() != nil { + f.Close() + return count, states + } fields := strings.Fields(scanner.Text()) if len(fields) < 4 { continue @@ -52,6 +69,9 @@ func readTCPStates() (uint32, map[string]uint32) { states[name]++ } } + // Best-effort: keep the partial state counts collected before any read + // error rather than failing the sample. + _ = scanner.Err() f.Close() } return count, states diff --git a/conncount_linux_test.go b/conncount_linux_test.go index 0199426..96953b6 100644 --- a/conncount_linux_test.go +++ b/conncount_linux_test.go @@ -2,7 +2,10 @@ package vminfo -import "testing" +import ( + "context" + "testing" +) func TestDecodeTCPState(t *testing.T) { tests := []struct { @@ -31,3 +34,16 @@ func TestDecodeTCPState(t *testing.T) { } } } + +func TestConnectionCountsHonorCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if got := countUDPConnections(ctx); got != 0 { + t.Fatalf("countUDPConnections() = %d, want 0 for canceled context", got) + } + count, states := readTCPStates(ctx) + if count != 0 || len(states) != 0 { + t.Fatalf("readTCPStates() = (%d, %v), want empty result for canceled context", count, states) + } +} diff --git a/conncount_stub.go b/conncount_stub.go index cfbf1b2..4fdc272 100644 --- a/conncount_stub.go +++ b/conncount_stub.go @@ -8,12 +8,12 @@ import ( gnet "github.com/shirou/gopsutil/v3/net" ) -func countUDPConnections() uint32 { - return countConnsGopsutil("udp") +func countUDPConnections(ctx context.Context) uint32 { + return countConnsGopsutil(ctx, "udp") } -func countConnsGopsutil(kind string) uint32 { - conns, err := gnet.ConnectionsWithContext(context.Background(), kind) +func countConnsGopsutil(ctx context.Context, kind string) uint32 { + conns, err := gnet.ConnectionsWithContext(ctx, kind) if err != nil { return 0 } @@ -23,8 +23,8 @@ func countConnsGopsutil(kind string) uint32 { // readTCPStates buckets TCP connections by their decoded status via gopsutil. // Non-Linux platforms lack /proc/net/tcp, so gopsutil is the cross-platform // source for both the total count and the state distribution. -func readTCPStates() (uint32, map[string]uint32) { - conns, err := gnet.ConnectionsWithContext(context.Background(), "tcp") +func readTCPStates(ctx context.Context) (uint32, map[string]uint32) { + conns, err := gnet.ConnectionsWithContext(ctx, "tcp") if err != nil { return 0, nil } diff --git a/doc.go b/doc.go index 27c21e0..8128e58 100644 --- a/doc.go +++ b/doc.go @@ -1,3 +1,43 @@ -// Package vminfo provides host runtime inspection helpers for the vminfo CLI -// and for external Go programs that need lightweight system snapshots. +// Package vminfo collects local host metrics and runs lightweight network +// diagnostics for use inside other Go programs. +// +// It is the library behind the vminfo CLI: the same functions feed the terminal +// UI, the web dashboard, and the one-shot commands. Import it when you need host +// information or network probes in your own tool without shelling out to an +// external binary. +// +// Collection is split into two layers that match how the underlying values +// change: +// +// - [CollectStatic] returns rarely-changing host properties: CPU model and +// core count, total memory and swap, total disk, hostname, OS, kernel, and +// architecture. +// - [CollectStats] samples runtime metrics: overall and per-core CPU usage, +// memory and swap in use, network and disk I/O with per-second rates, TCP +// and UDP counts, conntrack saturation, TCP state distribution, load +// averages, per-interface error/drop rates, temperatures, and uptime. Rates +// are derived from consecutive samples, so the first call returns zero +// rates; call it on a steady cadence of Options.SampleInterval (default +// [DefaultSampleInterval]) for stable values. +// - [CollectAll] returns both in a single call. +// +// Network diagnostics are independent of the collectors: +// +// - [ResolveDNS] queries a resolver for a domain. +// - [CheckPort] reports whether a TCP port is reachable. +// - [Ping] measures TCP round-trip latency to a host. +// - [LookupIP] returns network metadata for an IP address. +// +// Process listing ([ListProcesses]) and termination ([TerminateProcess]) are +// Linux-only; they return an unsupported error on other platforms. +// +// Example: +// +// static, _ := vminfo.CollectStatic(ctx) +// stats, _ := vminfo.CollectStats(ctx, vminfo.Options{SampleInterval: time.Second}) +// fmt.Println(static.Hostname, stats.CPU) +// +// The interactive terminal UI is a separate, importable package at +// [github.com/cloudapp3/vminfo/tui]. The web dashboard lives under internal/ +// and is not importable. package vminfo diff --git a/internal/app/net.go b/internal/app/net.go index 313c0ae..07d1cbe 100644 --- a/internal/app/net.go +++ b/internal/app/net.go @@ -15,10 +15,15 @@ import ( "github.com/cloudapp3/vminfo/internal/i18n" ) +const ( + maxNetProbeCount = 100 + maxNetProbeTimeout = 10 * time.Second +) + // runNet dispatches `vminfo net ` subcommands. func runNet(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i18n.Translator) error { if len(args) == 0 { - return fmt.Errorf("%w: net requires an action: dns | port", ErrUsage) + return fmt.Errorf("%w: net requires an action: dns | port | ping | ip", ErrUsage) } action := strings.ToLower(strings.TrimSpace(args[0])) rest := args[1:] @@ -32,11 +37,53 @@ func runNet(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i1 case "ip": return runNetIP(ctx, stdout, stderr, rest, tr) default: - return fmt.Errorf("%w: unknown net action %q (want: dns | port)", ErrUsage, action) + return fmt.Errorf("%w: unknown net action %q (want: dns | port | ping | ip)", ErrUsage, action) + } +} + +// reorderNetFlags lets net subcommands accept flags before or after their +// positional target while still using the standard library flag package. +func reorderNetFlags(args []string, specs map[string]bool) ([]string, error) { + flags := make([]string, 0, len(args)) + positionals := make([]string, 0, len(args)) + hasTerminator := false + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + hasTerminator = true + positionals = append(positionals, args[i+1:]...) + break + } + if arg == "-" || !strings.HasPrefix(arg, "-") { + positionals = append(positionals, arg) + continue + } + + nameValue := strings.TrimLeft(arg, "-") + name, _, hasValue := strings.Cut(nameValue, "=") + requiresValue, known := specs[name] + flags = append(flags, arg) + if !known || !requiresValue || hasValue { + continue + } + if i+1 >= len(args) || args[i+1] == "--" { + return nil, fmt.Errorf("%w: flag --%s requires a value", ErrUsage, name) + } + flags = append(flags, args[i+1]) + i++ + } + if hasTerminator { + flags = append(flags, "--") } + return append(flags, positionals...), nil } func runNetDNS(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i18n.Translator) error { + var err error + args, err = reorderNetFlags(args, map[string]bool{"server": true, "json": false}) + if err != nil { + return err + } fs := flag.NewFlagSet("net dns", flag.ContinueOnError) fs.SetOutput(stderr) var ( @@ -49,7 +96,7 @@ func runNetDNS(ctx context.Context, stdout, stderr io.Writer, args []string, tr if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if fs.NArg() != 1 { return fmt.Errorf("%w: net dns requires exactly one domain", ErrUsage) @@ -63,6 +110,11 @@ func runNetDNS(ctx context.Context, stdout, stderr io.Writer, args []string, tr } func runNetPort(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i18n.Translator) error { + var err error + args, err = reorderNetFlags(args, map[string]bool{"timeout": true, "json": false}) + if err != nil { + return err + } fs := flag.NewFlagSet("net port", flag.ContinueOnError) fs.SetOutput(stderr) var ( @@ -75,15 +127,18 @@ func runNetPort(ctx context.Context, stdout, stderr io.Writer, args []string, tr if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if fs.NArg() != 2 { return fmt.Errorf("%w: net port requires ", ErrUsage) } port, err := strconv.Atoi(strings.TrimSpace(fs.Arg(1))) - if err != nil || port < 0 || port > 65535 { + if err != nil || port < 1 || port > 65535 { return fmt.Errorf("%w: invalid port %q", ErrUsage, fs.Arg(1)) } + if timeout <= 0 || timeout > maxNetProbeTimeout { + return fmt.Errorf("%w: timeout must be > 0 and <= %s", ErrUsage, maxNetProbeTimeout) + } res := vminfo.CheckPort(ctx, fs.Arg(0), port, timeout) if asJSON { @@ -129,6 +184,13 @@ func writePort(w io.Writer, res vminfo.PortResult, tr *i18n.Translator) error { } func runNetPing(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i18n.Translator) error { + var err error + args, err = reorderNetFlags(args, map[string]bool{ + "mode": true, "count": true, "timeout": true, "tcp-port": true, "json": false, + }) + if err != nil { + return err + } fs := flag.NewFlagSet("net ping", flag.ContinueOnError) fs.SetOutput(stderr) var ( @@ -147,11 +209,24 @@ func runNetPing(ctx context.Context, stdout, stderr io.Writer, args []string, tr if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if fs.NArg() != 1 { return fmt.Errorf("%w: net ping requires exactly one host", ErrUsage) } + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode != "tcp" && mode != "icmp" { + return fmt.Errorf("%w: invalid ping mode %q (want tcp or icmp)", ErrUsage, mode) + } + if count < 1 || count > maxNetProbeCount { + return fmt.Errorf("%w: ping count must be between 1 and %d", ErrUsage, maxNetProbeCount) + } + if timeout <= 0 || timeout > maxNetProbeTimeout { + return fmt.Errorf("%w: ping timeout must be > 0 and <= %s", ErrUsage, maxNetProbeTimeout) + } + if port < 1 || port > 65535 { + return fmt.Errorf("%w: invalid tcp port %d (want 1-65535)", ErrUsage, port) + } res := vminfo.Ping(ctx, fs.Arg(0), vminfo.PingOptions{Mode: mode, Count: count, Timeout: timeout, Port: port}) if asJSON { @@ -178,6 +253,11 @@ func writePing(w io.Writer, res vminfo.PingResult, tr *i18n.Translator) error { } func runNetIP(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i18n.Translator) error { + var err error + args, err = reorderNetFlags(args, map[string]bool{"server": true, "json": false}) + if err != nil { + return err + } fs := flag.NewFlagSet("net ip", flag.ContinueOnError) fs.SetOutput(stderr) var ( @@ -190,7 +270,7 @@ func runNetIP(ctx context.Context, stdout, stderr io.Writer, args []string, tr * if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if fs.NArg() > 1 { return fmt.Errorf("%w: net ip accepts at most one IP", ErrUsage) diff --git a/internal/app/net_test.go b/internal/app/net_test.go index 019ee93..b1c6dd4 100644 --- a/internal/app/net_test.go +++ b/internal/app/net_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "net" + "slices" "strconv" "strings" "testing" @@ -42,7 +43,7 @@ func TestRunNetPortMissingPort(t *testing.T) { func TestRunNetDNSJSON(t *testing.T) { var out bytes.Buffer - if err := runNet(context.Background(), &out, &bytes.Buffer{}, []string{"dns", "--json", "localhost"}, i18n.New("en")); err != nil { + if err := runNet(context.Background(), &out, &bytes.Buffer{}, []string{"dns", "localhost", "--json"}, i18n.New("en")); err != nil { t.Fatalf("runNet dns returned error: %v", err) } if !strings.Contains(out.String(), `"domain": "localhost"`) { @@ -66,7 +67,7 @@ func TestRunNetPingTCPJSON(t *testing.T) { port := ln.Addr().(*net.TCPAddr).Port var out bytes.Buffer - args := []string{"ping", "--mode", "tcp", "--tcp-port", strconv.Itoa(port), "--count", "2", "--json", "127.0.0.1"} + args := []string{"ping", "127.0.0.1", "--mode", "tcp", "--tcp-port", strconv.Itoa(port), "--count", "2", "--json"} if err := runNet(context.Background(), &out, &bytes.Buffer{}, args, i18n.New("en")); err != nil { t.Fatalf("runNet ping returned error: %v", err) } @@ -75,6 +76,44 @@ func TestRunNetPingTCPJSON(t *testing.T) { } } +func TestRunNetRejectsInvalidProbeOptions(t *testing.T) { + tests := [][]string{ + {"port", "localhost", "0"}, + {"port", "localhost", "80", "--timeout", "11s"}, + {"ping", "localhost", "--mode", "bogus"}, + {"ping", "localhost", "--count", "0"}, + {"ping", "localhost", "--count", "101"}, + {"ping", "localhost", "--tcp-port", "0"}, + {"ping", "localhost", "--timeout", "11s"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + err := runNet(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, args, i18n.New("en")) + if !errors.Is(err, ErrUsage) { + t.Fatalf("runNet(%v) error = %v, want ErrUsage", args, err) + } + }) + } +} + +func TestReorderNetFlagsRejectsMissingValue(t *testing.T) { + _, err := reorderNetFlags([]string{"example.com", "--server"}, map[string]bool{"server": true}) + if !errors.Is(err, ErrUsage) { + t.Fatalf("reorderNetFlags error = %v, want ErrUsage", err) + } +} + +func TestReorderNetFlagsPreservesTerminatorBeforePositionals(t *testing.T) { + got, err := reorderNetFlags([]string{"--", "--json", "example.com"}, map[string]bool{"json": false}) + if err != nil { + t.Fatalf("reorderNetFlags returned error: %v", err) + } + want := []string{"--", "--json", "example.com"} + if !slices.Equal(got, want) { + t.Fatalf("reorderNetFlags = %v, want %v", got, want) + } +} + func TestRunNetIPTooManyArgs(t *testing.T) { err := runNet(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, []string{"ip", "1.1.1.1", "2.2.2.2"}, i18n.New("en")) if !errors.Is(err, ErrUsage) { diff --git a/internal/app/ps_test.go b/internal/app/ps_test.go index b86a7f5..b749def 100644 --- a/internal/app/ps_test.go +++ b/internal/app/ps_test.go @@ -146,3 +146,25 @@ func TestWriteProcessesShowsCommandAndAge(t *testing.T) { t.Fatalf("expected command and age in output, got:\n%s", text) } } + +func TestWriteProcessesSanitizesTerminalControlsAndFallsBackToName(t *testing.T) { + items := []vminfo.ProcessInfo{{ + PID: 42, + Name: "safe-name", + Command: "\x1b]0;malicious-title\a", + User: "root\x1b[31m", + State: "S", + }} + + var out bytes.Buffer + if err := writeProcesses(&out, items, i18n.New("en")); err != nil { + t.Fatalf("writeProcesses returned error: %v", err) + } + text := out.String() + if strings.ContainsAny(text, "\x1b\a") || strings.Contains(text, "malicious-title") { + t.Fatalf("process output contains terminal control payload: %q", text) + } + if !strings.Contains(text, "safe-name") { + t.Fatalf("process output did not fall back to sanitized name: %q", text) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index 84ec6a8..af197fa 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -18,6 +18,7 @@ import ( "slices" "strconv" "strings" + "sync" "syscall" "text/tabwriter" "time" @@ -25,6 +26,7 @@ import ( "github.com/cloudapp3/vminfo" "github.com/cloudapp3/vminfo/internal/collector" "github.com/cloudapp3/vminfo/internal/i18n" + "github.com/cloudapp3/vminfo/internal/textsafe" "github.com/cloudapp3/vminfo/internal/updater" "github.com/cloudapp3/vminfo/internal/web" vminfotui "github.com/cloudapp3/vminfo/tui" @@ -36,6 +38,13 @@ var ErrUsage = errors.New("usage") // not turn into a tight loop that re-reads all of /proc on every iteration. const minPSWatchInterval = 500 * time.Millisecond +const ( + defaultWebBind = "127.0.0.1" + defaultWebPort = 20021 + defaultWebInterval = 3 * time.Second + updateCleanupWait = 250 * time.Millisecond +) + type watchSnapshot struct { CollectedAt time.Time `json:"collected_at"` Static vminfo.StaticInfo `json:"static"` @@ -58,127 +67,284 @@ type psOptions struct { count int } -func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { - stdout = defaultWriter(stdout) - stderr = defaultWriter(stderr) - - // Pre-scan global flags (--lang, --web, --port, --bind, --token, --tui, --interval, --silent, --no-update-check) - langFlag := "" - webMode := false - webPort := 20021 - webBind := "127.0.0.1" - webTokenFlag := "" - webTokenRequested := false - tuiMode := false - silent := false - noUpdateCheck := os.Getenv("VMINFO_NO_UPDATE_CHECK") != "" - webInterval := 3 * time.Second - filtered := make([]string, 0, len(args)) +type globalOptions struct { + lang string + web bool + webPort int + webBind string + webToken string + webTokenRequested bool + tui bool + silent bool + noUpdateCheck bool + webInterval time.Duration + webOptionSeen bool +} + +type synchronizedWriter struct { + mu sync.Mutex + w io.Writer +} + +func (w *synchronizedWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.w.Write(p) +} + +func parseGlobalOptions(args []string) (globalOptions, []string, error) { + opts := globalOptions{ + webPort: defaultWebPort, + webBind: defaultWebBind, + webInterval: defaultWebInterval, + noUpdateCheck: os.Getenv("VMINFO_NO_UPDATE_CHECK") != "", + } + + globalArgs := args + if terminator := slices.Index(args, "--"); terminator >= 0 { + globalArgs = args[:terminator] + } + webRequested := slices.Contains(globalArgs, "--web") + remaining := make([]string, 0, len(args)) for i := 0; i < len(args); i++ { + arg := args[i] switch { - case strings.HasPrefix(args[i], "--lang="): - langFlag = strings.TrimPrefix(args[i], "--lang=") - case args[i] == "--lang" && i+1 < len(args): - langFlag = args[i+1] - i++ - case args[i] == "--web": - webMode = true - case strings.HasPrefix(args[i], "--port="): - if p, err := strconv.Atoi(strings.TrimPrefix(args[i], "--port=")); err == nil && p > 0 && p < 65536 { - webPort = p + case arg == "--": + if len(remaining) > 0 { + remaining = append(remaining, arg) } - case args[i] == "--port" && i+1 < len(args): - if p, err := strconv.Atoi(args[i+1]); err == nil && p > 0 && p < 65536 { - webPort = p + remaining = append(remaining, args[i+1:]...) + i = len(args) + case arg == "--web": + opts.web = true + case strings.HasPrefix(arg, "--lang="): + opts.lang = strings.TrimSpace(strings.TrimPrefix(arg, "--lang=")) + if opts.lang == "" { + return globalOptions{}, nil, fmt.Errorf("%w: --lang requires a value", ErrUsage) } - i++ - case strings.HasPrefix(args[i], "--bind="): - webBind = strings.TrimPrefix(args[i], "--bind=") - case args[i] == "--bind" && i+1 < len(args): - webBind = args[i+1] - i++ - case strings.HasPrefix(args[i], "--token="): - webTokenRequested = true - webTokenFlag = strings.TrimPrefix(args[i], "--token=") - case args[i] == "--token": - webTokenRequested = true - // --token without a value means auto-generate + case arg == "--lang": + value, next, err := requiredOptionValue(args, i, "--lang") + if err != nil { + return globalOptions{}, nil, err + } + opts.lang = value + i = next + case strings.HasPrefix(arg, "--port="): + port, err := parseWebPort(strings.TrimPrefix(arg, "--port=")) + if err != nil { + return globalOptions{}, nil, err + } + opts.webPort = port + opts.webOptionSeen = true + case arg == "--port": + value, next, err := requiredOptionValue(args, i, "--port") + if err != nil { + return globalOptions{}, nil, err + } + port, err := parseWebPort(value) + if err != nil { + return globalOptions{}, nil, err + } + opts.webPort = port + opts.webOptionSeen = true + i = next + case strings.HasPrefix(arg, "--bind="): + opts.webBind = strings.TrimSpace(strings.TrimPrefix(arg, "--bind=")) + if opts.webBind == "" { + return globalOptions{}, nil, fmt.Errorf("%w: --bind requires a non-empty address", ErrUsage) + } + opts.webOptionSeen = true + case arg == "--bind": + value, next, err := requiredOptionValue(args, i, "--bind") + if err != nil { + return globalOptions{}, nil, err + } + opts.webBind = value + opts.webOptionSeen = true + i = next + case strings.HasPrefix(arg, "--token="): + opts.webTokenRequested = true + opts.webToken = strings.TrimSpace(strings.TrimPrefix(arg, "--token=")) + opts.webOptionSeen = true + case arg == "--token": + opts.webTokenRequested = true + opts.webOptionSeen = true if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - webTokenFlag = args[i+1] + opts.webToken = strings.TrimSpace(args[i+1]) i++ } - case args[i] == "--tui": - tuiMode = true - case args[i] == "--silent", args[i] == "-s": - silent = true - case webMode && strings.HasPrefix(args[i], "--interval="): - if d, err := time.ParseDuration(strings.TrimPrefix(args[i], "--interval=")); err == nil { - webInterval = d + case arg == "--tui": + opts.tui = true + opts.webOptionSeen = true + case arg == "--silent" || arg == "-s": + opts.silent = true + case webRequested && strings.HasPrefix(arg, "--interval="): + interval, err := parseWebInterval(strings.TrimPrefix(arg, "--interval=")) + if err != nil { + return globalOptions{}, nil, err } - case webMode && args[i] == "--interval" && i+1 < len(args): - if d, err := time.ParseDuration(args[i+1]); err == nil { - webInterval = d + opts.webInterval = interval + opts.webOptionSeen = true + case webRequested && arg == "--interval": + value, next, err := requiredOptionValue(args, i, "--interval") + if err != nil { + return globalOptions{}, nil, err } - i++ - case args[i] == "--no-update-check": - noUpdateCheck = true + interval, err := parseWebInterval(value) + if err != nil { + return globalOptions{}, nil, err + } + opts.webInterval = interval + opts.webOptionSeen = true + i = next + case arg == "--no-update-check": + opts.noUpdateCheck = true default: - filtered = append(filtered, args[i]) + remaining = append(remaining, arg) } } - args = filtered + + if !opts.web && opts.webOptionSeen { + return globalOptions{}, nil, fmt.Errorf("%w: web options require --web", ErrUsage) + } + return opts, remaining, nil +} + +func requiredOptionValue(args []string, index int, name string) (string, int, error) { + if index+1 >= len(args) || strings.HasPrefix(args[index+1], "-") { + return "", index, fmt.Errorf("%w: %s requires a value", ErrUsage, name) + } + value := strings.TrimSpace(args[index+1]) + if value == "" { + return "", index, fmt.Errorf("%w: %s requires a value", ErrUsage, name) + } + return value, index + 1, nil +} + +func parseWebPort(raw string) (int, error) { + port, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("%w: invalid web port %q (want 1-65535)", ErrUsage, raw) + } + return port, nil +} + +func parseWebInterval(raw string) (time.Duration, error) { + interval, err := time.ParseDuration(strings.TrimSpace(raw)) + if err != nil || interval <= 0 { + return 0, fmt.Errorf("%w: invalid web interval %q (want a positive duration)", ErrUsage, raw) + } + return interval, nil +} + +func normalizeWebBind(bind string) string { + return strings.Trim(strings.TrimSpace(bind), "[]") +} + +func validateWebExposure(bind, token string) error { + host := normalizeWebBind(bind) + if host == "" { + return fmt.Errorf("%w: --bind requires a non-empty address", ErrUsage) + } + if strings.EqualFold(host, "localhost") { + return nil + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return nil + } + if strings.TrimSpace(token) == "" { + return fmt.Errorf("%w: non-loopback web bind %q requires --token", ErrUsage, bind) + } + return nil +} + +func startBackgroundUpdateCheck(ctx context.Context, stderr io.Writer, tr *i18n.Translator, meta vminfo.AppMetadata) func() { + checkCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + token := updateTokenFromEnv() + go func() { + defer close(done) + u := newUpdateClient(updater.Config{ + Repo: defaultUpdateRepo(meta), + CurrentVer: meta.Version, + GitHubToken: token, + }) + result, err := u.CheckForUpdate(checkCtx) + if checkCtx.Err() != nil || err != nil || result == nil || !result.UpdateAvailable { + return + } + msg := fmt.Sprintf(tr.T("A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.")+"\n", + formatReleaseTag(result.LatestVersion), formatReleaseTag(result.CurrentVersion)) + _, _ = fmt.Fprint(stderr, msg) + }() + return func() { + cancel() + timer := time.NewTimer(updateCleanupWait) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } + } +} + +func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { + stdout = defaultWriter(stdout) + stderr = &synchronizedWriter{w: defaultWriter(stderr)} + + opts, args, err := parseGlobalOptions(args) + if err != nil { + return err + } // Detect locale: --lang > VMINFO_LANG > LANG/LC_ALL > default "en" locale := i18n.Detect() - if langFlag != "" { - locale = strings.ToLower(strings.TrimSpace(langFlag)) + if opts.lang != "" { + locale = strings.ToLower(strings.TrimSpace(opts.lang)) } tr := i18n.New(locale) - - // Background update check (non-blocking) - if !noUpdateCheck && !silent && vminfo.Version != "dev" { - go func() { - cfg := updater.Config{ - Repo: "cloudapp3/vminfo", - CurrentVer: vminfo.Version, - GitHubToken: updateTokenFromEnv(), - } - u := updater.New(cfg) - result, err := u.CheckForUpdate(context.Background()) - if err != nil { - return - } - if result != nil && result.UpdateAvailable { - msg := fmt.Sprintf(tr.T("A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.")+"\n", - formatReleaseTag(result.LatestVersion), formatReleaseTag(result.CurrentVersion)) - _, _ = fmt.Fprint(stderr, msg) - } - }() + if len(args) == 1 && isHelpAlias(args[0]) { + _, _ = io.WriteString(stdout, helpText(tr)) + return nil } // Handle web mode - if webMode { - addr := fmt.Sprintf("%s:%d", webBind, webPort) - webToken, webTokenGenerated, err := resolveRequestedWebToken(webTokenFlag, webTokenRequested) + meta := vminfo.Metadata() + if opts.web { + if len(args) != 0 { + return fmt.Errorf("%w: --web does not accept command arguments: %s", ErrUsage, strings.Join(args, " ")) + } + webToken, webTokenGenerated, err := resolveRequestedWebToken(opts.webToken, opts.webTokenRequested) if err != nil { return err } - return runWeb(ctx, stdout, stderr, tr, addr, webInterval, tuiMode, silent, webToken, webTokenGenerated) + if err := validateWebExposure(opts.webBind, webToken); err != nil { + return err + } + if !opts.noUpdateCheck && !opts.silent && !strings.EqualFold(strings.TrimSpace(meta.Version), "dev") { + defer startBackgroundUpdateCheck(ctx, stderr, tr, meta)() + } + addr := net.JoinHostPort(normalizeWebBind(opts.webBind), strconv.Itoa(opts.webPort)) + return runWeb(ctx, stdout, stderr, tr, addr, opts.webInterval, opts.tui, opts.silent, webToken, webTokenGenerated) } if len(args) == 0 { + if !opts.noUpdateCheck && !opts.silent && !strings.EqualFold(strings.TrimSpace(meta.Version), "dev") { + defer startBackgroundUpdateCheck(ctx, stderr, tr, meta)() + } return runInfo(ctx, stdout, tr) } cmd := strings.ToLower(strings.TrimSpace(args[0])) - if isHelpAlias(cmd) { - _, _ = io.WriteString(stdout, helpText(tr)) - return nil + if cmd != "update" && !opts.noUpdateCheck && !opts.silent && !strings.EqualFold(strings.TrimSpace(meta.Version), "dev") { + defer startBackgroundUpdateCheck(ctx, stderr, tr, meta)() } switch cmd { case "version", "--version": - meta := vminfo.Metadata() + if len(args) != 1 { + return fmt.Errorf("%w: version does not accept arguments", ErrUsage) + } lines := []string{fmt.Sprintf("%s %s", meta.Name, meta.Version)} if meta.Commit != "" { lines = append(lines, fmt.Sprintf(tr.T("commit:")+" %s", meta.Commit)) @@ -522,11 +688,14 @@ func runSummary(ctx context.Context, stdout, stderr io.Writer, args []string, tr if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if len(fs.Args()) != 0 { return fmt.Errorf("%w: summary does not accept positional args", ErrUsage) } + if interval <= 0 { + return fmt.Errorf("%w: summary interval must be > 0", ErrUsage) + } staticInfo, stats, err := vminfo.CollectAll(ctx, vminfo.Options{SampleInterval: interval}) if err != nil { @@ -553,7 +722,7 @@ func runWatch(ctx context.Context, stdout, stderr io.Writer, args []string, tr * if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if len(fs.Args()) != 0 { return fmt.Errorf("%w: watch does not accept positional args", ErrUsage) @@ -561,6 +730,9 @@ func runWatch(ctx context.Context, stdout, stderr io.Writer, args []string, tr * if count < 0 { return fmt.Errorf("%w: watch count must be >= 0", ErrUsage) } + if interval <= 0 { + return fmt.Errorf("%w: watch interval must be > 0", ErrUsage) + } encoder := json.NewEncoder(stdout) for emitted := 0; count == 0 || emitted < count; emitted++ { @@ -610,7 +782,7 @@ func runPS(ctx context.Context, stdout, stderr io.Writer, args []string, tr *i18 if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if len(fs.Args()) > 1 { return fmt.Errorf("%w: ps accepts at most one filter term", ErrUsage) @@ -688,26 +860,39 @@ func runKill(ctx context.Context, stdout io.Writer, args []string, tr *i18n.Tran if len(args) != 1 { return fmt.Errorf("%w: kill requires exactly one pid", ErrUsage) } - pidValue, err := strconv.Atoi(strings.TrimSpace(args[0])) + pidValue, err := parsePID(args[0]) if err != nil { - return fmt.Errorf("%w: invalid pid %q", ErrUsage, args[0]) + return err } - if err := vminfo.TerminateProcess(ctx, int32(pidValue)); err != nil { + if err := vminfo.TerminateProcess(ctx, pidValue); err != nil { return fmt.Errorf("failed to terminate PID %d: %w", pidValue, err) } _, err = fmt.Fprintf(stdout, tr.T("sent SIGTERM to PID %d")+"\n", pidValue) return err } +func parsePID(raw string) (int32, error) { + value := strings.TrimSpace(raw) + pid, err := strconv.ParseInt(value, 10, 32) + if err != nil || pid <= 0 { + return 0, fmt.Errorf("%w: invalid pid %q", ErrUsage, raw) + } + return int32(pid), nil +} + func writeSummary(w io.Writer, staticInfo vminfo.StaticInfo, stats vminfo.RuntimeStats, tr *i18n.Translator) error { + osText := strings.TrimSpace(strings.Join([]string{ + terminalText(staticInfo.Platform, staticInfo.OS, "-"), + terminalText(staticInfo.OSVersion), + }, " ")) lines := []string{ tr.T("Host Summary"), "============", - fmt.Sprintf(tr.T("Host :")+" %s", firstNonEmpty(staticInfo.Hostname, "-")), - fmt.Sprintf(tr.T("OS :")+" %s", strings.TrimSpace(strings.Join([]string{firstNonEmpty(staticInfo.Platform, staticInfo.OS, "-"), strings.TrimSpace(staticInfo.OSVersion)}, " "))), - fmt.Sprintf(tr.T("Kernel :")+" %s", firstNonEmpty(staticInfo.Kernel, "-")), - fmt.Sprintf(tr.T("Arch :")+" %s", firstNonEmpty(staticInfo.Arch, "-")), - fmt.Sprintf(tr.T("CPU :")+" %s ("+tr.T("%d cores")+")", firstNonEmpty(staticInfo.CPUModel, "-"), staticInfo.CPUCores), + fmt.Sprintf(tr.T("Host :")+" %s", terminalText(staticInfo.Hostname, "-")), + fmt.Sprintf(tr.T("OS :")+" %s", osText), + fmt.Sprintf(tr.T("Kernel :")+" %s", terminalText(staticInfo.Kernel, "-")), + fmt.Sprintf(tr.T("Arch :")+" %s", terminalText(staticInfo.Arch, "-")), + fmt.Sprintf(tr.T("CPU :")+" %s ("+tr.T("%d cores")+")", terminalText(staticInfo.CPUModel, "-"), staticInfo.CPUCores), fmt.Sprintf(tr.T("Memory :")+" %s"+tr.T(" used / ")+"%s"+tr.T(" total"), formatBytes(stats.MemUsed), formatBytes(staticInfo.MemTotal)), fmt.Sprintf(tr.T("Swap :")+" %s"+tr.T(" used / ")+"%s"+tr.T(" total"), formatBytes(stats.SwapUsed), formatBytes(staticInfo.SwapTotal)), fmt.Sprintf(tr.T("Disk :")+" %s"+tr.T(" used / ")+"%s"+tr.T(" total"), formatBytes(stats.DiskUsed), formatBytes(staticInfo.DiskTotal)), @@ -723,12 +908,12 @@ func writeSummary(w io.Writer, staticInfo vminfo.StaticInfo, stats vminfo.Runtim func writeWatchSnapshot(w io.Writer, collectedAt time.Time, staticInfo vminfo.StaticInfo, stats vminfo.RuntimeStats, tr *i18n.Translator) error { osText := strings.TrimSpace(strings.Join([]string{ - firstNonEmpty(staticInfo.Platform, staticInfo.OS, "-"), - strings.TrimSpace(staticInfo.OSVersion), + terminalText(staticInfo.Platform, staticInfo.OS, "-"), + terminalText(staticInfo.OSVersion), }, " ")) lines := []string{ - fmt.Sprintf("[%s] host=%s os=%s", collectedAt.Format(time.RFC3339), firstNonEmpty(staticInfo.Hostname, "-"), osText), + fmt.Sprintf("[%s] host=%s os=%s", collectedAt.Format(time.RFC3339), terminalText(staticInfo.Hostname, "-"), osText), fmt.Sprintf( "cpu=%s mem=%s/%s swap=%s/%s disk=%s/%s", formatPercent(stats.CPU), @@ -770,10 +955,10 @@ func writeProcesses(w io.Writer, items []vminfo.ProcessInfo, tr *i18n.Translator item.CPUPercent, item.MemoryPercent, formatBytes(item.RSSBytes), - firstNonEmpty(item.User, "-"), - firstNonEmpty(item.State, "-"), + terminalText(item.User, "-"), + terminalText(item.State, "-"), formatUptime(item.Uptime), - firstNonEmpty(item.Command, item.Name, "-"), + terminalText(item.Command, item.Name, "-"), ); err != nil { return err } @@ -796,11 +981,11 @@ func writeProcessTree(w io.Writer, items []vminfo.ProcessInfo, filter string, li item.CPUPercent, item.MemoryPercent, formatBytes(item.RSSBytes), - firstNonEmpty(item.User, "-"), - firstNonEmpty(item.State, "-"), + terminalText(item.User, "-"), + terminalText(item.State, "-"), formatUptime(item.Uptime), strings.Repeat(" ", row.depth), - firstNonEmpty(item.Command, item.Name, "-"), + terminalText(item.Command, item.Name, "-"), ); err != nil { return err } @@ -1023,7 +1208,7 @@ func helpText(tr *i18n.Translator) string { " --lang " + tr.T("force language: en|zh|de|es|fr|ja|ko|pt|ru"), " --web " + tr.T("enable web dashboard"), " --port " + tr.T("web dashboard port (default 20021)"), - " --bind " + tr.T("bind address (default 127.0.0.1, use 0.0.0.0 for all)"), + " --bind " + tr.T("bind address (default 127.0.0.1; non-loopback requires --token)"), " --token [value] " + tr.T("protect --web with a token; bare --token generates one"), " --tui " + tr.T("start TUI alongside --web"), " --silent, -s " + tr.T("suppress informational output"), @@ -1061,6 +1246,16 @@ func firstNonEmpty(values ...string) string { return "" } +func terminalText(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(textsafe.Terminal(value)) + if value != "" { + return value + } + } + return "" +} + func formatPercent(value float64) string { if value < 0 { return "-" diff --git a/internal/app/root_test.go b/internal/app/root_test.go new file mode 100644 index 0000000..479fb91 --- /dev/null +++ b/internal/app/root_test.go @@ -0,0 +1,225 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "slices" + "strings" + "testing" + "time" + + "github.com/cloudapp3/vminfo" + "github.com/cloudapp3/vminfo/internal/i18n" + "github.com/cloudapp3/vminfo/internal/updater" +) + +func TestParseGlobalOptionsWebFlagsAreOrderIndependent(t *testing.T) { + opts, remaining, err := parseGlobalOptions([]string{ + "--interval", "750ms", "--port=8080", "--bind", "0.0.0.0", "--token", "secret", "--web", + }) + if err != nil { + t.Fatalf("parseGlobalOptions returned error: %v", err) + } + if len(remaining) != 0 { + t.Fatalf("remaining args = %v, want none", remaining) + } + if !opts.web || opts.webPort != 8080 || opts.webBind != "0.0.0.0" || opts.webToken != "secret" { + t.Fatalf("unexpected options: %+v", opts) + } + if opts.webInterval != 750*time.Millisecond { + t.Fatalf("web interval = %s, want 750ms", opts.webInterval) + } +} + +func TestParseGlobalOptionsRejectsInvalidWebValues(t *testing.T) { + for _, args := range [][]string{ + {"--web", "--port", "nope"}, + {"--web", "--port=0"}, + {"--web", "--bind="}, + {"--web", "--interval", "0s"}, + {"--bind", "127.0.0.1"}, + } { + t.Run(args[1], func(t *testing.T) { + if _, _, err := parseGlobalOptions(args); !errors.Is(err, ErrUsage) { + t.Fatalf("parseGlobalOptions(%v) error = %v, want ErrUsage", args, err) + } + }) + } +} + +func TestParseGlobalOptionsHonorsFlagTerminator(t *testing.T) { + opts, remaining, err := parseGlobalOptions([]string{"ps", "--", "--token", "--web", "--interval", "1s"}) + if err != nil { + t.Fatalf("parseGlobalOptions returned error: %v", err) + } + if opts.web || opts.webOptionSeen { + t.Fatalf("options after -- were parsed as globals: %+v", opts) + } + want := []string{"ps", "--", "--token", "--web", "--interval", "1s"} + if !slices.Equal(remaining, want) { + t.Fatalf("remaining args = %v, want %v", remaining, want) + } + + _, remaining, err = parseGlobalOptions([]string{"--", "ps", "--token"}) + if err != nil { + t.Fatalf("parseGlobalOptions with leading terminator returned error: %v", err) + } + if want := []string{"ps", "--token"}; !slices.Equal(remaining, want) { + t.Fatalf("remaining args after leading -- = %v, want %v", remaining, want) + } +} + +func TestValidateWebExposure(t *testing.T) { + for _, bind := range []string{"127.0.0.1", "::1", "[::1]", "localhost"} { + if err := validateWebExposure(bind, ""); err != nil { + t.Fatalf("validateWebExposure(%q) returned error: %v", bind, err) + } + } + if err := validateWebExposure("0.0.0.0", ""); !errors.Is(err, ErrUsage) { + t.Fatalf("wildcard bind error = %v, want ErrUsage", err) + } + if err := validateWebExposure("0.0.0.0", "secret"); err != nil { + t.Fatalf("token-protected wildcard bind returned error: %v", err) + } +} + +func TestParsePIDRejectsOverflow(t *testing.T) { + if _, err := parsePID("4294967298"); !errors.Is(err, ErrUsage) { + t.Fatalf("overflow PID error = %v, want ErrUsage", err) + } + if _, err := parsePID("0"); !errors.Is(err, ErrUsage) { + t.Fatalf("zero PID error = %v, want ErrUsage", err) + } + pid, err := parsePID("42") + if err != nil || pid != 42 { + t.Fatalf("parsePID(42) = %d, %v", pid, err) + } +} + +func TestRunRejectsUnsafeOrUnknownWebArguments(t *testing.T) { + for _, args := range [][]string{ + {"--web", "--bind", "0.0.0.0", "--silent"}, + {"--web", "--unexpected"}, + } { + err := Run(context.Background(), args, &bytes.Buffer{}, &bytes.Buffer{}) + if !errors.Is(err, ErrUsage) { + t.Fatalf("Run(%v) error = %v, want ErrUsage", args, err) + } + } +} + +func TestRunWrapsFlagErrorsAsUsage(t *testing.T) { + err := Run(context.Background(), []string{"summary", "--bogus"}, &bytes.Buffer{}, &bytes.Buffer{}) + if !errors.Is(err, ErrUsage) { + t.Fatalf("Run error = %v, want ErrUsage", err) + } +} + +func TestSummaryTextRemovesTerminalControlPayloads(t *testing.T) { + staticInfo := vminfo.StaticInfo{ + Hostname: "safe-host\x1b]0;hostname-payload\a", + Platform: "linux\x1b]0;platform-payload\a", + OSVersion: "12\x1b]0;version-payload\a", + Kernel: "6.1\x1b]0;kernel-payload\a", + Arch: "amd64\x1b]0;arch-payload\a", + CPUModel: "example-cpu\x1b]0;cpu-payload\a", + CPUCores: 4, + } + payloads := []string{ + "hostname-payload", + "platform-payload", + "version-payload", + "kernel-payload", + "arch-payload", + "cpu-payload", + } + + var summary bytes.Buffer + if err := writeSummary(&summary, staticInfo, vminfo.RuntimeStats{}, i18n.New("en")); err != nil { + t.Fatalf("writeSummary returned error: %v", err) + } + assertNoTerminalPayloads(t, summary.String(), payloads) + for _, want := range []string{"safe-host", "linux 12", "6.1", "amd64", "example-cpu"} { + if !strings.Contains(summary.String(), want) { + t.Fatalf("summary output %q does not contain sanitized value %q", summary.String(), want) + } + } + + var watch bytes.Buffer + if err := writeWatchSnapshot(&watch, time.Unix(0, 0).UTC(), staticInfo, vminfo.RuntimeStats{}, i18n.New("en")); err != nil { + t.Fatalf("writeWatchSnapshot returned error: %v", err) + } + assertNoTerminalPayloads(t, watch.String(), payloads[:3]) + if !strings.Contains(watch.String(), "host=safe-host os=linux 12") { + t.Fatalf("watch output does not contain sanitized host and OS: %q", watch.String()) + } +} + +func assertNoTerminalPayloads(t *testing.T, output string, payloads []string) { + t.Helper() + for _, payload := range payloads { + if strings.Contains(output, payload) { + t.Fatalf("output exposed terminal control payload %q: %q", payload, output) + } + } +} + +func TestBackgroundUpdateCleanupDoesNotWaitForever(t *testing.T) { + restoreClient := newUpdateClient + t.Cleanup(func() { newUpdateClient = restoreClient }) + + client := &blockingUpdateClient{ + entered: make(chan struct{}), + release: make(chan struct{}), + exited: make(chan struct{}), + } + newUpdateClient = func(updater.Config) updateClient { return client } + + cleanup := startBackgroundUpdateCheck( + context.Background(), + io.Discard, + i18n.New("en"), + vminfo.AppMetadata{Version: "1.0.0"}, + ) + select { + case <-client.entered: + case <-time.After(time.Second): + t.Fatal("background update check did not start") + } + + started := time.Now() + cleanup() + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("cleanup blocked for %s", elapsed) + } + + close(client.release) + select { + case <-client.exited: + case <-time.After(time.Second): + t.Fatal("background update check did not exit after release") + } +} + +type blockingUpdateClient struct { + entered chan struct{} + release chan struct{} + exited chan struct{} +} + +func (c *blockingUpdateClient) CheckForUpdate(ctx context.Context) (*updater.CheckResult, error) { + close(c.entered) + <-c.release + close(c.exited) + return nil, ctx.Err() +} + +func (*blockingUpdateClient) CheckSpecificVersion(context.Context, string) (*updater.CheckResult, error) { + return nil, errors.New("unexpected CheckSpecificVersion call") +} + +func (*blockingUpdateClient) DownloadAndInstall(context.Context, *updater.Release, io.Writer) error { + return errors.New("unexpected DownloadAndInstall call") +} diff --git a/internal/app/update.go b/internal/app/update.go index f6943f4..f03a350 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -43,7 +43,7 @@ func runUpdate(ctx context.Context, stdout, stderr io.Writer, args []string, tr if errors.Is(err, flag.ErrHelp) { return nil } - return err + return fmt.Errorf("%w: %v", ErrUsage, err) } if len(fs.Args()) != 0 { return fmt.Errorf("%w: update does not accept positional args", ErrUsage) @@ -81,6 +81,29 @@ func runUpdate(ctx context.Context, stdout, stderr io.Writer, args []string, tr if checkOnly { return writeUpdateCheck(stdout, result, targetTag != "", tr) } + if targetTag == "" && result.UpdateAvailable && result.Release == nil { + latestTag := normalizeReleaseTag(result.LatestVersion) + if latestTag == "" || strings.EqualFold(latestTag, "dev") { + return fmt.Errorf("failed to install update: release metadata is unavailable for version %q", result.LatestVersion) + } + + result, err = client.CheckSpecificVersion(ctx, latestTag) + if err != nil { + return fmt.Errorf("failed to fetch release metadata for %s: %w", latestTag, err) + } + if result == nil { + return fmt.Errorf("failed to fetch release metadata for %s: empty result", latestTag) + } + if normalizeReleaseTag(result.LatestVersion) != latestTag { + return fmt.Errorf("failed to fetch release metadata for %s: returned version is %s", latestTag, formatReleaseTag(result.LatestVersion)) + } + if result.Release == nil { + return fmt.Errorf("failed to install update: release metadata is unavailable") + } + if normalizeReleaseTag(result.Release.TagName) != latestTag { + return fmt.Errorf("failed to fetch release metadata for %s: release tag is %s", latestTag, formatReleaseTag(result.Release.TagName)) + } + } if !result.UpdateAvailable { if targetTag != "" && normalizeReleaseTag(result.CurrentVersion) == normalizeReleaseTag(result.LatestVersion) { diff --git a/internal/app/update_test.go b/internal/app/update_test.go index 0a50a93..93ebadf 100644 --- a/internal/app/update_test.go +++ b/internal/app/update_test.go @@ -3,6 +3,7 @@ package app import ( "bytes" "context" + "errors" "io" "strings" "testing" @@ -16,7 +17,11 @@ type stubUpdateClient struct { checkCalled bool checkSpecificCalled bool checkSpecificTag string + checkSpecificResult *updater.CheckResult + checkSpecificErr error + useSpecificResult bool downloadCalled bool + downloadRelease *updater.Release checkResult *updater.CheckResult checkErr error downloadErr error @@ -30,11 +35,15 @@ func (s *stubUpdateClient) CheckForUpdate(context.Context) (*updater.CheckResult func (s *stubUpdateClient) CheckSpecificVersion(_ context.Context, tag string) (*updater.CheckResult, error) { s.checkSpecificCalled = true s.checkSpecificTag = tag + if s.useSpecificResult { + return s.checkSpecificResult, s.checkSpecificErr + } return s.checkResult, s.checkErr } -func (s *stubUpdateClient) DownloadAndInstall(_ context.Context, _ *updater.Release, progress io.Writer) error { +func (s *stubUpdateClient) DownloadAndInstall(_ context.Context, release *updater.Release, progress io.Writer) error { s.downloadCalled = true + s.downloadRelease = release if progress != nil { _, _ = progress.Write([]byte("installing...\n")) } @@ -79,6 +88,13 @@ func TestRunUpdateCheckRoutesThroughUpdater(t *testing.T) { } } +func TestRunUpdateWrapsFlagErrorsAsUsage(t *testing.T) { + err := runUpdate(context.Background(), new(bytes.Buffer), new(bytes.Buffer), []string{"--unknown"}, i18n.New("en")) + if !errors.Is(err, ErrUsage) { + t.Fatalf("runUpdate error = %v, want ErrUsage", err) + } +} + func TestRunUpdateNormalizesSpecificVersion(t *testing.T) { restoreClient := newUpdateClient restoreVersion := vminfo.Version @@ -145,6 +161,215 @@ func TestRunUpdateInstallsAvailableRelease(t *testing.T) { } } +func TestRunUpdateInstallsReleaseAfterCacheHit(t *testing.T) { + restoreClient := newUpdateClient + restoreVersion := vminfo.Version + t.Cleanup(func() { + newUpdateClient = restoreClient + vminfo.Version = restoreVersion + }) + + release := &updater.Release{TagName: "v1.1.0"} + stub := &stubUpdateClient{ + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + checkSpecificResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + Release: release, + }, + useSpecificResult: true, + } + newUpdateClient = func(updater.Config) updateClient { + return stub + } + vminfo.Version = "v1.0.0" + + var stdout bytes.Buffer + if err := runUpdate(context.Background(), &stdout, new(bytes.Buffer), nil, i18n.New("en")); err != nil { + t.Fatalf("runUpdate returned error: %v", err) + } + if !stub.checkCalled { + t.Fatal("expected CheckForUpdate to be called") + } + if !stub.checkSpecificCalled { + t.Fatal("expected CheckSpecificVersion to be called") + } + if stub.checkSpecificTag != "v1.1.0" { + t.Fatalf("expected normalized tag v1.1.0, got %q", stub.checkSpecificTag) + } + if stub.downloadRelease != release { + t.Fatalf("DownloadAndInstall received release %p, want %p", stub.downloadRelease, release) + } + if got := stdout.String(); !strings.Contains(got, "updated successfully to v1.1.0") { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestRunUpdateRejectsInvalidCachedReleaseMetadata(t *testing.T) { + tests := []struct { + name string + checkResult *updater.CheckResult + specificResult *updater.CheckResult + specificErr error + wantSpecificTag string + wantErr string + }{ + { + name: "empty cached version", + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + UpdateAvailable: true, + }, + wantErr: "release metadata is unavailable for version \"\"", + }, + { + name: "specific lookup error", + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + specificErr: errors.New("offline"), + wantSpecificTag: "v1.1.0", + wantErr: "failed to fetch release metadata for v1.1.0: offline", + }, + { + name: "empty specific result", + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + wantSpecificTag: "v1.1.0", + wantErr: "failed to fetch release metadata for v1.1.0: empty result", + }, + { + name: "mismatched specific version", + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + specificResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.2.0", + UpdateAvailable: true, + Release: &updater.Release{TagName: "v1.2.0"}, + }, + wantSpecificTag: "v1.1.0", + wantErr: "returned version is v1.2.0", + }, + { + name: "missing release", + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + specificResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + wantSpecificTag: "v1.1.0", + wantErr: "release metadata is unavailable", + }, + { + name: "mismatched release tag", + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + specificResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + Release: &updater.Release{TagName: "v1.2.0"}, + }, + wantSpecificTag: "v1.1.0", + wantErr: "release tag is v1.2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restoreClient := newUpdateClient + restoreVersion := vminfo.Version + t.Cleanup(func() { + newUpdateClient = restoreClient + vminfo.Version = restoreVersion + }) + + stub := &stubUpdateClient{ + checkResult: tt.checkResult, + checkSpecificResult: tt.specificResult, + checkSpecificErr: tt.specificErr, + useSpecificResult: true, + } + newUpdateClient = func(updater.Config) updateClient { + return stub + } + vminfo.Version = "v1.0.0" + + err := runUpdate(context.Background(), new(bytes.Buffer), new(bytes.Buffer), nil, i18n.New("en")) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("runUpdate error = %v, want substring %q", err, tt.wantErr) + } + if stub.checkSpecificTag != tt.wantSpecificTag { + t.Fatalf("CheckSpecificVersion tag = %q, want %q", stub.checkSpecificTag, tt.wantSpecificTag) + } + if stub.downloadCalled { + t.Fatal("DownloadAndInstall should not be called") + } + }) + } +} + +func TestRunUpdateRechecksCachedVersionBeforeInstall(t *testing.T) { + restoreClient := newUpdateClient + restoreVersion := vminfo.Version + t.Cleanup(func() { + newUpdateClient = restoreClient + vminfo.Version = restoreVersion + }) + + stub := &stubUpdateClient{ + checkResult: &updater.CheckResult{ + CurrentVersion: "1.0.0", + LatestVersion: "1.1.0", + UpdateAvailable: true, + }, + checkSpecificResult: &updater.CheckResult{ + CurrentVersion: "1.1.0", + LatestVersion: "1.1.0", + UpdateAvailable: false, + Release: &updater.Release{TagName: "v1.1.0"}, + }, + useSpecificResult: true, + } + newUpdateClient = func(updater.Config) updateClient { + return stub + } + vminfo.Version = "v1.0.0" + + var stdout bytes.Buffer + if err := runUpdate(context.Background(), &stdout, new(bytes.Buffer), nil, i18n.New("en")); err != nil { + t.Fatalf("runUpdate returned error: %v", err) + } + if stub.downloadCalled { + t.Fatal("DownloadAndInstall should not be called when the refreshed result is current") + } + if got := stdout.String(); !strings.Contains(got, "already up to date: v1.1.0") { + t.Fatalf("unexpected output: %q", got) + } +} + func TestRunUpdateCheckAllowsDevBuild(t *testing.T) { restoreClient := newUpdateClient restoreVersion := vminfo.Version diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 8cb65e4..a2f9a32 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -1,9 +1,12 @@ package collector import ( + "bytes" "context" "encoding/json" "log" + "maps" + "slices" "sync" "sync/atomic" "time" @@ -60,6 +63,8 @@ type Collector struct { subs map[string]chan *Snapshot stopCh chan struct{} + startOnce sync.Once + stopOnce sync.Once procConsumers int32 // atomic: >0 means someone wants process data } @@ -99,14 +104,18 @@ func (c *Collector) Unsubscribe(id string) { func (c *Collector) Latest() *Snapshot { c.mu.RLock() defer c.mu.RUnlock() - return c.snapshot + if c.snapshot == nil { + return nil + } + snapshot := cloneSnapshot(*c.snapshot) + return &snapshot } // LatestJSON returns the pre-serialized JSON of the latest snapshot. func (c *Collector) LatestJSON() []byte { c.mu.RLock() defer c.mu.RUnlock() - return c.cachedJSON + return bytes.Clone(c.cachedJSON) } // LatestWithProcesses returns the most recent snapshot, hydrating process @@ -117,7 +126,7 @@ func (c *Collector) LatestWithProcesses(ctx context.Context) *Snapshot { c.mu.RUnlock() return nil } - snap := *c.snapshot + snap := cloneSnapshot(*c.snapshot) c.mu.RUnlock() if !needsProcessHydration(snap) { @@ -145,8 +154,8 @@ func (c *Collector) LatestJSONWithProcesses(ctx context.Context) []byte { c.mu.RUnlock() return nil } - snap := *c.snapshot - cached := c.cachedJSON + snap := cloneSnapshot(*c.snapshot) + cached := bytes.Clone(c.cachedJSON) c.mu.RUnlock() if !needsProcessHydration(snap) { @@ -173,6 +182,20 @@ func (c *Collector) LatestJSONWithProcesses(ctx context.Context) []byte { // Start begins the collection loop. Blocks until Stop is called. func (c *Collector) Start(ctx context.Context) { + c.startOnce.Do(func() { + c.run(ctx) + }) +} + +func (c *Collector) run(ctx context.Context) { + select { + case <-c.stopCh: + return + case <-ctx.Done(): + return + default: + } + c.collectOnce(ctx) ticker := time.NewTicker(c.interval) @@ -192,10 +215,9 @@ func (c *Collector) Start(ctx context.Context) { // Stop signals the collector to stop. func (c *Collector) Stop() { - select { - case c.stopCh <- struct{}{}: - default: - } + c.stopOnce.Do(func() { + close(c.stopCh) + }) } // RequestProcesses increments the process consumer counter. @@ -226,25 +248,39 @@ func (c *Collector) collectOnce(ctx context.Context) { procs, _ = vminfo.ListProcesses(ctx) } - // Update CPU history and snapshot in a single lock acquisition + // Update CPU history before publishing the newly constructed snapshot. c.history.push(stats.CPU) historyCopy := c.history.slice() snap := BuildSnapshot(staticInfo, stats, procs, historyCopy) data, _ := json.Marshal(snap) + storedSnapshot := cloneSnapshot(snap) c.mu.Lock() - c.snapshot = &snap + c.snapshot = &storedSnapshot c.cachedJSON = data c.mu.Unlock() // Broadcast to subscribers (non-blocking) c.subMu.RLock() for _, ch := range c.subs { + subscriberSnapshot := cloneSnapshot(snap) select { - case ch <- &snap: + case ch <- &subscriberSnapshot: default: } } c.subMu.RUnlock() } + +func cloneSnapshot(snapshot Snapshot) Snapshot { + snapshot.CPU.PerCore = slices.Clone(snapshot.CPU.PerCore) + snapshot.CPU.History = slices.Clone(snapshot.CPU.History) + snapshot.Disk.Filesystems = slices.Clone(snapshot.Disk.Filesystems) + snapshot.Disk.IO = slices.Clone(snapshot.Disk.IO) + snapshot.Network.TCPStates = maps.Clone(snapshot.Network.TCPStates) + snapshot.Network.Interfaces = slices.Clone(snapshot.Network.Interfaces) + snapshot.Processes.List = slices.Clone(snapshot.Processes.List) + snapshot.Health.Warnings = slices.Clone(snapshot.Health.Warnings) + return snapshot +} diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go new file mode 100644 index 0000000..788df25 --- /dev/null +++ b/internal/collector/collector_test.go @@ -0,0 +1,81 @@ +package collector + +import ( + "context" + "testing" + "time" +) + +func TestCollectorStopBeforeStartIsPersistentAndIdempotent(t *testing.T) { + collector := New(time.Hour) + collector.Stop() + collector.Stop() + + done := make(chan struct{}) + go func() { + collector.Start(context.Background()) + collector.Start(context.Background()) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Start did not honor a prior Stop") + } + if got := collector.Latest(); got != nil { + t.Fatalf("pre-stopped collector unexpectedly collected a snapshot: %+v", got) + } +} + +func TestCollectorLatestReturnsDeepCopy(t *testing.T) { + collector := New(time.Second) + collector.snapshot = &Snapshot{ + CPU: CPUInfo{PerCore: []float64{1}, History: []float64{2}}, + Disk: DiskInfo{ + Filesystems: []Filesystem{{Mount: "/"}}, + IO: []DiskIO{{Device: "sda"}}, + }, + Network: NetworkInfo{ + TCPStates: map[string]uint32{"ESTABLISHED": 1}, + Interfaces: []NetInterface{{Name: "eth0"}}, + }, + Processes: ProcessInfo{Total: 1, List: []ProcessEntry{{Name: "init"}}}, + Health: HealthInfo{Warnings: []HealthWarning{{Code: "test"}}}, + } + + first := collector.Latest() + first.CPU.PerCore[0] = 10 + first.CPU.History[0] = 20 + first.Disk.Filesystems[0].Mount = "/mutated" + first.Disk.IO[0].Device = "mutated" + first.Network.TCPStates["ESTABLISHED"] = 10 + first.Network.Interfaces[0].Name = "mutated" + first.Processes.List[0].Name = "mutated" + first.Health.Warnings[0].Code = "mutated" + + second := collector.Latest() + if second.CPU.PerCore[0] != 1 || second.CPU.History[0] != 2 { + t.Fatalf("CPU slices were mutated through Latest: %+v", second.CPU) + } + if second.Disk.Filesystems[0].Mount != "/" || second.Disk.IO[0].Device != "sda" { + t.Fatalf("disk slices were mutated through Latest: %+v", second.Disk) + } + if second.Network.TCPStates["ESTABLISHED"] != 1 || second.Network.Interfaces[0].Name != "eth0" { + t.Fatalf("network data was mutated through Latest: %+v", second.Network) + } + if second.Processes.List[0].Name != "init" || second.Health.Warnings[0].Code != "test" { + t.Fatalf("process or health slices were mutated through Latest: %+v %+v", second.Processes, second.Health) + } +} + +func TestCollectorLatestJSONReturnsCopy(t *testing.T) { + collector := New(time.Second) + collector.cachedJSON = []byte(`{"status":"ok"}`) + + first := collector.LatestJSON() + first[0] = 'x' + if got := string(collector.LatestJSON()); got != `{"status":"ok"}` { + t.Fatalf("LatestJSON cache mutated through caller slice: %q", got) + } +} diff --git a/internal/collector/snapshot.go b/internal/collector/snapshot.go index 315be91..076effb 100644 --- a/internal/collector/snapshot.go +++ b/internal/collector/snapshot.go @@ -164,9 +164,7 @@ func BuildSnapshot( var sum float64 for _, v := range stats.CPUPerCore { sum += v - if v > maxCore { - maxCore = v - } + maxCore = max(maxCore, v) } avgCore = sum / float64(len(stats.CPUPerCore)) } diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index ef55a30..eb9985d 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -127,7 +127,7 @@ "enable web dashboard": "Web-Dashboard aktivieren", "web dashboard on port N (default 20021)": "Web-Dashboard auf Port N (Standard 20021)", "web dashboard port (default 20021)": "Web-Dashboard-Port (Standard 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "Bind-Adresse (Standard 127.0.0.1, 0.0.0.0 für alle)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "Bind-Adresse (Standard 127.0.0.1; Nicht-Loopback erfordert --token)", "refresh interval (default 3s)": "Aktualisierungsintervall (Standard 3s)", "suppress informational output": "Informationsausgaben unterdrücken", "protect --web with a token; bare --token generates one": "--web mit Token schützen; blankes --token erzeugt eins", diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 359b899..12859f6 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -26,7 +26,7 @@ "enable web dashboard": "enable web dashboard", "web dashboard on port N (default 20021)": "web dashboard on port N (default 20021)", "web dashboard port (default 20021)": "web dashboard port (default 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "bind address (default 127.0.0.1, use 0.0.0.0 for all)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "bind address (default 127.0.0.1; non-loopback requires --token)", "refresh interval (default 3s)": "refresh interval (default 3s)", "suppress informational output": "suppress informational output", "protect --web with a token; bare --token generates one": "protect --web with a token; bare --token generates one", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 9f670db..936d512 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -127,7 +127,7 @@ "enable web dashboard": "activar panel web", "web dashboard on port N (default 20021)": "panel web en el puerto N (predeterminado 20021)", "web dashboard port (default 20021)": "puerto del panel web (predeterminado 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "dirección de enlace (predeterminada 127.0.0.1, usa 0.0.0.0 para todas)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "dirección de enlace (predeterminada 127.0.0.1; las direcciones no loopback requieren --token)", "refresh interval (default 3s)": "intervalo de actualización (predeterminado 3s)", "suppress informational output": "suprimir salida informativa", "protect --web with a token; bare --token generates one": "proteger --web con token; --token sin valor genera uno", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index ecf3cba..3268854 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -127,7 +127,7 @@ "enable web dashboard": "activer le tableau de bord web", "web dashboard on port N (default 20021)": "tableau de bord web sur le port N (défaut 20021)", "web dashboard port (default 20021)": "port du tableau de bord web (défaut 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "adresse d’écoute (défaut 127.0.0.1, 0.0.0.0 pour toutes)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "adresse d’écoute (défaut 127.0.0.1 ; une adresse non loopback nécessite --token)", "refresh interval (default 3s)": "intervalle d’actualisation (défaut 3s)", "suppress informational output": "masquer les messages informatifs", "protect --web with a token; bare --token generates one": "protéger --web par un jeton ; --token seul en génère un", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 8bbc58c..79db21a 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -114,7 +114,7 @@ "enable web dashboard": "Web ダッシュボードを有効化", "web dashboard on port N (default 20021)": "Web ダッシュボードのポート N(既定 20021)", "web dashboard port (default 20021)": "Web ダッシュボードのポート(既定 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "バインドアドレス(既定 127.0.0.1、全ては 0.0.0.0)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "バインドアドレス(既定 127.0.0.1、非ループバックには --token が必要)", "refresh interval (default 3s)": "更新間隔(既定 3s)", "suppress informational output": "情報出力を抑制", "protect --web with a token; bare --token generates one": "token で --web を保護;値なし --token は生成", diff --git a/internal/i18n/locales/ko.json b/internal/i18n/locales/ko.json index c2225f5..10ad230 100644 --- a/internal/i18n/locales/ko.json +++ b/internal/i18n/locales/ko.json @@ -114,7 +114,7 @@ "enable web dashboard": "웹 대시보드 활성화", "web dashboard on port N (default 20021)": "포트 N에서 웹 대시보드 실행(기본 20021)", "web dashboard port (default 20021)": "웹 대시보드 포트(기본 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "바인드 주소(기본 127.0.0.1, 전체는 0.0.0.0)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "바인드 주소(기본 127.0.0.1, 루프백이 아닌 주소에는 --token 필요)", "refresh interval (default 3s)": "새로고침 간격(기본 3s)", "suppress informational output": "정보 출력 숨기기", "protect --web with a token; bare --token generates one": "토큰으로 --web 보호; 값 없는 --token은 생성", diff --git a/internal/i18n/locales/pt.json b/internal/i18n/locales/pt.json index 3b33977..06d6605 100644 --- a/internal/i18n/locales/pt.json +++ b/internal/i18n/locales/pt.json @@ -127,7 +127,7 @@ "enable web dashboard": "ativar painel web", "web dashboard on port N (default 20021)": "painel web na porta N (padrão 20021)", "web dashboard port (default 20021)": "porta do painel web (padrão 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "endereço de bind (padrão 127.0.0.1, use 0.0.0.0 para todos)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "endereço de bind (padrão 127.0.0.1; endereço não loopback exige --token)", "refresh interval (default 3s)": "intervalo de atualização (padrão 3s)", "suppress informational output": "suprimir saída informativa", "protect --web with a token; bare --token generates one": "proteger --web com token; --token sem valor gera um", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index de1766e..0921c5b 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -114,7 +114,7 @@ "enable web dashboard": "включить веб-панель", "web dashboard on port N (default 20021)": "веб-панель на порту N (по умолчанию 20021)", "web dashboard port (default 20021)": "порт веб-панели (по умолчанию 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "адрес привязки (по умолчанию 127.0.0.1, 0.0.0.0 для всех)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "адрес привязки (по умолчанию 127.0.0.1; для нелокального адреса требуется --token)", "refresh interval (default 3s)": "интервал обновления (по умолчанию 3s)", "suppress informational output": "подавлять информационный вывод", "protect --web with a token; bare --token generates one": "защитить --web токеном; пустой --token создаёт его", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 83831a6..981319c 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -126,7 +126,7 @@ "enable web dashboard": "启用 Web 仪表盘", "web dashboard on port N (default 20021)": "Web 仪表盘监听端口 N(默认 20021)", "web dashboard port (default 20021)": "Web 仪表盘端口(默认 20021)", - "bind address (default 127.0.0.1, use 0.0.0.0 for all)": "绑定地址(默认 127.0.0.1,使用 0.0.0.0 监听全部)", + "bind address (default 127.0.0.1; non-loopback requires --token)": "绑定地址(默认 127.0.0.1;非回环地址必须使用 --token)", "refresh interval (default 3s)": "刷新间隔(默认 3s)", "suppress informational output": "抑制提示信息输出", "protect --web with a token; bare --token generates one": "使用 token 保护 --web;裸 --token 会生成一个", diff --git a/internal/textsafe/terminal.go b/internal/textsafe/terminal.go new file mode 100644 index 0000000..8296e8b --- /dev/null +++ b/internal/textsafe/terminal.go @@ -0,0 +1,90 @@ +package textsafe + +import "strings" + +const ( + escape = '\x1b' + csi = '\u009b' + st = '\u009c' +) + +// Terminal removes terminal control sequences and control characters from s. +func Terminal(s string) string { + var out strings.Builder + out.Grow(len(s)) + runes := []rune(s) + for i := 0; i < len(runes); { + r := runes[i] + switch { + case r == escape: + i = skipEscapeSequence(runes, i+1) + case r == csi: + i = skipCSI(runes, i+1) + case isControlStringStart(r): + i = skipControlString(runes, i+1) + case isControl(r): + i++ + default: + out.WriteRune(r) + i++ + } + } + return out.String() +} + +func skipEscapeSequence(runes []rune, start int) int { + if start >= len(runes) { + return len(runes) + } + switch runes[start] { + case '[': + return skipCSI(runes, start+1) + case ']', 'P', 'X', '^', '_': + return skipControlString(runes, start+1) + } + + i := start + for i < len(runes) && runes[i] >= 0x20 && runes[i] <= 0x2f { + i++ + } + if i < len(runes) { + return i + 1 + } + return len(runes) +} + +func skipCSI(runes []rune, start int) int { + for i := start; i < len(runes); i++ { + if runes[i] >= 0x40 && runes[i] <= 0x7e { + return i + 1 + } + } + return len(runes) +} + +func skipControlString(runes []rune, start int) int { + for i := start; i < len(runes); i++ { + switch runes[i] { + case '\a', st: + return i + 1 + case escape: + if i+1 < len(runes) && runes[i+1] == '\\' { + return i + 2 + } + } + } + return len(runes) +} + +func isControlStringStart(r rune) bool { + switch r { + case '\u0090', '\u0098', '\u009d', '\u009e', '\u009f': + return true + default: + return false + } +} + +func isControl(r rune) bool { + return r <= 0x1f || (r >= 0x7f && r <= 0x9f) +} diff --git a/internal/textsafe/terminal_test.go b/internal/textsafe/terminal_test.go new file mode 100644 index 0000000..109ce08 --- /dev/null +++ b/internal/textsafe/terminal_test.go @@ -0,0 +1,29 @@ +package textsafe + +import "testing" + +func TestTerminal(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "plain unicode", input: "vminfo process", want: "vminfo process"}, + {name: "CSI color", input: "before\x1b[31mafter\x1b[0m", want: "beforeafter"}, + {name: "C1 CSI color", input: "before\u009b31mafter", want: "beforeafter"}, + {name: "OSC with bell", input: "before\x1b]0;malicious-title\aafter", want: "beforeafter"}, + {name: "OSC with ST", input: "before\x1b]8;;https://example.invalid\x1b\\after", want: "beforeafter"}, + {name: "C1 OSC", input: "before\u009dmalicious-title\u009cafter", want: "beforeafter"}, + {name: "C0 and C1", input: "a\n\tb\u007fc\u0085d", want: "abcd"}, + {name: "generic escape", input: "before\x1b7after", want: "beforeafter"}, + {name: "unterminated OSC", input: "before\x1b]0;malicious-title", want: "before"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Terminal(tt.input); got != tt.want { + t.Fatalf("Terminal(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index ea7534a..8d924aa 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/cloudapp3/vminfo" + "github.com/cloudapp3/vminfo/internal/textsafe" ) // ── Styles & Constants ──────────────────────────────────────────────── @@ -63,7 +64,7 @@ func (m Model) View() string { func (m Model) renderMain() string { // Header bar host := lipgloss.NewStyle().Bold(true).Foreground(CText).Render( - " vminfo " + firstNonEmpty(m.static.Hostname, "-"), + " vminfo " + terminalText(m.static.Hostname, "-"), ) stateBadge := m.renderBadge(m.stateLabel(), m.stateColor()) pageBadge := m.renderBadge(m.pageLabel(), CBlue) @@ -154,11 +155,11 @@ func (m Model) renderOverview() string { // renderSystemOneLine produces a single-line system summary for tight layouts. func (m Model) renderSystemOneLine(width int) string { parts := []string{} - if h := strings.TrimSpace(m.static.Hostname); h != "" { + if h := terminalText(m.static.Hostname); h != "" { parts = append(parts, valueStyle.Render(h)) } - if v := strings.TrimSpace(firstNonEmpty(m.static.Platform, m.static.OS, "")); v != "" { - ver := strings.TrimSpace(m.static.OSVersion) + if v := terminalText(m.static.Platform, m.static.OS); v != "" { + ver := terminalText(m.static.OSVersion) if ver != "" { v += " " + ver } @@ -178,15 +179,19 @@ func (m Model) renderSystemOneLine(width int) string { func (m Model) renderSystemContent() string { sysW := sysInnerWidth(m.width) valW := max(sysW-labelW-2, 10) + osText := strings.TrimSpace(strings.Join([]string{ + terminalText(m.static.Platform, m.static.OS, "-"), + terminalText(m.static.OSVersion), + }, " ")) lines := []string{ m.panelTitle("System"), "", - m.kv("OS", truncate(firstNonEmpty(m.static.Platform, m.static.OS, "-")+" "+strings.TrimSpace(m.static.OSVersion), valW)), - m.kv("Kernel", truncate(firstNonEmpty(m.static.Kernel, "-"), valW)), - m.kv("Arch", firstNonEmpty(m.static.Arch, "-")), - m.kv("Host", firstNonEmpty(m.static.Hostname, "-")), - m.kv("CPU", truncate(fmt.Sprintf("%s ("+m.tr.T("%d cores")+")", firstNonEmpty(m.static.CPUModel, "-"), m.static.CPUCores), valW)), + m.kv("OS", truncate(osText, valW)), + m.kv("Kernel", truncate(terminalText(m.static.Kernel, "-"), valW)), + m.kv("Arch", terminalText(m.static.Arch, "-")), + m.kv("Host", terminalText(m.static.Hostname, "-")), + m.kv("CPU", truncate(fmt.Sprintf("%s ("+m.tr.T("%d cores")+")", terminalText(m.static.CPUModel, "-"), m.static.CPUCores), valW)), } if v := firstNonEmpty(m.static.Virtualization, ""); v != "" && v != "-" { lines = append(lines, m.kv("Virt", v)) @@ -200,41 +205,6 @@ func (m Model) renderSystemContent() string { return strings.Join(lines, "\n") } -func (m Model) renderCPUContent() string { - lines := []string{ - m.panelTitle("CPU"), - "", - } - - if len(m.cpuHistory) > 1 { - sparkW := max(m.width/3, 30) - spark := renderSparkline(m.cpuHistory, sparkW) - lines = append(lines, spark) - - cur := m.cpuHistory[len(m.cpuHistory)-1] - statsLine := subtleStyle.Render(" cur ") + colorizePercent(cur) + - subtleStyle.Render(" "+m.tr.T("avg")+" ") + colorizePercent(avgFloat64(m.cpuHistory)) + - subtleStyle.Render(" "+m.tr.T("max")+" ") + colorizePercent(maxFloat64(m.cpuHistory)) - lines = append(lines, statsLine) - } else { - lines = append(lines, subtleStyle.Render(m.tr.T("Collecting..."))) - } - - if m.hasStats { - var extras []string - if len(m.stats.Temps) > 0 { - t := m.stats.Temps[0] - tc := colorForTempEnhanced(t.Temperature) - extras = append(extras, lipgloss.NewStyle().Foreground(tc).Bold(true).Render( - fmt.Sprintf("%.0f°C", t.Temperature))) - } - if len(extras) > 0 { - lines = append(lines, subtleStyle.Render(" ")+strings.Join(extras, subtleStyle.Render(" "))) - } - } - return strings.Join(lines, "\n") -} - func (m Model) renderResourceContent() string { title := m.panelTitle("Resources") @@ -292,7 +262,7 @@ func (m Model) renderResourceContent() string { limit := min(len(m.stats.CPUPerCore), 16) isCompact := len(m.stats.CPUPerCore) > 8 chars := make([]string, 0, limit) - for i := 0; i < limit; i++ { + for i := range limit { chars = append(chars, miniBar(m.stats.CPUPerCore[i])) } sep := " " @@ -322,10 +292,7 @@ func (m Model) renderResourceContent() string { } // ─── Equalize line count ─── - maxLines := len(leftLines) - if len(rightLines) > maxLines { - maxLines = len(rightLines) - } + maxLines := max(len(leftLines), len(rightLines)) for len(leftLines) < maxLines { leftLines = append(leftLines, "") } @@ -335,7 +302,7 @@ func (m Model) renderResourceContent() string { // ─── Build body lines ─── bodyLines := []string{""} - for i := 0; i < maxLines; i++ { + for i := range maxLines { left := lipgloss.NewStyle().Width(leftW).Render(leftLines[i]) sepChar := lipgloss.NewStyle().Foreground(CBorder).Render("\u2502") right := rightLines[i] @@ -550,11 +517,11 @@ func (m Model) renderNetworkInterfaces() string { " ", padRight("IFACE", ifaceW+2, false), padRight("IP", ipW, false), - padLeft("RX/s", rxW, false), - padLeft("TX/s", txW, false), + padLeft("RX/s", rxW), + padLeft("TX/s", txW), } if showTotal { - headers = append(headers, padLeft("TOTAL RX", totalW, false), padLeft("TOTAL TX", totalW, false)) + headers = append(headers, padLeft("TOTAL RX", totalW), padLeft("TOTAL TX", totalW)) } lines = append(lines, subtleStyle.Render(strings.Join(headers, ""))) } @@ -592,8 +559,8 @@ func (m Model) renderNetworkInterfaces() string { ipStyle = lipgloss.NewStyle().Foreground(CInfo).Bold(true) } - rxText := lipgloss.NewStyle().Foreground(CBrightGreen).Render("↓ " + padLeft(formatBytes(iface.RxSpeed)+"/s", rxW-2, false)) - txText := lipgloss.NewStyle().Foreground(CPink).Render("↑ " + padLeft(formatBytes(iface.TxSpeed)+"/s", txW-2, false)) + rxText := lipgloss.NewStyle().Foreground(CBrightGreen).Render("↓ " + padLeft(formatBytes(iface.RxSpeed)+"/s", rxW-2)) + txText := lipgloss.NewStyle().Foreground(CPink).Render("↑ " + padLeft(formatBytes(iface.TxSpeed)+"/s", txW-2)) if compact { line := " " + dot + " " + rowStyle.Render(padRight(name, ifaceW, false)) + ipStyle.Render(ipText) + " " + rxText + " " + txText @@ -611,8 +578,8 @@ func (m Model) renderNetworkInterfaces() string { } if showTotal { parts = append(parts, - " "+rowStyle.Render(padLeft(formatBytes(iface.RxBytes), totalW, false)), - " "+rowStyle.Render(padLeft(formatBytes(iface.TxBytes), totalW, false)), + " "+rowStyle.Render(padLeft(formatBytes(iface.RxBytes), totalW)), + " "+rowStyle.Render(padLeft(formatBytes(iface.TxBytes), totalW)), ) } line := strings.Join(parts, "") @@ -624,7 +591,7 @@ func (m Model) renderNetworkInterfaces() string { if compact { lines = append(lines, idleStyle.Render(label)) } else if showTotal { - lines = append(lines, idleStyle.Render(label+padLeft("", max(0, ifaceW+ipW+rxW+txW-15), false)+padLeft(formatBytes(foldedRx), totalW+1, false)+padLeft(formatBytes(foldedTx), totalW+1, false))) + lines = append(lines, idleStyle.Render(label+padLeft("", max(0, ifaceW+ipW+rxW+txW-15))+padLeft(formatBytes(foldedRx), totalW+1)+padLeft(formatBytes(foldedTx), totalW+1))) } else { lines = append(lines, idleStyle.Render(label)) } @@ -700,7 +667,7 @@ func padRight(value string, width int, styled bool) string { return value + strings.Repeat(" ", width-len(runes)) } -func padLeft(value string, width int, styled bool) string { +func padLeft(value string, width int) string { if width <= 0 { return "" } @@ -814,9 +781,7 @@ func equalizeContent(contents ...string) []string { for i, c := range contents { lines := strings.Split(c, "\n") split[i] = lines - if len(lines) > maxLines { - maxLines = len(lines) - } + maxLines = max(maxLines, len(lines)) } result := make([]string, len(contents)) for i, lines := range split { @@ -857,12 +822,12 @@ func (m Model) kv(key, value string) string { // depthColor returns a dimmer text color based on tree depth. func depthColor(depth int) lipgloss.Color { - switch { - case depth == 0: + switch depth { + case 0: return CText - case depth == 1: + case 1: return lipgloss.Color("#a0a8c0") - case depth == 2: + case 2: return lipgloss.Color("#8088a0") default: return CDim @@ -911,7 +876,7 @@ func (m Model) renderProcessTree() string { fmt.Sprintf("%5.1f ", node.proc.MemoryPercent)) + subtleStyle.Render(prefix) + lipgloss.NewStyle().Foreground(connectorColor).Render(connector) + - lipgloss.NewStyle().Foreground(nameColor).Render(firstNonEmpty(node.proc.Name, "-")) + lipgloss.NewStyle().Foreground(nameColor).Render(terminalText(node.proc.Name, "-")) if rowIndex == selectedIndex { line = lipgloss.NewStyle().Background(selectedBg).Width(innerW).Render(line) } else { @@ -943,13 +908,13 @@ func (m Model) renderProcessTree() string { stateColor = CCritical } infoLine = subtleStyle.Render(" "+m.tr.T("PID:")) + lipgloss.NewStyle().Foreground(CInfo).Render(fmt.Sprintf(" %d", selected.PID)) + - subtleStyle.Render(" "+m.tr.T("Name:")) + lipgloss.NewStyle().Foreground(CText).Bold(true).Render(" "+firstNonEmpty(selected.Name, "-")) + - subtleStyle.Render(" "+m.tr.T("State:")) + lipgloss.NewStyle().Foreground(stateColor).Render(" "+firstNonEmpty(selected.State, "-")) + + subtleStyle.Render(" "+m.tr.T("Name:")) + lipgloss.NewStyle().Foreground(CText).Bold(true).Render(" "+terminalText(selected.Name, "-")) + + subtleStyle.Render(" "+m.tr.T("State:")) + lipgloss.NewStyle().Foreground(stateColor).Render(" "+terminalText(selected.State, "-")) + subtleStyle.Render(" "+m.tr.T("CPU:")) + lipgloss.NewStyle().Foreground(ThresholdColor(selected.CPUPercent)).Render(fmt.Sprintf(" %.1f%%", selected.CPUPercent)) + subtleStyle.Render(" "+m.tr.T("Mem:")) + lipgloss.NewStyle().Foreground(ThresholdColor(float64(selected.MemoryPercent))).Render(fmt.Sprintf(" %.1f%%", selected.MemoryPercent)) + subtleStyle.Render(" "+m.tr.T("RSS:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+formatBytes(selected.RSSBytes)) + subtleStyle.Render(" "+m.tr.T("Age:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+formatUptime(selected.Uptime)) + - subtleStyle.Render(" "+m.tr.T("Cmd:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+truncate(firstNonEmpty(selected.Command, selected.Name, "-"), 80)) + subtleStyle.Render(" "+m.tr.T("Cmd:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+truncate(terminalText(selected.Command, selected.Name, "-"), 80)) } allLines := append(headerLines, rowLines...) @@ -1007,13 +972,10 @@ func (m Model) renderProcesses() string { colGap := 2 colUser := 4 // minimum for "USER" header for _, item := range items { - if u := firstNonEmpty(item.User, "-"); len(u) > colUser { - colUser = len(u) - } - } - if colUser > 16 { - colUser = 16 + u := terminalText(item.User, "-") + colUser = max(colUser, len(u)) } + colUser = min(colUser, 16) fixedW := 4 + colPID + colCPU + colMEM + colRSS + colUser + colGap*6 // sel marker + gaps colName := max(innerW-fixedW, 12) @@ -1072,9 +1034,9 @@ func (m Model) renderProcesses() string { rssS := lipgloss.NewStyle().Foreground(CDim).Width(colRSS).Render(formatBytes(item.RSSBytes)) - userS := lipgloss.NewStyle().Foreground(CDim).Width(colUser).Render(truncate(firstNonEmpty(item.User, "-"), colUser)) + userS := lipgloss.NewStyle().Foreground(CDim).Width(colUser).Render(truncate(terminalText(item.User, "-"), colUser)) - nameS := lipgloss.NewStyle().Foreground(CText).Width(colName).Render(truncate(firstNonEmpty(item.Name, "-"), colName)) + nameS := lipgloss.NewStyle().Foreground(CText).Width(colName).Render(truncate(terminalText(item.Name, "-"), colName)) gap := strings.Repeat(" ", colGap) row := " " + marker + " " + pidS + gap + cpuS + gap + memS + gap + rssS + gap + userS + gap + nameS @@ -1112,15 +1074,15 @@ func (m Model) renderProcesses() string { stateColor = CCritical } infoLine = subtleStyle.Render(" "+m.tr.T("PID:")) + lipgloss.NewStyle().Foreground(CInfo).Render(fmt.Sprintf(" %d", selected.PID)) + - subtleStyle.Render(" "+m.tr.T("Name:")) + lipgloss.NewStyle().Foreground(CText).Bold(true).Render(" "+firstNonEmpty(selected.Name, "-")) + - subtleStyle.Render(" "+m.tr.T("State:")) + lipgloss.NewStyle().Foreground(stateColor).Render(" "+firstNonEmpty(selected.State, "-")) + + subtleStyle.Render(" "+m.tr.T("Name:")) + lipgloss.NewStyle().Foreground(CText).Bold(true).Render(" "+terminalText(selected.Name, "-")) + + subtleStyle.Render(" "+m.tr.T("State:")) + lipgloss.NewStyle().Foreground(stateColor).Render(" "+terminalText(selected.State, "-")) + subtleStyle.Render(" "+m.tr.T("CPU:")) + lipgloss.NewStyle().Foreground(ThresholdColor(selected.CPUPercent)).Render(fmt.Sprintf(" %.1f%%", selected.CPUPercent)) + subtleStyle.Render(" "+m.tr.T("Mem:")) + lipgloss.NewStyle().Foreground(ThresholdColor(float64(selected.MemoryPercent))).Render(fmt.Sprintf(" %.1f%%", selected.MemoryPercent)) + subtleStyle.Render(" "+m.tr.T("RSS:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+formatBytes(selected.RSSBytes)) + subtleStyle.Render(" "+m.tr.T("Age:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+formatUptime(selected.Uptime)) + subtleStyle.Render(" "+m.tr.T("Threads:")) + lipgloss.NewStyle().Foreground(CDim).Render(fmt.Sprintf(" %d", selected.Threads)) + subtleStyle.Render(" "+m.tr.T("Nice:")) + lipgloss.NewStyle().Foreground(CDim).Render(fmt.Sprintf(" %d", selected.Nice)) + - subtleStyle.Render(" "+m.tr.T("Cmd:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+truncate(firstNonEmpty(selected.Command, selected.Name, "-"), 100)) + subtleStyle.Render(" "+m.tr.T("Cmd:")) + lipgloss.NewStyle().Foreground(CDim).Render(" "+truncate(terminalText(selected.Command, selected.Name, "-"), 100)) } // Build viewport content: header + all rows @@ -1183,9 +1145,9 @@ func (m Model) renderKillConfirm() string { pidLabel := subtleStyle.Render(m.tr.T("PID:")) pidVal := lipgloss.NewStyle().Foreground(CInfo).Bold(true).Render(fmt.Sprintf("%d", target.PID)) nameLabel := subtleStyle.Render(m.tr.T("Name:")) - nameVal := valueStyle.Render(firstNonEmpty(target.Name, "-")) + nameVal := valueStyle.Render(terminalText(target.Name, "-")) userLabel := subtleStyle.Render(m.tr.T("User:")) - userVal := lipgloss.NewStyle().Foreground(CDim).Render(firstNonEmpty(target.User, "-")) + userVal := lipgloss.NewStyle().Foreground(CDim).Render(terminalText(target.User, "-")) body := []string{ title, @@ -1336,6 +1298,16 @@ func firstNonEmpty(values ...string) string { return "" } +func terminalText(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(textsafe.Terminal(value)) + if value != "" { + return value + } + } + return "" +} + func formatBytes(bytes uint64) string { units := []string{"B", "K", "M", "G", "T", "P"} value := float64(bytes) diff --git a/internal/tui/view_terminal_test.go b/internal/tui/view_terminal_test.go new file mode 100644 index 0000000..b946ef3 --- /dev/null +++ b/internal/tui/view_terminal_test.go @@ -0,0 +1,83 @@ +package tui + +import ( + "context" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/viewport" + + "github.com/cloudapp3/vminfo" + "github.com/cloudapp3/vminfo/internal/i18n" +) + +func TestProcessViewsRemoveTerminalControlPayloads(t *testing.T) { + model := newModel(context.Background(), vminfo.StaticInfo{}, i18n.New("en")) + model.width = 120 + model.height = 40 + model.ready = true + model.showKernel = true + model.viewport = viewport.New(116, 24) + model.processes = []vminfo.ProcessInfo{{ + PID: 42, + Name: "safe\x1b]0;malicious-title\aname", + Command: "before\x1b]8;;https://evil.invalid\aafter", + User: "root\x1b]0;forged-user\a", + }} + model.refreshProcessListState() + + for _, treeView := range []bool{false, true} { + model.treeView = treeView + output := model.renderProcesses() + if strings.Contains(output, "malicious-title") || strings.Contains(output, "evil.invalid") || strings.Contains(output, "forged-user") { + t.Fatalf("process view exposed terminal control payload (tree=%v): %q", treeView, output) + } + } +} + +func TestTerminalTextFallsBackAfterSanitizing(t *testing.T) { + if got := terminalText("\x1b]0;malicious-title\a", "fallback"); got != "fallback" { + t.Fatalf("terminalText() = %q, want fallback", got) + } +} + +func TestSystemViewsRemoveTerminalControlPayloads(t *testing.T) { + staticInfo := vminfo.StaticInfo{ + Hostname: "safe-host\x1b]0;hostname-payload\a", + Platform: "linux\x1b]0;platform-payload\a", + OSVersion: "12\x1b]0;version-payload\a", + Kernel: "6.1\x1b]0;kernel-payload\a", + Arch: "amd64\x1b]0;arch-payload\a", + CPUModel: "example-cpu\x1b]0;cpu-payload\a", + CPUCores: 4, + } + model := newModel(context.Background(), staticInfo, i18n.New("en")) + model.width = 120 + + outputs := map[string]string{ + "header": model.renderMain(), + "compact": model.renderSystemOneLine(120), + "panel": model.renderSystemContent(), + } + payloads := []string{ + "hostname-payload", + "platform-payload", + "version-payload", + "kernel-payload", + "arch-payload", + "cpu-payload", + } + for name, output := range outputs { + for _, payload := range payloads { + if strings.Contains(output, payload) { + t.Fatalf("%s exposed terminal control payload %q: %q", name, payload, output) + } + } + } + + for _, want := range []string{"safe-host", "linux 12", "6.1", "amd64", "example-cpu"} { + if !strings.Contains(outputs["panel"], want) { + t.Fatalf("system panel %q does not contain sanitized value %q", outputs["panel"], want) + } + } +} diff --git a/internal/updater/cache.go b/internal/updater/cache.go index 39171c5..ea97986 100644 --- a/internal/updater/cache.go +++ b/internal/updater/cache.go @@ -46,6 +46,10 @@ func ReadCache() (CacheFile, error) { // back to CacheDir(). func ReadCacheAt(dir string) (CacheFile, error) { path := cacheFilePath(dir) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return CacheFile{}, nil + } data, err := os.ReadFile(path) if err != nil { return CacheFile{}, nil diff --git a/internal/updater/cache_linux_test.go b/internal/updater/cache_linux_test.go new file mode 100644 index 0000000..c0d246a --- /dev/null +++ b/internal/updater/cache_linux_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package updater + +import ( + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestReadCacheAtRejectsFIFO(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, cacheFileName) + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatalf("create cache FIFO: %v", err) + } + + done := make(chan struct{}) + go func() { + _, _ = ReadCacheAt(dir) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("ReadCacheAt blocked on a FIFO") + } +} diff --git a/internal/updater/replace.go b/internal/updater/replace.go index e157def..1025c2c 100644 --- a/internal/updater/replace.go +++ b/internal/updater/replace.go @@ -3,6 +3,7 @@ package updater import ( + "errors" "fmt" "io" "os" @@ -26,37 +27,69 @@ func SelfPath() (string, error) { // AtomicReplace replaces the binary at currentBinary with the new binary at // newBinary. It writes to a temp file in the same directory and renames, // which is atomic on Linux and macOS when on the same filesystem. -func AtomicReplace(newBinary, currentBinary string) error { +func AtomicReplace(newBinary, currentBinary string) (retErr error) { dir := filepath.Dir(currentBinary) - tmp := filepath.Join(dir, ".vminfo-update-tmp") src, err := os.Open(newBinary) if err != nil { return fmt.Errorf("cannot open new binary: %w", err) } - defer src.Close() + srcOpen := true + defer func() { + if !srcOpen { + return + } + if err := src.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("cannot close new binary: %w", err)) + } + }() - dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + dst, err := os.CreateTemp(dir, ".vminfo-update-*") if err != nil { return fmt.Errorf("cannot create temp file: %w", err) } - defer dst.Close() + tmp := dst.Name() + dstOpen := true + keepTemp := true + defer func() { + if dstOpen { + if err := dst.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("cannot close temp file: %w", err)) + } + } + if keepTemp { + if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) { + retErr = errors.Join(retErr, fmt.Errorf("cannot remove temp file: %w", err)) + } + } + }() if _, err := io.Copy(dst, src); err != nil { - os.Remove(tmp) return fmt.Errorf("cannot copy binary: %w", err) } - dst.Close() + if err := src.Close(); err != nil { + srcOpen = false + return fmt.Errorf("cannot close new binary: %w", err) + } + srcOpen = false + + if err := dst.Sync(); err != nil { + return fmt.Errorf("cannot sync temp file: %w", err) + } + if err := dst.Close(); err != nil { + dstOpen = false + return fmt.Errorf("cannot close temp file: %w", err) + } + dstOpen = false if err := os.Chmod(tmp, 0o755); err != nil { - os.Remove(tmp) return fmt.Errorf("cannot chmod temp file: %w", err) } if err := os.Rename(tmp, currentBinary); err != nil { - os.Remove(tmp) return fmt.Errorf("cannot replace binary (try running with appropriate privileges): %w", err) } + keepTemp = false return nil } diff --git a/internal/updater/replace_test.go b/internal/updater/replace_test.go new file mode 100644 index 0000000..1b33a81 --- /dev/null +++ b/internal/updater/replace_test.go @@ -0,0 +1,168 @@ +//go:build !windows + +package updater + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestAtomicReplace(t *testing.T) { + dir := t.TempDir() + newBinary := filepath.Join(dir, "new-vminfo") + currentBinary := filepath.Join(dir, "vminfo") + if err := os.WriteFile(newBinary, []byte("new binary"), 0o600); err != nil { + t.Fatalf("write new binary: %v", err) + } + if err := os.WriteFile(currentBinary, []byte("old binary"), 0o700); err != nil { + t.Fatalf("write current binary: %v", err) + } + + if err := AtomicReplace(newBinary, currentBinary); err != nil { + t.Fatalf("AtomicReplace returned error: %v", err) + } + + data, err := os.ReadFile(currentBinary) + if err != nil { + t.Fatalf("read replaced binary: %v", err) + } + if got := string(data); got != "new binary" { + t.Fatalf("replaced binary = %q, want %q", got, "new binary") + } + info, err := os.Stat(currentBinary) + if err != nil { + t.Fatalf("stat replaced binary: %v", err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("replaced binary mode = %o, want 755", got) + } + assertNoUpdateTemps(t, dir) +} + +func TestAtomicReplaceConcurrent(t *testing.T) { + const replacements = 16 + + dir := t.TempDir() + currentBinary := filepath.Join(dir, "vminfo") + if err := os.WriteFile(currentBinary, []byte("old binary"), 0o700); err != nil { + t.Fatalf("write current binary: %v", err) + } + + wantContents := make(map[string]struct{}, replacements) + newBinaries := make([]string, replacements) + for i := range replacements { + content := fmt.Sprintf("new binary %d", i) + wantContents[content] = struct{}{} + newBinaries[i] = filepath.Join(dir, fmt.Sprintf("new-vminfo-%d", i)) + if err := os.WriteFile(newBinaries[i], []byte(content), 0o600); err != nil { + t.Fatalf("write new binary %d: %v", i, err) + } + } + + start := make(chan struct{}) + errs := make(chan error, replacements) + var wg sync.WaitGroup + for _, newBinary := range newBinaries { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs <- AtomicReplace(newBinary, currentBinary) + }() + } + close(start) + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + t.Errorf("AtomicReplace returned error: %v", err) + } + } + if t.Failed() { + return + } + + data, err := os.ReadFile(currentBinary) + if err != nil { + t.Fatalf("read replaced binary: %v", err) + } + if _, ok := wantContents[string(data)]; !ok { + t.Fatalf("replaced binary has unexpected contents %q", string(data)) + } + assertNoUpdateTemps(t, dir) +} + +func TestAtomicReplaceDoesNotFollowLegacyTempSymlink(t *testing.T) { + dir := t.TempDir() + newBinary := filepath.Join(dir, "new-vminfo") + currentBinary := filepath.Join(dir, "vminfo") + victim := filepath.Join(dir, "victim") + legacyTemp := filepath.Join(dir, ".vminfo-update-tmp") + + if err := os.WriteFile(newBinary, []byte("new binary"), 0o600); err != nil { + t.Fatalf("write new binary: %v", err) + } + if err := os.WriteFile(currentBinary, []byte("old binary"), 0o700); err != nil { + t.Fatalf("write current binary: %v", err) + } + if err := os.WriteFile(victim, []byte("do not modify"), 0o600); err != nil { + t.Fatalf("write victim: %v", err) + } + if err := os.Symlink(victim, legacyTemp); err != nil { + t.Fatalf("create legacy temp symlink: %v", err) + } + + if err := AtomicReplace(newBinary, currentBinary); err != nil { + t.Fatalf("AtomicReplace returned error: %v", err) + } + + data, err := os.ReadFile(victim) + if err != nil { + t.Fatalf("read victim: %v", err) + } + if got := string(data); got != "do not modify" { + t.Fatalf("victim contents = %q, want %q", got, "do not modify") + } + info, err := os.Lstat(legacyTemp) + if err != nil { + t.Fatalf("lstat legacy temp symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("legacy temp path mode = %v, want symlink", info.Mode()) + } +} + +func TestAtomicReplaceCleansUpAfterRenameFailure(t *testing.T) { + dir := t.TempDir() + newBinary := filepath.Join(dir, "new-vminfo") + currentBinary := filepath.Join(dir, "vminfo") + if err := os.WriteFile(newBinary, []byte("new binary"), 0o600); err != nil { + t.Fatalf("write new binary: %v", err) + } + if err := os.Mkdir(currentBinary, 0o700); err != nil { + t.Fatalf("create target directory: %v", err) + } + if err := os.WriteFile(filepath.Join(currentBinary, "keep"), []byte("keep"), 0o600); err != nil { + t.Fatalf("write target directory entry: %v", err) + } + + if err := AtomicReplace(newBinary, currentBinary); err == nil { + t.Fatal("AtomicReplace returned nil error, want rename failure") + } + assertNoUpdateTemps(t, dir) +} + +func assertNoUpdateTemps(t *testing.T, dir string) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, ".vminfo-update-*")) + if err != nil { + t.Fatalf("glob update temp files: %v", err) + } + if len(matches) != 0 { + t.Fatalf("update temp files were not cleaned up: %v", matches) + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index c2cbddd..df15f79 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -50,11 +50,17 @@ func New(cfg Config) *Updater { // CheckForUpdate queries the GitHub Releases API and compares versions. // It uses the cache to avoid redundant API calls within CacheTTL. func (u *Updater) CheckForUpdate(ctx context.Context) (*CheckResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } current := stripVersionPrefix(u.cfg.CurrentVer) unknownCurrent := current == "" || current == "dev" // Check cache first cache, _ := ReadCacheAt(u.cfg.CacheDir) + if err := ctx.Err(); err != nil { + return nil, err + } if !ShouldCheck(cache, u.cfg.CacheTTL) && cache.LatestVersion != "" { latest := stripVersionPrefix(cache.LatestVersion) return &CheckResult{ diff --git a/internal/updater/version.go b/internal/updater/version.go index 63ef737..ce36151 100644 --- a/internal/updater/version.go +++ b/internal/updater/version.go @@ -10,11 +10,8 @@ import ( func compareVersions(a, b string) int { aParts := strings.Split(a, ".") bParts := strings.Split(b, ".") - maxLen := len(aParts) - if len(bParts) > maxLen { - maxLen = len(bParts) - } - for i := 0; i < maxLen; i++ { + maxLen := max(len(aParts), len(bParts)) + for i := range maxLen { var ai, bi int if i < len(aParts) { ai, _ = strconv.Atoi(aParts[i]) diff --git a/internal/web/auth.go b/internal/web/auth.go index dd1c0a8..e9b3ba5 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -51,6 +51,7 @@ func (a *authConfig) wrap(next http.Handler) http.Handler { Value: queryToken, Path: "/", HttpOnly: true, + Secure: requestScheme(r) == "https", SameSite: http.SameSiteLaxMode, }) @@ -133,15 +134,6 @@ func isWebSocketUpgrade(r *http.Request) bool { strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") } -func sameOriginHost(requestHost, originHost string) bool { - reqHost, reqPort := splitHostPort(requestHost) - originHostOnly, originPort := splitHostPort(originHost) - if !strings.EqualFold(reqHost, originHostOnly) { - return false - } - return reqPort == originPort -} - func splitHostPort(value string) (host, port string) { if strings.TrimSpace(value) == "" { return "", "" diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go index 3a6ceb8..d9af1a0 100644 --- a/internal/web/auth_test.go +++ b/internal/web/auth_test.go @@ -51,6 +51,20 @@ func TestAuthQueryTokenSetsCookieAndRedirects(t *testing.T) { } } +func TestAuthQueryTokenSetsSecureCookieForForwardedHTTPS(t *testing.T) { + auth := newAuthConfig("secret-token") + req := httptest.NewRequest(http.MethodGet, "/?token=secret-token", nil) + req.Header.Set("X-Forwarded-Proto", "https") + rr := httptest.NewRecorder() + + auth.wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rr, req) + + cookies := rr.Result().Cookies() + if len(cookies) != 1 || !cookies[0].Secure { + t.Fatalf("expected one Secure auth cookie, got %#v", cookies) + } +} + func TestAuthCookieAllowsProtectedRoute(t *testing.T) { srv := NewServer("127.0.0.1:0", nil, Options{AuthToken: "secret-token"}) handler, err := srv.handler() diff --git a/internal/web/hub.go b/internal/web/hub.go index 4679306..169965d 100644 --- a/internal/web/hub.go +++ b/internal/web/hub.go @@ -2,92 +2,216 @@ package web import ( "sync" + "time" "github.com/gorilla/websocket" "github.com/cloudapp3/vminfo/internal/collector" ) -// wsClient wraps a websocket connection with a write mutex. +const ( + maxWSClients = 64 + wsQueueSize = 8 + wsReadLimit = 4 << 10 + wsWriteWait = 5 * time.Second + wsPingPeriod = 30 * time.Second + wsPongWait = 60 * time.Second +) + +// wsClient owns one WebSocket connection. Only writePump writes data frames; +// readPump is the sole reader. type wsClient struct { - conn *websocket.Conn - mu sync.Mutex + conn *websocket.Conn + send chan []byte + done chan struct{} + closeOnce sync.Once } func newWSClient(conn *websocket.Conn) *wsClient { - return &wsClient{conn: conn} + return &wsClient{ + conn: conn, + send: make(chan []byte, wsQueueSize), + done: make(chan struct{}), + } +} + +func (c *wsClient) enqueue(data []byte) bool { + select { + case <-c.done: + return false + default: + } + + select { + case c.send <- data: + return true + case <-c.done: + return false + default: + return false + } +} + +func (c *wsClient) readPump(h *WSHub) { + defer h.unregister(c) + + c.conn.SetReadLimit(wsReadLimit) + _ = c.conn.SetReadDeadline(time.Now().Add(wsPongWait)) + c.conn.SetPongHandler(func(string) error { + return c.conn.SetReadDeadline(time.Now().Add(wsPongWait)) + }) + + for { + if _, _, err := c.conn.ReadMessage(); err != nil { + return + } + } } -func (c *wsClient) writeMessage(msgType int, data []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - return c.conn.WriteMessage(msgType, data) +func (c *wsClient) writePump(h *WSHub) { + ticker := time.NewTicker(wsPingPeriod) + defer func() { + ticker.Stop() + h.unregister(c) + }() + + for { + select { + case data := <-c.send: + if err := c.writeMessage(websocket.TextMessage, data); err != nil { + return + } + case <-ticker.C: + if err := c.writeControl(websocket.PingMessage, nil); err != nil { + return + } + case <-c.done: + return + } + } +} + +func (c *wsClient) writeMessage(messageType int, data []byte) error { + if err := c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil { + return err + } + return c.conn.WriteMessage(messageType, data) +} + +func (c *wsClient) writeControl(messageType int, data []byte) error { + return c.conn.WriteControl(messageType, data, time.Now().Add(wsWriteWait)) } func (c *wsClient) close() { - if c == nil || c.conn == nil { + if c == nil { return } - c.conn.Close() + c.closeOnce.Do(func() { + close(c.done) + if c.conn != nil { + _ = c.conn.Close() + } + }) } // WSHub manages WebSocket client connections. type WSHub struct { mu sync.RWMutex - clients map[*wsClient]bool + clients map[*wsClient]struct{} + closed bool col *collector.Collector } func newHub(col *collector.Collector) *WSHub { return &WSHub{ - clients: make(map[*wsClient]bool), + clients: make(map[*wsClient]struct{}), col: col, } } -func (h *WSHub) register(client *wsClient) { - var added bool +// tryRegister adds a client unless the hub is closed or at capacity. +func (h *WSHub) tryRegister(client *wsClient) bool { + if client == nil { + return false + } + h.mu.Lock() - if !h.clients[client] { - h.clients[client] = true - added = true + defer h.mu.Unlock() + + if h.closed { + return false } - h.mu.Unlock() - if added && h.col != nil { + if _, ok := h.clients[client]; ok { + return true + } + if len(h.clients) >= maxWSClients { + return false + } + + h.clients[client] = struct{}{} + if h.col != nil { h.col.RequestProcesses() } + return true } func (h *WSHub) unregister(client *wsClient) { var removed bool h.mu.Lock() - if h.clients[client] { + if _, ok := h.clients[client]; ok { delete(h.clients, client) removed = true + if h.col != nil { + h.col.ReleaseProcesses() + } } h.mu.Unlock() - if !removed { - return - } - client.close() - if h.col != nil { - h.col.ReleaseProcesses() + + if removed { + client.close() } } func (h *WSHub) broadcast(data []byte) { + if len(data) == 0 { + return + } + + var slowClients []*wsClient h.mu.RLock() - clients := make([]*wsClient, 0, len(h.clients)) for client := range h.clients { - clients = append(clients, client) + if !client.enqueue(data) { + slowClients = append(slowClients, client) + } } h.mu.RUnlock() - for _, client := range clients { - if err := client.writeMessage(websocket.TextMessage, data); err != nil { - h.unregister(client) + for _, client := range slowClients { + h.unregister(client) + } +} + +// closeAll permanently closes the hub and every registered connection. +func (h *WSHub) closeAll() { + var clients []*wsClient + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return + } + h.closed = true + for client := range h.clients { + clients = append(clients, client) + delete(h.clients, client) + if h.col != nil { + h.col.ReleaseProcesses() } } + h.mu.Unlock() + + for _, client := range clients { + client.close() + } } func (h *WSHub) clientCount() int { diff --git a/internal/web/hub_test.go b/internal/web/hub_test.go index 997263f..fc0e94e 100644 --- a/internal/web/hub_test.go +++ b/internal/web/hub_test.go @@ -2,14 +2,18 @@ package web import "testing" -func TestHubRegisterUnregisterIsIdempotent(t *testing.T) { +func TestHubTryRegisterUnregisterIsIdempotent(t *testing.T) { hub := newHub(nil) - client := &wsClient{} + client := newWSClient(nil) - hub.register(client) - hub.register(client) + if !hub.tryRegister(client) { + t.Fatal("expected first registration to succeed") + } + if !hub.tryRegister(client) { + t.Fatal("expected duplicate registration to be idempotent") + } if got := hub.clientCount(); got != 1 { - t.Fatalf("expected 1 client after duplicate register, got %d", got) + t.Fatalf("expected 1 client after duplicate registration, got %d", got) } hub.unregister(client) @@ -17,4 +21,76 @@ func TestHubRegisterUnregisterIsIdempotent(t *testing.T) { if got := hub.clientCount(); got != 0 { t.Fatalf("expected 0 clients after duplicate unregister, got %d", got) } + assertClosed(t, client.done) +} + +func TestHubRejectsClientsAboveCapacity(t *testing.T) { + hub := newHub(nil) + clients := make([]*wsClient, maxWSClients) + for i := range clients { + clients[i] = newWSClient(nil) + if !hub.tryRegister(clients[i]) { + t.Fatalf("registration %d unexpectedly failed", i) + } + } + + overflow := newWSClient(nil) + if hub.tryRegister(overflow) { + t.Fatalf("expected client %d to be rejected", maxWSClients+1) + } + if got := hub.clientCount(); got != maxWSClients { + t.Fatalf("client count = %d, want %d", got, maxWSClients) + } + + hub.closeAll() +} + +func TestHubDropsOnlySlowClient(t *testing.T) { + hub := newHub(nil) + slow := newWSClient(nil) + if !hub.tryRegister(slow) { + t.Fatal("expected registration to succeed") + } + + for i := 0; i < wsQueueSize; i++ { + hub.broadcast([]byte("snapshot")) + } + if got := hub.clientCount(); got != 1 { + t.Fatalf("client removed before queue filled: count = %d", got) + } + + hub.broadcast([]byte("overflow")) + if got := hub.clientCount(); got != 0 { + t.Fatalf("slow client was not removed: count = %d", got) + } + assertClosed(t, slow.done) +} + +func TestHubCloseAllRejectsFutureClients(t *testing.T) { + hub := newHub(nil) + first := newWSClient(nil) + second := newWSClient(nil) + if !hub.tryRegister(first) || !hub.tryRegister(second) { + t.Fatal("expected initial registrations to succeed") + } + + hub.closeAll() + hub.closeAll() + if got := hub.clientCount(); got != 0 { + t.Fatalf("client count after closeAll = %d, want 0", got) + } + assertClosed(t, first.done) + assertClosed(t, second.done) + if hub.tryRegister(newWSClient(nil)) { + t.Fatal("closed hub accepted a new client") + } +} + +func assertClosed(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + default: + t.Fatal("channel is not closed") + } } diff --git a/internal/web/server.go b/internal/web/server.go index 2e07c2f..980b280 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -7,8 +7,11 @@ import ( "embed" "encoding/json" "fmt" + "io" "io/fs" "log" + "mime" + "net" "net/http" "net/url" "slices" @@ -43,8 +46,13 @@ type Server struct { addr string collector *collector.Collector hub *WSHub - server *http.Server auth *authConfig + + lifecycleMu sync.Mutex + server *http.Server + cancelBroadcast context.CancelFunc + started bool + stopped bool } // NewServer creates a new web server listening on addr (e.g. "127.0.0.1:20021"). @@ -64,25 +72,69 @@ func (s *Server) Start() error { return err } - s.server = &http.Server{ - Addr: s.addr, - Handler: handler, - } - - // Start WS broadcast loop for the lifetime of the HTTP server. broadcastCtx, cancelBroadcast := context.WithCancel(context.Background()) - defer cancelBroadcast() - go s.broadcastLoop(broadcastCtx) - - return s.server.ListenAndServe() + httpServer := newHTTPServer(s.addr, handler) + + s.lifecycleMu.Lock() + if s.stopped { + s.lifecycleMu.Unlock() + cancelBroadcast() + return http.ErrServerClosed + } + if s.started { + s.lifecycleMu.Unlock() + cancelBroadcast() + return fmt.Errorf("web server already started") + } + s.started = true + s.server = httpServer + s.cancelBroadcast = cancelBroadcast + s.lifecycleMu.Unlock() + + defer func() { + cancelBroadcast() + s.hub.closeAll() + s.lifecycleMu.Lock() + s.stopped = true + s.cancelBroadcast = nil + s.lifecycleMu.Unlock() + }() + if s.collector != nil { + go s.broadcastLoop(broadcastCtx) + } + + return httpServer.ListenAndServe() } // Shutdown gracefully stops the server. func (s *Server) Shutdown(ctx context.Context) error { - if s.server == nil { + s.lifecycleMu.Lock() + s.stopped = true + httpServer := s.server + cancelBroadcast := s.cancelBroadcast + s.lifecycleMu.Unlock() + + if cancelBroadcast != nil { + cancelBroadcast() + } + if s.hub != nil { + s.hub.closeAll() + } + if httpServer == nil { return nil } - return s.server.Shutdown(ctx) + return httpServer.Shutdown(ctx) +} + +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } } func (s *Server) handler() (http.Handler, error) { @@ -114,7 +166,11 @@ func (s *Server) handler() (http.Handler, error) { protectedHandler = s.auth.wrap(protectedHandler) } - return withCORS(protectedHandler, !s.auth.enabled()), nil + handler := requireSameOrigin(protectedHandler) + if !s.auth.enabled() { + handler = requireLoopbackHost(s.addr, handler) + } + return handler, nil } func (s *Server) broadcastLoop(ctx context.Context) { @@ -241,16 +297,30 @@ func (s *Server) handleNetDiag(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + if !requestHasSameOrigin(r) { + http.Error(w, "forbidden origin", http.StatusForbidden) + return + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + http.Error(w, "content type must be application/json", http.StatusUnsupportedMediaType) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) var req NetDiagRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } - req.Action = strings.ToLower(strings.TrimSpace(req.Action)) - req.Target = strings.TrimSpace(req.Target) - if req.Target == "" { - http.Error(w, "target is required", http.StatusBadRequest) + if err := decoder.Decode(&struct{}{}); err != io.EOF { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + normalizeNetDiagRequest(&req) + if err := validateNetDiagRequest(req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -262,11 +332,7 @@ func (s *Server) handleNetDiag(w http.ResponseWriter, r *http.Request) { case "dns": result = vminfo.ResolveDNS(ctx, req.Target, req.Server) case "port": - timeout := time.Duration(req.TimeoutMs) * time.Millisecond - if timeout <= 0 { - timeout = 2 * time.Second - } - result = vminfo.CheckPort(ctx, req.Target, req.Port, timeout) + result = vminfo.CheckPort(ctx, req.Target, req.Port, time.Duration(req.TimeoutMs)*time.Millisecond) case "ping": result = vminfo.Ping(ctx, req.Target, vminfo.PingOptions{ Mode: req.Mode, @@ -277,12 +343,74 @@ func (s *Server) handleNetDiag(w http.ResponseWriter, r *http.Request) { case "ip": result = vminfo.LookupIP(ctx, req.Target, req.Server) default: - http.Error(w, "unknown action (want: dns | port | ping | ip)", http.StatusBadRequest) + http.Error(w, "unknown network diagnostic action", http.StatusBadRequest) return } writeJSONGzip(w, r, result) } +func normalizeNetDiagRequest(req *NetDiagRequest) { + req.Action = strings.ToLower(strings.TrimSpace(req.Action)) + req.Target = strings.TrimSpace(req.Target) + req.Server = strings.TrimSpace(req.Server) + req.Mode = strings.ToLower(strings.TrimSpace(req.Mode)) + + switch req.Action { + case "port": + if req.TimeoutMs == 0 { + req.TimeoutMs = 2000 + } + case "ping": + if req.Count == 0 { + req.Count = 4 + } + if req.TimeoutMs == 0 { + req.TimeoutMs = 2000 + } + if req.Mode == "" { + req.Mode = "tcp" + } + } +} + +func validateNetDiagRequest(req NetDiagRequest) error { + if req.Target == "" { + return fmt.Errorf("target is required") + } + + switch req.Action { + case "dns", "ip": + return nil + case "port": + if req.Port < 1 || req.Port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + if req.TimeoutMs < 1 || req.TimeoutMs > 3000 { + return fmt.Errorf("timeout_ms must be between 1 and 3000") + } + return nil + case "ping": + if req.Count < 1 || req.Count > 10 { + return fmt.Errorf("count must be between 1 and 10") + } + if req.TimeoutMs < 1 || req.TimeoutMs > 3000 { + return fmt.Errorf("timeout_ms must be between 1 and 3000") + } + if req.Mode != "tcp" && req.Mode != "icmp" { + return fmt.Errorf("mode must be tcp or icmp") + } + if req.Mode == "tcp" && (req.Port < 1 || req.Port > 65535) { + return fmt.Errorf("port must be between 1 and 65535 for tcp ping") + } + if req.Mode == "icmp" && (req.Port < 0 || req.Port > 65535) { + return fmt.Errorf("port must be between 1 and 65535 when provided") + } + return nil + default: + return fmt.Errorf("unknown action (want: dns | port | ping | ip)") + } +} + func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{ CheckOrigin: s.checkWebSocketOrigin, @@ -294,47 +422,26 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { } client := newWSClient(conn) - s.hub.register(client) + if !s.hub.tryRegister(client) { + _ = client.writeControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "server busy")) + client.close() + return + } // Send current snapshot immediately if data := s.collector.LatestJSONWithProcesses(r.Context()); data != nil { - if err := client.writeMessage(websocket.TextMessage, data); err != nil { + if !client.enqueue(data) { s.hub.unregister(client) return } } - // Read loop (handles close/ping) - for { - if _, _, err := conn.ReadMessage(); err != nil { - s.hub.unregister(client) - break - } - } + go client.writePump(s.hub) + client.readPump(s.hub) } func (s *Server) checkWebSocketOrigin(r *http.Request) bool { - if !s.auth.enabled() { - return true - } - - originValue := strings.TrimSpace(r.Header.Get("Origin")) - if originValue == "" { - return true - } - - originURL, err := url.Parse(originValue) - if err != nil { - return false - } - return sameOriginHost(r.Host, originURL.Host) -} - -// --- Helpers --- - -func writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + return requestHasSameOrigin(r) } // writeJSONGzip writes JSON with optional gzip compression. @@ -354,21 +461,90 @@ func writeJSONGzip(w http.ResponseWriter, r *http.Request, v any) { json.NewEncoder(w).Encode(v) } -func withCORS(next http.Handler, enabled bool) http.Handler { +func requireSameOrigin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if enabled { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if !requestHasSameOrigin(r) { + http.Error(w, "forbidden origin", http.StatusForbidden) + return } - if enabled && r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) + next.ServeHTTP(w, r) + }) +} + +func requireLoopbackHost(listenAddr string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isAllowedLoopbackHost(r.Host, listenAddr) { + http.Error(w, "forbidden host", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } +func isAllowedLoopbackHost(requestHost, listenAddr string) bool { + _, listenPort, err := net.SplitHostPort(strings.TrimSpace(listenAddr)) + if err != nil { + return false + } + host, port := splitHostPort(requestHost) + if port != "" && listenPort != "" && listenPort != "0" && port != listenPort { + return false + } + host = strings.TrimSuffix(strings.TrimSpace(host), ".") + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func requestHasSameOrigin(r *http.Request) bool { + originValue := strings.TrimSpace(r.Header.Get("Origin")) + if originValue == "" { + return true + } + + originURL, err := url.Parse(originValue) + if err != nil || originURL.Scheme == "" || originURL.Host == "" || originURL.User != nil || + originURL.RawQuery != "" || originURL.Fragment != "" || (originURL.Path != "" && originURL.Path != "/") { + return false + } + expectedScheme := requestScheme(r) + if !strings.EqualFold(originURL.Scheme, expectedScheme) { + return false + } + return sameOriginHostWithScheme(r.Host, originURL.Host, expectedScheme) +} + +func requestScheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); strings.EqualFold(forwarded, "http") || strings.EqualFold(forwarded, "https") { + return strings.ToLower(forwarded) + } + return "http" +} + +func sameOriginHostWithScheme(requestHost, originHost, scheme string) bool { + reqHost, reqPort := splitHostPort(requestHost) + originHostOnly, originPort := splitHostPort(originHost) + if !strings.EqualFold(reqHost, originHostOnly) { + return false + } + defaultPort := "80" + if strings.EqualFold(scheme, "https") { + defaultPort = "443" + } + if reqPort == "" { + reqPort = defaultPort + } + if originPort == "" { + originPort = defaultPort + } + return reqPort == originPort +} + type processQueryOptions struct { filter string sortKey string diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 524f354..99c9162 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1,11 +1,16 @@ package web import ( + "context" + "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" + + "github.com/gorilla/websocket" "github.com/cloudapp3/vminfo/internal/collector" ) @@ -153,7 +158,7 @@ func TestHandleNetDiagRejectsNonPOST(t *testing.T) { func TestHandleNetDiagRequiresTarget(t *testing.T) { srv := &Server{} - req := httptest.NewRequest(http.MethodPost, "/api/v1/net/diag", strings.NewReader(`{"action":"dns"}`)) + req := newNetDiagRequest(`{"action":"dns"}`) rr := httptest.NewRecorder() srv.handleNetDiag(rr, req) if rr.Code != http.StatusBadRequest { @@ -163,7 +168,7 @@ func TestHandleNetDiagRequiresTarget(t *testing.T) { func TestHandleNetDiagUnknownAction(t *testing.T) { srv := &Server{} - req := httptest.NewRequest(http.MethodPost, "/api/v1/net/diag", strings.NewReader(`{"action":"frob","target":"x"}`)) + req := newNetDiagRequest(`{"action":"frob","target":"x"}`) rr := httptest.NewRecorder() srv.handleNetDiag(rr, req) if rr.Code != http.StatusBadRequest { @@ -173,7 +178,7 @@ func TestHandleNetDiagUnknownAction(t *testing.T) { func TestHandleNetDiagDNS(t *testing.T) { srv := &Server{} - req := httptest.NewRequest(http.MethodPost, "/api/v1/net/diag", strings.NewReader(`{"action":"dns","target":"localhost"}`)) + req := newNetDiagRequest(`{"action":"dns","target":"localhost"}`) rr := httptest.NewRecorder() srv.handleNetDiag(rr, req) if rr.Code != http.StatusOK { @@ -185,9 +190,322 @@ func TestHandleNetDiagPing(t *testing.T) { srv := &Server{} body := strings.NewReader(`{"action":"ping","target":"127.0.0.1","mode":"tcp","port":1,"count":1,"timeout_ms":100}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/net/diag", body) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() srv.handleNetDiag(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String()) } } + +func TestHandleNetDiagRequiresJSONContentType(t *testing.T) { + for _, contentType := range []string{"", "text/plain"} { + t.Run(contentType, func(t *testing.T) { + srv := &Server{} + req := httptest.NewRequest(http.MethodPost, "/api/v1/net/diag", strings.NewReader(`{"action":"dns","target":"localhost"}`)) + req.Header.Set("Content-Type", contentType) + rr := httptest.NewRecorder() + + srv.handleNetDiag(rr, req) + + if rr.Code != http.StatusUnsupportedMediaType { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnsupportedMediaType) + } + }) + } +} + +func TestHandleNetDiagAcceptsJSONCharset(t *testing.T) { + srv := &Server{} + req := newNetDiagRequest(`{"action":"dns","target":"localhost"}`) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + rr := httptest.NewRecorder() + + srv.handleNetDiag(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } +} + +func TestHandleNetDiagRejectsCrossOrigin(t *testing.T) { + srv := &Server{} + req := newNetDiagRequest(`{"action":"dns","target":"localhost"}`) + req.Host = "127.0.0.1:20021" + req.Header.Set("Origin", "http://evil.example") + rr := httptest.NewRecorder() + + srv.handleNetDiag(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusForbidden) + } +} + +func TestNormalizeAndValidateNetDiagRequest(t *testing.T) { + tests := []struct { + name string + req NetDiagRequest + want NetDiagRequest + wantErr bool + }{ + { + name: "ping defaults", + req: NetDiagRequest{Action: " PING ", Target: " 127.0.0.1 ", Port: 80}, + want: NetDiagRequest{Action: "ping", Target: "127.0.0.1", Port: 80, Count: 4, TimeoutMs: 2000, Mode: "tcp"}, + }, + { + name: "count too large", + req: NetDiagRequest{Action: "ping", Target: "127.0.0.1", Port: 80, Count: 11, TimeoutMs: 100, Mode: "tcp"}, + wantErr: true, + }, + { + name: "timeout too large", + req: NetDiagRequest{Action: "port", Target: "127.0.0.1", Port: 80, TimeoutMs: 3001}, + wantErr: true, + }, + { + name: "invalid port", + req: NetDiagRequest{Action: "port", Target: "127.0.0.1", Port: 65536, TimeoutMs: 100}, + wantErr: true, + }, + { + name: "invalid mode", + req: NetDiagRequest{Action: "ping", Target: "127.0.0.1", Port: 80, Count: 1, TimeoutMs: 100, Mode: "udp"}, + wantErr: true, + }, + { + name: "icmp does not require port", + req: NetDiagRequest{Action: "ping", Target: "127.0.0.1", Count: 1, TimeoutMs: 100, Mode: "icmp"}, + want: NetDiagRequest{Action: "ping", Target: "127.0.0.1", Count: 1, TimeoutMs: 100, Mode: "icmp"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizeNetDiagRequest(&tt.req) + err := validateNetDiagRequest(tt.req) + if tt.wantErr { + if err == nil { + t.Fatal("expected validation error") + } + return + } + if err != nil { + t.Fatalf("validateNetDiagRequest returned error: %v", err) + } + if tt.req != tt.want { + t.Fatalf("request = %+v, want %+v", tt.req, tt.want) + } + }) + } +} + +func TestHandlerEnforcesSameOriginWithoutCORS(t *testing.T) { + srv := NewServer("127.0.0.1:20021", collector.New(time.Second), Options{}) + handler, err := srv.handler() + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + crossOrigin := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil) + crossOrigin.Host = "127.0.0.1:20021" + crossOrigin.Header.Set("Origin", "http://evil.example") + crossRecorder := httptest.NewRecorder() + handler.ServeHTTP(crossRecorder, crossOrigin) + if crossRecorder.Code != http.StatusForbidden { + t.Fatalf("cross-origin status = %d, want %d", crossRecorder.Code, http.StatusForbidden) + } + if got := crossRecorder.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("unexpected Access-Control-Allow-Origin header %q", got) + } + + sameOrigin := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil) + sameOrigin.Host = "127.0.0.1:20021" + sameOrigin.Header.Set("Origin", "http://127.0.0.1:20021") + sameRecorder := httptest.NewRecorder() + handler.ServeHTTP(sameRecorder, sameOrigin) + if sameRecorder.Code == http.StatusForbidden { + t.Fatal("same-origin request was rejected") + } + + nativeRequest := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil) + nativeRequest.Host = "127.0.0.1:20021" + nativeRecorder := httptest.NewRecorder() + handler.ServeHTTP(nativeRecorder, nativeRequest) + if nativeRecorder.Code == http.StatusForbidden { + t.Fatal("request without Origin was rejected") + } +} + +func TestHandlerRejectsDNSRebindingHostWithoutAuth(t *testing.T) { + srv := NewServer("127.0.0.1:20021", collector.New(time.Second), Options{}) + handler, err := srv.handler() + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/system", nil) + req.Host = "attacker.example:20021" + req.Header.Set("Origin", "http://attacker.example:20021") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("DNS rebinding request status = %d, want %d", rr.Code, http.StatusForbidden) + } +} + +func TestAllowedLoopbackHostValidatesHostAndPort(t *testing.T) { + for _, host := range []string{"127.0.0.1:20021", "[::1]:20021", "localhost:20021", "localhost.:20021"} { + if !isAllowedLoopbackHost(host, "127.0.0.1:20021") { + t.Fatalf("expected loopback host %q to be allowed", host) + } + } + for _, host := range []string{"attacker.example:20021", "127.0.0.1:8080", "", "0.0.0.0:20021"} { + if isAllowedLoopbackHost(host, "127.0.0.1:20021") { + t.Fatalf("expected host %q to be rejected", host) + } + } +} + +func TestRequestHasSameOriginChecksSchemeAndDefaultPort(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + req.Host = "example.test:80" + req.Header.Set("Origin", "http://example.test") + if !requestHasSameOrigin(req) { + t.Fatal("expected equivalent HTTP default ports to match") + } + + req.Header.Set("Origin", "https://example.test") + if requestHasSameOrigin(req) { + t.Fatal("expected cross-scheme origin to be rejected") + } + + req.Header.Set("Origin", "null") + if requestHasSameOrigin(req) { + t.Fatal("expected opaque origin to be rejected") + } + + req.Host = "example.test:443" + req.Header.Set("Origin", "https://example.test") + req.Header.Set("X-Forwarded-Proto", "https") + if !requestHasSameOrigin(req) { + t.Fatal("expected forwarded HTTPS origin to match") + } +} + +func TestNewHTTPServerTimeouts(t *testing.T) { + srv := newHTTPServer("127.0.0.1:0", http.NotFoundHandler()) + if srv.ReadHeaderTimeout != 5*time.Second { + t.Fatalf("ReadHeaderTimeout = %s", srv.ReadHeaderTimeout) + } + if srv.ReadTimeout != 15*time.Second { + t.Fatalf("ReadTimeout = %s", srv.ReadTimeout) + } + if srv.WriteTimeout != 15*time.Second { + t.Fatalf("WriteTimeout = %s", srv.WriteTimeout) + } + if srv.IdleTimeout != 60*time.Second { + t.Fatalf("IdleTimeout = %s", srv.IdleTimeout) + } +} + +func TestShutdownBeforeStartPreventsFutureStartAndClosesHub(t *testing.T) { + srv := NewServer("127.0.0.1:0", collector.New(time.Second), Options{}) + client := newWSClient(nil) + if !srv.hub.tryRegister(client) { + t.Fatal("expected registration to succeed") + } + if err := srv.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown returned error: %v", err) + } + assertClosed(t, client.done) + if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("Start error = %v, want http.ErrServerClosed", err) + } +} + +func TestStartAndShutdownAreSynchronized(t *testing.T) { + srv := NewServer("127.0.0.1:0", collector.New(time.Second), Options{}) + errCh := make(chan error, 1) + go func() { + errCh <- srv.Start() + }() + + deadline := time.Now().Add(2 * time.Second) + for { + srv.lifecycleMu.Lock() + started := srv.started + srv.lifecycleMu.Unlock() + if started { + break + } + if time.Now().After(deadline) { + t.Fatal("server did not enter started state") + } + time.Sleep(time.Millisecond) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown returned error: %v", err) + } + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("Start returned %v, want http.ErrServerClosed", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Start did not return after Shutdown") + } +} + +func TestWebSocketEnforcesOriginAndReadLimit(t *testing.T) { + srv := NewServer("127.0.0.1:0", collector.New(time.Second), Options{}) + handler, err := srv.handler() + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + defer srv.hub.closeAll() + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws" + + crossHeaders := http.Header{"Origin": []string{"http://evil.example"}} + crossConn, response, err := websocket.DefaultDialer.Dial(wsURL, crossHeaders) + if crossConn != nil { + _ = crossConn.Close() + } + if err == nil { + t.Fatal("cross-origin WebSocket connection unexpectedly succeeded") + } + if response == nil || response.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin response = %#v, want status %d", response, http.StatusForbidden) + } + + sameHeaders := http.Header{"Origin": []string{httpServer.URL}} + conn, response, err := websocket.DefaultDialer.Dial(wsURL, sameHeaders) + if err != nil { + if response != nil { + t.Fatalf("same-origin dial failed with status %d: %v", response.StatusCode, err) + } + t.Fatalf("same-origin dial failed: %v", err) + } + defer conn.Close() + + if err := conn.WriteMessage(websocket.TextMessage, make([]byte, wsReadLimit+1)); err != nil { + t.Fatalf("write oversized message: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, err := conn.ReadMessage(); err == nil { + t.Fatal("connection remained open after oversized inbound message") + } +} + +func newNetDiagRequest(body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/api/v1/net/diag", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 06d96f3..aded480 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -122,7 +122,7 @@ var isIdle = d.read_bytes_sec === 0 && d.write_bytes_sec === 0 && d.iops === 0; var cls = isIdle ? ' class="color-muted"' : ''; html += '' + - '' + d.device + '' + + '' + escapeHtml(String(d.device || '')) + '' + '' + formatBytesPerSec(d.read_bytes_sec) + '' + '' + formatBytesPerSec(d.write_bytes_sec) + '' + '' + d.iops + '' + @@ -413,25 +413,37 @@ dom.procCount.textContent = '(' + list.length + ' shown / ' + totalCount + ' total)'; - var html = ''; + var fragment = document.createDocumentFragment(); for (var i = 0; i < list.length; i++) { var p = list[i]; var cpuColor = thresholdColor(p.cpu_percent); var memColor = thresholdColor(p.mem_percent); var command = p.command || p.name || ''; - html += '' + - '' + p.pid + '' + - '' + p.cpu_percent.toFixed(1) + '' + - '' + p.mem_percent.toFixed(1) + '' + - '' + formatBytes(p.rss) + '' + - '' + escapeHtml(p.user || '—') + '' + - '' + escapeHtml(p.status || '—') + '' + - '' + formatDuration(p.uptime || 0) + '' + - '' + escapeHtml(p.name || '—') + '' + - '' + escapeHtml(command || '—') + '' + - ''; + var row = document.createElement('tr'); + appendProcessCell(row, 'col-pid', p.pid); + appendProcessCell(row, 'col-cpu', Number(p.cpu_percent || 0).toFixed(1), cpuColor); + appendProcessCell(row, 'col-mem', Number(p.mem_percent || 0).toFixed(1), memColor); + appendProcessCell(row, 'col-rss', formatBytes(p.rss)); + appendProcessCell(row, 'col-user', p.user || '—'); + appendProcessCell(row, 'col-status', p.status || '—'); + appendProcessCell(row, 'col-age', formatDuration(p.uptime || 0)); + appendProcessCell(row, 'col-name', p.name || '—', '', command); + appendProcessCell(row, 'col-command', command || '—', '', command); + fragment.appendChild(row); + } + while (dom.procTbody.firstChild) { + dom.procTbody.removeChild(dom.procTbody.firstChild); } - dom.procTbody.innerHTML = html; + dom.procTbody.appendChild(fragment); + } + + function appendProcessCell(row, className, value, color, title) { + var cell = document.createElement('td'); + cell.className = className; + cell.textContent = String(value); + if (color) cell.style.color = color; + if (title !== undefined) cell.title = String(title); + row.appendChild(cell); } function escapeHtml(str) { diff --git a/internal/web/static_test.go b/internal/web/static_test.go new file mode 100644 index 0000000..f3ccac0 --- /dev/null +++ b/internal/web/static_test.go @@ -0,0 +1,32 @@ +package web + +import ( + "strings" + "testing" +) + +func TestProcessTableUsesDOMTextProperties(t *testing.T) { + data, err := staticFS.ReadFile("static/js/app.js") + if err != nil { + t.Fatalf("read app.js: %v", err) + } + source := string(data) + + if strings.Contains(source, "dom.procTbody.innerHTML") { + t.Fatal("process table still renders untrusted process data through innerHTML") + } + for _, required := range []string{ + "document.createElement('tr')", + "document.createElement('td')", + "cell.textContent = String(value)", + "cell.title = String(title)", + "dom.procTbody.appendChild(fragment)", + } { + if !strings.Contains(source, required) { + t.Fatalf("app.js is missing safe process rendering construct %q", required) + } + } + if !strings.Contains(source, "escapeHtml(String(d.device || ''))") { + t.Fatal("app.js does not escape disk device names before using innerHTML") + } +} diff --git a/netprobe.go b/netprobe.go index 4d3767e..42698f6 100644 --- a/netprobe.go +++ b/netprobe.go @@ -50,7 +50,8 @@ func ResolveDNS(ctx context.Context, domain, server string) DNSResult { resolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { - return net.Dial(network, target) + dialer := net.Dialer{} + return dialer.DialContext(ctx, network, target) }, } } @@ -89,11 +90,19 @@ func CheckPort(ctx context.Context, host string, port int, timeout time.Duration // PingOptions controls a Ping probe sequence. type PingOptions struct { Mode string // "tcp" (default) or "icmp" - Count int // number of probes (default 4) - Timeout time.Duration // per-probe timeout (default 1s) - Port int // tcp mode target port (default 80) + Count int // number of probes (default 4, maximum 100) + Timeout time.Duration // per-probe timeout (default 1s, maximum 10s) + Port int // tcp mode target port (default 80, range 1..65535) } +const ( + defaultPingCount = 4 + maxPingCount = 100 + defaultPingTimeout = time.Second + maxPingTimeout = 10 * time.Second + defaultPingPort = 80 +) + // PingResult is the outcome of a Ping probe sequence. type PingResult struct { Host string `json:"host"` @@ -113,41 +122,79 @@ type PingResult struct { // cross-platform / unprivileged; Mode "icmp" sends ICMP Echo via golang.org/x/net // (unprivileged udp4: needs net.ipv4.ping_group_range on Linux, unsupported on Windows). func Ping(ctx context.Context, host string, opts PingOptions) PingResult { - if opts.Count <= 0 { - opts.Count = 4 + normalized, err := normalizePingOptions(opts) + res := PingResult{Host: host, Mode: normalized.Mode} + if normalized.Mode == "tcp" { + res.Port = normalized.Port } - if opts.Timeout <= 0 { - opts.Timeout = time.Second - } - mode := strings.ToLower(strings.TrimSpace(opts.Mode)) - if mode == "" { - mode = "tcp" + if err != nil { + res.Err = err.Error() + return res } - res := PingResult{Host: host, Mode: mode} - if mode == "icmp" { - rtts, lost, err := pingICMP(ctx, host, opts.Count, opts.Timeout) + if normalized.Mode == "icmp" { + rtts, lost, err := pingICMP(ctx, host, normalized.Count, normalized.Timeout) if err != nil { res.Err = err.Error() return res } - fillPingStats(&res, opts.Count, lost, rtts) + fillPingStats(&res, normalized.Count, lost, rtts) return res } - if opts.Port <= 0 { - opts.Port = 80 - } - res.Port = opts.Port - rtts, lost, err := pingTCP(ctx, host, opts.Port, opts.Count, opts.Timeout) + rtts, lost, err := pingTCP( + ctx, + host, + normalized.Port, + normalized.Count, + normalized.Timeout, + ) if err != nil { res.Err = err.Error() return res } - fillPingStats(&res, opts.Count, lost, rtts) + fillPingStats(&res, normalized.Count, lost, rtts) return res } +func normalizePingOptions(opts PingOptions) (PingOptions, error) { + opts.Mode = strings.ToLower(strings.TrimSpace(opts.Mode)) + if opts.Mode == "" { + opts.Mode = "tcp" + } + if opts.Mode != "tcp" && opts.Mode != "icmp" { + return opts, fmt.Errorf("unsupported ping mode %q", opts.Mode) + } + + if opts.Count < 0 { + return opts, fmt.Errorf("ping count must not be negative") + } + if opts.Count == 0 { + opts.Count = defaultPingCount + } + if opts.Count > maxPingCount { + return opts, fmt.Errorf("ping count must not exceed %d", maxPingCount) + } + + if opts.Timeout < 0 { + return opts, fmt.Errorf("ping timeout must not be negative") + } + if opts.Timeout == 0 { + opts.Timeout = defaultPingTimeout + } + if opts.Timeout > maxPingTimeout { + return opts, fmt.Errorf("ping timeout must not exceed %s", maxPingTimeout) + } + + if opts.Port < 0 || opts.Port > 65535 { + return opts, fmt.Errorf("ping port must be between 1 and 65535") + } + if opts.Mode == "tcp" && opts.Port == 0 { + opts.Port = defaultPingPort + } + return opts, nil +} + func fillPingStats(res *PingResult, sent, lost int, rtts []float64) { res.Sent = sent res.Lost = lost @@ -161,12 +208,8 @@ func fillPingStats(res *PingResult, sent, lost int, rtts []float64) { mn, mx := rtts[0], rtts[0] sum := 0.0 for _, r := range rtts { - if r < mn { - mn = r - } - if r > mx { - mx = r - } + mn = min(mn, r) + mx = max(mx, r) sum += r } res.MinMs = mn @@ -179,7 +222,7 @@ func pingTCP(ctx context.Context, host string, port, count int, timeout time.Dur lost := 0 dialer := net.Dialer{Timeout: timeout} addr := net.JoinHostPort(host, strconv.Itoa(port)) - for i := 0; i < count; i++ { + for range count { if err := ctx.Err(); err != nil { return rtts, lost, err } @@ -202,16 +245,24 @@ func pingICMP(ctx context.Context, host string, count int, timeout time.Duration return nil, 0, fmt.Errorf("icmp unavailable (try --mode tcp): %w", err) } defer c.Close() + stopClose := context.AfterFunc(ctx, func() { + _ = c.Close() + }) + defer stopClose() - dst, err := net.ResolveIPAddr("ip4", host) + addresses, err := net.DefaultResolver.LookupIP(ctx, "ip4", host) if err != nil { return nil, 0, err } + if len(addresses) == 0 { + return nil, 0, fmt.Errorf("no IPv4 address found for %q", host) + } + dst := &net.IPAddr{IP: addresses[0]} id := os.Getpid() & 0xffff rtts := make([]float64, 0, count) lost := 0 - for i := 0; i < count; i++ { + for i := range count { if err := ctx.Err(); err != nil { return rtts, lost, err } @@ -226,10 +277,17 @@ func pingICMP(ctx context.Context, host string, count int, timeout time.Duration } start := time.Now() if _, err := c.WriteTo(wb, dst); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return rtts, lost, ctxErr + } lost++ continue } - if err := c.SetReadDeadline(time.Now().Add(timeout)); err != nil { + deadline := nextProbeDeadline(ctx, time.Now(), timeout) + if err := c.SetReadDeadline(deadline); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return rtts, lost, ctxErr + } lost++ continue } @@ -237,6 +295,9 @@ func pingICMP(ctx context.Context, host string, count int, timeout time.Duration n, _, err := c.ReadFrom(rb) elapsed := time.Since(start) if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return rtts, lost, ctxErr + } lost++ continue } @@ -254,6 +315,14 @@ func pingICMP(ctx context.Context, host string, count int, timeout time.Duration return rtts, lost, nil } +func nextProbeDeadline(ctx context.Context, now time.Time, timeout time.Duration) time.Time { + deadline := now.Add(timeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + return contextDeadline + } + return deadline +} + // DefaultIPLookupServer is the default IP geo/ASN lookup service. const DefaultIPLookupServer = "https://ip.bestcheapvps.org" diff --git a/netprobe_test.go b/netprobe_test.go index ef829f2..292218d 100644 --- a/netprobe_test.go +++ b/netprobe_test.go @@ -3,6 +3,7 @@ package vminfo import ( "context" "net" + "strings" "testing" "time" ) @@ -50,8 +51,84 @@ func TestPingTCPOpenAndClosed(t *testing.T) { t.Fatalf("expected 3 ok probes, got %+v", res) } - closed := Ping(context.Background(), "127.0.0.1", PingOptions{Mode: "tcp", Port: port + 1, Count: 2, Timeout: 200 * time.Millisecond}) + if err := ln.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + closed := Ping(context.Background(), "127.0.0.1", PingOptions{Mode: "tcp", Port: port, Count: 2, Timeout: 200 * time.Millisecond}) if closed.Lost != 2 || len(closed.RTTs) != 0 { t.Fatalf("expected 2 lost probes, got %+v", closed) } } + +func TestNormalizePingOptions(t *testing.T) { + tests := []struct { + name string + opts PingOptions + want PingOptions + wantErr string + }{ + { + name: "defaults", + want: PingOptions{ + Mode: "tcp", + Count: defaultPingCount, + Timeout: defaultPingTimeout, + Port: defaultPingPort, + }, + }, + { + name: "normalizes mode", + opts: PingOptions{Mode: " ICMP ", Count: 1, Timeout: time.Second}, + want: PingOptions{Mode: "icmp", Count: 1, Timeout: time.Second}, + }, + {name: "negative count", opts: PingOptions{Count: -1}, wantErr: "count"}, + {name: "excessive count", opts: PingOptions{Count: maxPingCount + 1}, wantErr: "count"}, + {name: "negative timeout", opts: PingOptions{Timeout: -time.Second}, wantErr: "timeout"}, + {name: "excessive timeout", opts: PingOptions{Timeout: maxPingTimeout + time.Nanosecond}, wantErr: "timeout"}, + {name: "negative port", opts: PingOptions{Port: -1}, wantErr: "port"}, + {name: "excessive port", opts: PingOptions{Port: 65536}, wantErr: "port"}, + {name: "unsupported mode", opts: PingOptions{Mode: "udp"}, wantErr: "mode"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizePingOptions(tt.opts) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("normalizePingOptions() error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizePingOptions() error = %v", err) + } + if got != tt.want { + t.Fatalf("normalizePingOptions() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestPingRejectsUnboundedCount(t *testing.T) { + res := Ping(context.Background(), "127.0.0.1", PingOptions{Count: 1_000_000_000}) + if res.Err == "" || !strings.Contains(res.Err, "count") { + t.Fatalf("Ping() error = %q, want count validation error", res.Err) + } + if res.Sent != 0 || len(res.RTTs) != 0 { + t.Fatalf("Ping() performed probes for invalid count: %+v", res) + } +} + +func TestNextProbeDeadlineUsesEarlierContextDeadline(t *testing.T) { + now := time.Now() + contextDeadline := now.Add(2 * time.Second) + ctx, cancel := context.WithDeadline(context.Background(), contextDeadline) + defer cancel() + + if got := nextProbeDeadline(ctx, now, 5*time.Second); !got.Equal(contextDeadline) { + t.Fatalf("nextProbeDeadline() = %v, want context deadline %v", got, contextDeadline) + } + if got := nextProbeDeadline(context.Background(), now, time.Second); !got.Equal(now.Add(time.Second)) { + t.Fatalf("nextProbeDeadline() = %v, want timeout deadline", got) + } +} diff --git a/process_linux.go b/process_linux.go index 78d9052..bc44d61 100644 --- a/process_linux.go +++ b/process_linux.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "os" - "os/user" "strconv" "strings" "sync" @@ -57,20 +56,15 @@ func listProcesses(ctx context.Context) ([]ProcessInfo, error) { out := make(chan result, len(pids)) var wg sync.WaitGroup - workers := procListWorkers - if workers > len(pids) { - workers = len(pids) - } - for i := 0; i < workers; i++ { - wg.Add(1) - go func() { - defer wg.Done() + workers := min(procListWorkers, len(pids)) + for range workers { + wg.Go(func() { for pid := range jobs { if info, ok := readProcEntry(pid, systemUptime, memTotal, users); ok { out <- result{info: info, ok: true} } } - }() + }) } for _, pid := range pids { jobs <- pid @@ -324,6 +318,9 @@ func readMemTotalBytes() (uint64, error) { } return kb * 1024, nil } + if err := scanner.Err(); err != nil { + return 0, err + } return 0, fmt.Errorf("MemTotal not found") } @@ -350,35 +347,18 @@ func readPasswdMap() map[uint32]string { m[uint32(uid)] = parts[0] } } + // Best-effort lookup: return entries parsed before any read error or + // oversize line rather than dropping the whole map. + _ = scanner.Err() return m } -// lookupUser resolves uid → username, preferring the cached /etc/passwd -// map and falling back to os/user.LookupId for NSS-backed users (LDAP, -// SSSD, nss_systemd). Numeric UID is the last resort. +// lookupUser resolves uid from the local passwd snapshot. Avoiding NSS here +// keeps process collection bounded when remote identity providers are slow. func lookupUser(uid uint32, cached map[uint32]string) string { if name, ok := cached[uid]; ok && name != "" { return name } - nssUserCache.mu.RLock() - v, ok := nssUserCache.m[uid] - nssUserCache.mu.RUnlock() - if ok { - if v == "" { - return strconv.FormatUint(uint64(uid), 10) - } - return v - } - resolved := "" - if u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil && u.Username != "" { - resolved = u.Username - } - nssUserCache.mu.Lock() - nssUserCache.m[uid] = resolved - nssUserCache.mu.Unlock() - if resolved != "" { - return resolved - } return strconv.FormatUint(uint64(uid), 10) } @@ -392,15 +372,6 @@ func firstNonEmptyString(values ...string) string { return "" } -// nssUserCache memoizes os/user.LookupId results across listProcesses -// calls. NSS lookups can hit a remote directory (LDAP/SSSD), so caching -// avoids spending hundreds of cgo calls per refresh. Empty-string value -// means "looked up, not found" — still prevents repeat lookups. -var nssUserCache = struct { - mu sync.RWMutex - m map[uint32]string -}{m: make(map[uint32]string, 16)} - func terminateProcess(ctx context.Context, pid int32) error { if pid <= 0 { return fmt.Errorf("invalid pid") diff --git a/process_linux_test.go b/process_linux_test.go new file mode 100644 index 0000000..2d4f50b --- /dev/null +++ b/process_linux_test.go @@ -0,0 +1,28 @@ +//go:build linux + +package vminfo + +import ( + "context" + "testing" +) + +func TestListProcesses(t *testing.T) { + items, err := listProcesses(context.Background()) + if err != nil { + t.Fatalf("listProcesses() error = %v", err) + } + if len(items) == 0 { + t.Fatal("listProcesses() returned no processes") + } +} + +func TestLookupUserUsesLocalPasswdSnapshot(t *testing.T) { + users := map[uint32]string{1000: "local-user"} + if got := lookupUser(1000, users); got != "local-user" { + t.Fatalf("lookupUser() = %q, want local-user", got) + } + if got := lookupUser(424242, users); got != "424242" { + t.Fatalf("lookupUser() = %q, want numeric UID", got) + } +} diff --git a/tui/doc.go b/tui/doc.go new file mode 100644 index 0000000..4e7a2be --- /dev/null +++ b/tui/doc.go @@ -0,0 +1,23 @@ +// Package tui exposes the interactive terminal UI used by the vminfo CLI so it +// can be embedded in other Go programs. +// +// [Run] starts the same full-screen, keyboard-driven dashboard the vminfo binary +// shows: live CPU, memory, network, and disk metrics, TCP and conntrack state, +// a process list, and host metadata. It requires a real TTY on the provided +// Options.Stdin and Options.Stdout; in a non-interactive context Run returns an +// error. +// +// The UI language is selected via Options.Lang (for example "en" or "zh"); when +// empty it is auto-detected from the VMINFO_LANG, LC_ALL, and LANG environment +// variables. +// +// Example: +// +// err := tui.Run(ctx, tui.Options{Lang: "en"}) +// if err != nil { +// log.Fatal(err) +// } +// +// Host metric collection lives in the root package at +// [github.com/cloudapp3/vminfo]. +package tui diff --git a/tui/tui.go b/tui/tui.go index e048417..7d39339 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -1,4 +1,3 @@ -// Package tui exposes the interactive terminal UI used by the vminfo CLI. package tui import (