blob: 14869dad6f49cf049e96bcc2cb4b9191192b3543 [file] [log] [blame]
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001package loop
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
Philip Zeyliger9b39aa62025-07-14 11:56:02 -07007 "os"
Philip Zeyliger5f26a342025-07-04 01:30:29 +00008 "sort"
Philip Zeyliger9b39aa62025-07-14 11:56:02 -07009 "strings"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000010 "sync"
11 "time"
12
13 "tailscale.com/portlist"
14)
15
16// PortMonitor monitors open/listening TCP ports and sends notifications
17// to an Agent when ports are detected or removed.
18type PortMonitor struct {
19 mu sync.RWMutex
20 ports []portlist.Port // cached list of current ports
21 poller *portlist.Poller
22 agent *Agent
23 ctx context.Context
24 cancel context.CancelFunc
25 interval time.Duration
26 running bool
27 wg sync.WaitGroup
28}
29
30// NewPortMonitor creates a new PortMonitor instance.
31func NewPortMonitor(agent *Agent, interval time.Duration) *PortMonitor {
32 if interval <= 0 {
33 interval = 5 * time.Second // default polling interval
34 }
35
36 ctx, cancel := context.WithCancel(context.Background())
37 poller := &portlist.Poller{
38 IncludeLocalhost: true, // include localhost-bound services
39 }
40
41 return &PortMonitor{
42 poller: poller,
43 agent: agent,
44 ctx: ctx,
45 cancel: cancel,
46 interval: interval,
47 }
48}
49
50// Start begins monitoring ports in a background goroutine.
51func (pm *PortMonitor) Start(ctx context.Context) error {
52 pm.mu.Lock()
53 defer pm.mu.Unlock()
54
55 if pm.running {
56 return fmt.Errorf("port monitor is already running")
57 }
58
59 // Update the internal context to use the provided context
60 pm.cancel() // Cancel the old context
61 pm.ctx, pm.cancel = context.WithCancel(ctx)
62
63 pm.running = true
64 pm.wg.Add(1)
65
66 // Do initial port scan
67 if err := pm.initialScan(); err != nil {
68 pm.running = false
69 pm.wg.Done()
70 return fmt.Errorf("initial port scan failed: %w", err)
71 }
72
73 go pm.monitor()
74 return nil
75}
76
77// Stop stops the port monitor.
78func (pm *PortMonitor) Stop() {
79 pm.mu.Lock()
Philip Zeyliger5f26a342025-07-04 01:30:29 +000080 if !pm.running {
Josh Bleecher Snyderc7a98d82025-07-07 19:12:53 -070081 pm.mu.Unlock()
Philip Zeyliger5f26a342025-07-04 01:30:29 +000082 return
83 }
84
85 pm.running = false
86 pm.cancel()
Josh Bleecher Snyderc7a98d82025-07-07 19:12:53 -070087 pm.mu.Unlock()
Philip Zeyliger5f26a342025-07-04 01:30:29 +000088 pm.wg.Wait()
89 pm.poller.Close()
90}
91
92// GetPorts returns the cached list of open ports.
93func (pm *PortMonitor) GetPorts() []portlist.Port {
94 pm.mu.RLock()
95 defer pm.mu.RUnlock()
96
97 // Return a copy to prevent data races
98 ports := make([]portlist.Port, len(pm.ports))
99 copy(ports, pm.ports)
100 return ports
101}
102
103// initialScan performs the initial port scan without sending notifications.
104func (pm *PortMonitor) initialScan() error {
105 ports, _, err := pm.poller.Poll()
106 if err != nil {
107 return err
108 }
109
110 // Filter for TCP ports only
111 pm.ports = filterTCPPorts(ports)
112 sortPorts(pm.ports)
113
114 return nil
115}
116
117// monitor runs the port monitoring loop.
118func (pm *PortMonitor) monitor() {
119 defer pm.wg.Done()
120
121 ticker := time.NewTicker(pm.interval)
122 defer ticker.Stop()
123
124 for {
125 select {
126 case <-pm.ctx.Done():
127 return
128 case <-ticker.C:
129 if err := pm.checkPorts(); err != nil {
130 slog.WarnContext(pm.ctx, "port monitoring error", "error", err)
131 }
132 }
133 }
134}
135
136// checkPorts polls for current ports and sends notifications for changes.
137func (pm *PortMonitor) checkPorts() error {
138 ports, changed, err := pm.poller.Poll()
139 if err != nil {
140 return err
141 }
142
143 if !changed {
144 return nil
145 }
146
147 // Filter for TCP ports only
148 currentTCPPorts := filterTCPPorts(ports)
149 sortPorts(currentTCPPorts)
150
151 pm.mu.Lock()
152 previousPorts := pm.ports
153 pm.ports = currentTCPPorts
154 pm.mu.Unlock()
155
156 // Find added and removed ports
157 addedPorts := findAddedPorts(previousPorts, currentTCPPorts)
158 removedPorts := findRemovedPorts(previousPorts, currentTCPPorts)
159
160 // Send notifications for changes
161 for _, port := range addedPorts {
162 pm.sendPortNotification("opened", port)
163 }
164
165 for _, port := range removedPorts {
166 pm.sendPortNotification("closed", port)
167 }
168
169 return nil
170}
171
172// sendPortNotification sends a port event notification to the agent.
173func (pm *PortMonitor) sendPortNotification(event string, port portlist.Port) {
174 if pm.agent == nil {
175 return
176 }
177
178 // Skip low ports and sketch's ports
179 if port.Port < 1024 || port.Pid == 1 {
Autoformattere48f2bb2025-07-04 04:15:26 +0000180 return
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000181 }
182
Philip Zeyliger9b39aa62025-07-14 11:56:02 -0700183 // Skip processes with SKETCH_IGNORE_PORTS environment variable
184 if pm.shouldIgnoreProcess(port.Pid) {
185 return
186 }
187
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000188 // TODO: Structure this so that UI can display it more nicely.
189 content := fmt.Sprintf("Port %s: %s:%d", event, port.Proto, port.Port)
190 if port.Process != "" {
191 content += fmt.Sprintf(" (process: %s)", port.Process)
192 }
193 if port.Pid != 0 {
194 content += fmt.Sprintf(" (pid: %d)", port.Pid)
195 }
196
197 msg := AgentMessage{
198 Type: PortMessageType,
199 Content: content,
200 }
201
202 pm.agent.pushToOutbox(pm.ctx, msg)
203}
204
205// filterTCPPorts filters the port list to include only TCP ports.
206func filterTCPPorts(ports []portlist.Port) []portlist.Port {
207 var tcpPorts []portlist.Port
208 for _, port := range ports {
209 if port.Proto == "tcp" {
210 tcpPorts = append(tcpPorts, port)
211 }
212 }
213 return tcpPorts
214}
215
216// sortPorts sorts ports by port number for consistent comparisons.
217func sortPorts(ports []portlist.Port) {
218 sort.Slice(ports, func(i, j int) bool {
219 return ports[i].Port < ports[j].Port
220 })
221}
222
223// findAddedPorts finds ports that are in current but not in previous.
224func findAddedPorts(previous, current []portlist.Port) []portlist.Port {
225 prevSet := make(map[uint16]bool)
226 for _, port := range previous {
227 prevSet[port.Port] = true
228 }
229
230 var added []portlist.Port
231 for _, port := range current {
232 if !prevSet[port.Port] {
233 added = append(added, port)
234 }
235 }
236 return added
237}
238
239// findRemovedPorts finds ports that are in previous but not in current.
240func findRemovedPorts(previous, current []portlist.Port) []portlist.Port {
241 currentSet := make(map[uint16]bool)
242 for _, port := range current {
243 currentSet[port.Port] = true
244 }
245
246 var removed []portlist.Port
247 for _, port := range previous {
248 if !currentSet[port.Port] {
249 removed = append(removed, port)
250 }
251 }
252 return removed
253}
Philip Zeyliger9b39aa62025-07-14 11:56:02 -0700254
255// shouldIgnoreProcess checks if a process should be ignored based on its environment variables.
256func (pm *PortMonitor) shouldIgnoreProcess(pid int) bool {
257 if pid <= 0 {
258 return false
259 }
260
261 // Read the process environment from /proc/[pid]/environ
262 envFile := fmt.Sprintf("/proc/%d/environ", pid)
263 envData, err := os.ReadFile(envFile)
264 if err != nil {
265 // If we can't read the environment, don't ignore the process
266 return false
267 }
268
269 // Parse the environment variables (null-separated)
270 envVars := strings.Split(string(envData), "\x00")
271 for _, envVar := range envVars {
272 if envVar == "SKETCH_IGNORE_PORTS=1" {
273 return true
274 }
275 }
276
277 return false
278}