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:
parent
3ed78fdcfc
commit
c298ed2fc1
11 changed files with 1029 additions and 23 deletions
|
|
@ -345,6 +345,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
}
|
||||
result, err := d.PingVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(result, err)
|
||||
case "vm.ports":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
result, err := d.PortsVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(result, err)
|
||||
case "image.list":
|
||||
images, err := d.store.ListImages(ctx)
|
||||
return marshalResultOrError(api.ImageListResult{Images: images}, err)
|
||||
|
|
|
|||
126
internal/daemon/ports.go
Normal file
126
internal/daemon/ports.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vmdns"
|
||||
"banger/internal/vsockagent"
|
||||
)
|
||||
|
||||
const httpProbeTimeout = 750 * time.Millisecond
|
||||
|
||||
func (d *Daemon) PortsVM(ctx context.Context, idOrName string) (result api.VMPortsResult, err error) {
|
||||
_, err = d.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) {
|
||||
result.Name = vm.Name
|
||||
result.DNSName = strings.TrimSpace(vm.Runtime.DNSName)
|
||||
if result.DNSName == "" && strings.TrimSpace(vm.Name) != "" {
|
||||
result.DNSName = vmdns.RecordName(vm.Name)
|
||||
}
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
return model.VMRecord{}, fmt.Errorf("vm %s is not running", vm.Name)
|
||||
}
|
||||
if strings.TrimSpace(vm.Runtime.GuestIP) == "" {
|
||||
return model.VMRecord{}, errors.New("vm has no guest IP")
|
||||
}
|
||||
if strings.TrimSpace(vm.Runtime.VSockPath) == "" {
|
||||
return model.VMRecord{}, errors.New("vm has no vsock path")
|
||||
}
|
||||
if vm.Runtime.VSockCID == 0 {
|
||||
return model.VMRecord{}, errors.New("vm has no vsock cid")
|
||||
}
|
||||
if err := d.ensureSocketAccess(ctx, vm.Runtime.VSockPath, "firecracker vsock socket"); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
portsCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
listeners, err := vsockagent.Ports(portsCtx, d.logger, vm.Runtime.VSockPath)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
result.Ports = buildVMPorts(vm, listeners)
|
||||
return vm, nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func buildVMPorts(vm model.VMRecord, listeners []vsockagent.PortListener) []api.VMPort {
|
||||
endpointHost := strings.TrimSpace(vm.Runtime.DNSName)
|
||||
if endpointHost == "" {
|
||||
endpointHost = strings.TrimSpace(vm.Runtime.GuestIP)
|
||||
}
|
||||
probeHost := strings.TrimSpace(vm.Runtime.GuestIP)
|
||||
ports := make([]api.VMPort, 0, len(listeners))
|
||||
for _, listener := range listeners {
|
||||
if listener.Port <= 0 {
|
||||
continue
|
||||
}
|
||||
port := api.VMPort{
|
||||
Proto: strings.ToLower(strings.TrimSpace(listener.Proto)),
|
||||
BindAddress: strings.TrimSpace(listener.BindAddress),
|
||||
Port: listener.Port,
|
||||
PID: listener.PID,
|
||||
Process: strings.TrimSpace(listener.Process),
|
||||
Command: strings.TrimSpace(listener.Command),
|
||||
Endpoint: net.JoinHostPort(endpointHost, strconv.Itoa(listener.Port)),
|
||||
}
|
||||
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)) + "/"
|
||||
}
|
||||
ports = append(ports, port)
|
||||
}
|
||||
sort.Slice(ports, func(i, j int) bool {
|
||||
if ports[i].Proto != ports[j].Proto {
|
||||
return ports[i].Proto < ports[j].Proto
|
||||
}
|
||||
if ports[i].Port != ports[j].Port {
|
||||
return ports[i].Port < ports[j].Port
|
||||
}
|
||||
if ports[i].Process != ports[j].Process {
|
||||
return ports[i].Process < ports[j].Process
|
||||
}
|
||||
return ports[i].Command < ports[j].Command
|
||||
})
|
||||
return ports
|
||||
}
|
||||
|
||||
func probeHTTPListener(guestIP string, port int) bool {
|
||||
if strings.TrimSpace(guestIP) == "" || port <= 0 {
|
||||
return false
|
||||
}
|
||||
url := "http://" + net.JoinHostPort(strings.TrimSpace(guestIP), strconv.Itoa(port)) + "/"
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: httpProbeTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1))
|
||||
return resp.ProtoMajor >= 1
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -427,6 +428,145 @@ func TestHealthVMReturnsFalseForStoppedVM(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPortsVMReturnsEnrichedPortsAndWebURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
fake := startFakeFirecrackerProcess(t, apiSock)
|
||||
t.Cleanup(func() {
|
||||
_ = fake.Process.Kill()
|
||||
_ = fake.Wait()
|
||||
})
|
||||
|
||||
webServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(webServer.Close)
|
||||
webAddr, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(webServer.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveTCPAddr: %v", err)
|
||||
}
|
||||
|
||||
vsockSock := filepath.Join(t.TempDir(), "fc.vsock")
|
||||
listener, err := net.Listen("unix", vsockSock)
|
||||
if err != nil {
|
||||
t.Fatalf("listen vsock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
_ = os.Remove(vsockSock)
|
||||
})
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
if got := string(buf[:n]); got != "CONNECT 42070\n" {
|
||||
serverDone <- fmt.Errorf("unexpected connect message %q", got)
|
||||
return
|
||||
}
|
||||
if _, err := conn.Write([]byte("OK 1\n")); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
reqBuf := make([]byte, 0, 1024)
|
||||
for {
|
||||
n, err = conn.Read(buf)
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
reqBuf = append(reqBuf, buf[:n]...)
|
||||
if strings.Contains(string(reqBuf), "\r\n\r\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if got := string(reqBuf); !strings.Contains(got, "GET /ports HTTP/1.1\r\n") {
|
||||
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)
|
||||
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
|
||||
}()
|
||||
|
||||
vm := testVM("ports", "image-ports", "127.0.0.1")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.PID = fake.Process.Pid
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
vm.Runtime.VSockPath = vsockSock
|
||||
vm.Runtime.VSockCID = 10043
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
steps: []runnerStep{
|
||||
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
|
||||
sudoStep("", nil, "chmod", "600", vsockSock),
|
||||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
|
||||
result, err := d.PortsVM(ctx, vm.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("PortsVM: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
wantWeb := fmt.Sprintf("http://ports.vm:%d/", webAddr.Port)
|
||||
var tcpPort, udpPort api.VMPort
|
||||
for _, port := range result.Ports {
|
||||
switch port.Proto {
|
||||
case "tcp":
|
||||
tcpPort = port
|
||||
case "udp":
|
||||
udpPort = port
|
||||
}
|
||||
}
|
||||
if udpPort.Endpoint != "ports.vm:53" || udpPort.WebURL != "" {
|
||||
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)
|
||||
}
|
||||
runner.assertExhausted()
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortsVMReturnsErrorForStoppedVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
vm := testVM("stopped-ports", "image-stopped", "172.16.0.50")
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
d := &Daemon{store: db}
|
||||
_, err := d.PortsVM(ctx, vm.Name)
|
||||
if err == nil || !strings.Contains(err.Error(), "is not running") {
|
||||
t.Fatalf("PortsVM error = %v, want not running", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVMDiskResizeFailsPreflightWhenToolsMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue