" + 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 (