Modify Arista service ACL solution to listen to ACL changes in ConfigDB (#1385)

This commit is contained in:
Joe LeVeque 2018-02-12 11:10:01 -08:00 committed by GitHub
parent ed408cde54
commit 6ccd1601b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 436 additions and 312 deletions

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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/

View File

@ -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<type>" r"[^\]]*?)\s*\] \[(?P<pid>" r"[^\]]*?)\s*\] " \
r"\[(?P<tty4>" r"[^\]]*?)\s*\] \[(?P<user>" r"[^\]]*?)\s*\] " \
r"\[(?P<tty>" r"[^\]]*?)\s*\] \[(?P<host>" r"[^\]]*?)\s*\] " \
r"\[(?P<ipAddr>" r"[^\]]*?)\s*\] \[(?P<time>" r"[^\]]*?)\s*\]" )
entries = []
for line in lines:
m = regExp.match(line)
if not m:
# Skip header and any other lines we don't recognize
continue
entry = m.groupdict()
entries.append(entry)
return entries
"""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<type>" r"[^\]]*?)\s*\] \[(?P<pid>" r"[^\]]*?)\s*\] " \
r"\[(?P<tty4>" r"[^\]]*?)\s*\] \[(?P<user>" r"[^\]]*?)\s*\] " \
r"\[(?P<tty>" r"[^\]]*?)\s*\] \[(?P<host>" r"[^\]]*?)\s*\] " \
r"\[(?P<ipAddr>" r"[^\]]*?)\s*\] \[(?P<time>" r"[^\]]*?)\s*\]" )
entries = []
for line in lines:
m = regExp.match(line)
if not m:
# Skip header and any other lines we don't recognize
continue
entry = m.groupdict()
entries.append(entry)
return entries
# Find the source ip addresses of all ssh sessions, verify they are still allowed, and if not kill the ssh session
if __name__ == '__main__':
for e in get_utmp_data():
if e["host"] and e["ipAddr"] != "0.0.0.0": # entry is for a live connection
# check allowness
r = os.system('tcpdmatch sshd %s | grep "access.*granted" > /dev/null' % e["ipAddr"])
# print some debugs
print "From:", e["ipAddr"], "ssh pid:", e["pid"], "allowed" if r == 0 else "denied"
# if needed go for the kill
if r != 0:
os.system("kill -1 %s" % e["pid"])
for e in get_utmp_data():
if e["host"] and e["ipAddr"] != "0.0.0.0": # entry is for a live connection
# check allowness
r = os.system('tcpdmatch sshd %s | grep "access.*granted" > /dev/null' % e["ipAddr"])
# print some debugs
print "From:", e["ipAddr"], "ssh pid:", e["pid"], "allowed" if r == 0 else "denied"
# if needed go for the kill
if r != 0:
os.system("kill -1 %s" % e["pid"])

View File

@ -1,133 +1,185 @@
#!/usr/bin/env python
# Daemon that listens to updates from redis about the source IP prefixes from which
# Daemon that listens to updates from ConfigDB about the source IP prefixes from which
# SSH connections are allowed. In case of change, it will update the SSHD configuration
# file accordingly. SSHD will notice the file has changed next time a connection comes in.
# Future enhancement: if an entry it modified/removed, go through all existing ssh
# connections and recompute their permission, and in case one is now denied, kill it.
# Currently this code uses redis, but may have to be rewritten to use the abstracted
# apis from ./sonic-py-swsssdk/src/swsssdk/configdb.py
#
# 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 subprocess
import sys
import syslog
import time
import redis
from swsssdk import ConfigDBConnector
service="sshd"
config_file_path="/etc"
redis_key="SSH_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()
SYSLOG_IDENTIFIER = "sshd-config-updater"
# 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 lose 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:
# ============================== Classes ==============================
# 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
# bash# cat /etc/sshd.allow
# sshd: [fd7a:629f:52a4:b0c3:ec4:7aff:fe99:201e]/128
# sshd: 172.17.0.1/32
# sshd: 172.18.1.0/24
# Note that any matches are 'permits', and the default action is 'denied'
# We assume the database contains valid ip addresses/hostnames.
def write_configuration_file(v):
filename="%s/%s.allow" % (config_file_path, service)
if len(v) == 0:
if os.path.exists(filename): os.remove(filename)
return
filename_tmp = filename + ".tmp"
f=open(filename_tmp, "w")
for value in v:
f.write("%s: %s\n" % (service, value))
f.close()
os.rename(filename_tmp, filename)
# some previously accepted sessions might no longer be allowed: clear them
os.system("/usr/bin/sshd-clear-denied-sessions")
class ConfigUpdater(object):
SERVICE = "sshd"
CONFIG_FILE_PATH = "/etc"
# 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'}
ACL_TABLE = "ACL_TABLE"
ACL_RULE = "ACL_RULE"
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
ACL_TABLE_TYPE_CTRLPLANE = "CTRLPLANE"
except redis.exceptions.ConnectionError as e:
sys.exit(-2)
ACL_SERVICE_SSH = "SSH"
# redis list operations, cli cheat sheet
# -create/set
# LPUSH key value [value ...] : Prepend one or multiple values to a list
# RPUSH key value [value ...] : Append one or multiple values to a list
# LPUSHX key value : Prepend a value to a list, only if the list exists
# RPUSHX key value : Append a value to a list, only if the list exists
# LINSERT key BEFORE|AFTER pivot value : Insert an element before or after another element in a list
# LSET key index value : Set the value of an element in a list by its index
# -get
# LINDEX key index : Get an element from a list by its index
# LRANGE key start stop : Get a range of elements from a list
# LLEN key : Get the length of a list
# -remove
# LREM key count value : Remove elements from a list
# LPOP key : Remove and get the first element in a list
# RPOP key : Remove and get the last element in a list
# BLPOP key [key ...] timeout : Remove and get the first element in a list, or block until one is available
# BRPOP key [key ...] timeout : Remove and get the last element in a list, or block until one is available
# BRPOPLPUSH source destination timeout : Remove a value from a list, push it to another list and return it; or block until one is available
# RPOPLPUSH source destination : Remove the last element in a list, prepend it to another list and return it
# LTRIM key start stop : Trim a list to the specified range
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_SSH:
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
# bash# cat /etc/sshd.allow
# sshd: [fd7a:629f:52a4:b0c3:ec4:7aff:fe99:201e]/128
# sshd: 172.17.0.1/32
# sshd: 172.18.1.0/24
# Note that any matches are 'permits', and the default action is 'denied'
# We assume the database contains valid ip addresses/hostnames.
#
# 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.allow" % (self.CONFIG_FILE_PATH, self.SERVICE)
if len(src_ip_allow_list) == 0:
if os.path.exists(filename):
os.remove(filename)
return
filename_tmp = filename + ".tmp"
f = open(filename_tmp, "w")
for value in src_ip_allow_list:
f.write("%s: %s\n" % (self.SERVICE, value))
f.close()
os.rename(filename_tmp, filename)
# some previously accepted sessions might no longer be allowed: clear them
os.system("/usr/bin/sshd-clear-denied-sessions")
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()

View File

@ -1,6 +1,6 @@
[Unit]
Description=Takes care of updates to ssh config file with respect to the SSH allow list
After=network.target auditd.service
Description=Takes care of updates to SSH config file with respect to the SSH allow list
After=database.service
ConditionPathExists=!/etc/ssh/sshd_not_to_be_run
[Service]
@ -11,4 +11,3 @@ Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@ -12,5 +12,6 @@ SONIC_INSTALL_DOCKER_IMAGES += $(DOCKER_SNMP_SV2)
$(DOCKER_SNMP_SV2)_CONTAINER_NAME = snmp
$(DOCKER_SNMP_SV2)_RUN_OPT += --net=host --privileged -t
$(DOCKER_SNMP_SV2)_RUN_OPT += -v /etc/sonic:/etc/sonic:ro
$(DOCKER_SNMP_SV2)_RUN_OPT += -v /host/machine.conf:/host/machine.conf
# mount Arista platform python libraries to support corresponding platforms SNMP power status query
$(DOCKER_SNMP_SV2)_RUN_OPT += -v /usr/lib/python3/dist-packages/arista:/usr/lib/python3/dist-packages/arista:ro