Modify Arista service ACL solution to listen to ACL changes in ConfigDB (#1385)
This commit is contained in:
parent
ed408cde54
commit
6ccd1601b8
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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/
|
||||
|
@ -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"])
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user