Add vsock-backed VM port inspection

Let the host ask the guest vsock agent to run ss so open ports can be surfaced without SSHing in manually.

Add a narrow /ports agent endpoint, a daemon vm.ports RPC that enriches listeners with <hostname>.vm endpoints and best-effort HTTP links, and a concurrent 'banger vm ports' CLI table for one or more VMs.

Update the guest package contract to include ss for rebuilt Debian images, allow the guest agent package in the shell-out policy, and cover the new parsing/RPC/CLI flow in tests.

Verified with GOCACHE=/tmp/banger-gocache go test ./... outside the sandbox, make build, bash -n customize.sh make-rootfs-void.sh verify.sh, and ./banger vm ports --help.
This commit is contained in:
Thales Maciel 2026-03-19 15:52:11 -03:00
parent 3ed78fdcfc
commit c298ed2fc1
No known key found for this signature in database
GPG key ID: 33112E6833C34679
11 changed files with 1029 additions and 23 deletions

View file

@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
@ -45,6 +46,9 @@ var (
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName})
}
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
}
)
func NewBangerCommand() *cobra.Command {
@ -243,6 +247,7 @@ func newVMCommand() *cobra.Command {
newVMSSHCommand(),
newVMLogsCommand(),
newVMStatsCommand(),
newVMPortsCommand(),
)
return cmd
}
@ -542,6 +547,50 @@ func newVMStatsCommand() *cobra.Command {
}
}
func newVMPortsCommand() *cobra.Command {
return &cobra.Command{
Use: "ports <id-or-name>...",
Short: "Show host-reachable listening guest ports",
Args: minArgsUsage(1, "usage: banger vm ports <id-or-name>..."),
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{})
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
},
}
}
func newImageCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "image",
@ -744,6 +793,12 @@ 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 {
@ -852,6 +907,27 @@ 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 {
@ -1147,6 +1223,77 @@ func printImageSummary(out anyWriter, image model.Image) error {
return err
}
func printVMPortsTable(out anyWriter, results []vmPortsBatchResult) 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,
})
}
}
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
}
if rows[i].Port != rows[j].Port {
return rows[i].Port < rows[j].Port
}
if rows[i].Process != rows[j].Process {
return rows[i].Process < rows[j].Process
}
return rows[i].Command < rows[j].Command
})
if len(rows) == 0 {
return nil
}
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "VM\tPROTO\tENDPOINT\tPROCESS\tCOMMAND\tWEB"); 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,
row.Proto,
emptyDash(row.Endpoint),
emptyDash(row.Process),
emptyDash(row.Command),
row.WebURL,
); err != nil {
return err
}
}
return w.Flush()
}
func printDoctorReport(out anyWriter, report system.Report) error {
for _, check := range report.Checks {
status := strings.ToUpper(string(check.Status))
@ -1162,6 +1309,14 @@ func printDoctorReport(out anyWriter, report system.Report) error {
return nil
}
func emptyDash(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "-"
}
return value
}
type anyWriter interface {
Write(p []byte) (n int, err error)
}

View file

@ -151,6 +151,17 @@ func TestVMKillFlagsExist(t *testing.T) {
}
}
func TestVMPortsCommandExists(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
if _, _, err := vm.Find([]string{"ports"}); err != nil {
t.Fatalf("find ports: %v", err)
}
}
func TestVMSetParamsFromFlags(t *testing.T) {
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false)
if err != nil {
@ -268,6 +279,59 @@ 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/",
}},
},
},
{
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",
}},
},
},
}
var out bytes.Buffer
if err := printVMPortsTable(&out, results); 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[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[2], "beta") || !strings.Contains(lines[2], "http://beta.vm:8080/") {
t.Fatalf("second row = %q, want beta web url", lines[2])
}
}
func TestRunSSHSessionPrintsReminderWhenHealthCheckPasses(t *testing.T) {
origSSHExec := sshExecFunc
origHealth := vmHealthFunc