summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'ansible/roles/common/files/net-listeners.py')
-rwxr-xr-xansible/roles/common/files/net-listeners.py334
1 files changed, 334 insertions, 0 deletions
diff --git a/ansible/roles/common/files/net-listeners.py b/ansible/roles/common/files/net-listeners.py
new file mode 100755
index 0000000..f8b39cd
--- /dev/null
+++ b/ansible/roles/common/files/net-listeners.py
@@ -0,0 +1,334 @@
1#!/usr/bin/env python3
2
3""" Output a colorized list of listening addresses with owners.
4
5This tool parses files in /proc directly to obtain the list
6of IPv4 and IPv6 addresses listening on tcp, tcp6, udp, and udp6 ports
7also with pids of processes responsible for the listening.
8
9Due to permission restrictions on Linux, script must be run as root
10to determine which pids match which listening sockets.
11
12This is also something like:
13 osqueryi "select po.pid, rtrim(p.cmdline), po.family, po.local_address, po.local_port from process_open_sockets as po JOIN processes as p ON po.pid=p.pid WHERE po.state='LISTEN';"
14
15"""
16
17import collections
18import subprocess
19import codecs
20import socket
21import struct
22import glob
23import sys
24import re
25import os
26
27TERMINAL_WIDTH = "/usr/bin/tput cols" # could also be "stty size"
28
29ONLY_LOWEST_PID = False
30
31# oooh, look, a big dirty global dict collecting all our data without being
32# passed around! call the programming police!
33inodes = {}
34
35
36class Color:
37 HEADER = '\033[95m'
38 OKBLUE = '\033[94m'
39 OKGREEN = '\033[92m'
40 WARNING = '\033[93m'
41 FAIL = '\033[91m'
42 BOLD = '\033[1m'
43 UNDERLINE = '\033[4m'
44 END = '\033[0m'
45
46
47COLOR_HEADER = Color.HEADER
48COLOR_OKAY = Color.OKBLUE
49COLOR_WARNING = Color.FAIL
50COLOR_END = Color.END
51
52# This should capture:
53# 127.0.0.0/8
54# 192.168.0.0/16
55# 10.0.0.0/8
56# 169.254.0.0/16
57# 172.16.0.0/12
58# ::1
59# fe80::/10
60# fc00::/7
61# fd00::/8
62NON_ROUTABLE_REGEX = r"""^((127\.) |
63 (192\.168\.) |
64 (10\.) |
65 (169\.254\.) |
66 (172\.1[6-9]\.) |
67 (172\.2[0-9]\.) |
68 (172\.3[0-1]\.) |
69 (::1) |
70 ([fF][eE]80)
71 ([fF][cCdD]))"""
72likelyLocalOnly = re.compile(NON_ROUTABLE_REGEX, re.VERBOSE)
73
74
75def run(thing):
76 """ Run any string as an async command invocation. """
77 # We don't use subprocess.check_output because we want to run all
78 # processes async
79 return subprocess.Popen(thing.split(), stdout=subprocess.PIPE)
80
81
82def readOutput(ranCommand):
83 """ Return array of rows split by newline from previous invocation. """
84 stdout, stderr = ranCommand.communicate()
85 return stdout.decode('utf-8').strip().splitlines()
86
87
88def procListeners():
89 """ Wrapper to parse all IPv4 tcp udp, and, IPv6 tcp6 udp6 listeners. """
90
91 def processProc(name):
92 """ Process IPv4 and IPv6 versions of listeners based on ``name``.
93
94 ``name`` is either 'udp' or 'tcp' so we parse, for each ``name``:
95 - /proc/net/[name]
96 - /proc/net/[name]6
97
98 As in:
99 - /proc/net/tcp
100 - /proc/net/tcp6
101 - /proc/net/udp
102 - /proc/net/udp6
103 """
104
105 def ipv6(addr):
106 """ Convert /proc IPv6 hex address into standard IPv6 notation. """
107 # turn ASCII hex address into binary
108 addr = codecs.decode(addr, "hex")
109
110 # unpack into 4 32-bit integers in big endian / network byte order
111 addr = struct.unpack('!LLLL', addr)
112
113 # re-pack as 4 32-bit integers in system native byte order
114 addr = struct.pack('@IIII', *addr)
115
116 # now we can use standard network APIs to format the address
117 addr = socket.inet_ntop(socket.AF_INET6, addr)
118 return addr
119
120 def ipv4(addr):
121 """ Convert /proc IPv4 hex address into standard IPv4 notation. """
122 # Instead of codecs.decode(), we can just convert a 4 byte hex
123 # string to an integer directly using python radix conversion.
124 # Basically, int(addr, 16) EQUALS:
125 # aOrig = addr
126 # addr = codecs.decode(addr, "hex")
127 # addr = struct.unpack(">L", addr)
128 # assert(addr == (int(aOrig, 16),))
129 addr = int(addr, 16)
130
131 # system native byte order, 4-byte integer
132 addr = struct.pack("=L", addr)
133 addr = socket.inet_ntop(socket.AF_INET, addr)
134 return addr
135
136 isUDP = name == "udp"
137
138 # Iterate four files: /proc/net/{tcp,udp}{,6}
139 # ipv4 has no prefix, while ipv6 has 6 appended.
140 for ver in ["", "6"]:
141 with open(f"/proc/net/{name}{ver}", 'r') as proto:
142 proto = proto.read().splitlines()
143 proto = proto[1:] # drop header row
144
145 for cxn in proto:
146 cxn = cxn.split()
147
148 # /proc/net/udp{,6} uses different constants for LISTENING
149 if isUDP:
150 # These constants are based on enum offsets inside
151 # the Linux kernel itself. They aren't likely to ever
152 # change since they are hardcoded in utilities.
153 isListening = cxn[3] == "07"
154 else:
155 isListening = cxn[3] == "0A"
156
157 # Right now this is a single-purpose tool so if process is
158 # not listening, we avoid further processing of this row.
159 if not isListening:
160 continue
161
162 ip, port = cxn[1].split(':')
163 if ver:
164 ip = ipv6(ip)
165 else:
166 ip = ipv4(ip)
167
168 port = int(port, 16)
169 inode = cxn[9]
170
171 # We just use a list here because creating a new sub-dict
172 # for each entry was noticably slower than just indexing
173 # into lists.
174 inodes[int(inode)] = [ip, port, f"{name}{ver}"]
175
176 processProc("tcp")
177 processProc("udp")
178
179
180def appendToInodePidMap(fd, inodePidMap):
181 """ Take a full path to /proc/[pid]/fd/[fd] for reading.
182
183 Populates both pid and full command line of pid owning an inode we
184 are interested in.
185
186 Basically finds if any inodes on this pid is a listener we previously
187 recorded into our ``inodes`` dict. """
188 _, _, pid, _, _ = fd.split('/')
189 try:
190 target = os.readlink(fd)
191 except FileNotFoundError:
192 # file vanished, can't do anything else
193 return
194
195 if target.startswith("socket"):
196 ostype, inode = target.split(':')
197 # strip brackets from fd string (it looks like: [fd])
198 inode = int(inode[1:-1])
199 inodePidMap[inode].append(int(pid))
200
201
202def addProcessNamesToInodes():
203 """ Loop over every fd in every process in /proc.
204
205 The only way to map an fd back to a process is by looking
206 at *every* processes fd and extracting backing inodes.
207
208 It's basically like a big awkward database join where you don't
209 have an index on the field you want.
210
211 Also, due to Linux permissions (and Linux security concerns),
212 only the root user can read fd listing of processes not owned
213 by the current user. """
214
215 # glob glob glob it all
216 allFDs = glob.iglob("/proc/*/fd/*")
217 inodePidMap = collections.defaultdict(list)
218
219 for fd in allFDs:
220 appendToInodePidMap(fd, inodePidMap)
221
222 for inode in inodes:
223 if inode in inodePidMap:
224 for pid in inodePidMap[inode]:
225 try:
226 with open(f"/proc/{pid}/cmdline", 'r') as cmd:
227 # /proc command line arguments are delimited by
228 # null bytes, so undo that here...
229 cmdline = cmd.read().split('\0')
230 inodes[inode].append((pid, cmdline))
231 except BaseException:
232 # files can vanish on us at any time (and that's okay!)
233 # But, since the file is gone, we want the entire fd
234 # entry gone too:
235 pass # del inodes[inode]
236
237
238def checkListenersProc():
239 terminalWidth = run(TERMINAL_WIDTH)
240
241 procListeners()
242 addProcessNamesToInodes()
243 tried = inodes
244
245 try:
246 cols = readOutput(terminalWidth)[0]
247 cols = int(cols)
248 except BaseException:
249 cols = 80
250
251 # Print our own custom output header...
252 proto = "Proto"
253 addr = "Listening"
254 pid = "PID"
255 process = "Process"
256 print(f"{COLOR_HEADER}{proto:^5} {addr:^25} {pid:>5} {process:^30}")
257
258 # Could sort by anything: ip, port, proto, pid, command name
259 # (or even the fd integer if that provided any insight whatsoever)
260 def compareByPidOrPort(what):
261 k, v = what
262 # v = [ip, port, proto, pid, cmd]
263 # - OR -
264 # v = [ip, port, proto]
265
266 # If we're not running as root we can't pid and command mappings for
267 # the processes of other users, so sort the pids we did find at end
268 # of list and show UNKNOWN entries first
269 # (because the lines will be shorter most likely so the bigger visual
270 # weight should be lower in the display table)
271 try:
272 # Pid available! Sort by first pid, subsort by IP then port.
273 return (1, v[3], v[0], v[1])
274 except BaseException:
275 # No pid available! Sort by port number then IP then... port again.
276 return (0, v[1], v[0], v[1])
277
278 # Sort results by pid...
279 for name, vals in sorted(tried.items(), key=compareByPidOrPort):
280 attachedPids = vals[3:]
281 if attachedPids:
282 desc = [f"{pid:5} {' '.join(cmd)}" for pid, cmd in vals[3:]]
283 else:
284 # If not running as root, we won't have pid or process, so use
285 # defaults
286 desc = ["UNKNOWN (must be root for global pid mappings)"]
287
288 port = vals[1]
289 try:
290 # Convert port integer to service name if possible
291 port = socket.getservbyport(port)
292 except BaseException:
293 # If no match, just use port number directly.
294 pass
295
296 addr = f"{vals[0]}:{port}"
297 proto = vals[2]
298
299 # If IP address looks like it could be visible to the world,
300 # throw up a color.
301 # Note: due to port forwarding and NAT and other issues,
302 # this clearly isn't exhaustive.
303 if re.match(likelyLocalOnly, addr):
304 colorNotice = COLOR_OKAY
305 else:
306 colorNotice = COLOR_WARNING
307
308 isFirstLine = True
309 for line in desc:
310 if isFirstLine:
311 output = f"{colorNotice}{proto:5} {addr:25} {line}"
312 isFirstLine = False
313 else:
314 output = f"{' ':31} {line}"
315
316 # Be a polite terminal citizen by limiting our width to user's width
317 # (colors take up non-visible space, so add it to our col count)
318 print(output[:cols + (len(colorNotice) if isFirstLine else 0)])
319
320 if ONLY_LOWEST_PID:
321 break
322
323 print(COLOR_END)
324
325
326if __name__ == "__main__":
327 # cheap hack garbage way of setting one option
328 # if we need more options, obviously pull in argparse
329 if len(sys.argv) > 1:
330 ONLY_LOWEST_PID = True
331 else:
332 ONLY_LOWEST_PID = False
333
334 checkListenersProc()
Powered by cgit v1.2.3 (git 2.41.0)