blob: 105e1e76e653ce9464a732c755cd5a215085e9c1 [file] [log] [blame]
Sean McCullough364f7412025-06-02 00:55:44 +00001package loop
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
bankseancff0ff82025-06-25 16:43:47 +00007 "net"
8 "os"
Sean McCullough364f7412025-06-02 00:55:44 +00009 "os/exec"
bankseancff0ff82025-06-25 16:43:47 +000010 "strconv"
Sean McCullough364f7412025-06-02 00:55:44 +000011 "strings"
12 "sync"
13 "time"
14)
15
Sean McCullough138ec242025-06-02 22:42:06 +000016// PortEvent represents a port change event
17type PortEvent struct {
18 Type string `json:"type"` // "opened" or "closed"
19 Port string `json:"port"` // "proto:address:port" format
20 Timestamp time.Time `json:"timestamp"` // when the event occurred
21}
22
Sean McCullough364f7412025-06-02 00:55:44 +000023// PortMonitor handles periodic monitoring of listening ports in containers
24type PortMonitor struct {
Sean McCullough138ec242025-06-02 22:42:06 +000025 mu sync.Mutex // protects following
26 lastPorts string // last netstat/ss output for comparison
27 events []PortEvent // circular buffer of recent port events
28 maxEvents int // maximum events to keep in buffer
Sean McCullough364f7412025-06-02 00:55:44 +000029}
30
31// NewPortMonitor creates a new PortMonitor instance
32func NewPortMonitor() *PortMonitor {
Sean McCullough138ec242025-06-02 22:42:06 +000033 return &PortMonitor{
34 maxEvents: 100, // keep last 100 events
35 events: make([]PortEvent, 0, 100),
36 }
Sean McCullough364f7412025-06-02 00:55:44 +000037}
38
39// Start begins periodic port monitoring in a background goroutine
40func (pm *PortMonitor) Start(ctx context.Context) {
41 go func() {
42 ticker := time.NewTicker(5 * time.Second) // Check every 5 seconds
43 defer ticker.Stop()
44
45 // Get initial port state
46 pm.updatePortState(ctx)
47
48 for {
49 select {
50 case <-ctx.Done():
51 return
52 case <-ticker.C:
53 pm.updatePortState(ctx)
54 }
55 }
56 }()
57}
58
59// updatePortState runs ss and checks for changes in listening ports
bankseancff0ff82025-06-25 16:43:47 +000060// Falls back to /proc/net/tcp* parsing if ss is not available
Sean McCullough364f7412025-06-02 00:55:44 +000061func (pm *PortMonitor) updatePortState(ctx context.Context) {
bankseancff0ff82025-06-25 16:43:47 +000062 var currentPorts string
63 var err error
64
65 // Try ss command first
Sean McCullough364f7412025-06-02 00:55:44 +000066 cmd := exec.CommandContext(ctx, "ss", "-lntu")
67 output, err := cmd.Output()
68 if err != nil {
bankseancff0ff82025-06-25 16:43:47 +000069 // ss command failed, try /proc filesystem fallback
70 slog.DebugContext(ctx, "ss command failed, trying /proc fallback", "error", err)
71 currentPorts, err = pm.getListeningPortsFromProc()
72 if err != nil {
73 // Both methods failed - log and return
74 slog.DebugContext(ctx, "Failed to get listening ports", "ss_error", err)
75 return
76 }
77 } else {
78 currentPorts = string(output)
Sean McCullough364f7412025-06-02 00:55:44 +000079 }
80
Sean McCullough364f7412025-06-02 00:55:44 +000081 pm.mu.Lock()
82 defer pm.mu.Unlock()
83
84 // Check if ports have changed
85 if pm.lastPorts != "" && pm.lastPorts != currentPorts {
86 // Ports have changed, log the difference
87 slog.InfoContext(ctx, "Container port changes detected",
88 slog.String("previous_ports", pm.lastPorts),
89 slog.String("current_ports", currentPorts))
90
91 // Parse and compare the port lists for more detailed logging
92 pm.logPortDifferences(ctx, pm.lastPorts, currentPorts)
93 }
94
95 pm.lastPorts = currentPorts
96}
97
98// logPortDifferences parses ss output and logs specific port changes
99func (pm *PortMonitor) logPortDifferences(ctx context.Context, oldPorts, newPorts string) {
100 oldPortSet := parseSSPorts(oldPorts)
101 newPortSet := parseSSPorts(newPorts)
Sean McCullough138ec242025-06-02 22:42:06 +0000102 now := time.Now()
Sean McCullough364f7412025-06-02 00:55:44 +0000103
104 // Find newly opened ports
105 for port := range newPortSet {
106 if !oldPortSet[port] {
107 slog.InfoContext(ctx, "New port detected", slog.String("port", port))
Sean McCullough138ec242025-06-02 22:42:06 +0000108 pm.addEvent(PortEvent{
109 Type: "opened",
110 Port: port,
111 Timestamp: now,
112 })
Sean McCullough364f7412025-06-02 00:55:44 +0000113 }
114 }
115
116 // Find closed ports
117 for port := range oldPortSet {
118 if !newPortSet[port] {
119 slog.InfoContext(ctx, "Port closed", slog.String("port", port))
Sean McCullough138ec242025-06-02 22:42:06 +0000120 pm.addEvent(PortEvent{
121 Type: "closed",
122 Port: port,
123 Timestamp: now,
124 })
Sean McCullough364f7412025-06-02 00:55:44 +0000125 }
126 }
127}
128
Sean McCullough138ec242025-06-02 22:42:06 +0000129// addEvent adds a port event to the circular buffer (must be called with mutex held)
130func (pm *PortMonitor) addEvent(event PortEvent) {
131 // If buffer is full, remove oldest event
132 if len(pm.events) >= pm.maxEvents {
133 // Shift all events left by 1 to remove oldest
134 copy(pm.events, pm.events[1:])
135 pm.events = pm.events[:len(pm.events)-1]
136 }
137 // Add new event
138 pm.events = append(pm.events, event)
139}
140
141// GetRecentEvents returns a copy of recent port events since the given timestamp
142func (pm *PortMonitor) GetRecentEvents(since time.Time) []PortEvent {
143 pm.mu.Lock()
144 defer pm.mu.Unlock()
145
146 // Find events since the given timestamp
147 var result []PortEvent
148 for _, event := range pm.events {
149 if event.Timestamp.After(since) {
150 result = append(result, event)
151 }
152 }
153 return result
154}
155
bankseancff0ff82025-06-25 16:43:47 +0000156// UpdatePortState is a public wrapper for updatePortState for testing purposes
157func (pm *PortMonitor) UpdatePortState(ctx context.Context) {
158 pm.updatePortState(ctx)
159}
160
Sean McCullough138ec242025-06-02 22:42:06 +0000161// GetAllRecentEvents returns a copy of all recent port events
162func (pm *PortMonitor) GetAllRecentEvents() []PortEvent {
163 pm.mu.Lock()
164 defer pm.mu.Unlock()
165
166 // Return a copy of all events
167 result := make([]PortEvent, len(pm.events))
168 copy(result, pm.events)
169 return result
170}
171
Sean McCullough364f7412025-06-02 00:55:44 +0000172// parseSSPorts extracts listening ports from ss -lntu output
173// Returns a map with "proto:address:port" as keys
174// ss output format: Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
175func parseSSPorts(output string) map[string]bool {
176 ports := make(map[string]bool)
177 lines := strings.Split(output, "\n")
178
179 for _, line := range lines {
180 fields := strings.Fields(line)
181 if len(fields) < 5 {
182 continue
183 }
184
185 // Skip header line and non-LISTEN states
186 if fields[0] == "Netid" || fields[1] != "LISTEN" {
187 continue
188 }
189
190 proto := fields[0]
191 localAddr := fields[4] // Local Address:Port
192 portKey := fmt.Sprintf("%s:%s", proto, localAddr)
193 ports[portKey] = true
194 }
195
196 return ports
197}
bankseancff0ff82025-06-25 16:43:47 +0000198
199// getListeningPortsFromProc reads /proc/net/tcp and /proc/net/tcp6 to find listening ports
200// Returns output in a format similar to ss -lntu
201func (pm *PortMonitor) getListeningPortsFromProc() (string, error) {
202 var result strings.Builder
203 result.WriteString("Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\n")
204
205 // Parse IPv4 listening ports
206 if err := pm.parseProc("/proc/net/tcp", "tcp", &result); err != nil {
207 return "", fmt.Errorf("failed to parse /proc/net/tcp: %w", err)
208 }
209
210 // Parse IPv6 listening ports
211 if err := pm.parseProc("/proc/net/tcp6", "tcp", &result); err != nil {
212 // IPv6 might not be available, log but don't fail
213 slog.Debug("Failed to parse /proc/net/tcp6", "error", err)
214 }
215
216 // Parse UDP ports
217 if err := pm.parseProc("/proc/net/udp", "udp", &result); err != nil {
218 slog.Debug("Failed to parse /proc/net/udp", "error", err)
219 }
220
221 if err := pm.parseProc("/proc/net/udp6", "udp", &result); err != nil {
222 slog.Debug("Failed to parse /proc/net/udp6", "error", err)
223 }
224
225 return result.String(), nil
226}
227
228// parseProc parses a /proc/net/* file for listening sockets
229func (pm *PortMonitor) parseProc(filename, protocol string, result *strings.Builder) error {
230 data, err := os.ReadFile(filename)
231 if err != nil {
232 return err
233 }
234
235 lines := strings.Split(string(data), "\n")
236 for i, line := range lines {
237 if i == 0 || strings.TrimSpace(line) == "" {
238 continue // Skip header and empty lines
239 }
240
241 fields := strings.Fields(line)
242 if len(fields) < 4 {
243 continue
244 }
245
246 // Parse socket state (4th field, index 3)
247 stateHex := fields[3]
248 state, err := strconv.ParseInt(stateHex, 16, 32)
249 if err != nil {
250 continue
251 }
252
253 // Check if socket is in LISTEN state (0x0A for TCP) or bound state for UDP
254 isListening := false
255 if protocol == "tcp" && state == 0x0A {
256 isListening = true
257 } else if protocol == "udp" && state == 0x07 {
258 // UDP sockets in state 0x07 (TCP_CLOSE) are bound/listening
259 isListening = true
260 }
261
262 if !isListening {
263 continue
264 }
265
266 // Parse local address (2nd field, index 1)
267 localAddr := fields[1]
268 addr, port, err := pm.parseAddress(localAddr, strings.Contains(filename, "6"))
269 if err != nil {
270 continue
271 }
272
273 // Format similar to ss output
274 result.WriteString(fmt.Sprintf("%s LISTEN 0 0 %s:%d 0.0.0.0:*\n",
275 protocol, addr, port))
276 }
277
278 return nil
279}
280
281// parseAddress parses hex-encoded address:port from /proc/net files
282func (pm *PortMonitor) parseAddress(addrPort string, isIPv6 bool) (string, int, error) {
283 parts := strings.Split(addrPort, ":")
284 if len(parts) != 2 {
285 return "", 0, fmt.Errorf("invalid address:port format: %s", addrPort)
286 }
287
288 // Parse port (stored in hex, big-endian)
289 portHex := parts[1]
290 port, err := strconv.ParseInt(portHex, 16, 32)
291 if err != nil {
292 return "", 0, fmt.Errorf("invalid port hex: %s", portHex)
293 }
294
295 // Parse IP address
296 addrHex := parts[0]
297 var addr string
298
299 if isIPv6 {
300 // IPv6: 32 hex chars representing 16 bytes
301 if len(addrHex) != 32 {
302 return "", 0, fmt.Errorf("invalid IPv6 address hex length: %d", len(addrHex))
303 }
304 // Convert hex to IPv6 address
305 var ipBytes [16]byte
306 for i := 0; i < 16; i++ {
307 b, err := strconv.ParseInt(addrHex[i*2:(i+1)*2], 16, 8)
308 if err != nil {
309 return "", 0, fmt.Errorf("invalid IPv6 hex: %s", addrHex)
310 }
311 ipBytes[i] = byte(b)
312 }
313 // /proc stores IPv6 in little-endian 32-bit chunks, need to reverse each chunk
314 for i := 0; i < 16; i += 4 {
315 ipBytes[i], ipBytes[i+1], ipBytes[i+2], ipBytes[i+3] = ipBytes[i+3], ipBytes[i+2], ipBytes[i+1], ipBytes[i]
316 }
317 addr = net.IP(ipBytes[:]).String()
318 } else {
319 // IPv4: 8 hex chars representing 4 bytes in little-endian
320 if len(addrHex) != 8 {
321 return "", 0, fmt.Errorf("invalid IPv4 address hex length: %d", len(addrHex))
322 }
323 // Parse as little-endian 32-bit integer
324 addrInt, err := strconv.ParseInt(addrHex, 16, 64)
325 if err != nil {
326 return "", 0, fmt.Errorf("invalid IPv4 hex: %s", addrHex)
327 }
328 // Convert to IP address (reverse byte order for little-endian)
329 addr = fmt.Sprintf("%d.%d.%d.%d",
330 addrInt&0xFF,
331 (addrInt>>8)&0xFF,
332 (addrInt>>16)&0xFF,
333 (addrInt>>24)&0xFF)
334 }
335
336 // Handle special addresses
337 if addr == "0.0.0.0" {
338 addr = "*"
339 } else if addr == "::" {
340 addr = "*"
341 }
342
343 return addr, int(port), nil
344}