| #!/usr/bin/env python3 | |
| # Copyright 2023 The ChromiumOS Authors | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Monitor conntrack connections. | |
| Note that this only tracks connections that were started after the script was | |
| run. It doesn't catch connections that were already being tracked by conntrack. | |
| Usage: | |
| Build and install a test CrOS image, and ssh into the DUT | |
| # For basic output | |
| (dut) $ /usr/lib/flimflam/test/monitor-conntrack | |
| # See print_help() for explanations: | |
| (dut) $ /usr/lib/flimflam/test/monitor-conntrack -h | |
| (dut) $ /usr/lib/flimflam/test/monitor-conntrack -p | |
| (dut) $ /usr/lib/flimflam/test/monitor-conntrack -v | |
| (dut) $ /usr/lib/flimflam/test/monitor-conntrack -v -p | |
| (dut) $ /usr/lib/flimflam/test/monitor-conntrack --awk | |
| """ | |
| import datetime | |
| import re | |
| import select | |
| import subprocess | |
| import sys | |
| import signal | |
| AWK_COMPAT = False | |
| PRINT_LINES = False | |
| VERBOSE = False | |
| # Buffer to hold the unprocessed conntrack output. | |
| CONNTRACK_LINES = [] | |
| # Dictionary mapping {id: Connection}. Only active connections that are being | |
| # tracked by conntrack should be in this dictionary. Once a connection has been | |
| # destroyed by conntrack, the corresponding entry should be removed from this | |
| # dictionary. | |
| ACTIVE_CONNECTIONS = {} | |
| # List of old connections that are no longer actively tracked by conntrack. | |
| # Should be in order of when the connection was destroyed. | |
| DESTROYED_CONNECTIONS = [] | |
| # Must be in sync with src/platform2/patchpanel/routing_service.h | |
| MARKS_TO_SOURCES = { | |
| 0x0: "UNKNOWN", | |
| 0x100: "CHROME", | |
| 0x200: "USER", | |
| 0x300: "UPDATE_ENGINE", | |
| 0x400: "SYSTEM", | |
| 0x500: "HOST_VPN", | |
| 0x2000: "ARC", | |
| 0x2100: "CROSVM", | |
| 0x2200: "PLUGINVM", | |
| 0x2300: "TETHERING", | |
| 0x2400: "ARC_VPN", | |
| } | |
| def log(message): | |
| """Prints the |message| if |VERBOSE| is enabled.""" | |
| if VERBOSE: | |
| print(message) | |
| class Connection: | |
| """Class that encapsulates a network connection. | |
| Attributes: | |
| conn_id: String of the id that conntrack assigned this connection. Note | |
| that this is only guaranteed(?) to be unique among active connections. | |
| After a connection is closed, conntrack can choose to reuse the id. | |
| src_ip: String of the source IP address from the originator of the | |
| connection. Can be IPv4 or IPv6. | |
| src_port: String of the source port that from the originator of the | |
| connection. | |
| nat_src_ip: String of the potentially NAT-ed source IP address of the | |
| originator of the connection. If the source IP is not NAT-ed, then | |
| this will be unset. Can be IPv4 or IPv6. | |
| nat_src_port: String of the potentially NAT-ed source port of the | |
| originator of the connection. If the source port is not NAT-ed, then | |
| this will be unset. | |
| dst_ip: String of the destination IP address from the originator of the | |
| connection. Can be IPv4 or IPv6. | |
| dst_port: String of the destination port from the originator of the | |
| connection. | |
| proto: String of the L4 protocol, e.g. UDP, TCP, ICMP, etc. | |
| start_timestamp: String of when the connection was first detected by | |
| conntrack (correpsonds to the NEW event). In "YYYY/MM/DD HH:MM:SS" | |
| format. | |
| end_timestamp: String of when the connection was destroyed by conntrack | |
| (corresponds to the DESTROY event). In "YYYY/MM/DD HH:MM:SS" format. | |
| fwmark: String of the integer 'fwmark' traffic tag on a connection. | |
| source: String of the source enum extracted from the |fwmark| | |
| """ | |
| def __init__( | |
| self, | |
| conn_id, | |
| src_ip, | |
| src_port, | |
| nat_src_ip, | |
| nat_src_port, | |
| dst_ip, | |
| dst_port, | |
| proto, | |
| start_timestamp, | |
| ): | |
| self.conn_id = conn_id | |
| self.src_ip = src_ip | |
| self.src_port = src_port | |
| self.nat_src_ip = nat_src_ip | |
| self.nat_src_port = nat_src_port | |
| self.dst_ip = dst_ip | |
| self.dst_port = dst_port | |
| self.proto = proto | |
| self.start_timestamp = start_timestamp | |
| self.end_timestamp = "" | |
| self.fwmark = "" | |
| self.source = "" | |
| def __repr__(self): | |
| return ( | |
| f"Connection(id: {self.conn_id}, src_ip: {self.src_ip}, " | |
| f"src_port: {self.src_port}, nat_src_ip: {self.nat_src_ip}, " | |
| f"nat_src_port: {self.nat_src_port}, dst_ip: {self.dst_ip}, " | |
| f"dst_port: {self.dst_port}, proto: {self.proto}, " | |
| f"start_timestamp: {self.start_timestamp}, " | |
| f"end_timestamp: {self.end_timestamp}, fwmark: {self.fwmark}, " | |
| f"source: {self.source})" | |
| ) | |
| def set_fwmark(self, fwmark): | |
| self.fwmark = fwmark | |
| self.source = self.parse_fwmark(fwmark) | |
| def parse_fwmark(self, fwmark): | |
| """Transforms the fwmark into a hex source value. | |
| (copied from src/platform2/patchpanel/routing_service.h) | |
| A representation of how fwmark bits are split and used for tagging and | |
| routing traffic. The 32 bits of the fwmark are currently organized as: | |
| 0 1 2 3 | |
| 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 | |
| +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | |
| | routing table id |VPN|source enum| reserved |*| | |
| +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | |
| routing table id (16bits): the routing table id of a physical device | |
| managed by shill or of a virtual private | |
| network. | |
| VPN (2bits): policy bits controlled by host application to force VPN | |
| routing or bypass VPN routing. | |
| source enum(6bits): policy bits controlled by patchpanel for grouping | |
| originated traffic by domain. | |
| reserved(7bits): no usage at the moment. | |
| legacy SNAT(1bit): legacy bit used for setting up SNAT for ARC, | |
| Crostini, and PluginVMs with iptables MASQUERADE. | |
| Args: | |
| fwmark: A string of an int fwmark value, e.g. "123456789" | |
| Returns: | |
| The 6 |source| bits from bits 18-24 in hex form. | |
| """ | |
| return MARKS_TO_SOURCES[int(fwmark) & 0x3F00] | |
| def awk(self): | |
| """Returns string output that would be easier for |awk| to parse.""" | |
| # Fields that might not be set in a connection | |
| nat_src_ip = None | |
| if self.nat_src_ip: | |
| nat_src_ip = self.nat_src_ip | |
| nat_src_port = None | |
| if self.nat_src_port: | |
| nat_src_port = self.nat_src_port | |
| end_epoch = None | |
| if self.end_timestamp: | |
| end_epoch = datetime_to_epoch(self.end_timestamp) | |
| fwmark = None | |
| if self.fwmark: | |
| fwmark = self.fwmark | |
| source = None | |
| if self.source: | |
| source = self.source | |
| return ( | |
| f"{self.conn_id} " | |
| f"{self.src_ip} " | |
| f"{self.src_port} " | |
| f"{nat_src_ip} " | |
| f"{nat_src_port} " | |
| f"{self.dst_ip} " | |
| f"{self.dst_port} " | |
| f"{self.proto} " | |
| f"{datetime_to_epoch(self.start_timestamp)} " | |
| f"{end_epoch} " | |
| f"{fwmark} " | |
| f"{source}" | |
| ) | |
| def exit_handler(signum, frame): | |
| """Handler to process all the conntrack lines""" | |
| # Unused, this is just a catch for exiting the infinite-running process | |
| del signum, frame | |
| for line in CONNTRACK_LINES: | |
| process_xml_line(line) | |
| if AWK_COMPAT: | |
| for conn in ACTIVE_CONNECTIONS.values(): | |
| print(conn.awk()) | |
| for conn in DESTROYED_CONNECTIONS: | |
| print(conn.awk()) | |
| else: | |
| print() | |
| print("Active connections:") | |
| for conn in ACTIVE_CONNECTIONS.values(): | |
| print(conn) | |
| print() | |
| print("Closed connections:") | |
| for conn in DESTROYED_CONNECTIONS: | |
| print(conn) | |
| sys.exit() | |
| def process_xml_line(line): | |
| """Processes each conntrack line. Assumed to be in |xml| form. | |
| Example |xml| line: | |
| Raw: | |
| <flow type="update"><meta direction="original"><layer3 protonum="10" | |
| protoname="ipv6"><src>2401:fa00:480:ee08:d027:80aa:5a5a:139a</src> | |
| <dst>2404:6800:4004:81f::2004</dst></layer3> ... </flow> | |
| Indented (for readability purposes): | |
| <flow type="update"> | |
| <meta direction="original"> | |
| <layer3 protonum="10" protoname="ipv6"> | |
| <src>2401:fa00:480:ee08:d027:80aa:5a5a:139a</src> | |
| <dst>2404:6800:4004:81f::2004</dst> | |
| </layer3> | |
| <layer4 protonum="6" protoname="tcp"> | |
| <sport>39128</sport> | |
| <dport>443</dport> | |
| </layer4> | |
| </meta> | |
| <meta direction="reply"> | |
| <layer3 protonum="10" protoname="ipv6"> | |
| <src>2404:6800:4004:81f::2004</src> | |
| <dst>2401:fa00:480:ee08:d027:80aa:5a5a:139a</dst> | |
| </layer3> | |
| <layer4 protonum="6" protoname="tcp"> | |
| <sport>443</sport> | |
| <dport>39128</dport> | |
| </layer4> | |
| </meta> | |
| <meta direction="independent"> | |
| <state>FIN_WAIT</state> | |
| <timeout>120</timeout> | |
| <mark>67895552</mark> | |
| <id>2009223818</id> | |
| <assured/> | |
| </meta> | |
| <when> | |
| <hour>14</hour> | |
| <min>07</min> | |
| <sec>39</sec> | |
| <wday>3</wday> | |
| <day>28</day> | |
| <month>2</month> | |
| <year>2023</year> | |
| </when> | |
| </flow> | |
| """ | |
| log("=============") | |
| log(line) | |
| event = "" | |
| event_match = re.search(r"type=\"([a-z]+)\"", line) | |
| if event_match: | |
| event = event_match.group(1) | |
| else: | |
| log("No event found") | |
| conn_id = "" | |
| id_match = re.search(r"\<id\>(\d+)\<\/id\>", line) | |
| if id_match: | |
| conn_id = id_match.group(1) | |
| log(f"id: {conn_id}") | |
| else: | |
| log("No id found") | |
| if event == "new": | |
| process_new(line, conn_id) | |
| elif event == "update": | |
| process_update(line, conn_id) | |
| elif event == "destroy": | |
| process_destroy(line, conn_id) | |
| log("=============\n") | |
| def process_new(line, conn_id): | |
| """Processes a conntrack line with the |new| event""" | |
| if conn_id in ACTIVE_CONNECTIONS: | |
| log(f"[process_new]: Already saw id {conn_id} in an active connection") | |
| return | |
| # There are two "src", "dst", "sport", and "dport"'s. | |
| # The first group will be in the original outgoing flow. | |
| # The second group will be in the reply direction, and may show any NAT | |
| # rules that are being applied. | |
| # Xml form: | |
| # <src>2401:fa00:480:ee08:d027:80aa:5a5a:139a</src> | |
| src_ip = "" | |
| src_ip_match = re.search(r"\<src\>([\d+\.\:a-zA-Z]+)\<\/src\>", line) | |
| if src_ip_match: | |
| src_ip = src_ip_match.group(1) | |
| log(f"src_ip: {src_ip}") | |
| else: | |
| log("No src_ip found") | |
| # Xml form: | |
| # <sport>39128</sport> | |
| src_port = "" | |
| src_port_match = re.search(r"\<sport\>(\d+)\<\/sport\>", line) | |
| if src_port_match: | |
| src_port = src_port_match.group(1) | |
| log(f"sport: {src_port}") | |
| else: | |
| log("No sport found") | |
| # Xml form: | |
| # <dst>2404:6800:4004:81f::2004</dst> | |
| dst_ip = "" | |
| nat_src_ip = "" | |
| dst_ip_match = re.findall(r"\<dst\>([\d+\.\:a-zA-Z]+)\<\/dst\>", line) | |
| if dst_ip_match: | |
| dst_ip = dst_ip_match[0] | |
| log(f"dst_ip: {dst_ip}") | |
| # Look at the reply-direction |dst*| to infer the NAT-ed source | |
| if len(dst_ip_match) >= 2 and dst_ip_match[1] != src_ip: | |
| nat_src_ip = dst_ip_match[1] | |
| log(f"nat_src_ip: {nat_src_ip}") | |
| else: | |
| log("No dst_ip found") | |
| # Xml form: | |
| # <dport>443</dport> | |
| dst_port = "" | |
| nat_src_port = "" | |
| dst_port_match = re.findall(r"\<dport\>(\d+)\<\/dport\>", line) | |
| if dst_port_match: | |
| dst_port = dst_port_match[0] | |
| log(f"dport: {dst_port}") | |
| # Look at the reply-direction |dst*| to infer the NAT-ed source | |
| if len(dst_port_match) >= 2 and dst_port_match[1] != src_port: | |
| nat_src_port = dst_port_match[1] | |
| log(f"nat_src_port: {nat_src_port}") | |
| else: | |
| log("No dport found") | |
| # Xml form: | |
| # <layer4 protonum="6" protoname="tcp"> | |
| proto = "" | |
| proto_match = re.search( | |
| r"\<layer4 protonum=\"\d+\" protoname=\"([a-z]+)\"\>", line | |
| ) | |
| if proto_match: | |
| proto = proto_match.group(1) | |
| log(f"proto: {proto}") | |
| else: | |
| log("No proto found") | |
| start_timestamp = format_timestamp(line) | |
| conn = Connection( | |
| conn_id, | |
| src_ip, | |
| src_port, | |
| nat_src_ip, | |
| nat_src_port, | |
| dst_ip, | |
| dst_port, | |
| proto, | |
| start_timestamp, | |
| ) | |
| ACTIVE_CONNECTIONS[conn_id] = conn | |
| log(f"[process_new]: Tracking new connection {conn}") | |
| def process_update(line, conn_id): | |
| """Processes a conntrack line with the |update| event. | |
| Example xml line: | |
| """ | |
| conn = ACTIVE_CONNECTIONS.get(conn_id) | |
| if conn is None: | |
| log(f"[process_update]: Didn't find id {conn_id} in active connections") | |
| return | |
| # Xml form: | |
| # <mark>67895552</mark> | |
| fwmark = "" | |
| fwmark_match = re.search(r"\<mark\>(\d+)\<\/mark\>", line) | |
| if fwmark_match: | |
| fwmark = fwmark_match.group(1) | |
| conn.set_fwmark(fwmark) | |
| log(f"fwmark: {fwmark}") | |
| else: | |
| log("No fwmark found") | |
| # Update the |nat*| fields if it's been updated. | |
| # | |
| # Xml form: | |
| # <dst>2404:6800:4004:81f::2004</dst> | |
| nat_src_ip = "" | |
| dst_ip_match = re.findall(r"\<dst\>([\d+\.\:a-zA-Z]+)\<\/dst\>", line) | |
| if len(dst_ip_match) >= 2: | |
| nat_src_ip = dst_ip_match[1] | |
| if nat_src_ip != conn.src_ip and nat_src_ip != conn.nat_src_ip: | |
| conn.nat_src_ip = nat_src_ip | |
| log(f"nat_src_ip updated to: {nat_src_ip}") | |
| # Xml form: | |
| # <dport>443</dport> | |
| nat_src_port = "" | |
| dst_port_match = re.findall(r"\<dport\>(\d+)\<\/dport\>", line) | |
| if len(dst_port_match) >= 2: | |
| nat_src_port = dst_port_match[1] | |
| if nat_src_port != conn.src_port and nat_src_port != conn.nat_src_port: | |
| conn.nat_src_port = nat_src_port | |
| log(f"nat_src_port updated to: {nat_src_port}") | |
| log(f"[process_update]: Updated {conn}") | |
| def process_destroy(line, conn_id): | |
| """Processes a conntrack line with the |destroy| event.""" | |
| conn = ACTIVE_CONNECTIONS.pop(conn_id, None) | |
| if conn is None: | |
| log( | |
| f"[process_destroy]: Didn't find id {conn_id} in active connections" | |
| ) | |
| return | |
| conn.end_timestamp = format_timestamp(line) | |
| DESTROYED_CONNECTIONS.append(conn) | |
| log(f"[process_destroy]: Updated {conn}") | |
| def format_timestamp(line): | |
| """Parses the xml entries for a timestamp into YYYY/MM/DD HH:MM:SS.""" | |
| # Xml form: | |
| # <hour>14</hour> | |
| # <min>07</min> | |
| # <sec>39</sec> | |
| # <wday>3</wday> | |
| # <day>28</day> | |
| # <month>2</month> | |
| # <year>2023</year> | |
| hour_match = re.search(r"\<hour\>(\d+)\<\/hour\>", line) | |
| minute_match = re.search(r"\<min\>(\d+)\<\/min\>", line) | |
| sec_match = re.search(r"\<sec\>(\d+)\<\/sec\>", line) | |
| day_match = re.search(r"\<day\>(\d+)\<\/day\>", line) | |
| month_match = re.search(r"\<month\>(\d+)\<\/month\>", line) | |
| year_match = re.search(r"\<year\>(\d+)\<\/year\>", line) | |
| timestamp = "" | |
| if hour_match: | |
| hour = hour_match.group(1) | |
| minute = minute_match.group(1) | |
| sec = sec_match.group(1) | |
| day = day_match.group(1) | |
| month = month_match.group(1) | |
| year = year_match.group(1) | |
| timestamp = f"{year}/{month}/{day} {hour}:{minute}:{sec}" | |
| log(f"timestamp: {timestamp}") | |
| else: | |
| log("No timestamp found") | |
| return timestamp | |
| def datetime_to_epoch(datetime_str): | |
| """Converts a "YYYY/MM/DD HH:MM:SS" string into an integer epoch time. | |
| For example: | |
| "2023/3/6 13:27:22" => 1678076842 | |
| """ | |
| return datetime.datetime.strptime( | |
| datetime_str, "%Y/%m/%d %H:%M:%S" | |
| ).strftime("%s") | |
| # Register the signal exit handler | |
| signal.signal(signal.SIGINT, exit_handler) | |
| def print_help(): | |
| print("Usage:") | |
| print(" monitor-conntrack --awk|[options]") | |
| print() | |
| print(" --awk: output the processed lines in an awk-compatible format") | |
| print(" options:") | |
| print(" -h: print this help information") | |
| print(" -p: print out the conntrack lines to stdout as we receive them") | |
| print(" -v: show verbose logs during processing") | |
| def main(): | |
| global AWK_COMPAT, VERBOSE, PRINT_LINES # pylint: disable=global-statement | |
| if len(sys.argv) > 1: | |
| if "--awk" in sys.argv: | |
| AWK_COMPAT = True | |
| else: | |
| if "-h" in sys.argv: | |
| print_help() | |
| return 1 | |
| if "-v" in sys.argv: | |
| VERBOSE = True | |
| if "-p" in sys.argv: | |
| PRINT_LINES = True | |
| # Show continuous output of events | |
| if not AWK_COMPAT: | |
| print("Monitoring conntrack...") | |
| print("Press Ctrl+C to stop") | |
| with subprocess.Popen( | |
| ["conntrack", "-E", "-o", "xml,timestamp,id"], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| encoding="utf-8", | |
| ) as proc: | |
| poll_obj = select.poll() | |
| poll_obj.register(proc.stdout, select.POLLIN) | |
| while True: | |
| if proc.poll(): | |
| print("conntrack died unexpectedly") | |
| break | |
| l = proc.stdout.readline() | |
| if PRINT_LINES: | |
| print(l) | |
| CONNTRACK_LINES.append(l) | |
| if not AWK_COMPAT: | |
| print("Stopping conntrack") | |
| proc.kill() | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |