Skip to content

Commit

Permalink
[TACACS+] Add Config DB schema and HostCfg Enforcer plugin to support…
Browse files Browse the repository at this point in the history
… TACACS+ per-command authorization&accounting. (#9029)

[TACACS+] Add Config DB schema and HostCfg Enforcer plugin to support TACACS+ per-command authorization&accounting. (#9029)

#### Why I did it
    Support TACACS per-command authorization&accounting.

#### How I did it
    Change ConfigDB schema and HostCfg enforcer.
    Add UT to cover changed code.

#### How to verify it
    Build following project and pass all UTs:
    make target/python-wheels/sonic_host_services-1.0-py3-none-any.whl

#### Which release branch to backport (provide reason below if selected)
    N/A

#### Description for the changelog
    Add Config DB schema and HostCfg Enforcer plugin to support TACACS+ per-command authorization&accounting.

#### A picture of a cute animal (not mandatory but encouraged)
  • Loading branch information
liuh-80 authored Nov 5, 2021
1 parent 2d7840c commit a61ffcd
Show file tree
Hide file tree
Showing 39 changed files with 2,378 additions and 18 deletions.
28 changes: 28 additions & 0 deletions src/sonic-host-services-data/templates/tacplus_nss.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@
debug=on
{% endif %}

# local_accounting - If you want to local accounting, set it
# Default: None
# local_accounting
{% if local_accounting %}
local_accounting
{% endif %}

# tacacs_accounting - If you want to tacacs+ accounting, set it
# Default: None
# tacacs_accounting
{% if tacacs_accounting %}
tacacs_accounting
{% endif %}

# local_authorization - If you want to local authorization, set it
# Default: None
# local_authorization
{% if local_authorization %}
local_authorization
{% endif %}

# tacacs_authorization - If you want to tacacs+ authorization, set it
# Default: None
# tacacs_authorization
{% if tacacs_authorization %}
tacacs_authorization
{% endif %}

# src_ip - set source address of TACACS+ protocol packets
# Default: None (auto source ip address)
# src_ip=2.2.2.2
Expand Down
2 changes: 1 addition & 1 deletion src/sonic-host-services/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[pytest]
addopts = --cov=scripts --cov-report html --cov-report term --cov-report xml --ignore=tests/hostcfgd/test_vectors.py --ignore=tests/hostcfgd/test_radius_vectors.py --ignore=tests/caclmgrd/test_dhcp_vectors.py
addopts = --cov=scripts --cov-report html --cov-report term --cov-report xml --ignore=tests/*/test*_vectors.py
65 changes: 52 additions & 13 deletions src/sonic-host-services/scripts/hostcfgd
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,15 @@ class Iptables(object):

class AaaCfg(object):
def __init__(self):
self.auth_default = {
self.authentication_default = {
'login': 'local',
}
self.authorization_default = {
'login': 'local',
}
self.accounting_default = {
'login': 'disable',
}
self.tacplus_global_default = {
'auth_type': TACPLUS_SERVER_AUTH_TYPE_DEFAULT,
'timeout': TACPLUS_SERVER_TIMEOUT_DEFAULT,
Expand All @@ -451,7 +457,9 @@ class AaaCfg(object):
self.radius_global = {}
self.radius_servers = {}

self.auth = {}
self.authentication = {}
self.authorization = {}
self.accounting = {}
self.debug = False
self.trace = False

Expand All @@ -475,11 +483,15 @@ class AaaCfg(object):

def aaa_update(self, key, data, modify_conf=True):
if key == 'authentication':
self.auth = data
self.authentication = data
if 'failthrough' in data:
self.auth['failthrough'] = is_true(data['failthrough'])
self.authentication['failthrough'] = is_true(data['failthrough'])
if 'debug' in data:
self.debug = is_true(data['debug'])
if key == 'authorization':
self.authorization = data
if key == 'accounting':
self.accounting = data
if modify_conf:
self.modify_conf_file()

Expand Down Expand Up @@ -628,8 +640,12 @@ class AaaCfg(object):
os.system(cmd)

def modify_conf_file(self):
auth = self.auth_default.copy()
auth.update(self.auth)
authentication = self.authentication_default.copy()
authentication.update(self.authentication)
authorization = self.authorization_default.copy()
authorization.update(self.authorization)
accounting = self.accounting_default.copy()
accounting.update(self.accounting)
tacplus_global = self.tacplus_global_default.copy()
tacplus_global.update(self.tacplus_global)
if 'src_ip' in tacplus_global:
Expand Down Expand Up @@ -688,10 +704,10 @@ class AaaCfg(object):
env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True)
env.filters['sub'] = sub
template = env.get_template(template_file)
if 'radius' in auth['login']:
pam_conf = template.render(debug=self.debug, trace=self.trace, auth=auth, servers=radsrvs_conf)
if 'radius' in authentication['login']:
pam_conf = template.render(debug=self.debug, trace=self.trace, auth=authentication, servers=radsrvs_conf)
else:
pam_conf = template.render(auth=auth, src_ip=src_ip, servers=servers_conf)
pam_conf = template.render(auth=authentication, src_ip=src_ip, servers=servers_conf)

# Use rename(), which is atomic (on the same fs) to avoid empty file
with open(PAM_AUTH_CONF + ".tmp", 'w') as f:
Expand All @@ -710,11 +726,11 @@ class AaaCfg(object):
self.modify_single_file(ETC_PAMD_LOGIN, [ "'/^@include/s/common-auth-sonic$/common-auth/'" ])

# Add tacplus/radius in nsswitch.conf if TACACS+/RADIUS enable
if 'tacacs+' in auth['login']:
if 'tacacs+' in authentication['login']:
if os.path.isfile(NSS_CONF):
self.modify_single_file(NSS_CONF, [ "'/^passwd/s/ radius//'" ])
self.modify_single_file(NSS_CONF, [ "'/tacplus/b'", "'/^passwd/s/compat/tacplus &/'", "'/^passwd/s/files/tacplus &/'" ])
elif 'radius' in auth['login']:
elif 'radius' in authentication['login']:
if os.path.isfile(NSS_CONF):
self.modify_single_file(NSS_CONF, [ "'/^passwd/s/tacplus //'" ])
self.modify_single_file(NSS_CONF, [ "'/radius/b'", "'/^passwd/s/compat/& radius/'", "'/^passwd/s/files/& radius/'" ])
Expand All @@ -723,10 +739,33 @@ class AaaCfg(object):
self.modify_single_file(NSS_CONF, [ "'/^passwd/s/tacplus //g'" ])
self.modify_single_file(NSS_CONF, [ "'/^passwd/s/ radius//'" ])

# Add tacplus authorization configration in nsswitch.conf
tacacs_authorization_conf = None
local_authorization_conf = None
if 'tacacs+' in authorization['login']:
tacacs_authorization_conf = "on"
if 'local' in authorization['login']:
local_authorization_conf = "on"

# Add tacplus accounting configration in nsswitch.conf
tacacs_accounting_conf = None
local_accounting_conf = None
if 'tacacs+' in accounting['login']:
tacacs_accounting_conf = "on"
if 'local' in accounting['login']:
local_accounting_conf = "on"

# Set tacacs+ server in nss-tacplus conf
template_file = os.path.abspath(NSS_TACPLUS_CONF_TEMPLATE)
template = env.get_template(template_file)
nss_tacplus_conf = template.render(debug=self.debug, src_ip=src_ip, servers=servers_conf)
nss_tacplus_conf = template.render(
debug=self.debug,
src_ip=src_ip,
servers=servers_conf,
local_accounting=local_accounting_conf,
tacacs_accounting=tacacs_accounting_conf,
local_authorization=local_authorization_conf,
tacacs_authorization=tacacs_authorization_conf)
with open(NSS_TACPLUS_CONF, 'w') as f:
f.write(nss_tacplus_conf)

Expand All @@ -752,7 +791,7 @@ class AaaCfg(object):
f.write(pam_radius_auth_conf)

# Start the statistics service. Only RADIUS implemented
if ('radius' in auth['login']) and ('statistics' in radius_global) and\
if ('radius' in authentication['login']) and ('statistics' in radius_global) and \
radius_global['statistics']:
cmd = 'service aaastatsd start'
else:
Expand Down
118 changes: 118 additions & 0 deletions src/sonic-host-services/tests/hostcfgd/hostcfgd_tacacs_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import importlib.machinery
import importlib.util
import filecmp
import shutil
import os
import sys
import subprocess
from swsscommon import swsscommon

from parameterized import parameterized
from unittest import TestCase, mock
from tests.hostcfgd.test_tacacs_vectors import HOSTCFGD_TEST_TACACS_VECTOR
from tests.common.mock_configdb import MockConfigDb, MockSubscriberStateTable
from tests.common.mock_configdb import MockSelect, MockDBConnector

test_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
modules_path = os.path.dirname(test_path)
scripts_path = os.path.join(modules_path, "scripts")
src_path = os.path.dirname(modules_path)
templates_path = os.path.join(src_path, "sonic-host-services-data/templates")
output_path = os.path.join(test_path, "hostcfgd/output")
sample_output_path = os.path.join(test_path, "hostcfgd/sample_output")
sys.path.insert(0, modules_path)

# Load the file under test
hostcfgd_path = os.path.join(scripts_path, 'hostcfgd')
loader = importlib.machinery.SourceFileLoader('hostcfgd', hostcfgd_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
hostcfgd = importlib.util.module_from_spec(spec)
loader.exec_module(hostcfgd)
sys.modules['hostcfgd'] = hostcfgd

# Mock swsscommon classes
hostcfgd.ConfigDBConnector = MockConfigDb
hostcfgd.SubscriberStateTable = MockSubscriberStateTable
hostcfgd.Select = MockSelect
hostcfgd.DBConnector = MockDBConnector

class TestHostcfgdTACACS(TestCase):
"""
Test hostcfd daemon - TACACS
"""
def run_diff(self, file1, file2):
return subprocess.check_output('diff -uR {} {} || true'.format(file1, file2), shell=True)

"""
Check different config
"""
def check_config(self, test_name, test_data, config_name):
t_path = templates_path
op_path = output_path + "/" + test_name + "_" + config_name
sop_path = sample_output_path + "/" + test_name + "_" + config_name

hostcfgd.PAM_AUTH_CONF_TEMPLATE = t_path + "/common-auth-sonic.j2"
hostcfgd.NSS_TACPLUS_CONF_TEMPLATE = t_path + "/tacplus_nss.conf.j2"
hostcfgd.NSS_RADIUS_CONF_TEMPLATE = t_path + "/radius_nss.conf.j2"
hostcfgd.PAM_RADIUS_AUTH_CONF_TEMPLATE = t_path + "/pam_radius_auth.conf.j2"
hostcfgd.PAM_AUTH_CONF = op_path + "/common-auth-sonic"
hostcfgd.NSS_TACPLUS_CONF = op_path + "/tacplus_nss.conf"
hostcfgd.NSS_RADIUS_CONF = op_path + "/radius_nss.conf"
hostcfgd.NSS_CONF = op_path + "/nsswitch.conf"
hostcfgd.ETC_PAMD_SSHD = op_path + "/sshd"
hostcfgd.ETC_PAMD_LOGIN = op_path + "/login"
hostcfgd.RADIUS_PAM_AUTH_CONF_DIR = op_path + "/"

shutil.rmtree( op_path, ignore_errors=True)
os.mkdir( op_path)

shutil.copyfile( sop_path + "/sshd.old", op_path + "/sshd")
shutil.copyfile( sop_path + "/login.old", op_path + "/login")

MockConfigDb.set_config_db(test_data[config_name])
host_config_daemon = hostcfgd.HostConfigDaemon()

aaa = host_config_daemon.config_db.get_table('AAA')

try:
tacacs_global = host_config_daemon.config_db.get_table('TACPLUS')
except:
tacacs_global = []
try:
tacacs_server = \
host_config_daemon.config_db.get_table('TACPLUS_SERVER')
except:
tacacs_server = []

host_config_daemon.aaacfg.load(aaa,tacacs_global,tacacs_server,[],[])
dcmp = filecmp.dircmp(sop_path, op_path)
diff_output = ""
for name in dcmp.diff_files:
diff_output += \
"Diff: file: {} expected: {} output: {}\n".format(\
name, dcmp.left, dcmp.right)
diff_output += self.run_diff( dcmp.left + "/" + name,\
dcmp.right + "/" + name)
self.assertTrue(len(diff_output) == 0, diff_output)


@parameterized.expand(HOSTCFGD_TEST_TACACS_VECTOR)
def test_hostcfgd_tacacs(self, test_name, test_data):
"""
Test TACACS hostcfd daemon initialization
Args:
test_name(str): test name
test_data(dict): test data which contains initial Config Db tables, and expected results
Returns:
None
"""
# test local config
self.check_config(test_name, test_data, "config_db_local")
# test remote config
self.check_config(test_name, test_data, "config_db_tacacs")
# test local + tacacs config
self.check_config(test_name, test_data, "config_db_local_and_tacacs")
# test disable accounting
self.check_config(test_name, test_data, "config_db_disable_accounting")
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@
# debug=on
debug=on

# local_accounting - If you want to local accounting, set it
# Default: None
# local_accounting

# tacacs_accounting - If you want to tacacs+ accounting, set it
# Default: None
# tacacs_accounting

# local_authorization - If you want to local authorization, set it
# Default: None
# local_authorization
local_authorization

# tacacs_authorization - If you want to tacacs+ authorization, set it
# Default: None
# tacacs_authorization

# src_ip - set source address of TACACS+ protocol packets
# Default: None (auto source ip address)
# src_ip=2.2.2.2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@
# debug=on
debug=on

# local_accounting - If you want to local accounting, set it
# Default: None
# local_accounting

# tacacs_accounting - If you want to tacacs+ accounting, set it
# Default: None
# tacacs_accounting

# local_authorization - If you want to local authorization, set it
# Default: None
# local_authorization
local_authorization

# tacacs_authorization - If you want to tacacs+ authorization, set it
# Default: None
# tacacs_authorization

# src_ip - set source address of TACACS+ protocol packets
# Default: None (auto source ip address)
# src_ip=2.2.2.2
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#THIS IS AN AUTO-GENERATED FILE
#
# /etc/pam.d/common-auth- authentication settings common to all services
# This file is included from other service-specific PAM config files,
# and should contain a list of the authentication modules that define
# the central authentication scheme for use on the system
# (e.g., /etc/shadow, LDAP, Kerberos, etc.). The default is to use the
# traditional Unix authentication mechanisms.
#
# here are the per-package modules (the "Primary" block)

auth [success=1 default=ignore] pam_unix.so nullok try_first_pass

#
# here's the fallback if no module succeeds
auth requisite pam_deny.so
# prime the stack with a positive return value if there isn't one already;
# this avoids us returning an error just because nothing sets a success code
# since the modules above will each just jump around
auth required pam_permit.so
# and here are more per-package modules (the "Additional" block)
Loading

0 comments on commit a61ffcd

Please sign in to comment.