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