diff --git a/config/aaa.py b/config/aaa.py index 3c76187126..fdb784dc4a 100644 --- a/config/aaa.py +++ b/config/aaa.py @@ -114,9 +114,9 @@ def trace(option): @click.command() -@click.argument('auth_protocol', nargs=-1, type=click.Choice(["radius", "tacacs+", "local", "default"])) +@click.argument('auth_protocol', nargs=-1, type=click.Choice(["ldap", "radius", "tacacs+", "local", "default"])) def login(auth_protocol): - """Switch login authentication [ {radius, tacacs+, local} | default ]""" + """Switch login authentication [ {ldap, radius, tacacs+, local} | default ]""" if len(auth_protocol) is 0: click.echo('Argument "auth_protocol" is required') return @@ -135,9 +135,9 @@ def login(auth_protocol): val2 = auth_protocol[1] good_ap = False if val == 'local': - if val2 == 'radius' or val2 == 'tacacs+': + if val2 == 'radius' or val2 == 'tacacs+' or val2 == 'ldap': good_ap = True - elif val == 'radius' or val == 'tacacs+': + elif val == 'radius' or val == 'tacacs+' or val == 'ldap': if val2 == 'local': good_ap = True if good_ap == True: diff --git a/config/plugins/sonic-system-ldap_yang.py b/config/plugins/sonic-system-ldap_yang.py new file mode 100644 index 0000000000..cc211cdb90 --- /dev/null +++ b/config/plugins/sonic-system-ldap_yang.py @@ -0,0 +1,393 @@ +""" +Autogenerated config CLI plugin. + + +""" + +import copy +import click +import utilities_common.cli as clicommon +import utilities_common.general as general +from config import config_mgmt + +# Load sonic-cfggen from source since /usr/local/bin/sonic-cfggen does not have .py extension. +sonic_cfggen = general.load_module_from_source('sonic_cfggen', '/usr/local/bin/sonic-cfggen') + + +def exit_with_error(*args, **kwargs): + """ Print a message with click.secho and abort CLI. + + Args: + args: Positional arguments to pass to click.secho + kwargs: Keyword arguments to pass to click.secho + """ + + click.secho(*args, **kwargs) + raise click.Abort() + + +def validate_config_or_raise(cfg): + """ Validate config db data using ConfigMgmt. + + Args: + cfg (Dict): Config DB data to validate. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + try: + cfg = sonic_cfggen.FormatConverter.to_serialized(copy.deepcopy(cfg)) + config_mgmt.ConfigMgmt().loadData(cfg) + except Exception as err: + raise Exception('Failed to validate configuration: {}'.format(err)) + + +def add_entry_validated(db, table, key, data): + """ Add new entry in table and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add new entry to. + key (Union[str, Tuple]): Key name in the table. + data (Dict): Entry data. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + if key in cfg[table]: + raise Exception(f"{key} already exists") + + cfg[table][key] = data + + validate_config_or_raise(cfg) + db.set_entry(table, key, data) + + +def update_entry_validated(db, table, key, data, create_if_not_exists=False): + """ Update entry in table and validate configuration. + If attribute value in data is None, the attribute is deleted. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add new entry to. + key (Union[str, Tuple]): Key name in the table. + data (Dict): Entry data. + create_if_not_exists (bool): + In case entry does not exists already a new entry + is not created if this flag is set to False and + creates a new entry if flag is set to True. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + + if not data: + raise Exception(f"No field/values to update {key}") + + if create_if_not_exists: + cfg[table].setdefault(key, {}) + + if key not in cfg[table]: + raise Exception(f"{key} does not exist") + + entry_changed = False + for attr, value in data.items(): + if value == cfg[table][key].get(attr): + continue + entry_changed = True + if value is None: + cfg[table][key].pop(attr, None) + else: + cfg[table][key][attr] = value + + if not entry_changed: + return + + validate_config_or_raise(cfg) + db.set_entry(table, key, cfg[table][key]) + + +def del_entry_validated(db, table, key): + """ Delete entry in table and validate configuration. + + Args: + db (swsscommon.ConfigDBConnector): Config DB connector obect. + table (str): Table name to add new entry to. + key (Union[str, Tuple]): Key name in the table. + Raises: + Exception: when cfg does not satisfy YANG schema. + """ + + cfg = db.get_config() + cfg.setdefault(table, {}) + if key not in cfg[table]: + raise Exception(f"{key} does not exist") + + cfg[table].pop(key) + + validate_config_or_raise(cfg) + db.set_entry(table, key, None) + + +@click.group(name="ldap-server", + cls=clicommon.AliasedGroup) +def LDAP_SERVER(): + """ """ + + pass + + +@LDAP_SERVER.command(name="add") +@click.argument( + "hostname", + nargs=1, + required=True, +) +@click.option( + "--priority", + help="Server priority", +) +@clicommon.pass_db +def LDAP_SERVER_add(db, hostname, priority): + """ Add object in LDAP_SERVER. """ + + table = "LDAP_SERVER" + key = hostname + data = {} + if priority is not None: + data["priority"] = priority + + try: + add_entry_validated(db.cfgdb, table, key, data) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_SERVER.command(name="update") +@click.argument( + "hostname", + nargs=1, + required=True, +) +@click.option( + "--priority", + help="Server priority", +) +@clicommon.pass_db +def LDAP_SERVER_update(db, hostname, priority): + """ Add object in LDAP_SERVER. """ + + table = "LDAP_SERVER" + key = hostname + data = {} + if priority is not None: + data["priority"] = priority + + try: + update_entry_validated(db.cfgdb, table, key, data) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_SERVER.command(name="delete") +@click.argument( + "hostname", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_SERVER_delete(db, hostname): + """ Delete object in LDAP_SERVER. """ + + table = "LDAP_SERVER" + key = hostname + try: + del_entry_validated(db.cfgdb, table, key) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@click.group(name="ldap", + cls=clicommon.AliasedGroup) +def LDAP(): + """ """ + + pass + + +@LDAP.group(name="global", cls=clicommon.AliasedGroup) +@clicommon.pass_db +def LDAP_global(db): + """ """ + + pass + + +@LDAP_global.command(name="bind-dn") +@click.argument( + "bind-dn", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_bind_dn(db, bind_dn): + """ LDAP global bind dn """ + + table = "LDAP" + key = "global" + data = { + "bind_dn": bind_dn, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_global.command(name="bind-password") +@click.argument( + "bind-password", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_bind_password(db, bind_password): + """ Shared secret used for encrypting the communication """ + + table = "LDAP" + key = "global" + data = { + "bind_password": bind_password, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_global.command(name="bind-timeout") +@click.argument( + "bind-timeout", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_bind_timeout(db, bind_timeout): + """ Ldap bind timeout """ + + table = "LDAP" + key = "global" + data = { + "bind_timeout": bind_timeout, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_global.command(name="version") +@click.argument( + "version", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_version(db, version): + """ Ldap version """ + + table = "LDAP" + key = "global" + data = { + "version": version, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_global.command(name="base-dn") +@click.argument( + "base-dn", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_base_dn(db, base_dn): + """ Ldap user base dn """ + + table = "LDAP" + key = "global" + data = { + "base_dn": base_dn, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_global.command(name="port") +@click.argument( + "port", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_port(db, port): + """ TCP port to communicate with LDAP server """ + + table = "LDAP" + key = "global" + data = { + "port": port, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +@LDAP_global.command(name="timeout") +@click.argument( + "timeout", + nargs=1, + required=True, +) +@clicommon.pass_db +def LDAP_global_timeout(db, timeout): + """ Ldap timeout duration in sec """ + + table = "LDAP" + key = "global" + data = { + "timeout": timeout, + } + try: + update_entry_validated(db.cfgdb, table, key, data, create_if_not_exists=True) + except Exception as err: + exit_with_error(f"Error: {err}", fg="red") + + +def register(cli): + """ Register new CLI nodes in root CLI. + + Args: + cli: Root CLI node. + Raises: + Exception: when root CLI already has a command + we are trying to register. + """ + cli_node = LDAP_SERVER + if cli_node.name in cli.commands: + raise Exception(f"{cli_node.name} already exists in CLI") + cli.add_command(LDAP_SERVER) + cli_node = LDAP + if cli_node.name in cli.commands: + raise Exception(f"{cli_node.name} already exists in CLI") + cli.add_command(LDAP) diff --git a/doc/Command-Reference.md b/doc/Command-Reference.md index 457436354e..dee689b9b8 100644 --- a/doc/Command-Reference.md +++ b/doc/Command-Reference.md @@ -96,6 +96,11 @@ * [Linux Kernel Dump](#linux-kernel-dump) * [Linux Kernel Dump show commands](#Linux-Kernel-Dump-show-commands) * [Linux Kernel Dump config commands](#Linux-Kernel-Dump-config-commands) +* [LDAP](#LDAP) + * [show LDAP global commands](#LDAP-global-show-commands) + * [LDAP global config commands](#LDAP-global-config-commands) + * [show LDAP server commands](#LDAP-server-show-commands) + * [LDAP server config commands](#LDAP-server-config-commands) * [LLDP](#lldp) * [LLDP show commands](#lldp-show-commands) * [Loading, Reloading And Saving Configuration](#loading-reloading-and-saving-configuration) @@ -6298,6 +6303,86 @@ This command displays the kubernetes server status. ``` Go Back To [Beginning of the document](#) or [Beginning of this section](#Kubernetes) +## LDAP + +### show LDAP global commands + +This command displays the global LDAP configuration that includes the following parameters: base_dn, bind_password, bind_timeout, version, port, timeout. + +- Usage: + ``` + show ldap global + ``` +- Example: + + ``` + admin@sonic:~$ show ldap global + base-dn Ldap user base dn + bind-dn LDAP global bind dn + bind-password Shared secret used for encrypting the communication + bind-timeout Ldap bind timeout <0-120> + port TCP port to communicate with LDAP server <1-65535> + timeout Ldap timeout duration in sec <1-60> + version Ldap version <1-3> + + ``` + +### LDAP global config commands + +These commands are used to configure the LDAP global parameters + + - Usage: + ``` + config ldap global + ``` +- Example: + ``` + admin@sonic:~$ config ldap global + + host
--prio <1 - 8> + base-dn Ldap user base dn + bind-dn LDAP global bind dn + bind-password Shared secret used for encrypting the communication + bind-timeout Ldap bind timeout <0-120> + port TCP port to communicate with LDAP server <1-65535> + timeout Ldap timeout duration in sec <1-60> + version Ldap version <1-3> + ``` + +### show LDAP server commands + +This command displays the global LDAP configuration that includes the following parameters: base_dn, bind_password, bind_timeout, version, port, timeout. + +- Usage: + ``` + show ldap-server + ``` +- Example: + + ``` + admin@sonic:~$ show ldap-server + hostname Ldap hostname or IP of the configured LDAP server + priority priority for the relevant LDAP server <1-8> + ``` + +### LDAP server config commands + +These commands are used to manage the LDAP servers in the system, they are created in correspondance to the global config parameters mentioned earlier. + + - Usage: + ``` + config ldap-server + ``` +- Example: + ``` + admin@sonic:~$ config ldap-server + + add Add a new LDAP server --priority <1-8> + delete Delete an existing LDAP server from the list --priority <1-8> + update Update and existing LDAP server + +Go Back To [Beginning of the document](#) or [Beginning of this section](#LDAP) + ## Linux Kernel Dump This section demonstrates the show commands and configuration commands of Linux kernel dump mechanism in SONiC. diff --git a/show/plugins/sonic-system-ldap_yang.py b/show/plugins/sonic-system-ldap_yang.py new file mode 100644 index 0000000000..a91c8609db --- /dev/null +++ b/show/plugins/sonic-system-ldap_yang.py @@ -0,0 +1,145 @@ +""" +Auto-generated show CLI plugin. + + +""" + +import click +import tabulate +import natsort +import utilities_common.cli as clicommon + + +def format_attr_value(entry, attr): + """ Helper that formats attribute to be presented in the table output. + + Args: + entry (Dict[str, str]): CONFIG DB entry configuration. + attr (Dict): Attribute metadata. + + Returns: + str: fomatted attribute value. + """ + + if attr["is-leaf-list"]: + return "\n".join(entry.get(attr["name"], [])) + return entry.get(attr["name"], "N/A") + + +@click.group(name="ldap-server", + cls=clicommon.AliasedGroup, + invoke_without_command=True) +@clicommon.pass_db +def LDAP_SERVER(db): + """ [Callable command group] """ + + header = ["HOSTNAME", "PRIORITY"] + + body = [] + + table = db.cfgdb.get_table("LDAP_SERVER") + for key in natsort.natsorted(table): + entry = table[key] + if not isinstance(key, tuple): + key = (key,) + + row = [*key] + [ + format_attr_value( + entry, + {'name': 'priority', 'description': 'Server priority', + 'is-leaf-list': False, 'is-mandatory': False, 'group': ''}), + ] + + body.append(row) + + click.echo(tabulate.tabulate(body, header)) + + +@click.group(name="ldap", + cls=clicommon.AliasedGroup) +def LDAP(): + """ """ + + pass + + +@LDAP.command(name="global") +@clicommon.pass_db +def LDAP_global(db): + """ """ + + header = [ + "BIND DN", + "BIND PASSWORD", + "BIND TIMEOUT", + "VERSION", + "BASE DN", + "PORT", + "TIMEOUT", + ] + + body = [] + + table = db.cfgdb.get_table("LDAP") + entry = table.get("global", {}) + row = [ + format_attr_value( + entry, + {'name': 'bind_dn', 'description': 'LDAP global bind dn', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + { + 'name': 'bind_password', 'description': 'Shared secret used for encrypting the communication', + 'is-leaf-list': False, 'is-mandatory': False, 'group': '' + } + ), + format_attr_value( + entry, + {'name': 'bind_timeout', 'description': 'Ldap bind timeout', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'version', 'description': 'Ldap version', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'base_dn', 'description': 'Ldap user base dn', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'port', 'description': 'TCP port to communicate with LDAP server', + 'is-leaf-list': False, 'is-mandatory': False, 'group': ''} + ), + format_attr_value( + entry, + {'name': 'timeout', 'description': 'Ldap timeout duration in sec', 'is-leaf-list': False, + 'is-mandatory': False, 'group': ''} + ), + ] + + body.append(row) + click.echo(tabulate.tabulate(body, header)) + + +def register(cli): + """ Register new CLI nodes in root CLI. + + Args: + cli (click.core.Command): Root CLI node. + Raises: + Exception: when root CLI already has a command + we are trying to register. + """ + cli_node = LDAP_SERVER + if cli_node.name in cli.commands: + raise Exception(f"{cli_node.name} already exists in CLI") + cli.add_command(LDAP_SERVER) + cli_node = LDAP + if cli_node.name in cli.commands: + raise Exception(f"{cli_node.name} already exists in CLI") + cli.add_command(LDAP) diff --git a/tests/ldap_input/assert_show_output.py b/tests/ldap_input/assert_show_output.py new file mode 100644 index 0000000000..c3ecaf472f --- /dev/null +++ b/tests/ldap_input/assert_show_output.py @@ -0,0 +1,20 @@ +""" +Module holding the correct values for show CLI command outputs for the ldap_test.py +""" + +show_ldap_global = """\ +BIND DN BIND PASSWORD BIND TIMEOUT VERSION BASE DN PORT TIMEOUT +---------------------------- --------------- -------------- --------- ----------------- ------ --------- +cn=ldapadm,dc=test1,dc=test2 password 3 3 dc=test1,dc=test2 389 2 +""" + +show_ldap_server = """\ +HOSTNAME PRIORITY +---------- ---------- +10.0.0.1 1 +""" + +show_ldap_server_deleted = """\ +HOSTNAME PRIORITY +---------- ---------- +""" diff --git a/tests/ldap_input/default_config_db.json b/tests/ldap_input/default_config_db.json new file mode 100644 index 0000000000..95aed20118 --- /dev/null +++ b/tests/ldap_input/default_config_db.json @@ -0,0 +1,11 @@ +{ + "LDAP|GLOBAL": { + "bind_dn": "cn=ldapadm,dc=test1,dc=test2", + "base_dn": "dc=test1,dc=test2", + "bind_password": "password", + "timeout": "2", + "bind_timeout": "3", + "version" : 3, + "port" : 389 + } +} diff --git a/tests/ldap_input/server_config_db.json b/tests/ldap_input/server_config_db.json new file mode 100644 index 0000000000..2fdea84748 --- /dev/null +++ b/tests/ldap_input/server_config_db.json @@ -0,0 +1,5 @@ +{ + "LDAP_SERVER|10.0.0.1": { + "priority": 1 + } +} diff --git a/tests/ldap_test.py b/tests/ldap_test.py new file mode 100644 index 0000000000..3ac824b446 --- /dev/null +++ b/tests/ldap_test.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python + +import os +import logging +import show.main as show +import config.main as config + +from .ldap_input import assert_show_output +from utilities_common.db import Db +from click.testing import CliRunner +from .mock_tables import dbconnector + +logger = logging.getLogger(__name__) +test_path = os.path.dirname(os.path.abspath(__file__)) +mock_db_path = os.path.join(test_path, "ldap_input") + +SUCCESS = 0 +ERROR = 1 +INVALID_VALUE = 'INVALID' +EXP_GOOD_FLOW = 1 +EXP_BAD_FLOW = 0 + + +class TestLdap: + @classmethod + def setup_class(cls): + logger.info("SETUP") + os.environ['UTILITIES_UNIT_TESTING'] = "2" + + @classmethod + def teardown_class(cls): + logger.info("TEARDOWN") + os.environ['UTILITIES_UNIT_TESTING'] = "0" + os.environ["UTILITIES_UNIT_TESTING_TOPOLOGY"] = "" + dbconnector.dedicated_dbs['CONFIG_DB'] = None + + def verify_ldap_global_output(self, db, runner, output, expected=EXP_GOOD_FLOW): + result = runner.invoke(show.cli.commands["ldap"].commands["global"], [], obj=db) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + logger.info("\n" + result.output) + logger.info(result.exit_code) + + if expected: # good flow expected (default) + assert result.exit_code == SUCCESS + assert result.output == output + else: # bad flow expected + assert result.exit_code == ERROR + + def verify_ldap_server_output(self, db, runner, output, expected=EXP_GOOD_FLOW): + result = runner.invoke(show.cli.commands["ldap-server"], [], obj=db) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + logger.info("\n" + result.output) + logger.info(result.exit_code) + + if expected: # good flow expected (default) + assert result.exit_code == SUCCESS + assert result.output == output + else: # bad flow expected + assert result.exit_code == ERROR + + def ldap_global_set_policy(self, runner, db, attr, value, expected=EXP_GOOD_FLOW): + result = runner.invoke( + config.config.commands["ldap"].commands["global"].commands[attr], + [value], obj=db + ) + if expected: # good flow expected (default) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + else: # bad flow expected + assert result.exit_code == ERROR + + def ldap_server_set_policy(self, runner, db, value, expected=EXP_GOOD_FLOW): + result = runner.invoke( + config.config.commands["ldap-server"].commands["add"], + value, obj=db + ) + + if expected: # good flow expected (default) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + else: # bad flow expected + assert result.exit_code == ERROR + + def ldap_server_del_policy(self, runner, db, value, expected=EXP_GOOD_FLOW): + result = runner.invoke( + config.config.commands["ldap-server"].commands["delete"], + value, obj=db + ) + if expected: # good flow expected (default) + logger.debug("\n" + result.output) + logger.debug(result.exit_code) + assert result.exit_code == SUCCESS + else: # bad flow expected + assert result.exit_code == ERROR + + # LDAP + + def test_ldap_global_feature_enabled(self): + dbconnector.dedicated_dbs['CONFIG_DB'] = os.path.join(mock_db_path, 'default_config_db.json') + db = Db() + runner = CliRunner() + + self.ldap_global_set_policy(runner, db, "base-dn", "dc=test1,dc=test2") + self.ldap_global_set_policy(runner, db, "bind-dn", "cn=ldapadm,dc=test1,dc=test2") + self.ldap_global_set_policy(runner, db, "bind-password", "password") + self.ldap_global_set_policy(runner, db, "bind-timeout", "3") + self.ldap_global_set_policy(runner, db, "port", "389") + self.ldap_global_set_policy(runner, db, "timeout", "2") + self.ldap_global_set_policy(runner, db, "version", "3") + + self.verify_ldap_global_output(db, runner, assert_show_output.show_ldap_global) + + def test_ldap_server(self): + dbconnector.dedicated_dbs['CONFIG_DB'] = os.path.join(mock_db_path, 'server_config_db.json') + db = Db() + runner = CliRunner() + + self.ldap_server_set_policy(runner, db, ["10.0.0.1", "--priority", "1"]) + self.verify_ldap_server_output(db, runner, assert_show_output.show_ldap_server) + + self.ldap_server_del_policy(runner, db, ["10.0.0.1"]) + self.verify_ldap_server_output(db, runner, assert_show_output.show_ldap_server_deleted)