Since the introduction of VRF, interface-related tables in ConfigDB will have multiple entries, one of which only contains the interface name and no IP prefix. Thus, when iterating over the keys in the tables, we need to ignore the entries which do not contain IP prefixes.
463 lines
21 KiB
Python
Executable File
463 lines
21 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# caclmgrd
|
|
#
|
|
# Control plane ACL manager daemon for SONiC
|
|
#
|
|
# Upon starting, this daemon reads control plane ACL tables and rules from
|
|
# Config DB, converts the rules into iptables rules and installs the iptables
|
|
# rules. The daemon then indefintely listens for notifications from Config DB
|
|
# and updates iptables rules if control plane ACL configuration has changed.
|
|
#
|
|
|
|
try:
|
|
import ipaddress
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import syslog
|
|
from swsssdk import ConfigDBConnector
|
|
except ImportError as err:
|
|
raise ImportError("%s - required module not found" % str(err))
|
|
|
|
VERSION = "1.0"
|
|
|
|
SYSLOG_IDENTIFIER = "caclmgrd"
|
|
|
|
|
|
# ========================== Syslog wrappers ==========================
|
|
|
|
def log_info(msg):
|
|
syslog.openlog(SYSLOG_IDENTIFIER)
|
|
syslog.syslog(syslog.LOG_INFO, msg)
|
|
syslog.closelog()
|
|
|
|
|
|
def log_warning(msg):
|
|
syslog.openlog(SYSLOG_IDENTIFIER)
|
|
syslog.syslog(syslog.LOG_WARNING, msg)
|
|
syslog.closelog()
|
|
|
|
|
|
def log_error(msg):
|
|
syslog.openlog(SYSLOG_IDENTIFIER)
|
|
syslog.syslog(syslog.LOG_ERR, msg)
|
|
syslog.closelog()
|
|
|
|
# ========================== Helper Functions =========================
|
|
|
|
def _ip_prefix_in_key(key):
|
|
"""
|
|
Function to check if IP prefix is present in a Redis database key.
|
|
If it is present, then the key will be a tuple. Otherwise, the
|
|
key will be a string.
|
|
"""
|
|
return (isinstance(key, tuple))
|
|
|
|
# ============================== Classes ==============================
|
|
|
|
class ControlPlaneAclManager(object):
|
|
"""
|
|
Class which reads control plane ACL tables and rules from Config DB,
|
|
translates them into equivalent iptables commands and runs those
|
|
commands in order to apply the control plane ACLs.
|
|
|
|
Attributes:
|
|
config_db: Handle to Config Redis database via SwSS SDK
|
|
"""
|
|
ACL_TABLE = "ACL_TABLE"
|
|
ACL_RULE = "ACL_RULE"
|
|
|
|
ACL_TABLE_TYPE_CTRLPLANE = "CTRLPLANE"
|
|
|
|
# To specify a port range instead of a single port, use iptables format:
|
|
# separate start and end ports with a colon, e.g., "1000:2000"
|
|
ACL_SERVICES = {
|
|
"NTP": {
|
|
"ip_protocols": ["udp"],
|
|
"dst_ports": ["123"]
|
|
},
|
|
"SNMP": {
|
|
"ip_protocols": ["tcp", "udp"],
|
|
"dst_ports": ["161"]
|
|
},
|
|
"SSH": {
|
|
"ip_protocols": ["tcp"],
|
|
"dst_ports": ["22"]
|
|
}
|
|
}
|
|
|
|
def __init__(self):
|
|
# Open a handle to the Config database
|
|
self.config_db = ConfigDBConnector()
|
|
self.config_db.connect()
|
|
|
|
def run_commands(self, commands):
|
|
"""
|
|
Given a list of shell commands, run them in order
|
|
|
|
Args:
|
|
commands: List of strings, each string is a shell command
|
|
"""
|
|
for cmd in commands:
|
|
proc = subprocess.Popen(cmd, shell=True)
|
|
|
|
(stdout, stderr) = proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
log_error("Error running command '{}'".format(cmd))
|
|
|
|
def parse_int_to_tcp_flags(self, hex_value):
|
|
tcp_flags_str = ""
|
|
if hex_value & 0x01:
|
|
tcp_flags_str += "FIN,"
|
|
if hex_value & 0x02:
|
|
tcp_flags_str += "SYN,"
|
|
if hex_value & 0x04:
|
|
tcp_flags_str += "RST,"
|
|
if hex_value & 0x08:
|
|
tcp_flags_str += "PSH,"
|
|
if hex_value & 0x10:
|
|
tcp_flags_str += "ACK,"
|
|
if hex_value & 0x20:
|
|
tcp_flags_str += "URG,"
|
|
# iptables doesn't handle the flags below now. It has some special keys for it:
|
|
# --ecn-tcp-cwr This matches if the TCP ECN CWR (Congestion Window Received) bit is set.
|
|
# --ecn-tcp-ece This matches if the TCP ECN ECE (ECN Echo) bit is set.
|
|
# if hex_value & 0x40:
|
|
# tcp_flags_str += "ECE,"
|
|
# if hex_value & 0x80:
|
|
# tcp_flags_str += "CWR,"
|
|
|
|
# Delete the trailing comma
|
|
tcp_flags_str = tcp_flags_str[:-1]
|
|
return tcp_flags_str
|
|
|
|
def generate_block_ip2me_traffic_iptables_commands(self):
|
|
LOOPBACK_INTERFACE_TABLE_NAME = "LOOPBACK_INTERFACE"
|
|
MGMT_INTERFACE_TABLE_NAME = "MGMT_INTERFACE"
|
|
VLAN_INTERFACE_TABLE_NAME = "VLAN_INTERFACE"
|
|
PORTCHANNEL_INTERFACE_TABLE_NAME = "PORTCHANNEL_INTERFACE"
|
|
INTERFACE_TABLE_NAME = "INTERFACE"
|
|
|
|
block_ip2me_cmds = []
|
|
|
|
# Add iptables rules to drop all packets destined for loopback interface IP addresses
|
|
loopback_iface_table = self.config_db.get_table(LOOPBACK_INTERFACE_TABLE_NAME)
|
|
if loopback_iface_table:
|
|
for key, _ in loopback_iface_table.iteritems():
|
|
if not _ip_prefix_in_key(key):
|
|
continue
|
|
iface_name, iface_cidr = key
|
|
ip_ntwrk = ipaddress.ip_network(iface_cidr, strict=False)
|
|
if isinstance(ip_ntwrk, ipaddress.IPv4Network):
|
|
block_ip2me_cmds.append("iptables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
elif isinstance(ip_ntwrk, ipaddress.IPv6Network):
|
|
block_ip2me_cmds.append("ip6tables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
else:
|
|
log_warning("Unrecognized IP address type on interface '{}': {}".format(iface_name, ip_ntwrk))
|
|
|
|
# Add iptables rules to drop all packets destined for management interface IP addresses
|
|
mgmt_iface_table = self.config_db.get_table(MGMT_INTERFACE_TABLE_NAME)
|
|
if mgmt_iface_table:
|
|
for key, _ in mgmt_iface_table.iteritems():
|
|
if not _ip_prefix_in_key(key):
|
|
continue
|
|
iface_name, iface_cidr = key
|
|
ip_ntwrk = ipaddress.ip_network(iface_cidr, strict=False)
|
|
if isinstance(ip_ntwrk, ipaddress.IPv4Network):
|
|
block_ip2me_cmds.append("iptables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
elif isinstance(ip_ntwrk, ipaddress.IPv6Network):
|
|
block_ip2me_cmds.append("ip6tables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
else:
|
|
log_warning("Unrecognized IP address type on interface '{}': {}".format(iface_name, ip_ntwrk))
|
|
|
|
# Add iptables rules to drop all packets destined for our VLAN interface gateway IP addresses
|
|
vlan_iface_table = self.config_db.get_table(VLAN_INTERFACE_TABLE_NAME)
|
|
if vlan_iface_table:
|
|
for key, _ in vlan_iface_table.iteritems():
|
|
if not _ip_prefix_in_key(key):
|
|
continue
|
|
iface_name, iface_cidr = key
|
|
ip_ntwrk = ipaddress.ip_network(iface_cidr, strict=False)
|
|
if isinstance(ip_ntwrk, ipaddress.IPv4Network):
|
|
block_ip2me_cmds.append("iptables -A INPUT -d {}/{} -j DROP".format(list(ip_ntwrk.hosts())[0], ip_ntwrk.max_prefixlen))
|
|
elif isinstance(ip_ntwrk, ipaddress.IPv6Network):
|
|
block_ip2me_cmds.append("ip6tables -A INPUT -d {}/{} -j DROP".format(list(ip_ntwrk.hosts())[0], ip_ntwrk.max_prefixlen))
|
|
else:
|
|
log_warning("Unrecognized IP address type on interface '{}': {}".format(iface_name, ip_ntwrk))
|
|
|
|
# Add iptables rules to drop all packets destined for point-to-point interface IP addresses
|
|
# (All portchannel interfaces and configured front-panel interfaces)
|
|
portchannel_iface_table = self.config_db.get_table(PORTCHANNEL_INTERFACE_TABLE_NAME)
|
|
if portchannel_iface_table:
|
|
for key, _ in portchannel_iface_table.iteritems():
|
|
if not _ip_prefix_in_key(key):
|
|
continue
|
|
iface_name, iface_cidr = key
|
|
ip_ntwrk = ipaddress.ip_network(iface_cidr, strict=False)
|
|
if isinstance(ip_ntwrk, ipaddress.IPv4Network):
|
|
block_ip2me_cmds.append("iptables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
elif isinstance(ip_ntwrk, ipaddress.IPv6Network):
|
|
block_ip2me_cmds.append("ip6tables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
else:
|
|
log_warning("Unrecognized IP address type on interface '{}': {}".format(iface_name, ip_ntwrk))
|
|
|
|
iface_table = self.config_db.get_table(INTERFACE_TABLE_NAME)
|
|
if iface_table:
|
|
for key, _ in iface_table.iteritems():
|
|
if not _ip_prefix_in_key(key):
|
|
continue
|
|
iface_name, iface_cidr = key
|
|
ip_ntwrk = ipaddress.ip_network(iface_cidr, strict=False)
|
|
if isinstance(ip_ntwrk, ipaddress.IPv4Network):
|
|
block_ip2me_cmds.append("iptables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
elif isinstance(ip_ntwrk, ipaddress.IPv6Network):
|
|
block_ip2me_cmds.append("ip6tables -A INPUT -d {}/{} -j DROP".format(ip_ntwrk.network_address, ip_ntwrk.max_prefixlen))
|
|
else:
|
|
log_warning("Unrecognized IP address type on interface '{}': {}".format(iface_name, ip_ntwrk))
|
|
|
|
return block_ip2me_cmds
|
|
|
|
|
|
def get_acl_rules_and_translate_to_iptables_commands(self):
|
|
"""
|
|
Retrieves current ACL tables and rules from Config DB, translates
|
|
control plane ACLs into a list of iptables commands that can be run
|
|
in order to install ACL rules.
|
|
|
|
Returns:
|
|
A list of strings, each string is an iptables shell command
|
|
|
|
"""
|
|
iptables_cmds = []
|
|
|
|
# First, add iptables commands to set default policies to accept all
|
|
# traffic. In case we are connected remotely, the connection will not
|
|
# drop when we flush the current rules
|
|
iptables_cmds.append("iptables -P INPUT ACCEPT")
|
|
iptables_cmds.append("iptables -P FORWARD ACCEPT")
|
|
iptables_cmds.append("iptables -P OUTPUT ACCEPT")
|
|
|
|
# Add iptables command to flush the current rules
|
|
iptables_cmds.append("iptables -F")
|
|
|
|
# Add iptables command to delete all non-default chains
|
|
iptables_cmds.append("iptables -X")
|
|
|
|
# Add same set of commands for ip6tables
|
|
iptables_cmds.append("ip6tables -P INPUT ACCEPT")
|
|
iptables_cmds.append("ip6tables -P FORWARD ACCEPT")
|
|
iptables_cmds.append("ip6tables -P OUTPUT ACCEPT")
|
|
iptables_cmds.append("ip6tables -F")
|
|
iptables_cmds.append("ip6tables -X")
|
|
|
|
# Add iptables/ip6tables commands to allow all traffic from localhost
|
|
iptables_cmds.append("iptables -A INPUT -s 127.0.0.1 -i lo -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -s ::1 -i lo -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow all incoming packets from established
|
|
# TCP sessions or new TCP sessions which are related to established TCP sessions
|
|
iptables_cmds.append("iptables -A INPUT -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow bidirectional ICMPv4 ping and traceroute
|
|
# TODO: Support processing ICMPv4 service ACL rules, and remove this blanket acceptance
|
|
iptables_cmds.append("iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT")
|
|
iptables_cmds.append("iptables -A INPUT -p icmp --icmp-type echo-reply -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow bidirectional ICMPv6 ping and traceroute
|
|
# TODO: Support processing ICMPv6 service ACL rules, and remove this blanket acceptance
|
|
iptables_cmds.append("ip6tables -A INPUT -p icmpv6 --icmpv6-type echo-request -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p icmpv6 --icmpv6-type echo-reply -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow all incoming Neighbor Discovery Protocol (NDP) NS/NA/RS/RA messages
|
|
# TODO: Support processing NDP service ACL rules, and remove this blanket acceptance
|
|
iptables_cmds.append("ip6tables -A INPUT -p icmpv6 --icmpv6-type neighbor-solicitation -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p icmpv6 --icmpv6-type neighbor-advertisement -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p icmpv6 --icmpv6-type router-solicitation -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p icmpv6 --icmpv6-type router-advertisement -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow all incoming IPv4 DHCP packets
|
|
iptables_cmds.append("iptables -A INPUT -p udp --dport 67:68 --sport 67:68 -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p udp --dport 67:68 --sport 67:68 -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow all incoming IPv6 DHCP packets
|
|
iptables_cmds.append("iptables -A INPUT -p udp --dport 546:547 --sport 546:547 -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p udp --dport 546:547 --sport 546:547 -j ACCEPT")
|
|
|
|
# Add iptables/ip6tables commands to allow all incoming BGP traffic
|
|
# TODO: Determine BGP ACLs based on configured device sessions, and remove this blanket acceptance
|
|
iptables_cmds.append("iptables -A INPUT -p tcp --dport 179 -j ACCEPT")
|
|
iptables_cmds.append("iptables -A INPUT -p tcp --sport 179 -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p tcp --dport 179 -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p tcp --sport 179 -j ACCEPT")
|
|
|
|
|
|
# Get current ACL tables and rules from Config DB
|
|
self._tables_db_info = self.config_db.get_table(self.ACL_TABLE)
|
|
self._rules_db_info = self.config_db.get_table(self.ACL_RULE)
|
|
|
|
num_ctrl_plane_acl_rules = 0
|
|
|
|
# Walk the ACL tables
|
|
for (table_name, table_data) in self._tables_db_info.iteritems():
|
|
|
|
table_ip_version = None
|
|
|
|
# Ignore non-control-plane ACL tables
|
|
if table_data["type"] != self.ACL_TABLE_TYPE_CTRLPLANE:
|
|
continue
|
|
|
|
acl_services = table_data["services"]
|
|
|
|
for acl_service in acl_services:
|
|
if acl_service not in self.ACL_SERVICES:
|
|
log_warning("Ignoring control plane ACL '{}' with unrecognized service '{}'"
|
|
.format(table_name, acl_service))
|
|
continue
|
|
|
|
log_info("Translating ACL rules for control plane ACL '{}' (service: '{}')"
|
|
.format(table_name, acl_service))
|
|
|
|
# Obtain default IP protocol(s) and destination port(s) for this service
|
|
ip_protocols = self.ACL_SERVICES[acl_service]["ip_protocols"]
|
|
dst_ports = self.ACL_SERVICES[acl_service]["dst_ports"]
|
|
|
|
acl_rules = {}
|
|
|
|
for ((rule_table_name, rule_id), rule_props) in self._rules_db_info.iteritems():
|
|
if rule_table_name == table_name:
|
|
if not rule_props:
|
|
log_warning("rule_props for rule_id {} empty or null!".format(rule_id))
|
|
continue
|
|
|
|
try:
|
|
acl_rules[rule_props["PRIORITY"]] = rule_props
|
|
except KeyError:
|
|
log_error("rule_props for rule_id {} does not have key 'PRIORITY'!".format(rule_id))
|
|
continue
|
|
|
|
# If we haven't determined the IP version for this ACL table yet,
|
|
# try to do it now. We attempt to determine heuristically based on
|
|
# whether the src or dst IP of this rule is an IPv4 or IPv6 address.
|
|
if not table_ip_version:
|
|
if (("SRC_IPV6" in rule_props and rule_props["SRC_IPV6"]) or
|
|
("DST_IPV6" in rule_props and rule_props["DST_IPV6"])):
|
|
table_ip_version = 6
|
|
elif (("SRC_IP" in rule_props and rule_props["SRC_IP"]) or
|
|
("DST_IP" in rule_props and rule_props["DST_IP"])):
|
|
table_ip_version = 4
|
|
|
|
# If we were unable to determine whether this ACL table contains
|
|
# IPv4 or IPv6 rules, log a message and skip processing this table.
|
|
if not table_ip_version:
|
|
log_warning("Unable to determine if ACL table '{}' contains IPv4 or IPv6 rules. Skipping table..."
|
|
.format(table_name))
|
|
continue
|
|
|
|
# For each ACL rule in this table (in descending order of priority)
|
|
for priority in sorted(acl_rules.iterkeys(), reverse=True):
|
|
rule_props = acl_rules[priority]
|
|
|
|
if "PACKET_ACTION" not in rule_props:
|
|
log_error("ACL rule does not contain PACKET_ACTION property")
|
|
continue
|
|
|
|
# Apply the rule to the default protocol(s) for this ACL service
|
|
for ip_protocol in ip_protocols:
|
|
for dst_port in dst_ports:
|
|
rule_cmd = "ip6tables" if table_ip_version == 6 else "iptables"
|
|
rule_cmd += " -A INPUT -p {}".format(ip_protocol)
|
|
|
|
if "SRC_IPV6" in rule_props and rule_props["SRC_IPV6"]:
|
|
rule_cmd += " -s {}".format(rule_props["SRC_IPV6"])
|
|
elif "SRC_IP" in rule_props and rule_props["SRC_IP"]:
|
|
rule_cmd += " -s {}".format(rule_props["SRC_IP"])
|
|
|
|
rule_cmd += " --dport {}".format(dst_port)
|
|
|
|
# If there are TCP flags present and ip protocol is TCP, append them
|
|
if ip_protocol == "tcp" and "TCP_FLAGS" in rule_props and rule_props["TCP_FLAGS"]:
|
|
tcp_flags, tcp_flags_mask = rule_props["TCP_FLAGS"].split("/")
|
|
|
|
tcp_flags = int(tcp_flags, 16)
|
|
tcp_flags_mask = int(tcp_flags_mask, 16)
|
|
|
|
if tcp_flags_mask > 0:
|
|
rule_cmd += " --tcp-flags {mask} {flags}".format(mask = self.parse_int_to_tcp_flags(tcp_flags_mask), flags = self.parse_int_to_tcp_flags(tcp_flags))
|
|
|
|
# Append the packet action as the jump target
|
|
rule_cmd += " -j {}".format(rule_props["PACKET_ACTION"])
|
|
|
|
iptables_cmds.append(rule_cmd)
|
|
num_ctrl_plane_acl_rules += 1
|
|
|
|
# Add iptables commands to block ip2me traffic
|
|
iptables_cmds += self.generate_block_ip2me_traffic_iptables_commands()
|
|
|
|
# Add iptables/ip6tables commands to allow all incoming packets with TTL of 0 or 1
|
|
# This allows the device to respond to tools like tcptraceroute
|
|
iptables_cmds.append("iptables -A INPUT -m ttl --ttl-lt 2 -j ACCEPT")
|
|
iptables_cmds.append("ip6tables -A INPUT -p tcp -m hl --hl-lt 2 -j ACCEPT")
|
|
|
|
# Finally, if the device has control plane ACLs configured,
|
|
# add iptables/ip6tables commands to drop all other incoming packets
|
|
if num_ctrl_plane_acl_rules > 0:
|
|
iptables_cmds.append("iptables -A INPUT -j DROP")
|
|
iptables_cmds.append("iptables -A FORWARD -j DROP")
|
|
iptables_cmds.append("ip6tables -A INPUT -j DROP")
|
|
iptables_cmds.append("ip6tables -A FORWARD -j DROP")
|
|
|
|
return iptables_cmds
|
|
|
|
def update_control_plane_acls(self):
|
|
"""
|
|
Convenience wrapper which retrieves current ACL tables and rules from
|
|
Config DB, translates control plane ACLs into a list of iptables
|
|
commands and runs them.
|
|
"""
|
|
iptables_cmds = self.get_acl_rules_and_translate_to_iptables_commands()
|
|
|
|
log_info("Issuing the following iptables commands:")
|
|
for cmd in iptables_cmds:
|
|
log_info(" " + cmd)
|
|
|
|
self.run_commands(iptables_cmds)
|
|
|
|
def notification_handler(self, key, data):
|
|
log_info("ACL configuration changed. Updating iptables rules for control plane ACLs...")
|
|
self.update_control_plane_acls()
|
|
|
|
def run(self):
|
|
# Unconditionally update control plane ACLs once at start
|
|
self.update_control_plane_acls()
|
|
|
|
# Subscribe to notifications when ACL tables or rules change
|
|
self.config_db.subscribe(self.ACL_TABLE,
|
|
lambda table, key, data: self.notification_handler(key, data))
|
|
self.config_db.subscribe(self.ACL_RULE,
|
|
lambda table, key, data: self.notification_handler(key, data))
|
|
|
|
# Indefinitely listen for Config DB notifications
|
|
self.config_db.listen()
|
|
|
|
|
|
# ============================= Functions =============================
|
|
|
|
def main():
|
|
log_info("Starting up...")
|
|
|
|
if not os.geteuid() == 0:
|
|
log_error("Must be root to run this daemon")
|
|
print "Error: Must be root to run this daemon"
|
|
sys.exit(1)
|
|
|
|
# Instantiate a ControlPlaneAclManager object
|
|
caclmgr = ControlPlaneAclManager()
|
|
caclmgr.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|