sketch/loop: add PortMonitor for TCP port monitoring with Agent integration
Add PortMonitor struct that uses Tailscale portlist library to monitor
open/listening TCP ports and send AgentMessage notifications to Agent
when ports are opened or closed, with cached port list access method.
When I asked Sketch to do this with the old implementation, it did
ok parsing /proc, but then it tried to conver it to ss format...
using a library seems to work ok!
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s8fc57de4b5583d34k
diff --git a/loop/port_monitor_test.go b/loop/port_monitor_test.go
new file mode 100644
index 0000000..5ad5c2e
--- /dev/null
+++ b/loop/port_monitor_test.go
@@ -0,0 +1,319 @@
+package loop
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "tailscale.com/portlist"
+)
+
+// TestPortMonitor_NewPortMonitor tests the creation of a new PortMonitor.
+func TestPortMonitor_NewPortMonitor(t *testing.T) {
+ agent := createTestAgent(t)
+ interval := 2 * time.Second
+
+ pm := NewPortMonitor(agent, interval)
+
+ if pm == nil {
+ t.Fatal("NewPortMonitor returned nil")
+ }
+
+ if pm.agent != agent {
+ t.Errorf("expected agent %v, got %v", agent, pm.agent)
+ }
+
+ if pm.interval != interval {
+ t.Errorf("expected interval %v, got %v", interval, pm.interval)
+ }
+
+ if pm.running {
+ t.Error("expected monitor to not be running initially")
+ }
+
+ if pm.poller == nil {
+ t.Error("expected poller to be initialized")
+ }
+
+ if !pm.poller.IncludeLocalhost {
+ t.Error("expected IncludeLocalhost to be true")
+ }
+}
+
+// TestPortMonitor_DefaultInterval tests that a default interval is set when invalid.
+func TestPortMonitor_DefaultInterval(t *testing.T) {
+ agent := createTestAgent(t)
+
+ pm := NewPortMonitor(agent, 0)
+ if pm.interval != 5*time.Second {
+ t.Errorf("expected default interval 5s, got %v", pm.interval)
+ }
+
+ pm2 := NewPortMonitor(agent, -1*time.Second)
+ if pm2.interval != 5*time.Second {
+ t.Errorf("expected default interval 5s, got %v", pm2.interval)
+ }
+}
+
+// TestPortMonitor_StartStop tests starting and stopping the monitor.
+func TestPortMonitor_StartStop(t *testing.T) {
+ agent := createTestAgent(t)
+ pm := NewPortMonitor(agent, 100*time.Millisecond)
+
+ // Test starting
+ ctx := context.Background()
+ err := pm.Start(ctx)
+ if err != nil {
+ t.Fatalf("failed to start port monitor: %v", err)
+ }
+
+ if !pm.running {
+ t.Error("expected monitor to be running after start")
+ }
+
+ // Test double start fails
+ err = pm.Start(ctx)
+ if err == nil {
+ t.Error("expected error when starting already running monitor")
+ }
+
+ // Test stopping
+ pm.Stop()
+ if pm.running {
+ t.Error("expected monitor to not be running after stop")
+ }
+
+ // Test double stop is safe
+ pm.Stop() // should not panic
+}
+
+// TestPortMonitor_GetPorts tests getting the cached port list.
+func TestPortMonitor_GetPorts(t *testing.T) {
+ agent := createTestAgent(t)
+ pm := NewPortMonitor(agent, 100*time.Millisecond)
+
+ // Initially should be empty
+ ports := pm.GetPorts()
+ if len(ports) != 0 {
+ t.Errorf("expected empty ports initially, got %d", len(ports))
+ }
+
+ // Start monitoring to populate ports
+ ctx := context.Background()
+ err := pm.Start(ctx)
+ if err != nil {
+ t.Fatalf("failed to start port monitor: %v", err)
+ }
+ defer pm.Stop()
+
+ // Allow some time for initial scan
+ time.Sleep(200 * time.Millisecond)
+
+ // Should have some ports now (at least system ports)
+ ports = pm.GetPorts()
+ // We can't guarantee specific ports, but there should be at least some TCP ports
+ // on most systems (like SSH, etc.)
+ t.Logf("Found %d TCP ports", len(ports))
+
+ // Verify all returned ports are TCP
+ for _, port := range ports {
+ if port.Proto != "tcp" {
+ t.Errorf("expected TCP port, got %s", port.Proto)
+ }
+ }
+}
+
+// TestPortMonitor_PortDetection tests actual port detection with a test server.
+func TestPortMonitor_PortDetection(t *testing.T) {
+ agent := createTestAgent(t)
+ pm := NewPortMonitor(agent, 50*time.Millisecond) // Fast polling for test
+
+ ctx := context.Background()
+ err := pm.Start(ctx)
+ if err != nil {
+ t.Fatalf("failed to start port monitor: %v", err)
+ }
+ defer pm.Stop()
+
+ // Allow initial scan
+ time.Sleep(100 * time.Millisecond)
+
+ // Get initial port count
+ initialPorts := pm.GetPorts()
+ initialCount := len(initialPorts)
+
+ // Start a test server
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("failed to start test listener: %v", err)
+ }
+ defer listener.Close()
+
+ addr := listener.Addr().(*net.TCPAddr)
+ testPort := uint16(addr.Port)
+
+ t.Logf("Started test server on port %d", testPort)
+
+ // Wait for port to be detected
+ detected := false
+ for i := 0; i < 50; i++ { // Wait up to 2.5 seconds
+ time.Sleep(50 * time.Millisecond)
+ ports := pm.GetPorts()
+ for _, port := range ports {
+ if port.Port == testPort {
+ detected = true
+ break
+ }
+ }
+ if detected {
+ break
+ }
+ }
+
+ if !detected {
+ t.Errorf("test port %d was not detected", testPort)
+ }
+
+ // Verify port count increased
+ currentPorts := pm.GetPorts()
+ if len(currentPorts) <= initialCount {
+ t.Errorf("expected port count to increase from %d, got %d", initialCount, len(currentPorts))
+ }
+
+ // Close the listener
+ listener.Close()
+
+ // Wait for port to be removed
+ removed := false
+ for i := 0; i < 50; i++ { // Wait up to 2.5 seconds
+ time.Sleep(50 * time.Millisecond)
+ ports := pm.GetPorts()
+ found := false
+ for _, port := range ports {
+ if port.Port == testPort {
+ found = true
+ break
+ }
+ }
+ if !found {
+ removed = true
+ break
+ }
+ }
+
+ if !removed {
+ t.Errorf("test port %d was not removed after listener closed", testPort)
+ }
+}
+
+// TestPortMonitor_FilterTCPPorts tests the TCP port filtering.
+func TestPortMonitor_FilterTCPPorts(t *testing.T) {
+ ports := []portlist.Port{
+ {Proto: "tcp", Port: 80},
+ {Proto: "udp", Port: 53},
+ {Proto: "tcp", Port: 443},
+ {Proto: "udp", Port: 123},
+ }
+
+ tcpPorts := filterTCPPorts(ports)
+
+ if len(tcpPorts) != 2 {
+ t.Errorf("expected 2 TCP ports, got %d", len(tcpPorts))
+ }
+
+ for _, port := range tcpPorts {
+ if port.Proto != "tcp" {
+ t.Errorf("expected TCP port, got %s", port.Proto)
+ }
+ }
+}
+
+// TestPortMonitor_SortPorts tests the port sorting.
+func TestPortMonitor_SortPorts(t *testing.T) {
+ ports := []portlist.Port{
+ {Proto: "tcp", Port: 443},
+ {Proto: "tcp", Port: 80},
+ {Proto: "tcp", Port: 8080},
+ {Proto: "tcp", Port: 22},
+ }
+
+ sortPorts(ports)
+
+ expected := []uint16{22, 80, 443, 8080}
+ for i, port := range ports {
+ if port.Port != expected[i] {
+ t.Errorf("expected port %d at index %d, got %d", expected[i], i, port.Port)
+ }
+ }
+}
+
+// TestPortMonitor_FindAddedPorts tests finding added ports.
+func TestPortMonitor_FindAddedPorts(t *testing.T) {
+ previous := []portlist.Port{
+ {Proto: "tcp", Port: 80},
+ {Proto: "tcp", Port: 443},
+ }
+
+ current := []portlist.Port{
+ {Proto: "tcp", Port: 80},
+ {Proto: "tcp", Port: 443},
+ {Proto: "tcp", Port: 8080},
+ {Proto: "tcp", Port: 22},
+ }
+
+ added := findAddedPorts(previous, current)
+
+ if len(added) != 2 {
+ t.Errorf("expected 2 added ports, got %d", len(added))
+ }
+
+ addedPorts := make(map[uint16]bool)
+ for _, port := range added {
+ addedPorts[port.Port] = true
+ }
+
+ if !addedPorts[8080] || !addedPorts[22] {
+ t.Errorf("expected ports 8080 and 22 to be added, got %v", added)
+ }
+}
+
+// TestPortMonitor_FindRemovedPorts tests finding removed ports.
+func TestPortMonitor_FindRemovedPorts(t *testing.T) {
+ previous := []portlist.Port{
+ {Proto: "tcp", Port: 80},
+ {Proto: "tcp", Port: 443},
+ {Proto: "tcp", Port: 8080},
+ {Proto: "tcp", Port: 22},
+ }
+
+ current := []portlist.Port{
+ {Proto: "tcp", Port: 80},
+ {Proto: "tcp", Port: 443},
+ }
+
+ removed := findRemovedPorts(previous, current)
+
+ if len(removed) != 2 {
+ t.Errorf("expected 2 removed ports, got %d", len(removed))
+ }
+
+ removedPorts := make(map[uint16]bool)
+ for _, port := range removed {
+ removedPorts[port.Port] = true
+ }
+
+ if !removedPorts[8080] || !removedPorts[22] {
+ t.Errorf("expected ports 8080 and 22 to be removed, got %v", removed)
+ }
+}
+
+// createTestAgent creates a minimal test agent for testing.
+func createTestAgent(t *testing.T) *Agent {
+ // Create a minimal agent for testing
+ // We need to initialize the required fields for the PortMonitor to work
+ agent := &Agent{
+ subscribers: make([]chan *AgentMessage, 0),
+ }
+ return agent
+}