From 641d2062da714883c1c2c52349108f7fea51e147 Mon Sep 17 00:00:00 2001 From: David Pilnik Date: Thu, 19 Oct 2023 09:37:45 +0300 Subject: [PATCH 1/4] Add LDAP feature support --- scripts/hostcfgd | 224 ++++++++++++++++++++++++- scripts/ldap.py | 149 ++++++++++++++++ setup.py | 3 +- tests/hostcfgd/hostcfgd_radius_test.py | 2 +- tests/hostcfgd/hostcfgd_tacacs_test.py | 8 +- tests/hostcfgd/test_vectors.py | 2 + 6 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 scripts/ldap.py diff --git a/scripts/hostcfgd b/scripts/hostcfgd index 12f2fd1b..667841dc 100644 --- a/scripts/hostcfgd +++ b/scripts/hostcfgd @@ -17,6 +17,10 @@ from sonic_py_common.general import check_output_pipe from swsscommon.swsscommon import ConfigDBConnector, DBConnector, Table from swsscommon import swsscommon from sonic_installer import bootloader +hostcfg_file_path = os.path.abspath(__file__) +hostcfg_dir_path = os.path.dirname(hostcfg_file_path) +sys.path.append(hostcfg_dir_path) +import ldap # FILE PAM_AUTH_CONF = "/etc/pam.d/common-auth-sonic" @@ -31,6 +35,16 @@ NSS_RADIUS_CONF = "/etc/radius_nss.conf" NSS_RADIUS_CONF_TEMPLATE = "/usr/share/sonic/templates/radius_nss.conf.j2" PAM_RADIUS_AUTH_CONF_TEMPLATE = "/usr/share/sonic/templates/pam_radius_auth.conf.j2" NSS_CONF = "/etc/nsswitch.conf" +LDAP_CONF_TEMPLATE = "/usr/share/sonic/templates/ldap.conf.j2" +LDAP_CONF = "/etc/ldap/ldap.conf" +NSLCD_CONF_TEMPLATE = "/usr/share/sonic/templates/nslcd.conf.j2" +NSLCD_CONF = "/etc/nslcd.conf" +PAM_SESSION_CONF = "/etc/pam.d/common-session" +PAM_SESSION_NONINT_CONF = "/etc/pam.d/common-session-noninteractive" +PAM_SESSION_LAST_LINE = '# end of pam-auth-update config' +MKHOME_DIR_RULE = 'session required pam_mkhomedir.so skel=/etc/skel/ umask=0022 silent' +MKHOME_DIR_LIB = 'pam_mkhomedir.so' +MKHOME_DIR_LIB_REG = r'.*pam_mkhomedir' ETC_PAMD_SSHD = "/etc/pam.d/sshd" ETC_PAMD_LOGIN = "/etc/pam.d/login" ETC_LOGIN_DEF = "/etc/login.defs" @@ -147,6 +161,79 @@ def obfuscate(data): return data +def run_cmd_output_custom_log(cmd, custom_log_func=None): + syslog.syslog(syslog.LOG_INFO, "run_cmd_output_custom_log - Executing cmd: {}".format(cmd)) + cmd_output = b'' + try: + if not isinstance(cmd, list): + raise TypeError(f'{cmd} is not list') + cmd_output = subprocess.check_output(cmd) + syslog.syslog(syslog.LOG_INFO, f"cmd_output: {cmd_output.decode()}") + except subprocess.CalledProcessError as err: + err_log_msg = f"cmd: {err.cmd}, return code: {err.returncode}, output: {err.output}" + if not custom_log_func: + syslog.syslog(syslog.LOG_ERR, err_log_msg) + else: + custom_log_func(err, err_log_msg) + cmd_output = err.output + + return cmd_output + + +def generate_file_from_template(template_j2, file_conf_output, permission, kwargs): + try: + syslog.syslog(syslog.LOG_INFO, f'generate_file_from_template template_j2={template_j2}' + f'file_conf_output={file_conf_output} kwargs={kwargs}') + template_j2_abspath = os.path.abspath(template_j2) + env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True) + env.filters['sub'] = sub + template_j2_ob = env.get_template(template_j2_abspath) + file_conf = template_j2_ob.render(**kwargs) + + with open(file_conf_output + ".tmp", 'w') as f: + f.write(file_conf) + os.chmod(file_conf_output + ".tmp", permission) + os.rename(file_conf_output + ".tmp", file_conf_output) + except Exception as e: + log_msg = f'Failed generate_file_from_template error={e}' + syslog.syslog(syslog.LOG_ERR, log_msg) + +def custom_service_en_log_func(err, err_log_msg): + """ + function checks if there are some log messages from cmd + that decided to modify the log level to INFO instead ERROR. + """ + # Omit error response when checking if a service is enabled. + syslog.syslog(syslog.LOG_DEBUG, f"err: {err}, err_log_msg: {err_log_msg}") + if 'is-enabled' in err.cmd and 'masked' in err.output.decode(): + syslog.syslog(syslog.LOG_INFO, err_log_msg) + else: + syslog.syslog(syslog.LOG_ERR, err_log_msg) + +def restart_service(service_name): + cmd_service_return = run_cmd_output_custom_log(['systemctl', 'is-enabled', service_name], custom_service_en_log_func) + if 'masked' in cmd_service_return.decode(): + syslog.syslog(syslog.LOG_DEBUG, f"{service_name}: unmask & starting") + run_cmd_output_custom_log(['systemctl', 'unmask', service_name]) + run_cmd_output_custom_log(['systemctl', 'start', service_name]) + else: + syslog.syslog(syslog.LOG_DEBUG, f"{service_name}: restarting") + run_cmd_output_custom_log(['systemctl', 'restart', service_name]) + + +def handle_nslcd_service(is_ldap_config_complete): + if is_ldap_config_complete: + # nslcd service should be restart after any ldap configuration. + restart_service("nslcd") + else: + # stopping nslcd service when Ldap feature disabled + cmd_nslcd_return = run_cmd_output_custom_log(['systemctl', 'is-enabled', 'nslcd'], custom_service_en_log_func) + if 'enabled' in cmd_nslcd_return.decode(): + syslog.syslog(syslog.LOG_DEBUG, "nslcd: deactivating (Ldap disabled)") + run_cmd_output_custom_log(['systemctl', 'stop', 'nslcd']) + run_cmd_output_custom_log(['systemctl', 'mask', 'nslcd']) + + def get_pid(procname): for dirname in os.listdir('/proc'): if dirname == 'curproc': @@ -161,6 +248,18 @@ def get_pid(procname): return "" +def is_match(pattern, file_path): + syslog.syslog(syslog.LOG_DEBUG, "looking for pattern {} line in file {}".format(pattern, file_path)) + res_match = False + with open(file_path, 'r') as f: + for (i, line) in enumerate(f): + if re.match(pattern, line): + syslog.syslog(syslog.LOG_INFO, "matched pattern {} in line {}".format(pattern, str(i))) + res_match = True + break + return res_match + + class Iptables(object): def __init__(self): ''' @@ -265,6 +364,10 @@ class AaaCfg(object): self.radius_global = {} self.radius_servers = {} + self.ldap_global_default = {} + self.ldap_global = {} + self.ldap_servers = {} + self.authentication = {} self.authorization = {} self.accounting = {} @@ -274,7 +377,7 @@ class AaaCfg(object): self.hostname = "" # Load conf from ConfigDb - def load(self, aaa_conf, tac_global_conf, tacplus_conf, rad_global_conf, radius_conf): + def load(self, aaa_conf, tac_global_conf, tacplus_conf, rad_global_conf, radius_conf, ldap_global_conf, ldap_conf): for row in aaa_conf: self.aaa_update(row, aaa_conf[row], modify_conf=False) for row in tac_global_conf: @@ -287,6 +390,11 @@ class AaaCfg(object): for row in radius_conf: self.radius_server_update(row, radius_conf[row], modify_conf=False) + for row in ldap_global_conf: + self.ldap_global_update(row, ldap_global_conf[row], modify_conf=False) + for row in ldap_conf: + self.ldap_server_update(row, ldap_conf[row], modify_conf=False) + self.modify_conf_file() def aaa_update(self, key, data, modify_conf=True): @@ -302,6 +410,17 @@ class AaaCfg(object): self.accounting = data if modify_conf: self.modify_conf_file() + + if key == 'authentication': + # Enable/Disable LDAP service (nslcd) according LDAP configuration. + handle_nslcd_service(self.is_ldap_config_complete()) + + def is_ldap_config_complete(self): + if self.ldap_global == {}: + return False + return self.ldap_global.get('bind_dn', "") and self.ldap_global.get('base_dn', "") and \ + self.ldap_global.get('bind_password', "") and 'ldap' in self.authentication['login'] and \ + self.ldap_servers def pick_src_intf_ipaddrs(self, keys, src_intf): new_ipv4_addr = "" @@ -404,6 +523,25 @@ class AaaCfg(object): if modify_conf: self.modify_conf_file() + def ldap_global_update(self, key, data, modify_conf=True): + if key == 'global': + self.ldap_global = data + + if modify_conf: + self.modify_conf_file() + handle_nslcd_service(self.is_ldap_config_complete()) + + def ldap_server_update(self, key, data, modify_conf=True): + if data == {}: + if key in self.ldap_servers: + del self.ldap_servers[key] + else: + self.ldap_servers[key] = data + + if modify_conf: + self.modify_conf_file() + handle_nslcd_service(self.is_ldap_config_complete()) + def hostname_update(self, hostname, modify_conf=True): if self.hostname == hostname: return @@ -468,7 +606,6 @@ class AaaCfg(object): syslog.syslog(syslog.LOG_INFO, "file size check pass: {} size is ({}) bytes".format(filename, size)) - def modify_single_file(self, filename, operations=None): if operations: e_list = ['-e'] * len(operations) @@ -489,6 +626,9 @@ class AaaCfg(object): accounting.update(self.accounting) tacplus_global = self.tacplus_global_default.copy() tacplus_global.update(self.tacplus_global) + ldap_global = self.ldap_global_default.copy() + ldap_global.update(self.ldap_global) + if 'src_ip' in tacplus_global: src_ip = tacplus_global['src_ip'] else: @@ -541,10 +681,23 @@ class AaaCfg(object): radsrvs_conf.append(server) radsrvs_conf = sorted(radsrvs_conf, key=lambda t: int(t['priority']), reverse=True) + # LDAP server configuration + ldapsrvs_conf = [] + if self.ldap_servers: + for addr in self.ldap_servers: + server = ldap_global.copy() + server['ip'] = addr + server.update(self.ldap_servers[addr]) + ldapsrvs_conf.append(server) + ldapsrvs_conf = sorted(ldapsrvs_conf, key=lambda t: int(t['priority']), reverse=True) + template_file = os.path.abspath(PAM_AUTH_CONF_TEMPLATE) env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True) env.filters['sub'] = sub template = env.get_template(template_file) + + if 'ldap' in authentication['login']: + pam_conf = template.render(debug=self.debug, trace=self.trace, auth=authentication, servers=ldapsrvs_conf) if 'radius' in authentication['login']: pam_conf = template.render(debug=self.debug, trace=self.trace, auth=authentication, servers=radsrvs_conf) else: @@ -556,6 +709,17 @@ class AaaCfg(object): os.chmod(PAM_AUTH_CONF + ".tmp", 0o644) os.rename(PAM_AUTH_CONF + ".tmp", PAM_AUTH_CONF) + if os.path.isfile(PAM_SESSION_CONF): + # Support to add home directory to LDAP AAA users + if 'ldap' in authentication['login']: + if not is_match(MKHOME_DIR_LIB_REG, PAM_SESSION_CONF): + modify_single_file_inplace(PAM_SESSION_CONF, [f"\'/^{PAM_SESSION_LAST_LINE}/i {MKHOME_DIR_RULE}\'"]) + modify_single_file_inplace(PAM_SESSION_NONINT_CONF, [f"\'/^{PAM_SESSION_LAST_LINE}/i {MKHOME_DIR_RULE}\'"]) + else: # login without ldap + syslog.syslog(syslog.LOG_DEBUG, f"auth login: not ldap type - rm {MKHOME_DIR_RULE} from {PAM_SESSION_CONF} file.") + modify_single_file_inplace(PAM_SESSION_CONF, [ f"'/{MKHOME_DIR_LIB}/d'" ]) + modify_single_file_inplace(PAM_SESSION_NONINT_CONF, [ f"'/{MKHOME_DIR_LIB}/d'" ]) + # Modify common-auth include file in /etc/pam.d/login, sshd. # /etc/pam.d/sudo is not handled, because it would change the existing # behavior. It can be modified once a config knob is added for sudo. @@ -566,19 +730,36 @@ class AaaCfg(object): self.modify_single_file(ETC_PAMD_SSHD, [ "/^@include/s/common-auth-sonic$/common-auth/" ]) 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 authentication['login']: + # Add tacplus/radius/ldap in nsswitch.conf if TACACS+/RADIUS enable + if 'tacacs+' in authentication['login'] and servers_conf: if os.path.isfile(NSS_CONF): self.modify_single_file(NSS_CONF, [ "/^passwd/s/ radius//" ]) + self.modify_single_file(NSS_CONF, [ "/^passwd/s/ ldap//" ]) self.modify_single_file(NSS_CONF, [ "/tacplus/b", "/^passwd/s/compat/tacplus &/", "/^passwd/s/files/tacplus &/" ]) + self.modify_single_file(NSS_CONF, [ "/^group/s/ ldap//" ]) + self.modify_single_file(NSS_CONF, [ "/^shadow/s/ ldap//" ]) + 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, [ "/^passwd/s/tacplus //" ]) + self.modify_single_file(NSS_CONF, [ "/^passwd/s/ ldap//" ]) self.modify_single_file(NSS_CONF, [ "/radius/b", "/^passwd/s/compat/& radius/", "/^passwd/s/files/& radius/" ]) + self.modify_single_file(NSS_CONF, [ "/^group/s/ ldap//" ]) + self.modify_single_file(NSS_CONF, [ "/^shadow/s/ ldap//" ]) + elif 'ldap' in authentication['login']: + if os.path.isfile(NSS_CONF): + self.modify_single_file(NSS_CONF, [ "/^passwd/s/tacplus //" ]) + self.modify_single_file(NSS_CONF, [ "/^passwd/s/ radius//" ]) + self.modify_single_file(NSS_CONF, [ "/ldap/b", "/^passwd/s/compat/& ldap/", "/^passwd/s/files/& ldap/" ]) + self.modify_single_file(NSS_CONF, [ "/ldap/b", "/^group/s/compat/& ldap/", "/^group/s/files/& ldap/" ]) + self.modify_single_file(NSS_CONF, [ "/ldap/b", "/^shadow/s/compat/& ldap/", "/^shadow/s/files/& ldap/" ]) else: if os.path.isfile(NSS_CONF): self.modify_single_file(NSS_CONF, [ "/^passwd/s/tacplus //g" ]) self.modify_single_file(NSS_CONF, [ "/^passwd/s/ radius//" ]) + self.modify_single_file(NSS_CONF, [ "/^passwd/s/ ldap//" ]) + self.modify_single_file(NSS_CONF, [ "/^group/s/ ldap//" ]) + self.modify_single_file(NSS_CONF, [ "/^shadow/s/ ldap//" ]) # Add tacplus authorization configration in nsswitch.conf tacacs_authorization_conf = None @@ -648,6 +829,19 @@ class AaaCfg(object): "{} - failed: return code - {}, output:\n{}" .format(err.cmd, err.returncode, err.output)) + + # Set NSLCD conf (LDAP) + generate_file_from_template(NSLCD_CONF_TEMPLATE, NSLCD_CONF, 0o640, {'servers': ldapsrvs_conf, 'ldap_cfg': ldap.LdapCfg}) + + # Set LDAP conf + if not os.path.exists(LDAP_CONF): + try: + os.makedirs(os.path.dirname(LDAP_CONF)) + except Exception as err: + syslog.syslog(syslog.LOG_ERR, "Error occurred when using cmd makedirs: {}".format(err)) + generate_file_from_template(LDAP_CONF_TEMPLATE, LDAP_CONF, 0o644, {'servers': ldapsrvs_conf, 'ldap_cfg': ldap.LdapCfg}) + + def modify_single_file_inplace(filename, operations=None): if operations: cmd = ["sed", '-i'] + operations + [filename] @@ -1557,6 +1751,8 @@ class HostConfigDaemon: tacacs_server = init_data['TACPLUS_SERVER'] radius_global = init_data['RADIUS'] radius_server = init_data['RADIUS_SERVER'] + ldap_global = init_data['LDAP'] + ldap_server = init_data['LDAP_SERVER'] lpbk_table = init_data['LOOPBACK_INTERFACE'] kdump = init_data['KDUMP'] passwh = init_data['PASSW_HARDENING'] @@ -1572,7 +1768,7 @@ class HostConfigDaemon: ntp_servers = init_data.get(swsscommon.CFG_NTP_SERVER_TABLE_NAME) ntp_keys = init_data.get(swsscommon.CFG_NTP_KEY_TABLE_NAME) - self.aaacfg.load(aaa, tacacs_global, tacacs_server, radius_global, radius_server) + self.aaacfg.load(aaa, tacacs_global, tacacs_server, radius_global, radius_server, ldap_global, ldap_server) self.iptables.load(lpbk_table) self.kdumpCfg.load(kdump) self.passwcfg.load(passwh) @@ -1635,6 +1831,20 @@ class HostConfigDaemon: log_data['passkey'] = obfuscate(log_data['passkey']) syslog.syslog(syslog.LOG_INFO, 'RADIUS Global update: key: {}, op: {}, data: {}'.format(key, op, log_data)) + def ldap_global_handler(self, key, op, data): + self.aaacfg.ldap_global_update(key, data) + log_data = copy.deepcopy(data) + if 'passkey' in log_data: + log_data['passkey'] = obfuscate(log_data['passkey']) + syslog.syslog(syslog.LOG_INFO, 'LDAP Global update: key: {}, op: {}, data: {}'.format(key, op, log_data)) + + def ldap_server_handler(self, key, op, data): + self.aaacfg.ldap_server_update(key, data) + log_data = copy.deepcopy(data) + if 'passkey' in log_data: + log_data['passkey'] = obfuscate(log_data['passkey']) + syslog.syslog(syslog.LOG_INFO, 'LDAP_SERVER update: key: {}, op: {}, data: {}'.format(key, op, log_data)) + def mgmt_intf_handler(self, key, op, data): key = ConfigDBConnector.deserialize_key(key) mgmt_intf_name = self.__get_intf_name(key) @@ -1740,6 +1950,8 @@ class HostConfigDaemon: self.config_db.subscribe('TACPLUS_SERVER', make_callback(self.tacacs_server_handler)) self.config_db.subscribe('RADIUS', make_callback(self.radius_global_handler)) self.config_db.subscribe('RADIUS_SERVER', make_callback(self.radius_server_handler)) + self.config_db.subscribe('LDAP', make_callback(self.ldap_global_handler)) + self.config_db.subscribe('LDAP_SERVER', make_callback(self.ldap_server_handler)) self.config_db.subscribe('PASSW_HARDENING', make_callback(self.passwh_handler)) self.config_db.subscribe('SSH_SERVER', make_callback(self.ssh_handler)) # Handle IPTables configuration diff --git a/scripts/ldap.py b/scripts/ldap.py new file mode 100644 index 00000000..f7b71901 --- /dev/null +++ b/scripts/ldap.py @@ -0,0 +1,149 @@ +import ipaddress +import syslog + +TLS1_2 = "SECURE128:SECURE192:SECURE256:-VERS-TLS1.0:-VERS-DTLS1.0:-VERS-TLS1.1:-SHA1" +TLS1_3 = "SECURE128:SECURE192:SECURE256:-VERS-TLS-ALL:-VERS-DTLS-ALL:+VERS-TLS1.3" + + +class LdapCfg: + BASE = 'ou=users,dc=example,dc=com' + BIND = '' + BINDPW = "" + VERSION = '3' + TIMEOUT_SEARCH = 5 + TIMEOUT_BIND = 5 + PORT = 389 + SCOPE = "sub" + HOST = "" + HOSTNAME_CHECK = "no" + GROUP_BASE_DN = "ou=users,dc=example,dc=com" + GROUP_MEMBER_ATTR = "member" + IPV6 = 6 + SSL_MODE = "none" + CERT_VERIFY = "try" # tls_reqcert never|allow|try|demand|hard + # Folder for CA certs + SSL_CACERT_FILE = "none" + SSL_CIPHERS = "all" + # CRL check is not implemented in current version of nslcd + SSL_CRL_CHECK = "none" + SSL_CRL_FILE = "default" + + @staticmethod + def _do_cfg(_ldapsrvs_conf, attr, cfg_str): + if _ldapsrvs_conf: + attr = _ldapsrvs_conf[0].get(cfg_str, attr) + return attr + + @staticmethod + def cfg_base(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.BASE, 'base_dn') + + @staticmethod + def cfg_bind(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.BIND, 'bind_dn') + + @staticmethod + def cfg_bindpw(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.BINDPW, 'bind_password') + + @staticmethod + def cfg_servers(_ldapsrvs_conf): + servers_resp = LdapCfg.HOST + if _ldapsrvs_conf: + servers_resp = f"uri " + ldap_mode = "ldap" + port = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'port') + ssl_mode = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'ssl_mode') + if ssl_mode == 'ssl': + port = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'ssl_port') + ldap_mode = "ldaps" + for server in _ldapsrvs_conf: + ip = server.get('ip', LdapCfg.HOST) + try: + if ipaddress.ip_address(ip).version == LdapCfg.IPV6: + # LDAP require ipv6 to be in [], i.e uri ldap://[fdfd:fdfd:10:222:250:eeff:fe1b:56]/ + ip = f"[{ip}]" + syslog.syslog(syslog.LOG_INFO, f"ldap server ip={ip} is an IPv6 address, " + f"port={port}") + else: + syslog.syslog(syslog.LOG_INFO, f"ldap server ip={ip}, port={port}") + except BaseException: + syslog.syslog(syslog.LOG_INFO, f"ldap server: {ip} its not a valid IP address, " + f"maybe a domain name, port={port}") + servers_resp += f"{ldap_mode}://{ip}:{port}/ " + syslog.syslog(syslog.LOG_INFO, f"ldap servers list={servers_resp}") + return servers_resp + + @staticmethod + def cfg_version(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.VERSION, 'version') + + @staticmethod + def cfg_scope(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SCOPE, 'scope') + + @staticmethod + def cfg_port(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'port') + + @staticmethod + def cfg_timeout(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.TIMEOUT_SEARCH, 'search_timeout') + + @staticmethod + def cfg_bind_timeout(_ldapsrvs_conf): + return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.TIMEOUT_BIND, 'bind_timeout') + + @staticmethod + def cfg_ssl_mode(_ldapsrvs_conf): + ssl_mode = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_MODE, 'ssl_mode') + ssl_modes = { + "start-tls": "start_tls", + "ssl": "on", + "none": "off" + } + ret_ssl_mode = ssl_modes.get(ssl_mode, "") + return ret_ssl_mode + + @staticmethod + def cfg_tls_reqcert(_ldapsrvs_conf): + ssl_mode = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_MODE, 'ssl_mode') + cert_verify_ret = "" + # cert verify is only active in case ssl or start-tls are active + if ssl_mode == 'ssl' or ssl_mode == 'start-tls': + cert_verify_db = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.CERT_VERIFY, 'cert_verify') + if cert_verify_db == "enabled": + cert_verify_ret = "tls_reqcert demand" + elif cert_verify_db == "disabled": + cert_verify_ret = "tls_reqcert never" + else: + syslog.syslog(syslog.LOG_WARNING, f"Cert verify contains an invalid value: {cert_verify_db}") + else: + cert_verify_ret = "" + return cert_verify_ret + + @staticmethod + def cfg_ca_certfile(_ldapsrvs_conf): + default_ca_list = "/etc/ssl/certs/ca-certificates.crt" + ca_list = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_CACERT_FILE, 'ca_list') + # Use default ca -list file. And add certificate manaement API to get list of certtificates + if ca_list == 'none': + ca_list_ret = "" + else: + ca_list_ret = f"tls_cacertfile {default_ca_list}" + return ca_list_ret + + @staticmethod + def cfg_tls_ciphers(_ldapsrvs_conf): + tls_ciphers_ret = "" + tls_ciphers = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_CIPHERS, 'tls_ciphers') + if tls_ciphers == "all": + tls_ciphers_ret = "" + elif tls_ciphers == "TLS1.2": + tls_ciphers_ret = f"tls_ciphers {TLS1_2}" + elif tls_ciphers == "TLS1.3": + tls_ciphers_ret = f"tls_ciphers {TLS1_3}" + else: + tls_ciphers_ret = "" + syslog.syslog(syslog.LOG_ERR, f"LDAP TLS cipher contains an invalid value:: {tls_ciphers}") + return tls_ciphers_ret diff --git a/setup.py b/setup.py index 51458f74..2ba2c928 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,8 @@ 'scripts/procdockerstatsd', 'scripts/determine-reboot-cause', 'scripts/process-reboot-cause', - 'scripts/sonic-host-server' + 'scripts/sonic-host-server', + 'scripts/ldap.py' ], install_requires = [ 'dbus-python', diff --git a/tests/hostcfgd/hostcfgd_radius_test.py b/tests/hostcfgd/hostcfgd_radius_test.py index d2930aa9..ec3746ff 100644 --- a/tests/hostcfgd/hostcfgd_radius_test.py +++ b/tests/hostcfgd/hostcfgd_radius_test.py @@ -91,7 +91,7 @@ def test_hostcfgd_radius(self, test_name, test_data): except: radius_server = [] - host_config_daemon.aaacfg.load(aaa,[],[],radius_global,radius_server) + host_config_daemon.aaacfg.load(aaa,[],[],radius_global,radius_server, {}, {}) dcmp = filecmp.dircmp(sop_path, op_path) diff_output = "" for name in dcmp.diff_files: diff --git a/tests/hostcfgd/hostcfgd_tacacs_test.py b/tests/hostcfgd/hostcfgd_tacacs_test.py index 0577e61f..dab306ef 100644 --- a/tests/hostcfgd/hostcfgd_tacacs_test.py +++ b/tests/hostcfgd/hostcfgd_tacacs_test.py @@ -57,6 +57,12 @@ def mock_hostcfgd(self, test_data, config_name, op_path, sop_path): hostcfgd.ETC_PAMD_SSHD = op_path + "/sshd" hostcfgd.ETC_PAMD_LOGIN = op_path + "/login" hostcfgd.RADIUS_PAM_AUTH_CONF_DIR = op_path + "/" + hostcfgd.LDAP_CONF_TEMPLATE = t_path + "/ldap.conf.j2" + hostcfgd.LDAP_CONF = op_path + "/ldap.conf" + # hostcfgd.PAM_LDAP_CONF_TEMPLATE = t_path + "/pam_ldap.conf.j2" + # hostcfgd.PAM_LDAP_CONF = op_path + "/pam_ldap.conf" + # hostcfgd.NSS_LDAP_CONF_TEMPLATE = t_path + "/libnss-ldap.conf.j2" + # hostcfgd.NSS_LDAP_CONF = op_path + "/libnss-ldap.conf" shutil.rmtree( op_path, ignore_errors=True) os.mkdir( op_path) @@ -83,7 +89,7 @@ def render_config_file(self, host_config_daemon): except: tacacs_server = [] - host_config_daemon.aaacfg.load(aaa,tacacs_global,tacacs_server,[],[]) + host_config_daemon.aaacfg.load(aaa,tacacs_global,tacacs_server,[],[], {}, {}) """ Check different config diff --git a/tests/hostcfgd/test_vectors.py b/tests/hostcfgd/test_vectors.py index 2f586988..afa50564 100644 --- a/tests/hostcfgd/test_vectors.py +++ b/tests/hostcfgd/test_vectors.py @@ -10,6 +10,8 @@ "TACPLUS_SERVER": {}, "RADIUS": {}, "RADIUS_SERVER": {}, + "LDAP": {}, + "LDAP_SERVER": {}, "PASSW_HARDENING": {}, "SSH_SERVER": {}, "KDUMP": {}, From 900fde5af5b12de5ca43b1eea73e062666611e2a Mon Sep 17 00:00:00 2001 From: David Pilnik Date: Thu, 26 Oct 2023 14:20:53 +0300 Subject: [PATCH 2/4] ldap: Add LDAP unittests in hostcfgd --- data/templates/common-auth-sonic.j2 | 9 ++ data/templates/ldap.conf.j2 | 17 +++ data/templates/nslcd.conf.j2 | 41 ++++++ scripts/ldap.py | 69 ---------- tests/hostcfgd/hostcfgd_ldap_test.py | 116 ++++++++++++++++ .../sample_output/LDAP/common-auth-sonic | 21 +++ tests/hostcfgd/sample_output/LDAP/ldap.conf | 21 +++ tests/hostcfgd/sample_output/LDAP/login | 116 ++++++++++++++++ tests/hostcfgd/sample_output/LDAP/login.old | 116 ++++++++++++++++ tests/hostcfgd/sample_output/LDAP/nslcd.conf | 40 ++++++ .../hostcfgd/sample_output/LDAP/nsswitch.conf | 18 +++ .../sample_output/LDAP/nsswitch.conf.old | 18 +++ .../sample_output/LDAP/radius_nss.conf | 56 ++++++++ tests/hostcfgd/sample_output/LDAP/sshd | 55 ++++++++ tests/hostcfgd/sample_output/LDAP/sshd.old | 55 ++++++++ .../sample_output/LDAP/tacplus_nss.conf | 40 ++++++ tests/hostcfgd/test_ldap_vectors.py | 128 ++++++++++++++++++ 17 files changed, 867 insertions(+), 69 deletions(-) create mode 100644 data/templates/ldap.conf.j2 create mode 100644 data/templates/nslcd.conf.j2 create mode 100644 tests/hostcfgd/hostcfgd_ldap_test.py create mode 100644 tests/hostcfgd/sample_output/LDAP/common-auth-sonic create mode 100644 tests/hostcfgd/sample_output/LDAP/ldap.conf create mode 100644 tests/hostcfgd/sample_output/LDAP/login create mode 100644 tests/hostcfgd/sample_output/LDAP/login.old create mode 100644 tests/hostcfgd/sample_output/LDAP/nslcd.conf create mode 100644 tests/hostcfgd/sample_output/LDAP/nsswitch.conf create mode 100644 tests/hostcfgd/sample_output/LDAP/nsswitch.conf.old create mode 100644 tests/hostcfgd/sample_output/LDAP/radius_nss.conf create mode 100644 tests/hostcfgd/sample_output/LDAP/sshd create mode 100644 tests/hostcfgd/sample_output/LDAP/sshd.old create mode 100644 tests/hostcfgd/sample_output/LDAP/tacplus_nss.conf create mode 100644 tests/hostcfgd/test_ldap_vectors.py diff --git a/data/templates/common-auth-sonic.j2 b/data/templates/common-auth-sonic.j2 index b20c9f4e..fa68e613 100644 --- a/data/templates/common-auth-sonic.j2 +++ b/data/templates/common-auth-sonic.j2 @@ -68,6 +68,15 @@ auth [success=2 default=ignore] pam_exec.so /usr/sbin/cache_radius # Local auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die maxtries=die' if not auth['failthrough'] }}] pam_unix.so nullok try_first_pass +{% elif auth['login'] == 'ldap,local' %} +auth [success=2 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass +auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +{% elif auth['login'] == 'local,ldap' %} +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass +auth [success=1 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass +{% elif auth['login'] == 'ldap' %} +auth [success=1 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass + {% else %} auth [success=1 default=ignore] pam_unix.so nullok try_first_pass diff --git a/data/templates/ldap.conf.j2 b/data/templates/ldap.conf.j2 new file mode 100644 index 00000000..c059b9a9 --- /dev/null +++ b/data/templates/ldap.conf.j2 @@ -0,0 +1,17 @@ +{{ ldap_cfg.cfg_servers(servers) }} + +base {{ ldap_cfg.cfg_base(servers) }} + +ldap_version {{ ldap_cfg.cfg_version(servers) }} + +binddn {{ ldap_cfg.cfg_bind(servers) }} + +bindpw {{ ldap_cfg.cfg_bindpw(servers) }} + +port {{ ldap_cfg.cfg_port(servers) }} + +scope {{ ldap_cfg.cfg_scope(servers) }} + +timelimit {{ ldap_cfg.cfg_timeout(servers) }} + +bind_timelimit {{ ldap_cfg.cfg_bind_timeout(servers) }} diff --git a/data/templates/nslcd.conf.j2 b/data/templates/nslcd.conf.j2 new file mode 100644 index 00000000..f5d2b6c8 --- /dev/null +++ b/data/templates/nslcd.conf.j2 @@ -0,0 +1,41 @@ +# /etc/nslcd.conf +# nslcd configuration file. See nslcd.conf(5) +# for details. + +# The user and group nslcd should run as. +uid nslcd +gid nslcd + +# The location at which the LDAP server(s) should be reachable. +{{ ldap_cfg.cfg_servers(servers) }} + +# The search base that will be used for all queries. +base {{ ldap_cfg.cfg_base(servers) }} + + +# The LDAP protocol version to use. +ldap_version {{ ldap_cfg.cfg_version(servers) }} + +# The DN to bind with for normal lookups. +binddn {{ ldap_cfg.cfg_bind(servers) }} +bindpw {{ ldap_cfg.cfg_bindpw(servers) }} + +# The DN used for password modifications by root. +#rootpwmoddn cn=admin,dc=example,dc=com + +# SSL options +#ssl off +#tls_reqcert never +tls_cacertfile /etc/ssl/certs/ca-certificates.crt + +# The search scope. +scope {{ ldap_cfg.cfg_scope(servers) }} + +timelimit {{ ldap_cfg.cfg_timeout(servers) }} + +bind_timelimit {{ ldap_cfg.cfg_bind_timeout(servers) }} + +nss_initgroups_ignoreusers ALLLOCAL + +nss_min_uid 1000 + diff --git a/scripts/ldap.py b/scripts/ldap.py index f7b71901..8a5dc21b 100644 --- a/scripts/ldap.py +++ b/scripts/ldap.py @@ -15,18 +15,7 @@ class LdapCfg: PORT = 389 SCOPE = "sub" HOST = "" - HOSTNAME_CHECK = "no" - GROUP_BASE_DN = "ou=users,dc=example,dc=com" - GROUP_MEMBER_ATTR = "member" IPV6 = 6 - SSL_MODE = "none" - CERT_VERIFY = "try" # tls_reqcert never|allow|try|demand|hard - # Folder for CA certs - SSL_CACERT_FILE = "none" - SSL_CIPHERS = "all" - # CRL check is not implemented in current version of nslcd - SSL_CRL_CHECK = "none" - SSL_CRL_FILE = "default" @staticmethod def _do_cfg(_ldapsrvs_conf, attr, cfg_str): @@ -53,10 +42,6 @@ def cfg_servers(_ldapsrvs_conf): servers_resp = f"uri " ldap_mode = "ldap" port = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'port') - ssl_mode = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'ssl_mode') - if ssl_mode == 'ssl': - port = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.PORT, 'ssl_port') - ldap_mode = "ldaps" for server in _ldapsrvs_conf: ip = server.get('ip', LdapCfg.HOST) try: @@ -93,57 +78,3 @@ def cfg_timeout(_ldapsrvs_conf): @staticmethod def cfg_bind_timeout(_ldapsrvs_conf): return LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.TIMEOUT_BIND, 'bind_timeout') - - @staticmethod - def cfg_ssl_mode(_ldapsrvs_conf): - ssl_mode = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_MODE, 'ssl_mode') - ssl_modes = { - "start-tls": "start_tls", - "ssl": "on", - "none": "off" - } - ret_ssl_mode = ssl_modes.get(ssl_mode, "") - return ret_ssl_mode - - @staticmethod - def cfg_tls_reqcert(_ldapsrvs_conf): - ssl_mode = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_MODE, 'ssl_mode') - cert_verify_ret = "" - # cert verify is only active in case ssl or start-tls are active - if ssl_mode == 'ssl' or ssl_mode == 'start-tls': - cert_verify_db = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.CERT_VERIFY, 'cert_verify') - if cert_verify_db == "enabled": - cert_verify_ret = "tls_reqcert demand" - elif cert_verify_db == "disabled": - cert_verify_ret = "tls_reqcert never" - else: - syslog.syslog(syslog.LOG_WARNING, f"Cert verify contains an invalid value: {cert_verify_db}") - else: - cert_verify_ret = "" - return cert_verify_ret - - @staticmethod - def cfg_ca_certfile(_ldapsrvs_conf): - default_ca_list = "/etc/ssl/certs/ca-certificates.crt" - ca_list = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_CACERT_FILE, 'ca_list') - # Use default ca -list file. And add certificate manaement API to get list of certtificates - if ca_list == 'none': - ca_list_ret = "" - else: - ca_list_ret = f"tls_cacertfile {default_ca_list}" - return ca_list_ret - - @staticmethod - def cfg_tls_ciphers(_ldapsrvs_conf): - tls_ciphers_ret = "" - tls_ciphers = LdapCfg._do_cfg(_ldapsrvs_conf, LdapCfg.SSL_CIPHERS, 'tls_ciphers') - if tls_ciphers == "all": - tls_ciphers_ret = "" - elif tls_ciphers == "TLS1.2": - tls_ciphers_ret = f"tls_ciphers {TLS1_2}" - elif tls_ciphers == "TLS1.3": - tls_ciphers_ret = f"tls_ciphers {TLS1_3}" - else: - tls_ciphers_ret = "" - syslog.syslog(syslog.LOG_ERR, f"LDAP TLS cipher contains an invalid value:: {tls_ciphers}") - return tls_ciphers_ret diff --git a/tests/hostcfgd/hostcfgd_ldap_test.py b/tests/hostcfgd/hostcfgd_ldap_test.py new file mode 100644 index 00000000..89fbaffa --- /dev/null +++ b/tests/hostcfgd/hostcfgd_ldap_test.py @@ -0,0 +1,116 @@ +import importlib.machinery +import importlib.util +import filecmp +import shutil +import os +import sys +from swsscommon import swsscommon + +from parameterized import parameterized +from unittest import TestCase, mock +from tests.hostcfgd.test_ldap_vectors import HOSTCFGD_TEST_LDAP_VECTOR +from tests.common.mock_configdb import MockConfigDb, MockDBConnector +from sonic_py_common.general import getstatusoutput_noshell + + +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") +templates_path = os.path.join(modules_path, "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.DBConnector = MockDBConnector +hostcfgd.Table = mock.Mock() + +class TestHostcfgdLDAP(TestCase): + """ + Test hostcfd daemon - LDAP + """ + def run_diff(self, file1, file2): + _, output = getstatusoutput_noshell(['diff', '-uR', file1, file2]) + return output + + + @parameterized.expand(HOSTCFGD_TEST_LDAP_VECTOR) + def test_hostcfgd_ldap(self, test_name, test_data): + """ + Test LDAP 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 + """ + + t_path = templates_path + op_path = output_path + "/" + test_name + sop_path = sample_output_path + "/" + test_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.NSLCD_CONF = op_path + "/nslcd.conf" + hostcfgd.NSLCD_CONF_TEMPLATE = t_path + "/nslcd.conf.j2" + 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_db"]) + host_config_daemon = hostcfgd.HostConfigDaemon() + + aaa = host_config_daemon.config_db.get_table('AAA') + + try: + ldap_global = host_config_daemon.config_db.get_table('LDAP') + except: + ldap_global = [] + try: + ldap_server = \ + host_config_daemon.config_db.get_table('LDAP_SERVER') + except: + ldap_server = [] + + host_config_daemon.aaacfg.load(aaa,[],[],[] ,[] , ldap_global, ldap_server) + + diff_output = "" + files_to_compare = ['common-auth-sonic', 'nslcd.conf'] + + # check output files exists + for name in files_to_compare: + if not os.path.isfile(sop_path + "/" + name): + raise ValueError('filename: %s not exit' % (sop_path + "/" + name)) + if not os.path.isfile(op_path + "/" + name): + raise ValueError('filename: %s not exit' % (op_path + "/" + name)) + + # deep comparison + match, mismatch, errors = filecmp.cmpfiles(sop_path, op_path, files_to_compare, shallow=False) + + if not match: + for name in files_to_compare: + diff_output += self.run_diff( sop_path + "/" + name,\ + op_path + "/" + name).decode('utf-8') + + self.assertTrue(len(diff_output) == 0, diff_output) diff --git a/tests/hostcfgd/sample_output/LDAP/common-auth-sonic b/tests/hostcfgd/sample_output/LDAP/common-auth-sonic new file mode 100644 index 00000000..848565cd --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/common-auth-sonic @@ -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=2 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass +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) diff --git a/tests/hostcfgd/sample_output/LDAP/ldap.conf b/tests/hostcfgd/sample_output/LDAP/ldap.conf new file mode 100644 index 00000000..d0fe09fb --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/ldap.conf @@ -0,0 +1,21 @@ +uri ldap://10.10.10.2/ +uri ldap://10.10.10.1/ + + +base ou=users,dc=example,dc=com + +ldap_version 3 + +binddn + +bindpw pass + +port 389 + +scope sub + +timelimit 3 + +bind_timelimit 5 + +pam_check_host_attr no \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LDAP/login b/tests/hostcfgd/sample_output/LDAP/login new file mode 100644 index 00000000..80ba6452 --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/login @@ -0,0 +1,116 @@ +# +# The PAM configuration file for the Shadow `login' service +# + +# Enforce a minimal delay in case of failure (in microseconds). +# (Replaces the `FAIL_DELAY' setting from login.defs) +# Note that other modules may require another minimal delay. (for example, +# to disable any delay, you should add the nodelay option to pam_unix) +auth optional pam_faildelay.so delay=3000000 + +# Outputs an issue file prior to each login prompt (Replaces the +# ISSUE_FILE option from login.defs). Uncomment for use +# auth required pam_issue.so issue=/etc/issue + +# Disallows root logins except on tty's listed in /etc/securetty +# (Replaces the `CONSOLE' setting from login.defs) +# +# With the default control of this module: +# [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] +# root will not be prompted for a password on insecure lines. +# if an invalid username is entered, a password is prompted (but login +# will eventually be rejected) +# +# You can change it to a "requisite" module if you think root may mis-type +# her login and should not be prompted for a password in that case. But +# this will leave the system as vulnerable to user enumeration attacks. +# +# You can change it to a "required" module if you think it permits to +# guess valid user names of your system (invalid user names are considered +# as possibly being root on insecure lines), but root passwords may be +# communicated over insecure lines. +auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so + +# Disallows other than root logins when /etc/nologin exists +# (Replaces the `NOLOGINS_FILE' option from login.defs) +auth requisite pam_nologin.so + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible +# that a module could execute code in the wrong domain. +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Sets the loginuid process attribute +session required pam_loginuid.so + +# SELinux needs to intervene at login time to ensure that the process +# starts in the proper default security context. Only sessions which are +# intended to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) + +# This module parses environment configuration file(s) +# and also allows you to use an extended config +# file /etc/security/pam_env.conf. +# +# parsing /etc/environment needs "readenv=1" +session required pam_env.so readenv=1 +# locale variables are also kept into /etc/default/locale in etch +# reading this file *in addition to /etc/environment* does not hurt +session required pam_env.so readenv=1 envfile=/etc/default/locale + +# Standard Un*x authentication. +@include common-auth-sonic + +# This allows certain extra groups to be granted to a user +# based on things like time of day, tty, service, and user. +# Please edit /etc/security/group.conf to fit your needs +# (Replaces the `CONSOLE_GROUPS' option in login.defs) +auth optional pam_group.so + +# Uncomment and edit /etc/security/time.conf if you need to set +# time restraint on logins. +# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs +# as well as /etc/porttime) +# account requisite pam_time.so + +# Uncomment and edit /etc/security/access.conf if you need to +# set access limits. +# (Replaces /etc/login.access file) +# account required pam_access.so + +# Sets up user limits according to /etc/security/limits.conf +# (Replaces the use of /etc/limits in old login) +session required pam_limits.so + +# Prints the last login info upon successful login +# (Replaces the `LASTLOG_ENAB' option from login.defs) +session optional pam_lastlog.so + +# Prints the message of the day upon successful login. +# (Replaces the `MOTD_FILE' option in login.defs) +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Prints the status of the user's mailbox upon successful login +# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). +# +# This also defines the MAIL environment variable +# However, userdel also needs MAIL_DIR and MAIL_FILE variables +# in /etc/login.defs to make sure that removing a user +# also removes the user's mail spool file. +# See comments in /etc/login.defs +session optional pam_mail.so standard + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x account and session +@include common-account +@include common-session +@include common-password diff --git a/tests/hostcfgd/sample_output/LDAP/login.old b/tests/hostcfgd/sample_output/LDAP/login.old new file mode 100644 index 00000000..07ff9540 --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/login.old @@ -0,0 +1,116 @@ +# +# The PAM configuration file for the Shadow `login' service +# + +# Enforce a minimal delay in case of failure (in microseconds). +# (Replaces the `FAIL_DELAY' setting from login.defs) +# Note that other modules may require another minimal delay. (for example, +# to disable any delay, you should add the nodelay option to pam_unix) +auth optional pam_faildelay.so delay=3000000 + +# Outputs an issue file prior to each login prompt (Replaces the +# ISSUE_FILE option from login.defs). Uncomment for use +# auth required pam_issue.so issue=/etc/issue + +# Disallows root logins except on tty's listed in /etc/securetty +# (Replaces the `CONSOLE' setting from login.defs) +# +# With the default control of this module: +# [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] +# root will not be prompted for a password on insecure lines. +# if an invalid username is entered, a password is prompted (but login +# will eventually be rejected) +# +# You can change it to a "requisite" module if you think root may mis-type +# her login and should not be prompted for a password in that case. But +# this will leave the system as vulnerable to user enumeration attacks. +# +# You can change it to a "required" module if you think it permits to +# guess valid user names of your system (invalid user names are considered +# as possibly being root on insecure lines), but root passwords may be +# communicated over insecure lines. +auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so + +# Disallows other than root logins when /etc/nologin exists +# (Replaces the `NOLOGINS_FILE' option from login.defs) +auth requisite pam_nologin.so + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible +# that a module could execute code in the wrong domain. +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Sets the loginuid process attribute +session required pam_loginuid.so + +# SELinux needs to intervene at login time to ensure that the process +# starts in the proper default security context. Only sessions which are +# intended to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) + +# This module parses environment configuration file(s) +# and also allows you to use an extended config +# file /etc/security/pam_env.conf. +# +# parsing /etc/environment needs "readenv=1" +session required pam_env.so readenv=1 +# locale variables are also kept into /etc/default/locale in etch +# reading this file *in addition to /etc/environment* does not hurt +session required pam_env.so readenv=1 envfile=/etc/default/locale + +# Standard Un*x authentication. +@include common-auth + +# This allows certain extra groups to be granted to a user +# based on things like time of day, tty, service, and user. +# Please edit /etc/security/group.conf to fit your needs +# (Replaces the `CONSOLE_GROUPS' option in login.defs) +auth optional pam_group.so + +# Uncomment and edit /etc/security/time.conf if you need to set +# time restraint on logins. +# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs +# as well as /etc/porttime) +# account requisite pam_time.so + +# Uncomment and edit /etc/security/access.conf if you need to +# set access limits. +# (Replaces /etc/login.access file) +# account required pam_access.so + +# Sets up user limits according to /etc/security/limits.conf +# (Replaces the use of /etc/limits in old login) +session required pam_limits.so + +# Prints the last login info upon successful login +# (Replaces the `LASTLOG_ENAB' option from login.defs) +session optional pam_lastlog.so + +# Prints the message of the day upon successful login. +# (Replaces the `MOTD_FILE' option in login.defs) +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Prints the status of the user's mailbox upon successful login +# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). +# +# This also defines the MAIL environment variable +# However, userdel also needs MAIL_DIR and MAIL_FILE variables +# in /etc/login.defs to make sure that removing a user +# also removes the user's mail spool file. +# See comments in /etc/login.defs +session optional pam_mail.so standard + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x account and session +@include common-account +@include common-session +@include common-password diff --git a/tests/hostcfgd/sample_output/LDAP/nslcd.conf b/tests/hostcfgd/sample_output/LDAP/nslcd.conf new file mode 100644 index 00000000..bc303b63 --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/nslcd.conf @@ -0,0 +1,40 @@ +# /etc/nslcd.conf +# nslcd configuration file. See nslcd.conf(5) +# for details. + +# The user and group nslcd should run as. +uid nslcd +gid nslcd + +# The location at which the LDAP server(s) should be reachable. +uri ldap://10.10.10.2:389/ ldap://10.10.10.1:389/ + +# The search base that will be used for all queries. +base ou=users,dc=example,dc=com + + +# The LDAP protocol version to use. +ldap_version 3 + +# The DN to bind with for normal lookups. +binddn cn=ldapadm,dc=example,dc=com +bindpw pass + +# The DN used for password modifications by root. +#rootpwmoddn cn=admin,dc=example,dc=com + +# SSL options +#ssl off +#tls_reqcert never +tls_cacertfile /etc/ssl/certs/ca-certificates.crt + +# The search scope. +scope sub + +timelimit 2 + +bind_timelimit 2 + +nss_initgroups_ignoreusers ALLLOCAL + +nss_min_uid 1000 diff --git a/tests/hostcfgd/sample_output/LDAP/nsswitch.conf b/tests/hostcfgd/sample_output/LDAP/nsswitch.conf new file mode 100644 index 00000000..d7365584 --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/nsswitch.conf @@ -0,0 +1,18 @@ +mple configuration of GNU Name Service Switch functionality. +# If you have the `glibc-doc-reference' and `info' packages installed, try: +# `info libc "Name Service Switch"' for information about this file. + +passwd: files ldap +group: files ldap +shadow: files ldap +gshadow: files + +hosts: files dns +networks: files + +protocols: db files +services: db files +ethers: db files +rpc: db files + +netgroup: nis \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LDAP/nsswitch.conf.old b/tests/hostcfgd/sample_output/LDAP/nsswitch.conf.old new file mode 100644 index 00000000..660d438c --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/nsswitch.conf.old @@ -0,0 +1,18 @@ +mple configuration of GNU Name Service Switch functionality. +# If you have the `glibc-doc-reference' and `info' packages installed, try: +# `info libc "Name Service Switch"' for information about this file. + +passwd: files ldap +group: files ldap +shadow: files +gshadow: files + +hosts: files dns +networks: files + +protocols: db files +services: db files +ethers: db files +rpc: db files + +netgroup: nis \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LDAP/radius_nss.conf b/tests/hostcfgd/sample_output/LDAP/radius_nss.conf new file mode 100644 index 00000000..8c31db9f --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/radius_nss.conf @@ -0,0 +1,56 @@ +#THIS IS AN AUTO-GENERATED FILE +# Generated from: /usr/share/sonic/templates/radius_nss.conf.j2 +# RADIUS NSS Configuration File +# +# Debug: on|off|trace +# Default: off +# +# debug=on +debug=on + +# +# User Privilege: +# Default: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/usr/bin/sonic-launch-shell +# user_priv=1;pw_info=remote_user;gid=999;group=docker;shell=/usr/bin/sonic-launch-shell + +# Eg: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/usr/bin/sonic-launch-shell +# user_priv=7;pw_info=netops;gid=999;group=docker;shell=/usr/bin/sonic-launch-shell +# user_priv=1;pw_info=operator;gid=100;group=docker;shell=/usr/bin/sonic-launch-shell +# + +# many_to_one: +# y: Map RADIUS users to one local user per privilege. +# n: Create local user account on first successful authentication. +# Default: n +# + +# Eg: +# many_to_one=y +# + +# unconfirmed_disallow: +# y: Do not allow unconfirmed users (users created before authentication) +# n: Allow unconfirmed users. +# Default: n + +# Eg: +# unconfirmed_disallow=y +# + +# unconfirmed_ageout: +# : Wait time before purging unconfirmed users +# Default: 600 +# + +# Eg: +# unconfirmed_ageout=900 +# + +# unconfirmed_regexp: +# : The RE to match the command line of processes for which the +# creation of unconfirmed users are to be allowed. +# Default: (.*: \[priv\])|(.*: \[accepted\]) +# where: is the unconfirmed user. +# \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LDAP/sshd b/tests/hostcfgd/sample_output/LDAP/sshd new file mode 100644 index 00000000..c025af35 --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/sshd @@ -0,0 +1,55 @@ +# PAM configuration for the Secure Shell service + +# Standard Un*x authentication. +@include common-auth-sonic + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x session setup and teardown. +@include common-session + +# Print the message of the day upon successful login. +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/tests/hostcfgd/sample_output/LDAP/sshd.old b/tests/hostcfgd/sample_output/LDAP/sshd.old new file mode 100644 index 00000000..d70b384b --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/sshd.old @@ -0,0 +1,55 @@ +# PAM configuration for the Secure Shell service + +# Standard Un*x authentication. +@include common-auth + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x session setup and teardown. +@include common-session + +# Print the message of the day upon successful login. +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/tests/hostcfgd/sample_output/LDAP/tacplus_nss.conf b/tests/hostcfgd/sample_output/LDAP/tacplus_nss.conf new file mode 100644 index 00000000..eac82849 --- /dev/null +++ b/tests/hostcfgd/sample_output/LDAP/tacplus_nss.conf @@ -0,0 +1,40 @@ +# Configuration for libnss-tacplus + +# debug - If you want to open debug log, set it on +# Default: off +# 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 + +# server - set ip address, tcp port, secret string and timeout for TACACS+ servers +# Default: None (no TACACS+ server) +# server=1.1.1.1:49,secret=test,timeout=3 + +# user_priv - set the map between TACACS+ user privilege and local user's passwd +# Default: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/bin/bash +# user_priv=1;pw_info=remote_user;gid=999;group=docker;shell=/bin/bash + +# many_to_one - create one local user for many TACACS+ users which has the same privilege +# Default: many_to_one=n +# many_to_one=y diff --git a/tests/hostcfgd/test_ldap_vectors.py b/tests/hostcfgd/test_ldap_vectors.py new file mode 100644 index 00000000..1026dcbb --- /dev/null +++ b/tests/hostcfgd/test_ldap_vectors.py @@ -0,0 +1,128 @@ +from unittest.mock import call + +""" + hostcfgd test ldap vector +""" +HOSTCFGD_TEST_LDAP_VECTOR = [ + [ + "LDAP", + { + "config_db": { + "DEVICE_METADATA": { + "localhost": { + "hostname": "radius", + } + }, + "FEATURE": { + "dhcp_relay": { + "auto_restart": "enabled", + "has_global_scope": "True", + "has_per_asic_scope": "False", + "has_timer": "False", + "high_mem_alert": "disabled", + "set_owner": "kube", + "state": "enabled" + }, + }, + "KDUMP": { + "config": { + "enabled": "false", + "num_dumps": "3", + "memory": "0M-2G:256M,2G-4G:320M,4G-8G:384M,8G-:448M" + } + }, + "AAA": { + "authentication": { + "login": "ldap,local", + "restrictions":{ + "lockout-state": "enabled", + "fail-delay": 0, + "lockout-reattempt": 15, + "lockout-attempts": 5 + }, + "failthrough": "True", + "debug": "True", + } + }, + "LDAP": { + "global": { + "port": "389", + "bind_password": "pass", + "bind_dn": "cn=ldapadm,dc=example,dc=com", + "base_dn": "ou=users,dc=example,dc=com", + "bind_timeout": "2", + "search_timeout": "2", + "version": "3" + } + }, + "LDAP_SERVER": { + "10.10.10.1": { + "priority": "1" + }, + "10.10.10.2": { + "priority": "2" + } + }, + "SSH_SERVER": { + "POLICIES" :{ + "max_sessions": "100" + } + } + }, + "expected_config_db": { + "DEVICE_METADATA": { + "localhost": { + "hostname": "radius", + } + }, + "FEATURE": { + "dhcp_relay": { + "auto_restart": "enabled", + "has_global_scope": "True", + "has_per_asic_scope": "False", + "has_timer": "False", + "high_mem_alert": "disabled", + "set_owner": "kube", + "state": "enabled" + }, + }, + "AAA": { + "authentication": { + "login": "ldap,local", + "restrictions":{ + "lockout-state": "enabled", + "fail-delay": 0, + "lockout-reattempt": 15, + "lockout-attempts": 5 + }, + "debug": "True", + } + }, + "LDAP": { + "global": { + "auth_port": "389", + "timeout": "3", + "passkey": "pass", + } + }, + "LDAP_SERVER": { + "10.10.10.1": { + "priority": "1", + "passkey": "pass1", + }, + "10.10.10.2": { + "priority": "2", + } + }, + "SSH_SERVER": { + "POLICIES" :{ + "max_sessions": "100" + } + } + }, + "expected_subprocess_calls": [ + call("service aaastatsd start", shell=True), + ], + } + ] +] From 8105f4d42e465e9a69c37c3be5e7e43c2678b09a Mon Sep 17 00:00:00 2001 From: David Pilnik Date: Mon, 18 Mar 2024 12:01:48 +0200 Subject: [PATCH 3/4] ldap: Fix dict return when item not exists --- scripts/hostcfgd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/hostcfgd b/scripts/hostcfgd index 667841dc..e5f0c7ac 100644 --- a/scripts/hostcfgd +++ b/scripts/hostcfgd @@ -419,7 +419,7 @@ class AaaCfg(object): if self.ldap_global == {}: return False return self.ldap_global.get('bind_dn', "") and self.ldap_global.get('base_dn', "") and \ - self.ldap_global.get('bind_password', "") and 'ldap' in self.authentication['login'] and \ + self.ldap_global.get('bind_password', "") and 'ldap' in self.authentication.get('login', "") and \ self.ldap_servers def pick_src_intf_ipaddrs(self, keys, src_intf): From ed10ab94fff4ed9a29eedc1ac159d7e87d1a3b65 Mon Sep 17 00:00:00 2001 From: David Pilnik Date: Tue, 19 Mar 2024 15:49:50 +0200 Subject: [PATCH 4/4] ldap: fix dir home syntax when using sed --- scripts/hostcfgd | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/hostcfgd b/scripts/hostcfgd index e5f0c7ac..e3f02b06 100644 --- a/scripts/hostcfgd +++ b/scripts/hostcfgd @@ -713,12 +713,12 @@ class AaaCfg(object): # Support to add home directory to LDAP AAA users if 'ldap' in authentication['login']: if not is_match(MKHOME_DIR_LIB_REG, PAM_SESSION_CONF): - modify_single_file_inplace(PAM_SESSION_CONF, [f"\'/^{PAM_SESSION_LAST_LINE}/i {MKHOME_DIR_RULE}\'"]) - modify_single_file_inplace(PAM_SESSION_NONINT_CONF, [f"\'/^{PAM_SESSION_LAST_LINE}/i {MKHOME_DIR_RULE}\'"]) + modify_single_file_inplace(PAM_SESSION_CONF, [f"/^{PAM_SESSION_LAST_LINE}/i {MKHOME_DIR_RULE}"]) + modify_single_file_inplace(PAM_SESSION_NONINT_CONF, [f"/^{PAM_SESSION_LAST_LINE}/i {MKHOME_DIR_RULE}"]) else: # login without ldap syslog.syslog(syslog.LOG_DEBUG, f"auth login: not ldap type - rm {MKHOME_DIR_RULE} from {PAM_SESSION_CONF} file.") - modify_single_file_inplace(PAM_SESSION_CONF, [ f"'/{MKHOME_DIR_LIB}/d'" ]) - modify_single_file_inplace(PAM_SESSION_NONINT_CONF, [ f"'/{MKHOME_DIR_LIB}/d'" ]) + modify_single_file_inplace(PAM_SESSION_CONF, [ f"/{MKHOME_DIR_LIB}/d" ]) + modify_single_file_inplace(PAM_SESSION_NONINT_CONF, [ f"/{MKHOME_DIR_LIB}/d" ]) # Modify common-auth include file in /etc/pam.d/login, sshd. # /etc/pam.d/sudo is not handled, because it would change the existing