Skip to content

Commit

Permalink
Modify Arista service ACL solution to listen to ACL changes in Config…
Browse files Browse the repository at this point in the history
…DB (#1385)
  • Loading branch information
jleveque authored Feb 12, 2018
1 parent ed408cd commit 6ccd160
Show file tree
Hide file tree
Showing 8 changed files with 447 additions and 323 deletions.
318 changes: 195 additions & 123 deletions dockers/docker-snmp-sv2/snmpd-config-updater
Original file line number Diff line number Diff line change
@@ -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

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"]

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)
from swsssdk import ConfigDBConnector

VERSION = "1.0"

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()
1 change: 1 addition & 0 deletions dockers/docker-snmp-sv2/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 11 additions & 11 deletions dockers/docker-snmp-sv2/supervisord.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,17 +19,26 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion files/build_templates/sonic_debian_extension.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Loading

0 comments on commit 6ccd160

Please sign in to comment.