blob: 9614db20608793493cc5844da25221c73f659389 [file] [log] [blame]
Sean McCullough364f7412025-06-02 00:55:44 +00001package loop
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os/exec"
8 "strings"
9 "sync"
10 "time"
11)
12
Sean McCullough138ec242025-06-02 22:42:06 +000013// PortEvent represents a port change event
14type PortEvent struct {
15 Type string `json:"type"` // "opened" or "closed"
16 Port string `json:"port"` // "proto:address:port" format
17 Timestamp time.Time `json:"timestamp"` // when the event occurred
18}
19
Sean McCullough364f7412025-06-02 00:55:44 +000020// PortMonitor handles periodic monitoring of listening ports in containers
21type PortMonitor struct {
Sean McCullough138ec242025-06-02 22:42:06 +000022 mu sync.Mutex // protects following
23 lastPorts string // last netstat/ss output for comparison
24 events []PortEvent // circular buffer of recent port events
25 maxEvents int // maximum events to keep in buffer
Sean McCullough364f7412025-06-02 00:55:44 +000026}
27
28// NewPortMonitor creates a new PortMonitor instance
29func NewPortMonitor() *PortMonitor {
Sean McCullough138ec242025-06-02 22:42:06 +000030 return &PortMonitor{
31 maxEvents: 100, // keep last 100 events
32 events: make([]PortEvent, 0, 100),
33 }
Sean McCullough364f7412025-06-02 00:55:44 +000034}
35
36// Start begins periodic port monitoring in a background goroutine
37func (pm *PortMonitor) Start(ctx context.Context) {
38 go func() {
39 ticker := time.NewTicker(5 * time.Second) // Check every 5 seconds
40 defer ticker.Stop()
41
42 // Get initial port state
43 pm.updatePortState(ctx)
44
45 for {
46 select {
47 case <-ctx.Done():
48 return
49 case <-ticker.C:
50 pm.updatePortState(ctx)
51 }
52 }
53 }()
54}
55
56// updatePortState runs ss and checks for changes in listening ports
57func (pm *PortMonitor) updatePortState(ctx context.Context) {
58 cmd := exec.CommandContext(ctx, "ss", "-lntu")
59 output, err := cmd.Output()
60 if err != nil {
61 // Log the error but don't fail - port monitoring is not critical
62 slog.DebugContext(ctx, "Failed to run ss command", "error", err)
63 return
64 }
65
66 currentPorts := string(output)
67
68 pm.mu.Lock()
69 defer pm.mu.Unlock()
70
71 // Check if ports have changed
72 if pm.lastPorts != "" && pm.lastPorts != currentPorts {
73 // Ports have changed, log the difference
74 slog.InfoContext(ctx, "Container port changes detected",
75 slog.String("previous_ports", pm.lastPorts),
76 slog.String("current_ports", currentPorts))
77
78 // Parse and compare the port lists for more detailed logging
79 pm.logPortDifferences(ctx, pm.lastPorts, currentPorts)
80 }
81
82 pm.lastPorts = currentPorts
83}
84
85// logPortDifferences parses ss output and logs specific port changes
86func (pm *PortMonitor) logPortDifferences(ctx context.Context, oldPorts, newPorts string) {
87 oldPortSet := parseSSPorts(oldPorts)
88 newPortSet := parseSSPorts(newPorts)
Sean McCullough138ec242025-06-02 22:42:06 +000089 now := time.Now()
Sean McCullough364f7412025-06-02 00:55:44 +000090
91 // Find newly opened ports
92 for port := range newPortSet {
93 if !oldPortSet[port] {
94 slog.InfoContext(ctx, "New port detected", slog.String("port", port))
Sean McCullough138ec242025-06-02 22:42:06 +000095 pm.addEvent(PortEvent{
96 Type: "opened",
97 Port: port,
98 Timestamp: now,
99 })
Sean McCullough364f7412025-06-02 00:55:44 +0000100 }
101 }
102
103 // Find closed ports
104 for port := range oldPortSet {
105 if !newPortSet[port] {
106 slog.InfoContext(ctx, "Port closed", slog.String("port", port))
Sean McCullough138ec242025-06-02 22:42:06 +0000107 pm.addEvent(PortEvent{
108 Type: "closed",
109 Port: port,
110 Timestamp: now,
111 })
Sean McCullough364f7412025-06-02 00:55:44 +0000112 }
113 }
114}
115
Sean McCullough138ec242025-06-02 22:42:06 +0000116// addEvent adds a port event to the circular buffer (must be called with mutex held)
117func (pm *PortMonitor) addEvent(event PortEvent) {
118 // If buffer is full, remove oldest event
119 if len(pm.events) >= pm.maxEvents {
120 // Shift all events left by 1 to remove oldest
121 copy(pm.events, pm.events[1:])
122 pm.events = pm.events[:len(pm.events)-1]
123 }
124 // Add new event
125 pm.events = append(pm.events, event)
126}
127
128// GetRecentEvents returns a copy of recent port events since the given timestamp
129func (pm *PortMonitor) GetRecentEvents(since time.Time) []PortEvent {
130 pm.mu.Lock()
131 defer pm.mu.Unlock()
132
133 // Find events since the given timestamp
134 var result []PortEvent
135 for _, event := range pm.events {
136 if event.Timestamp.After(since) {
137 result = append(result, event)
138 }
139 }
140 return result
141}
142
143// GetAllRecentEvents returns a copy of all recent port events
144func (pm *PortMonitor) GetAllRecentEvents() []PortEvent {
145 pm.mu.Lock()
146 defer pm.mu.Unlock()
147
148 // Return a copy of all events
149 result := make([]PortEvent, len(pm.events))
150 copy(result, pm.events)
151 return result
152}
153
Sean McCullough364f7412025-06-02 00:55:44 +0000154// parseSSPorts extracts listening ports from ss -lntu output
155// Returns a map with "proto:address:port" as keys
156// ss output format: Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
157func parseSSPorts(output string) map[string]bool {
158 ports := make(map[string]bool)
159 lines := strings.Split(output, "\n")
160
161 for _, line := range lines {
162 fields := strings.Fields(line)
163 if len(fields) < 5 {
164 continue
165 }
166
167 // Skip header line and non-LISTEN states
168 if fields[0] == "Netid" || fields[1] != "LISTEN" {
169 continue
170 }
171
172 proto := fields[0]
173 localAddr := fields[4] // Local Address:Port
174 portKey := fmt.Sprintf("%s:%s", proto, localAddr)
175 ports[portKey] = true
176 }
177
178 return ports
179}