From 3096de0a7f54729af94108c88c448f358774d1c3 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 19 Mar 2026 18:21:04 -0300 Subject: [PATCH] Refine vm ports output Make banger vm ports single-target and collapse the old VM/WEB table shape into a simpler PROTO ENDPOINT PROCESS COMMAND view. Web listeners now surface directly as http or https, with clickable endpoints in the main endpoint column instead of a separate URL field. Classify TCP listeners with HTTPS-first probing so TLS services are not mislabeled as plain HTTP just because they answer bad cleartext requests with an HTTP error, then dedupe rows by rendered PROTO+ENDPOINT so dual-stack binds like 0.0.0.0 and :: only show once. Update the CLI/daemon regressions and README to match the new contract. Verified with GOCACHE=/tmp/banger-gocache go test ./..., make build, git diff --check, and ./banger vm ports --help. --- README.md | 12 ++--- internal/api/types.go | 1 - internal/cli/banger.go | 102 ++++++------------------------------- internal/cli/cli_test.go | 68 ++++++++++++------------- internal/daemon/ports.go | 57 +++++++++++++++++---- internal/daemon/vm_test.go | 90 +++++++++++++++++++++++++++----- 6 files changed, 179 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index fa25fcb..dc2fd32 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ banger vm ssh calm-otter When the SSH session exits normally, `banger` checks the guest over vsock and reminds you if the VM is still running. -Inspect host-reachable listening ports for one or more running VMs: +Inspect host-reachable listening ports for a running VM: ```bash -banger vm ports calm-otter buildbox +banger vm ports calm-otter ``` Stop, restart, kill, or delete it: @@ -252,10 +252,10 @@ guest IPv4 address. Integrate your local resolver separately if you want transparent `.vm` lookups on the host. `banger vm ports` asks the guest-side `banger-vsock-agent` to run `ss`, then -prints host-usable `.vm:port` endpoints plus the owning -process/command. TCP listeners get a short best-effort HTTP probe; when the -probe sees a real HTTP response, the command includes a clickable -`http://.vm:port/` URL. Older images without `ss` may need rebuilding +prints host-usable endpoints plus the owning process/command. TCP listeners get +short best-effort HTTP and HTTPS probes; detected web listeners are shown as +`http` or `https`, and the endpoint column becomes a clickable URL such as +`https://.vm:port/`. Older images without `ss` may need rebuilding before `vm ports` works. ## Storage Model diff --git a/internal/api/types.go b/internal/api/types.go index 3ac2b67..55c962f 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -81,7 +81,6 @@ type VMPort struct { Process string `json:"process,omitempty"` Command string `json:"command,omitempty"` Endpoint string `json:"endpoint,omitempty"` - WebURL string `json:"web_url,omitempty"` } type VMPortsResult struct { diff --git a/internal/cli/banger.go b/internal/cli/banger.go index f35b78f..7f1160f 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -549,44 +549,19 @@ func newVMStatsCommand() *cobra.Command { func newVMPortsCommand() *cobra.Command { return &cobra.Command{ - Use: "ports ...", + Use: "ports ", Short: "Show host-reachable listening guest ports", - Args: minArgsUsage(1, "usage: banger vm ports ..."), + Args: exactArgsUsage(1, "usage: banger vm ports "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } - listResult, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{}) + result, err := vmPortsFunc(cmd.Context(), layout.SocketPath, args[0]) if err != nil { return err } - targets, resolutionErrs := resolveVMTargets(listResult.VMs, args) - results := executeVMPortsBatch(cmd.Context(), layout.SocketPath, targets) - - failed := false - for _, resolutionErr := range resolutionErrs { - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", resolutionErr.Ref, resolutionErr.Err); err != nil { - return err - } - failed = true - } - for _, result := range results { - if result.Err == nil { - continue - } - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", result.Target.Ref, result.Err); err != nil { - return err - } - failed = true - } - if err := printVMPortsTable(cmd.OutOrStdout(), results); err != nil { - return err - } - if failed { - return errors.New("one or more VM operations failed") - } - return nil + return printVMPortsTable(cmd.OutOrStdout(), result) }, } } @@ -793,12 +768,6 @@ type vmBatchActionResult struct { Err error } -type vmPortsBatchResult struct { - Target resolvedVMTarget - Result api.VMPortsResult - Err error -} - func runVMBatchAction(cmd *cobra.Command, socketPath string, refs []string, action func(context.Context, string) (model.VMRecord, error)) error { listResult, err := rpc.Call[api.VMListResult](cmd.Context(), socketPath, "vm.list", api.Empty{}) if err != nil { @@ -907,27 +876,6 @@ func executeVMActionBatch(ctx context.Context, targets []resolvedVMTarget, actio return results } -func executeVMPortsBatch(ctx context.Context, socketPath string, targets []resolvedVMTarget) []vmPortsBatchResult { - results := make([]vmPortsBatchResult, len(targets)) - var wg sync.WaitGroup - wg.Add(len(targets)) - for index, target := range targets { - index := index - target := target - go func() { - defer wg.Done() - result, err := vmPortsFunc(ctx, socketPath, target.VM.ID) - results[index] = vmPortsBatchResult{ - Target: target, - Result: result, - Err: err, - } - }() - } - wg.Wait() - return results -} - func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) { layout, err := paths.Resolve() if err != nil { @@ -1223,41 +1171,25 @@ func printImageSummary(out anyWriter, image model.Image) error { return err } -func printVMPortsTable(out anyWriter, results []vmPortsBatchResult) error { +func printVMPortsTable(out anyWriter, result api.VMPortsResult) error { type portRow struct { - VM string Proto string Endpoint string Process string Command string - WebURL string Port int } - rows := make([]portRow, 0) - for _, result := range results { - if result.Err != nil { - continue - } - vmName := strings.TrimSpace(result.Result.Name) - if vmName == "" { - vmName = result.Target.VM.Name - } - for _, port := range result.Result.Ports { - rows = append(rows, portRow{ - VM: vmName, - Proto: port.Proto, - Endpoint: port.Endpoint, - Process: port.Process, - Command: port.Command, - WebURL: emptyDash(port.WebURL), - Port: port.Port, - }) - } + rows := make([]portRow, 0, len(result.Ports)) + for _, port := range result.Ports { + rows = append(rows, portRow{ + Proto: port.Proto, + Endpoint: port.Endpoint, + Process: port.Process, + Command: port.Command, + Port: port.Port, + }) } sort.Slice(rows, func(i, j int) bool { - if rows[i].VM != rows[j].VM { - return rows[i].VM < rows[j].VM - } if rows[i].Proto != rows[j].Proto { return rows[i].Proto < rows[j].Proto } @@ -1274,19 +1206,17 @@ func printVMPortsTable(out anyWriter, results []vmPortsBatchResult) error { } w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) - if _, err := fmt.Fprintln(w, "VM\tPROTO\tENDPOINT\tPROCESS\tCOMMAND\tWEB"); err != nil { + if _, err := fmt.Fprintln(w, "PROTO\tENDPOINT\tPROCESS\tCOMMAND"); err != nil { return err } for _, row := range rows { if _, err := fmt.Fprintf( w, - "%s\t%s\t%s\t%s\t%s\t%s\n", - row.VM, + "%s\t%s\t%s\t%s\n", row.Proto, emptyDash(row.Endpoint), emptyDash(row.Process), emptyDash(row.Command), - row.WebURL, ); err != nil { return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5994cce..cbda292 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -162,6 +162,15 @@ func TestVMPortsCommandExists(t *testing.T) { } } +func TestVMPortsCommandRejectsMultipleRefs(t *testing.T) { + cmd := NewBangerCommand() + cmd.SetArgs([]string{"vm", "ports", "alpha", "beta"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "usage: banger vm ports ") { + t.Fatalf("Execute() error = %v, want single-vm usage error", err) + } +} + func TestVMSetParamsFromFlags(t *testing.T) { params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false) if err != nil { @@ -279,56 +288,43 @@ func TestAbsolutizeImageRegisterPaths(t *testing.T) { } } -func TestPrintVMPortsTableSortsAndRendersURLs(t *testing.T) { - results := []vmPortsBatchResult{ - { - Target: resolvedVMTarget{Ref: "beta"}, - Result: api.VMPortsResult{ - Name: "beta", - Ports: []api.VMPort{{ - Proto: "tcp", - Port: 8080, - Endpoint: "beta.vm:8080", - Process: "python3", - Command: "python3 -m http.server 8080", - WebURL: "http://beta.vm:8080/", - }}, +func TestPrintVMPortsTableSortsAndRendersURLEndpoints(t *testing.T) { + result := api.VMPortsResult{ + Name: "alpha", + Ports: []api.VMPort{ + { + Proto: "https", + Port: 443, + Endpoint: "https://alpha.vm:443/", + Process: "caddy", + Command: "caddy run", }, - }, - { - Target: resolvedVMTarget{Ref: "alpha"}, - Result: api.VMPortsResult{ - Name: "alpha", - Ports: []api.VMPort{{ - Proto: "udp", - Port: 53, - Endpoint: "alpha.vm:53", - Process: "dnsd", - Command: "dnsd --foreground", - }}, + { + Proto: "udp", + Port: 53, + Endpoint: "alpha.vm:53", + Process: "dnsd", + Command: "dnsd --foreground", }, }, } var out bytes.Buffer - if err := printVMPortsTable(&out, results); err != nil { + if err := printVMPortsTable(&out, result); err != nil { t.Fatalf("printVMPortsTable: %v", err) } lines := strings.Split(strings.TrimSpace(out.String()), "\n") if len(lines) != 3 { t.Fatalf("lines = %q, want header + 2 rows", lines) } - if !strings.Contains(lines[0], "VM") || !strings.Contains(lines[0], "WEB") { - t.Fatalf("header = %q, want VM/WEB columns", lines[0]) + if !strings.Contains(lines[0], "PROTO") || !strings.Contains(lines[0], "ENDPOINT") || strings.Contains(lines[0], "VM") || strings.Contains(lines[0], "WEB") { + t.Fatalf("header = %q, want PROTO/ENDPOINT without VM/WEB", lines[0]) } - if !strings.Contains(lines[1], "alpha") || !strings.Contains(lines[1], "alpha.vm:53") || !strings.Contains(lines[1], "\t-\n") { - // tabwriter output is space-expanded, so just require the dash placeholder. - if !strings.Contains(lines[1], "alpha") || !strings.Contains(lines[1], "alpha.vm:53") || !strings.HasSuffix(strings.TrimSpace(lines[1]), "-") { - t.Fatalf("first row = %q, want alpha row with dash web column", lines[1]) - } + if !strings.Contains(lines[1], "https") || !strings.Contains(lines[1], "https://alpha.vm:443/") { + t.Fatalf("first row = %q, want https endpoint row", lines[1]) } - if !strings.Contains(lines[2], "beta") || !strings.Contains(lines[2], "http://beta.vm:8080/") { - t.Fatalf("second row = %q, want beta web url", lines[2]) + if !strings.Contains(lines[2], "udp") || !strings.Contains(lines[2], "alpha.vm:53") { + t.Fatalf("second row = %q, want udp endpoint row", lines[2]) } } diff --git a/internal/daemon/ports.go b/internal/daemon/ports.go index c60e59a..0c472f0 100644 --- a/internal/daemon/ports.go +++ b/internal/daemon/ports.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "crypto/tls" "errors" "fmt" "io" @@ -78,8 +79,11 @@ func buildVMPorts(vm model.VMRecord, listeners []vsockagent.PortListener) []api. if port.Command == "" { port.Command = port.Process } - if port.Proto == "tcp" && probeHost != "" && endpointHost != "" && probeHTTPListener(probeHost, listener.Port) { - port.WebURL = "http://" + net.JoinHostPort(endpointHost, strconv.Itoa(listener.Port)) + "/" + if port.Proto == "tcp" && probeHost != "" && endpointHost != "" { + if scheme, ok := probeWebListener(probeHost, listener.Port); ok { + port.Proto = scheme + port.Endpoint = scheme + "://" + net.JoinHostPort(endpointHost, strconv.Itoa(listener.Port)) + "/" + } } ports = append(ports, port) } @@ -90,31 +94,49 @@ func buildVMPorts(vm model.VMRecord, listeners []vsockagent.PortListener) []api. if ports[i].Port != ports[j].Port { return ports[i].Port < ports[j].Port } + if ports[i].PID != ports[j].PID { + return ports[i].PID < ports[j].PID + } if ports[i].Process != ports[j].Process { return ports[i].Process < ports[j].Process } - return ports[i].Command < ports[j].Command + if ports[i].Command != ports[j].Command { + return ports[i].Command < ports[j].Command + } + return ports[i].BindAddress < ports[j].BindAddress }) - return ports + return dedupeVMPorts(ports) } -func probeHTTPListener(guestIP string, port int) bool { +func probeWebListener(guestIP string, port int) (string, bool) { + if probeHTTPScheme("https", guestIP, port) { + return "https", true + } + if probeHTTPScheme("http", guestIP, port) { + return "http", true + } + return "", false +} + +func probeHTTPScheme(scheme, guestIP string, port int) bool { if strings.TrimSpace(guestIP) == "" || port <= 0 { return false } - url := "http://" + net.JoinHostPort(strings.TrimSpace(guestIP), strconv.Itoa(port)) + "/" + url := scheme + "://" + net.JoinHostPort(strings.TrimSpace(guestIP), strconv.Itoa(port)) + "/" req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return false } + transport := &http.Transport{Proxy: nil} + if scheme == "https" { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } client := &http.Client{ Timeout: httpProbeTimeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, - Transport: &http.Transport{ - Proxy: nil, - }, + Transport: transport, } resp, err := client.Do(req) if err != nil { @@ -124,3 +146,20 @@ func probeHTTPListener(guestIP string, port int) bool { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1)) return resp.ProtoMajor >= 1 } + +func dedupeVMPorts(ports []api.VMPort) []api.VMPort { + if len(ports) < 2 { + return ports + } + deduped := make([]api.VMPort, 0, len(ports)) + seen := make(map[string]struct{}, len(ports)) + for _, port := range ports { + key := port.Proto + "\x00" + port.Endpoint + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + deduped = append(deduped, port) + } + return deduped +} diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 0319cd9..893c216 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -24,6 +24,7 @@ import ( "banger/internal/paths" "banger/internal/store" "banger/internal/vmdns" + "banger/internal/vsockagent" ) func TestFindVMPrefixResolution(t *testing.T) { @@ -428,7 +429,7 @@ func TestHealthVMReturnsFalseForStoppedVM(t *testing.T) { } } -func TestPortsVMReturnsEnrichedPortsAndWebURL(t *testing.T) { +func TestPortsVMReturnsEnrichedPortsAndWebSchemes(t *testing.T) { t.Parallel() ctx := context.Background() @@ -448,6 +449,20 @@ func TestPortsVMReturnsEnrichedPortsAndWebURL(t *testing.T) { if err != nil { t.Fatalf("ResolveTCPAddr: %v", err) } + tlsServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + tlsListener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen tls: %v", err) + } + tlsServer.Listener = tlsListener + tlsServer.StartTLS() + t.Cleanup(tlsServer.Close) + tlsAddr, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(tlsServer.URL, "https://")) + if err != nil { + t.Fatalf("ResolveTCPAddr(tls): %v", err) + } vsockSock := filepath.Join(t.TempDir(), "fc.vsock") listener, err := net.Listen("unix", vsockSock) @@ -496,7 +511,7 @@ func TestPortsVMReturnsEnrichedPortsAndWebURL(t *testing.T) { serverDone <- fmt.Errorf("unexpected ports payload %q", got) return } - body := fmt.Sprintf(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":%d,"pid":44,"process":"python3","command":"python3 -m http.server %d"},{"proto":"udp","bind_address":"0.0.0.0","port":53,"pid":1,"process":"dnsd","command":"dnsd --foreground"}]}`, webAddr.Port, webAddr.Port) + body := fmt.Sprintf(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":%d,"pid":44,"process":"python3","command":"python3 -m http.server %d"},{"proto":"tcp","bind_address":"0.0.0.0","port":%d,"pid":77,"process":"caddy","command":"caddy run"},{"proto":"udp","bind_address":"0.0.0.0","port":53,"pid":1,"process":"dnsd","command":"dnsd --foreground"}]}`, webAddr.Port, webAddr.Port, tlsAddr.Port) resp := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body) _, err = conn.Write([]byte(resp)) serverDone <- err @@ -527,24 +542,30 @@ func TestPortsVMReturnsEnrichedPortsAndWebURL(t *testing.T) { if result.Name != vm.Name || result.DNSName != vm.Runtime.DNSName { t.Fatalf("result = %+v, want name/dns", result) } - if len(result.Ports) != 2 { - t.Fatalf("ports = %+v, want 2 entries", result.Ports) + if len(result.Ports) != 3 { + t.Fatalf("ports = %+v, want 3 entries", result.Ports) } - wantWeb := fmt.Sprintf("http://ports.vm:%d/", webAddr.Port) - var tcpPort, udpPort api.VMPort + wantHTTP := fmt.Sprintf("http://ports.vm:%d/", webAddr.Port) + wantHTTPS := fmt.Sprintf("https://ports.vm:%d/", tlsAddr.Port) + var httpPort, httpsPort, udpPort api.VMPort for _, port := range result.Ports { - switch port.Proto { - case "tcp": - tcpPort = port - case "udp": + switch port.Port { + case webAddr.Port: + httpPort = port + case tlsAddr.Port: + httpsPort = port + case 53: udpPort = port } } - if udpPort.Endpoint != "ports.vm:53" || udpPort.WebURL != "" { + if udpPort.Endpoint != "ports.vm:53" { t.Fatalf("udp port = %+v, want endpoint only", udpPort) } - if tcpPort.Endpoint != net.JoinHostPort("ports.vm", strconv.Itoa(webAddr.Port)) || tcpPort.WebURL != wantWeb { - t.Fatalf("tcp port = %+v, want web url %q", tcpPort, wantWeb) + if httpPort.Proto != "http" || httpPort.Endpoint != wantHTTP { + t.Fatalf("http port = %+v, want http endpoint %q", httpPort, wantHTTP) + } + if httpsPort.Proto != "https" || httpsPort.Endpoint != wantHTTPS { + t.Fatalf("https port = %+v, want https endpoint %q", httpsPort, wantHTTPS) } runner.assertExhausted() if err := <-serverDone; err != nil { @@ -567,6 +588,49 @@ func TestPortsVMReturnsErrorForStoppedVM(t *testing.T) { } } +func TestBuildVMPortsDeduplicatesSameRenderedEndpoint(t *testing.T) { + t.Parallel() + + vm := testVM("dedupe-ports", "image-ports", "") + vm.Runtime.DNSName = "dedupe-ports.vm" + + ports := buildVMPorts(vm, []vsockagent.PortListener{ + { + Proto: "tcp", + BindAddress: "0.0.0.0", + Port: 8080, + PID: 44, + Process: "docker-proxy", + Command: "/usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080", + }, + { + Proto: "tcp", + BindAddress: "::", + Port: 8080, + PID: 45, + Process: "docker-proxy", + Command: "/usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 8080", + }, + { + Proto: "udp", + BindAddress: "0.0.0.0", + Port: 8080, + PID: 46, + Process: "dnsd", + Command: "dnsd --foreground", + }, + }) + if len(ports) != 2 { + t.Fatalf("ports = %+v, want tcp+udp entries after dedupe", ports) + } + if ports[0].Proto != "tcp" || ports[0].Endpoint != "dedupe-ports.vm:8080" { + t.Fatalf("first port = %+v, want deduped tcp endpoint", ports[0]) + } + if ports[1].Proto != "udp" || ports[1].Endpoint != "dedupe-ports.vm:8080" { + t.Fatalf("second port = %+v, want distinct udp endpoint", ports[1]) + } +} + func TestSetVMDiskResizeFailsPreflightWhenToolsMissing(t *testing.T) { ctx := context.Background() db := openDaemonStore(t)