diff --git a/dockers/docker-snmp-sv2/snmpd-config-updater b/dockers/docker-snmp-sv2/snmpd-config-updater index 9f3e858bdb..15d7dc9227 100755 --- a/dockers/docker-snmp-sv2/snmpd-config-updater +++ b/dockers/docker-snmp-sv2/snmpd-config-updater @@ -1,132 +1,204 @@ #!/usr/bin/env python -# Daemon that listens to updates about the source IP prefixes from which snmp access -# is allowed. In case of change, it will update the snmp configuration file accordingly. -# Also, after a change, it will notify snmpd to re-read its config file (service reload). +# Daemon that listens to updates from ConfigDB about the source IP prefixes from which +# SNMP connections are allowed. In case of change, it will update the SNMP configuration +# file accordingly. After a change, it will notify snmpd to re-read its config file +# via SIGHUP. +# +# This daemon is meant to be run on Arista platforms only. Service ACLs on all other +# platforms will be managed by caclmgrd. +# import os import re +import subprocess import sys +import syslog import time -import redis +from swsssdk import ConfigDBConnector -service="snmpd" -config_file_path="/etc/snmp" -redis_key="SNMP_ALLOW_LIST" # the redis list we listen to -subscription='__keyspace@0__:%s' % redis_key -temporization_duration = 3 # how long we wait for changes to settle (ride out a bursts of changes in redis_key) -fake_infinite = 9999 # How often we wake up when nothing is going on --get_message()'s timeout has no 'infinite' value -# after these operations we may need to revisit existing ssh connections because they removed or modified existing entries -delete_operations = ["lrem", "lpop", "rpop", "blpop", "brpop", "brpoplpush", "rpoplpush", "ltrim", "del", "lset"] +VERSION = "1.0" -r = redis.StrictRedis(host='localhost') -p = r.pubsub() - -# If redis is not up yet, this can fail, so wait for redis to be available -while True: - try: - p.subscribe(subscription) - break - except redis.exceptions.ConnectionError: - time.sleep(3) - -# We could loose contact with redis at a later stage, in which case we will exit with -# return code -2 and supervisor will restart us, at which point we are back in the -# while loop above waiting for redis to be ready. -try: - - # By default redis does enable events, so enable them - r.config_set("notify-keyspace-events", "KAE") - - - # To update the configuration file - # - # Example config file for reference: - # root@sonic:/# cat /etc/snmp/snmpd.conf - # <...some snmp config, like udp port to use etc...> - # rocommunity public 172.20.61.0/24 - # rocommunity public 172.20.60.0/24 - # rocommunity public 127.00.00.0/8 - # <...some more snmp config...> - # root@sonic:/# - # - # snmpd.conf supports include file, like so: - # includeFile /etc/snmp/community.conf - # includeDir /etc/snmp/config.d - # which could make file massaging simpler, but even then we still deal with lines - # that have shared "masters", since some other entity controls the community strings - # part of that line. - # If other database attributes need to be written to the snmp config file, then - # it should be done by this daemon as well (sure, we could inotify on the file - # and correct it back, but that's glitchy). - - def write_configuration_file(v): - filename="%s/%s.conf" % (config_file_path, service) - filename_tmp = filename + ".tmp" - f=open(filename, "r") - snmpd_config = f.read() - f.close() - f=open(filename_tmp, "w") - this_community = "not_a_community" - for l in snmpd_config.split('\n'): - m = re.match("^(..)community (\S+)", l) - if not m: - f.write(l) - f.write("\n") - else: - if not l.startswith(this_community): # already handled community (each community is duplicated per allow entry) - this_community="%scommunity %s" % (m.group(1), m.group(2)) - if len(v): - for value in v: - f.write("%s %s\n" % (this_community, value)) - else: - f.write("%s\n" % this_community) - f.close() - os.rename(filename_tmp, filename) - os.system("kill -HUP $(pgrep snmpd) > /dev/null 2> /dev/null || :") - - # write initial configuration - write_configuration_file(r.lrange(redis_key, 0, -1)) - - # listen for changes and rewrite configuration file if needed, after some temporization - # - # How those subscribed to messages look like, for reference: - # {'pattern': None, 'type': 'subscribe', 'channel': '__keyspace@0__:SNMP_PERMIT_LIST', 'data': 1L} - # {'pattern': None, 'type': 'message', 'channel': '__keyspace@0__:SNMP_PERMIT_LIST', 'data': 'rpush'} - # {'pattern': None, 'type': 'message', 'channel': '__keyspace@0__:SNMP_PERMIT_LIST', 'data': 'lpush'} - # {'pattern': None, 'type': 'message', 'channel': '__keyspace@0__:SNMP_PERMIT_LIST', 'data': 'lrem'} - # {'pattern': None, 'type': 'message', 'channel': '__keyspace@0__:SNMP_PERMIT_LIST', 'data': 'lset'} - # {'pattern': None, 'type': 'message', 'channel': '__keyspace@0__:SNMP_PERMIT_LIST', 'data': 'del'} - - select_timeout = fake_infinite - config_changed = False - while True: - try: - m = p.get_message(timeout=select_timeout) - except Exception: - sys.exit(-2) - # temporization: no change after 'timeout' seconds -> commit any accumulated changes - if not m and config_changed: - write_configuration_file(r.lrange(redis_key, 0, -1)) - config_changed = False - select_timeout = fake_infinite - if m and m['type'] == "message": - if m['channel'] != subscription: - print "WTF: unexpected case" - continue - config_changed = True - select_timeout = temporization_duration - # some debugs for now - print "-------------------- config change: ", - if m["data"] in delete_operations: - print "DELETE" - else: - print "" - v = r.lrange(redis_key, 0, -1) - for value in v: - print value - -except redis.exceptions.ConnectionError as e: - sys.exit(-2) +SYSLOG_IDENTIFIER = "snmpd-config-updater" +# ============================== Classes ============================== + +class ConfigUpdater(object): + SERVICE = "snmpd" + CONFIG_FILE_PATH = "/etc/snmp" + + ACL_TABLE = "ACL_TABLE" + ACL_RULE = "ACL_RULE" + + ACL_TABLE_TYPE_CTRLPLANE = "CTRLPLANE" + + ACL_SERVICE_SNMP = "SNMP" + + def get_src_ip_allow_list(self): + src_ip_allow_list = [] + + # Get current ACL tables and rules from Config DB + tables_db_info = self.config_db.get_table(self.ACL_TABLE) + rules_db_info = self.config_db.get_table(self.ACL_RULE) + + # Walk the ACL tables + for (table_name, table_data) in tables_db_info.iteritems(): + # Ignore non-control-plane ACL tables + if table_data["type"] != self.ACL_TABLE_TYPE_CTRLPLANE: + continue + + # Ignore non-SSH service ACLs + if table_data["service"] != self.ACL_SERVICE_SNMP: + continue + + acl_rules = {} + + for ((rule_table_name, rule_id), rule_props) in rules_db_info.iteritems(): + if rule_table_name == table_name: + acl_rules[rule_props["PRIORITY"]] = rule_props + + # 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 + + # We're only interested in ACCEPT rules + if rule_props["PACKET_ACTION"] != "ACCEPT": + continue + + if "SRC_IP" in rule_props and rule_props["SRC_IP"]: + src_ip_allow_list.append(rule_props["SRC_IP"]) + + return src_ip_allow_list + + # To update the configuration file + # + # Example config file for reference: + # root@sonic:/# cat /etc/snmp/snmpd.conf + # <...some snmp config, like udp port to use etc...> + # rocommunity public 172.20.61.0/24 + # rocommunity public 172.20.60.0/24 + # rocommunity public 127.00.00.0/8 + # <...some more snmp config...> + # root@sonic:/# + # + # snmpd.conf supports include file, like so: + # includeFile /etc/snmp/community.conf + # includeDir /etc/snmp/config.d + # which could make file massaging simpler, but even then we still deal with lines + # that have shared "masters", since some other entity controls the community strings + # part of that line. + # If other database attributes need to be written to the snmp config file, then + # it should be done by this daemon as well (sure, we could inotify on the file + # and correct it back, but that's glitchy). + # + # src_ip_allow_list may contain individual IP addresses or blocks of + # IP addresses using CIDR notation. + def write_configuration_file(self, src_ip_allow_list): + filename = "%s/%s.conf" % (self.CONFIG_FILE_PATH, self.SERVICE) + filename_tmp = filename + ".tmp" + + f = open(filename, "r") + snmpd_config = f.read() + f.close() + + f = open(filename_tmp, "w") + this_community = "not_a_community" + for line in snmpd_config.split('\n'): + m = re.match("^(..)community (\S+)", line) + if not m: + f.write(line) + f.write("\n") + else: + if not line.startswith(this_community): # already handled community (each community is duplicated per allow entry) + this_community = "%scommunity %s" % (m.group(1), m.group(2)) + if len(src_ip_allow_list): + for value in src_ip_allow_list: + f.write("%s %s\n" % (this_community, value)) + else: + f.write("%s\n" % this_community) + f.close() + + os.rename(filename_tmp, filename) + + # Force snmpd to reload its configuration + os.system("kill -HUP $(pgrep snmpd) > /dev/null 2> /dev/null || :") + + def notification_handler(self, key, data): + log_info("ACL configuration changed. Updating {} config accordingly...".format(self.SERVICE)) + self.write_configuration_file(self.get_src_ip_allow_list()) + + def run(self): + # Open a handle to the Config database + self.config_db = ConfigDBConnector() + self.config_db.connect() + + # Write initial configuration + self.write_configuration_file(self.get_src_ip_allow_list()) + + # 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() + + +# ========================== 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() + + +# Determine whether we are running on an Arista platform +def is_platform_arista(): + proc = subprocess.Popen(["sonic-cfggen", "-v", "platform"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + (stdout, stderr) = proc.communicate() + + if proc.returncode != 0: + log_error("Failed to retrieve platform string") + return false + + return "arista" in stdout + + +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) + + if not is_platform_arista(): + log_info("Platform is not an Arista platform. Exiting...") + sys.exit(0) + + # Instantiate a ConfigUpdater object + config_updater = ConfigUpdater() + config_updater.run() + +if __name__ == "__main__": + main() diff --git a/dockers/docker-snmp-sv2/start.sh b/dockers/docker-snmp-sv2/start.sh index 201239b1e7..1b83624068 100755 --- a/dockers/docker-snmp-sv2/start.sh +++ b/dockers/docker-snmp-sv2/start.sh @@ -13,5 +13,6 @@ echo "# Config files managed by sonic-config-engine" > /var/sonic/config_status rm -f /var/run/rsyslogd.pid supervisorctl start rsyslogd +supervisorctl start snmpd-config-updater supervisorctl start snmpd supervisorctl start snmp-subagent diff --git a/dockers/docker-snmp-sv2/supervisord.conf b/dockers/docker-snmp-sv2/supervisord.conf index b760d5c045..b3db3be95a 100644 --- a/dockers/docker-snmp-sv2/supervisord.conf +++ b/dockers/docker-snmp-sv2/supervisord.conf @@ -11,15 +11,6 @@ autorestart=false stdout_logfile=syslog stderr_logfile=syslog -[program:snmpd-config-updater] -command=/usr/bin/snmpd-config-updater -priority=1 -autostart=true -autorestart=unexpected -startsecs=0 -stdout_logfile=syslog -stderr_logfile=syslog - [program:rsyslogd] command=/usr/sbin/rsyslogd -n priority=2 @@ -28,9 +19,18 @@ autorestart=false stdout_logfile=syslog stderr_logfile=syslog +[program:snmpd-config-updater] +command=/usr/bin/snmpd-config-updater +priority=3 +autostart=false +autorestart=unexpected +startsecs=0 +stdout_logfile=syslog +stderr_logfile=syslog + [program:snmpd] command=/usr/sbin/snmpd -f -LS4d -u Debian-snmp -g Debian-snmp -I -smux,mteTrigger,mteTriggerConf,ifTable,ifXTable,inetCidrRouteTable,ipCidrRouteTable,ip,disk_hw -p /run/snmpd.pid -priority=3 +priority=4 autostart=false autorestart=false stdout_logfile=syslog @@ -38,7 +38,7 @@ stderr_logfile=syslog [program:snmp-subagent] command=/usr/bin/env python3.6 -m sonic_ax_impl -priority=4 +priority=5 autostart=false autorestart=false stdout_logfile=syslog diff --git a/files/build_templates/sonic_debian_extension.j2 b/files/build_templates/sonic_debian_extension.j2 index 218386baa1..972a66e3ae 100644 --- a/files/build_templates/sonic_debian_extension.j2 +++ b/files/build_templates/sonic_debian_extension.j2 @@ -224,7 +224,7 @@ if [ "$image_type" = "aboot" ]; then sudo sed -i 's/udevadm settle/udevadm settle -E \/sys\/class\/net\/eth0/' $FILESYSTEM_ROOT/etc/init.d/networking fi -# Service to update the sshd config file based on database changes +# Service to update the sshd config file based on database changes for Arista devices sudo cp $IMAGE_CONFIGS/ssh/sshd-config-updater.service $FILESYSTEM_ROOT/etc/systemd/system sudo mkdir -p $FILESYSTEM_ROOT/etc/systemd/system/multi-user.target.wants cd $FILESYSTEM_ROOT/etc/systemd/system/multi-user.target.wants/ diff --git a/files/image_config/ssh/sshd-clear-denied-sessions b/files/image_config/ssh/sshd-clear-denied-sessions index 76226e7fc6..d0ca655023 100755 --- a/files/image_config/ssh/sshd-clear-denied-sessions +++ b/files/image_config/ssh/sshd-clear-denied-sessions @@ -12,71 +12,70 @@ import subprocess # Run utmpdump, capture and return its output def run_utmpdump(_utmpFilename): - devnull = file("/dev/null", "w" ) - p = subprocess.Popen(args=["utmpdump", _utmpFilename], stdout=subprocess.PIPE, stderr=devnull) - (stdout, stderr) = p.communicate() - rc = p.returncode - assert rc is not None # because p.communicate() should wait. - out = (stdout or '') + (stderr or '') - if rc: - e = SystemCommandError("%r: error code %d" % (" ".join(argv), rc)) - e.error = rc - e.output = out - raise e - return stdout + devnull = file("/dev/null", "w" ) + p = subprocess.Popen(args=["utmpdump", _utmpFilename], stdout=subprocess.PIPE, stderr=devnull) + (stdout, stderr) = p.communicate() + rc = p.returncode + assert rc is not None # because p.communicate() should wait. + out = (stdout or '') + (stderr or '') + if rc: + e = SystemCommandError("%r: error code %d" % (" ".join(argv), rc)) + e.error = rc + e.output = out + raise e + return stdout # Run utmpdump and parse its output into a list of dicts and return that def get_utmp_data(utmpFileName=None): - """Reads the specified utmp file. - Returns a list of dictionaries, one for each utmp entry. - All dictionary keys and values are strings - Values are right padded with spaces and may contain all - spaces if that utmp field is empty. - Dictionary keys: - "type": See UTMP_TYPE_* above - "pid": Process ID as a string - "tty": TTY (line) name - device name of tty w/o "/dev/" - "tty4": 4 char abbreivated TTY (line) name - "user": User ID - "host": Hostname for remote login, - kernel release for Run Level and Boot Time - "ipAddr": IP Address - "time": Time and date entry was made - See linux docs on utmp and utmpdemp for more info. - Example output from utmpdump: - pid tty4 user tty host ipAddr time - [7] [22953] [/238] [myname ] [pts/238 ] [example.com] [253.122.98.159 ] [Mon Dec 18 21:08:09 2017 PST] - """ - if not utmpFileName: - utmpFileName = os.environ.get( "DEFAULT_UTMP_FILE", "/var/run/utmp" ) - if not os.path.exists(utmpFileName): - return [] - output = run_utmpdump(utmpFileName) - lines = re.split("\n", output) - regExp = re.compile( - r"\[(?P" r"[^\]]*?)\s*\] \[(?P" r"[^\]]*?)\s*\] " \ - r"\[(?P" r"[^\]]*?)\s*\] \[(?P" r"[^\]]*?)\s*\] " \ - r"\[(?P" r"[^\]]*?)\s*\] \[(?P" r"[^\]]*?)\s*\] " \ - r"\[(?P" r"[^\]]*?)\s*\] \[(?P