#!python

import copy
import ipaddress
import os
import sys
import subprocess
import syslog
import signal
import re
import jinja2
import json
from shutil import copy2
from datetime import datetime
from sonic_py_common import device_info
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

# FILE
PAM_AUTH_CONF = "/etc/pam.d/common-auth-sonic"
PAM_AUTH_CONF_TEMPLATE = "/usr/share/sonic/templates/common-auth-sonic.j2"
PAM_PASSWORD_CONF = "/etc/pam.d/common-password"
PAM_PASSWORD_CONF_TEMPLATE = "/usr/share/sonic/templates/common-password.j2"
SSH_CONFG = "/etc/ssh/sshd_config"
SSH_CONFG_TMP = SSH_CONFG + ".tmp"
NSS_TACPLUS_CONF = "/etc/tacplus_nss.conf"
NSS_TACPLUS_CONF_TEMPLATE = "/usr/share/sonic/templates/tacplus_nss.conf.j2"
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"
ETC_PAMD_SSHD = "/etc/pam.d/sshd"
ETC_PAMD_LOGIN = "/etc/pam.d/login"
ETC_LOGIN_DEF = "/etc/login.defs"

# Linux login.def default values (password hardening disable)
LINUX_DEFAULT_PASS_MAX_DAYS = 99999
LINUX_DEFAULT_PASS_WARN_AGE = 7

# Ssh min-max values
SSH_MIN_VALUES={"authentication_retries": 3, "login_timeout": 1, "ports": 1}
SSH_MAX_VALUES={"authentication_retries": 100, "login_timeout": 600, "ports": 65535}
SSH_CONFIG_NAMES={"authentication_retries": "MaxAuthTries" , "login_timeout": "LoginGraceTime"}

ACCOUNT_NAME = 0 # index of account name
AGE_DICT = { 'MAX_DAYS': {'REGEX_DAYS': r'^PASS_MAX_DAYS[ \t]*(?P<max_days>\d*)', 'DAYS': 'max_days', 'CHAGE_FLAG': '-M '},
            'WARN_DAYS': {'REGEX_DAYS': r'^PASS_WARN_AGE[ \t]*(?P<warn_days>\d*)', 'DAYS': 'warn_days', 'CHAGE_FLAG': '-W '}
            }
PAM_LIMITS_CONF_TEMPLATE = "/usr/share/sonic/templates/pam_limits.j2"
LIMITS_CONF_TEMPLATE = "/usr/share/sonic/templates/limits.conf.j2"
PAM_LIMITS_CONF = "/etc/pam.d/pam-limits-conf"
LIMITS_CONF = "/etc/security/limits.conf"

# TACACS+
TACPLUS_SERVER_PASSKEY_DEFAULT = ""
TACPLUS_SERVER_TIMEOUT_DEFAULT = "5"
TACPLUS_SERVER_AUTH_TYPE_DEFAULT = "pap"

# RADIUS
RADIUS_SERVER_AUTH_PORT_DEFAULT = "1812"
RADIUS_SERVER_PASSKEY_DEFAULT = ""
RADIUS_SERVER_RETRANSMIT_DEFAULT = "3"
RADIUS_SERVER_TIMEOUT_DEFAULT = "5"
RADIUS_SERVER_AUTH_TYPE_DEFAULT = "pap"
RADIUS_PAM_AUTH_CONF_DIR = "/etc/pam_radius_auth.d/"

# FIPS
FIPS_CONFIG_FILE = '/etc/sonic/fips.json'
OPENSSL_FIPS_CONFIG_FILE = '/etc/fips/fips_enable'
DEFAULT_FIPS_RESTART_SERVICES = ['ssh', 'telemetry.service', 'restapi']

# MISC Constants
CFG_DB = "CONFIG_DB"
STATE_DB = "STATE_DB"


def signal_handler(sig, frame):
    if sig == signal.SIGHUP:
        syslog.syslog(syslog.LOG_INFO, "HostCfgd: signal 'SIGHUP' is caught and ignoring..")
    elif sig == signal.SIGINT:
        syslog.syslog(syslog.LOG_INFO, "HostCfgd: signal 'SIGINT' is caught and exiting...")
        sys.exit(128 + sig)
    elif sig == signal.SIGTERM:
        syslog.syslog(syslog.LOG_INFO, "HostCfgd: signal 'SIGTERM' is caught and exiting...")
        sys.exit(128 + sig)
    else:
        syslog.syslog(syslog.LOG_INFO, "HostCfgd: invalid signal - ignoring..")


def run_cmd(cmd, log_err=True, raise_exception=False):
    try:
        subprocess.check_call(cmd)
    except Exception as err:
        if log_err:
            syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}"
                  .format(err.cmd, err.returncode, err.output))
        if raise_exception:
            raise

def run_cmd_pipe(cmd0, cmd1, cmd2, log_err=True, raise_exception=False):
    try:
        check_output_pipe(cmd0, cmd1, cmd2)
    except Exception as err:
        if log_err:
            syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}"
                  .format(err.cmd, err.returncode, err.output))
        if raise_exception:
            raise

def run_cmd_output(cmd, log_err=True, raise_exception=False):
    output = ''
    try:
        output = subprocess.check_output(cmd)
    except Exception as err:
        if log_err:
            syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}"
                  .format(err.cmd, err.returncode, err.output))
        if raise_exception:
            raise
    return output


def is_true(val):
    if val == 'True' or val == 'true':
        return True
    elif val == 'False' or val == 'false':
        return False
    syslog.syslog(syslog.LOG_ERR, "Failed to get bool value, instead val= {}".format(val))
    return False


def is_vlan_sub_interface(ifname):
    ifname_split = ifname.split(".")
    return (len(ifname_split) == 2)


def sub(l, start, end):
    return l[start:end]


def obfuscate(data):
    if data:
        return data[0] + '*****'
    else:
        return data


def get_pid(procname):
    for dirname in os.listdir('/proc'):
        if dirname == 'curproc':
            continue
        try:
            with open('/proc/{}/cmdline'.format(dirname), mode='r') as fd:
                content = fd.read()
        except Exception as ex:
            continue
        if procname in content:
            return dirname
    return ""


class Iptables(object):
    def __init__(self):
        '''
        Default MSS to 1460 - (MTU 1500 - 40 (TCP/IP Overhead))
        For IPv6, it would be 1440 - (MTU 1500 - 60 octects)
        '''
        self.tcpmss = 1460
        self.tcp6mss = 1440

    def is_ip_prefix_in_key(self, key):
        '''
        Function to check if IP address is present in the key. If it
        is present, then the key would be a tuple or else, it shall be
        be string
        '''
        return (isinstance(key, tuple))

    def load(self, lpbk_table):
        for row in lpbk_table:
            self.iptables_handler(row, lpbk_table[row])

    def command(self, chain, ip, ver, op):
        cmd = ['iptables'] if ver == '4' else ['ip6tables']
        cmd += ['-t', 'mangle', '--{}'.format(op), chain, "-p", "tcp", "--tcp-flags", 'SYN', 'SYN']
        cmd += ['-d'] if chain == 'PREROUTING' else ['-s']
        mss = str(self.tcpmss) if ver == '4' else str(self.tcp6mss)
        cmd += [ip, "-j", "TCPMSS", "--set-mss", mss]

        return cmd

    def iptables_handler(self, key, data, add=True):
        if not self.is_ip_prefix_in_key(key):
            return

        iface, ip = key
        ip_str = ip.split("/")[0]
        ip_addr = ipaddress.ip_address(ip_str)
        if isinstance(ip_addr, ipaddress.IPv6Address):
            ver = '6'
        else:
            ver = '4'

        self.mangle_handler(ip_str, ver, add)

    def mangle_handler(self, ip, ver, add):
        if not add:
            op = 'delete'
        else:
            op = 'check'

        iptables_cmds = []
        chains = ['PREROUTING', 'POSTROUTING']
        for chain in chains:
            cmd = self.command(chain, ip, ver, op)
            if not add:
                iptables_cmds.append(cmd)
            else:
                '''
                For add case, first check if rule exists. Iptables just appends to the chain
                as a new rule even if it is the same as an existing one. Check this and
                do nothing if rule exists
                '''
                ret = subprocess.call(cmd)
                if ret == 0:
                    syslog.syslog(syslog.LOG_INFO, "{} rule exists in {}".format(ip, chain))
                else:
                    # Modify command from Check to Append
                    iptables_cmds.append([word.replace('check', 'append') for word in cmd])

        for cmd in iptables_cmds:
            syslog.syslog(syslog.LOG_INFO, "Running cmd - {}".format(cmd))
            run_cmd(cmd)


class AaaCfg(object):
    def __init__(self):
        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,
            'passkey': TACPLUS_SERVER_PASSKEY_DEFAULT
        }
        self.tacplus_global = {}
        self.tacplus_servers = {}

        self.radius_global_default = {
            'priority': 0,
            'auth_port': RADIUS_SERVER_AUTH_PORT_DEFAULT,
            'auth_type': RADIUS_SERVER_AUTH_TYPE_DEFAULT,
            'retransmit': RADIUS_SERVER_RETRANSMIT_DEFAULT,
            'timeout': RADIUS_SERVER_TIMEOUT_DEFAULT,
            'passkey': RADIUS_SERVER_PASSKEY_DEFAULT
        }
        self.radius_global = {}
        self.radius_servers = {}

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

        self.hostname = ""

    # Load conf from ConfigDb
    def load(self, aaa_conf, tac_global_conf, tacplus_conf, rad_global_conf, radius_conf):
        for row in aaa_conf:
            self.aaa_update(row, aaa_conf[row], modify_conf=False)
        for row in tac_global_conf:
            self.tacacs_global_update(row, tac_global_conf[row], modify_conf=False)
        for row in tacplus_conf:
            self.tacacs_server_update(row, tacplus_conf[row], modify_conf=False)

        for row in rad_global_conf:
            self.radius_global_update(row, rad_global_conf[row], modify_conf=False)
        for row in radius_conf:
            self.radius_server_update(row, radius_conf[row], modify_conf=False)

        self.modify_conf_file()

    def aaa_update(self, key, data, modify_conf=True):
        if key == 'authentication':
            self.authentication = data
            if 'failthrough' in data:
                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()

    def pick_src_intf_ipaddrs(self, keys, src_intf):
        new_ipv4_addr = ""
        new_ipv6_addr = ""

        for it in keys:
            if src_intf != it[0] or (isinstance(it, tuple) == False):
                continue
            if new_ipv4_addr != "" and new_ipv6_addr != "":
                break
            ip_str = it[1].split("/")[0]
            ip_addr = ipaddress.IPAddress(ip_str)
            # Pick the first IP address from the table that matches the source interface
            if isinstance(ip_addr, ipaddress.IPv6Address):
                if new_ipv6_addr != "":
                    continue
                new_ipv6_addr = ip_str
            else:
                if new_ipv4_addr != "":
                    continue
                new_ipv4_addr = ip_str

        return(new_ipv4_addr, new_ipv6_addr)

    def tacacs_global_update(self, key, data, modify_conf=True):
        if key == 'global':
            self.tacplus_global = data
            if modify_conf:
                self.modify_conf_file()

    def tacacs_server_update(self, key, data, modify_conf=True):
        if data == {}:
            if key in self.tacplus_servers:
                del self.tacplus_servers[key]
        else:
            self.tacplus_servers[key] = data

        if modify_conf:
            self.modify_conf_file()

    def notify_audisp_tacplus_reload_config(self):
        pid = get_pid("/sbin/audisp-tacplus")
        syslog.syslog(syslog.LOG_INFO, "Found audisp-tacplus PID: {}".format(pid))
        if pid == "":
            return

        # audisp-tacplus will reload TACACS+ config when receive SIGHUP
        try:
            os.kill(int(pid), signal.SIGHUP)
        except Exception as ex:
            syslog.syslog(syslog.LOG_WARNING, "Send SIGHUP to audisp-tacplus failed with exception: {}".format(ex))

    def handle_radius_source_intf_ip_chg(self, key):
        modify_conf=False
        if 'src_intf' in self.radius_global:
            if key[0] == self.radius_global['src_intf']:
                modify_conf=True
        for addr in self.radius_servers:
            if ('src_intf' in self.radius_servers[addr]) and \
                    (key[0] == self.radius_servers[addr]['src_intf']):
                modify_conf=True
                break

        if not modify_conf:
            return

        syslog.syslog(syslog.LOG_INFO, 'RADIUS IP change - key:{}, current server info {}'.format(key, self.radius_servers))
        self.modify_conf_file()

    def handle_radius_nas_ip_chg(self, key):
        modify_conf=False
        # Mgmt IP configuration affects only the default nas_ip
        if 'nas_ip' not in self.radius_global:
            for addr in self.radius_servers:
                if 'nas_ip' not in self.radius_servers[addr]:
                    modify_conf=True
                    break

        if not modify_conf:
            return

        syslog.syslog(syslog.LOG_INFO, 'RADIUS (NAS) IP change - key:{}, current global info {}'.format(key, self.radius_global))
        self.modify_conf_file()

    def radius_global_update(self, key, data, modify_conf=True):
        if key == 'global':
            self.radius_global = data
            if 'statistics' in data:
                self.radius_global['statistics'] = is_true(data['statistics'])
            if modify_conf:
                self.modify_conf_file()

    def radius_server_update(self, key, data, modify_conf=True):
        if data == {}:
            if key in self.radius_servers:
                del self.radius_servers[key]
        else:
            self.radius_servers[key] = data

        if modify_conf:
            self.modify_conf_file()

    def hostname_update(self, hostname, modify_conf=True):
        if self.hostname == hostname:
            return

        self.hostname = hostname

        # Currently only used for RADIUS
        if len(self.radius_servers) == 0:
            return

        if modify_conf:
            self.modify_conf_file()

    def get_hostname(self):
        return self.hostname

    def get_interface_ip(self, source, addr=None):
        keys = None
        try:
            if source.startswith("Eth"):
                if is_vlan_sub_interface(source):
                    keys = self.config_db.get_keys('VLAN_SUB_INTERFACE')
                else:
                    keys = self.config_db.get_keys('INTERFACE')
            elif source.startswith("Po"):
                if is_vlan_sub_interface(source):
                    keys = self.config_db.get_keys('VLAN_SUB_INTERFACE')
                else:
                    keys = self.config_db.get_keys('PORTCHANNEL_INTERFACE')
            elif source.startswith("Vlan"):
                keys = self.config_db.get_keys('VLAN_INTERFACE')
            elif source.startswith("Loopback"):
                keys = self.config_db.get_keys('LOOPBACK_INTERFACE')
            elif source == "eth0":
                keys = self.config_db.get_keys('MGMT_INTERFACE')
        except Exception as e:
            pass

        interface_ip = ""
        if keys != None:
            ipv4_addr, ipv6_addr = self.pick_src_intf_ipaddrs(keys, source)
            # Based on the type of addr, return v4 or v6
            if addr and isinstance(addr, ipaddress.IPv6Address):
                interface_ip = ipv6_addr
            else:
                # This could be tuned, but that involves a DNS query, so
                # offline configuration might trip (or cause delays).
                interface_ip = ipv4_addr
        return interface_ip


    def check_file_not_empty(self, filename):
        exists = os.path.exists(filename)
        if not exists:
            syslog.syslog(syslog.LOG_ERR, "file size check failed: {} is missing".format(filename))
            return

        size = os.path.getsize(filename)
        if size == 0:
            syslog.syslog(syslog.LOG_ERR, "file size check failed: {} is empty, file corrupted".format(filename))
            return

        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)
            e_operations = [item for sublist in zip(e_list, operations) for item in sublist]
            with open(filename+'.new', 'w') as f:
                subprocess.call(["sed"] + e_operations + [filename], stdout=f)
            subprocess.call(["cp", '-f', filename, filename+'.old'])
            subprocess.call(['cp', '-f', filename+'.new', filename])

        self.check_file_not_empty(filename)

    def modify_conf_file(self):
        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:
            src_ip = tacplus_global['src_ip']
        else:
            src_ip = None

        servers_conf = []
        if self.tacplus_servers:
            for addr in self.tacplus_servers:
                server = tacplus_global.copy()
                server['ip'] = addr
                server.update(self.tacplus_servers[addr])
                servers_conf.append(server)
            servers_conf = sorted(servers_conf, key=lambda t: int(t['priority']), reverse=True)

        radius_global = self.radius_global_default.copy()
        radius_global.update(self.radius_global)

        # RADIUS: Set the default nas_ip, and nas_id
        if 'nas_ip' not in radius_global:
            nas_ip = self.get_interface_ip("eth0")
            if len(nas_ip) > 0:
                radius_global['nas_ip'] = nas_ip
        if 'nas_id' not in radius_global:
            nas_id = self.get_hostname()
            if len(nas_id) > 0:
                radius_global['nas_id'] = nas_id

        radsrvs_conf = []
        if self.radius_servers:
            for addr in self.radius_servers:
                server = radius_global.copy()
                server['ip'] = addr
                server.update(self.radius_servers[addr])

                if 'src_intf' in server:
                    # RADIUS: Log a message if src_ip is already defined.
                    if 'src_ip' in server:
                        syslog.syslog(syslog.LOG_INFO, \
            "RADIUS_SERVER|{}: src_intf found. Ignoring src_ip".format(addr))
                    # RADIUS: If server.src_intf, then get the corresponding
                    # src_ip based on the server.ip, and set it.
                    src_ip = self.get_interface_ip(server['src_intf'], addr)
                    if len(src_ip) > 0:
                        server['src_ip'] = src_ip
                    elif 'src_ip' in server:
                        syslog.syslog(syslog.LOG_INFO, \
            "RADIUS_SERVER|{}: src_intf has no usable IP addr.".format(addr))
                        del server['src_ip']

                radsrvs_conf.append(server)
            radsrvs_conf = sorted(radsrvs_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 '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=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:
            f.write(pam_conf)
        os.chmod(PAM_AUTH_CONF + ".tmp", 0o644)
        os.rename(PAM_AUTH_CONF + ".tmp", PAM_AUTH_CONF)

        # 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.
        if os.path.isfile(PAM_AUTH_CONF):
            self.modify_single_file(ETC_PAMD_SSHD,  [ "/^@include/s/common-auth$/common-auth-sonic/" ])
            self.modify_single_file(ETC_PAMD_LOGIN, [ "/^@include/s/common-auth$/common-auth-sonic/" ])
        else:
            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']:
            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 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/" ])
        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//" ])

        # 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,
                                        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)

        # Notify auditd plugin to reload tacacs config.
        self.notify_audisp_tacplus_reload_config()

        # Set debug in nss-radius conf
        template_file = os.path.abspath(NSS_RADIUS_CONF_TEMPLATE)
        template = env.get_template(template_file)
        nss_radius_conf = template.render(debug=self.debug, trace=self.trace, servers=radsrvs_conf)
        with open(NSS_RADIUS_CONF, 'w') as f:
            f.write(nss_radius_conf)

        # Create the per server pam_radius_auth.conf
        if radsrvs_conf:
            for srv in radsrvs_conf:
                # Configuration File
                pam_radius_auth_file = RADIUS_PAM_AUTH_CONF_DIR + srv['ip'] + "_" + srv['auth_port'] + ".conf"
                template_file = os.path.abspath(PAM_RADIUS_AUTH_CONF_TEMPLATE)
                template = env.get_template(template_file)
                pam_radius_auth_conf = template.render(server=srv)

                open(pam_radius_auth_file, 'a').close()
                os.chmod(pam_radius_auth_file, 0o600)
                with open(pam_radius_auth_file, 'w+') as f:
                    f.write(pam_radius_auth_conf)

        # Start the statistics service. Only RADIUS implemented
        if ('radius' in authentication['login']) and ('statistics' in radius_global) and \
                radius_global['statistics']:
            cmd = ['service', 'aaastatsd', 'start']
        else:
            cmd = ['service', 'aaastatsd', 'stop']
        syslog.syslog(syslog.LOG_INFO, "cmd - {}".format(cmd))
        try:
            subprocess.check_call(cmd)
        except subprocess.CalledProcessError as err:
            syslog.syslog(syslog.LOG_ERR,
                    "{} - failed: return code - {}, output:\n{}"
                    .format(err.cmd, err.returncode, err.output))

def modify_single_file_inplace(filename, operations=None):
    if operations:
        cmd = ["sed", '-i'] + operations + [filename]
        syslog.syslog(syslog.LOG_DEBUG, "modify_single_file_inplace: cmd - {}".format(cmd))
        subprocess.run(cmd)


class PasswHardening(object):
    def __init__(self):
        self.passw_policies_default = {}
        self.passw_policies = {}

        self.debug = False
        self.trace = False

    def load(self, policies_conf):
        for row in policies_conf:
            self.passw_policies_update(row, policies_conf[row], modify_conf=False)

        self.modify_passw_conf_file()

    def passw_policies_update(self, key, data, modify_conf=True):
        syslog.syslog(syslog.LOG_DEBUG, "passw_policies_update - key: {}".format(key))
        syslog.syslog(syslog.LOG_DEBUG, "passw_policies_update - data: {}".format(data))

        if data == {}:
            self.passw_policies = {}
        else:
            if 'reject_user_passw_match' in data:
                data['reject_user_passw_match'] = is_true(data['reject_user_passw_match'])
            if 'lower_class' in data:
                data['lower_class'] = is_true(data['lower_class'])
            if 'upper_class' in data:
                data['upper_class'] = is_true(data['upper_class'])
            if 'digits_class' in data:
                data['digits_class'] = is_true(data['digits_class'])
            if 'special_class' in data:
                data['special_class'] = is_true(data['special_class'])

            if key == 'POLICIES':
                self.passw_policies = data

        if modify_conf:
            self.modify_passw_conf_file()


    def set_passw_hardening_policies(self, passw_policies):
        # Password Hardening flow
        # When feature is enabled, the passw_policies from CONFIG_DB will be set in the pam files /etc/pam.d/common-password and /etc/login.def.
        # When the feature is disabled, the files above will be generate with the linux default (without secured passw_policies).
        syslog.syslog(syslog.LOG_DEBUG, "modify_conf_file: passw_policies - {}".format(passw_policies))

        template_passwh_file = os.path.abspath(PAM_PASSWORD_CONF_TEMPLATE)
        env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True)
        env.filters['sub'] = sub
        template_passwh = env.get_template(template_passwh_file)

        # Render common-password file with passw hardening policies if any. Other render without them.
        pam_passwh_conf = template_passwh.render(debug=self.debug, passw_policies=passw_policies)

        # Use rename(), which is atomic (on the same fs) to avoid empty file
        with open(PAM_PASSWORD_CONF + ".tmp", 'w') as f:
            f.write(pam_passwh_conf)
        os.chmod(PAM_PASSWORD_CONF + ".tmp", 0o644)
        os.rename(PAM_PASSWORD_CONF + ".tmp", PAM_PASSWORD_CONF)

        # Age policy
        # When feature disabled or age policy disabled, expiry days policy should be as linux default, other, accoriding CONFIG_DB.
        curr_expiration = LINUX_DEFAULT_PASS_MAX_DAYS
        curr_expiration_warning = LINUX_DEFAULT_PASS_WARN_AGE

        if passw_policies:
            if 'state' in passw_policies:
                if passw_policies['state'] == 'enabled':
                    if 'expiration' in passw_policies:
                        if int(self.passw_policies['expiration']) != 0: # value '0' meaning age policy is disabled
                            #  the logic is to modify the expiration time according the last updated modificatiion
                            #
                            curr_expiration = int(passw_policies['expiration'])

                    if 'expiration_warning' in passw_policies:
                        if int(self.passw_policies['expiration_warning']) != 0: # value '0' meaning age policy is disabled
                            curr_expiration_warning = int(passw_policies['expiration_warning'])

        if self.is_passwd_aging_expire_update(curr_expiration, 'MAX_DAYS'):
            # Set aging policy for existing users
            self.passwd_aging_expire_modify(curr_expiration, 'MAX_DAYS')

            # Aging policy for new users
            modify_single_file_inplace(ETC_LOGIN_DEF, ["/^PASS_MAX_DAYS/c\PASS_MAX_DAYS " +str(curr_expiration)])

        if self.is_passwd_aging_expire_update(curr_expiration_warning, 'WARN_DAYS'):
            # Aging policy for existing users
            self.passwd_aging_expire_modify(curr_expiration_warning, 'WARN_DAYS')

            # Aging policy for new users
            modify_single_file_inplace(ETC_LOGIN_DEF, ["/^PASS_WARN_AGE/c\PASS_WARN_AGE " +str(curr_expiration_warning)])

    def passwd_aging_expire_modify(self, curr_expiration, age_type):
        normal_accounts = self.get_normal_accounts()
        if not normal_accounts:
            syslog.syslog(syslog.LOG_ERR,"failed, no normal users found in /etc/passwd")
            return
        chage_flag = AGE_DICT[age_type]['CHAGE_FLAG']
        for normal_account in normal_accounts:
            try:
                chage_p_m = subprocess.Popen(('chage', chage_flag + str(curr_expiration), normal_account), stdout=subprocess.PIPE)
                return_code_chage_p_m = chage_p_m.poll()
                if return_code_chage_p_m != 0:
                    syslog.syslog(syslog.LOG_ERR, "failed: return code - {}".format(return_code_chage_p_m))

            except subprocess.CalledProcessError as e:
                syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}".format(e.cmd, e.returncode, e.output))

    def is_passwd_aging_expire_update(self, curr_expiration, age_type):
        """ Function verify that the current age expiry policy values are equal from the old one
            Return update_age_status 'True' value meaning that was a modification from the last time, and vice versa.
        """
        update_age_status = False
        days_num = None
        regex_days = AGE_DICT[age_type]['REGEX_DAYS']
        days_type = AGE_DICT[age_type]['DAYS']
        if os.path.exists(ETC_LOGIN_DEF):
            with open(ETC_LOGIN_DEF, 'r') as f:
                login_def_data = f.readlines()

            for line in login_def_data:
                m1 = re.match(regex_days, line)
                if m1:
                    days_num = int(m1.group(days_type))
                    break

        if curr_expiration != days_num:
            update_age_status = True

        return update_age_status

    def get_normal_accounts(self):
        # Get user list
        try:
            getent_out = subprocess.check_output(['getent', 'passwd']).decode('utf-8').split('\n')
        except subprocess.CalledProcessError as err:
            syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}".format(err.cmd, err.returncode, err.output))
            return False

        # Get range of normal users
        REGEX_UID_MAX = r'^UID_MAX[ \t]*(?P<uid_max>\d*)'
        REGEX_UID_MIN = r'^UID_MIN[ \t]*(?P<uid_min>\d*)'
        uid_max = None
        uid_min = None
        if os.path.exists(ETC_LOGIN_DEF):
            with open(ETC_LOGIN_DEF, 'r') as f:
                login_def_data = f.readlines()

            for line in login_def_data:
                m1 = re.match(REGEX_UID_MAX, line)
                m2 = re.match(REGEX_UID_MIN, line)
                if m1:
                    uid_max = int(m1.group("uid_max"))
                if m2:
                    uid_min = int(m2.group("uid_min"))

        if not uid_max or not uid_min:
            syslog.syslog(syslog.LOG_ERR,"failed, no UID_MAX/UID_MIN founded in login.def file")
            return False

        # Get normal user list
        normal_accounts = []
        for account in getent_out[0:-1]: # last item is always empty
            account_spl = account.split(':')
            account_number = int(account_spl[2])
            if account_number >= uid_min and account_number <= uid_max:
                normal_accounts.append(account_spl[ACCOUNT_NAME])

        normal_accounts.append('root') # root is also a candidate to be age modify.
        return normal_accounts

    def modify_passw_conf_file(self):
        passw_policies = self.passw_policies_default.copy()
        passw_policies.update(self.passw_policies)

        # set new Password Hardening policies.
        self.set_passw_hardening_policies(passw_policies)

class SshServer(object):
    def __init__(self):
        self.policies = {}

    def load(self, policies_conf):
        if 'POLICIES' in policies_conf:
            self.policies_update('POLICIES', policies_conf['POLICIES'], modify_conf=False)
        else:
            self.policies = {}

        self.modify_conf_file()

    def modify_conf_file(self):
        ssh_policies = {}
        ssh_policies.update(self.policies)

        # set new SSH server policies.
        if len(ssh_policies) > 0:
            self.set_policies(ssh_policies)

    def policies_update(self, key, data, modify_conf=True):
        syslog.syslog(syslog.LOG_DEBUG, "ssh_policies_update - key: {}".format(key))
        syslog.syslog(syslog.LOG_DEBUG, "ssh_policies_update - data: {}".format(data))
        if data:
            if 'ports' in data:
                data['ports'] = data['ports'].split(',')
            self.policies = data

        if modify_conf:
            self.modify_conf_file()

    # return first line apperience of pattern - else return number of lines in the file
    def get_line_num_of_pattern(self, pattern, file_path, find_commented=False):
        syslog.syslog(syslog.LOG_DEBUG, "looking for pattern {} line in file {}".format(pattern, file_path))
        return_value = 0
        with open(file_path, 'r') as f:
            for (i, line) in enumerate(f):
                if re.match(pattern, line):
                    syslog.syslog(syslog.LOG_DEBUG, "found pattern {} in line {}".format(pattern, str(i)))
                    return i + 1
                if find_commented and re.match('#' + pattern, line):
                    syslog.syslog(syslog.LOG_DEBUG, "found pattern {} in line {}".format('#' + pattern, str(i)))
                    return i + 1
                return_value = i
        return return_value

    def handle_ports_set(self, values_list):
        if len(values_list) == 0:
            return False
        key='ports'
        for port_num in values_list:
            if isinstance(port_num, int):
                syslog.syslog(syslog.LOG_ERR, "port num value {} in wrong format".format(port_num))
                return False
            if int(port_num) < SSH_MIN_VALUES[key] or SSH_MAX_VALUES[key] < int(port_num):
                syslog.syslog(syslog.LOG_ERR, "Ssh {} {} out of range".format('port', port_num))
                return False
        port_line_num = self.get_line_num_of_pattern("Port", SSH_CONFG_TMP, True)
        modify_single_file_inplace(SSH_CONFG_TMP, ['-E', "/^(#)?Port [0-9]+$/d"])

        for port_num in values_list:
            # add port in original line
            modify_single_file_inplace(SSH_CONFG_TMP, [f'{str(port_line_num)} i Port {str(port_num)}'])
        return True

    def set_policies(self, ssh_policies):
        # Ssh server flow
        # The ssh_policies from CONFIG_DB will be set in the ssh config files /etc/ssh/sshd_config
        copy2(SSH_CONFG, SSH_CONFG_TMP)

        for key, value in ssh_policies.items():
            if key == 'ports':
                if not self.handle_ports_set(value):
                    syslog.syslog(syslog.LOG_ERR, "Failed to update sshd config files - wrong port configuration")
                    return
            elif int(value) < SSH_MIN_VALUES.get(key, 65535) or SSH_MAX_VALUES.get(key, -1) < int(value):
                syslog.syslog(syslog.LOG_ERR, "Ssh {} {} out of range".format(key, value))
            elif key in SSH_CONFIG_NAMES:
                # search replace configuration - if not in config file - append
                kv_str = "{} {}".format(SSH_CONFIG_NAMES[key], str(value)) # name +' '+ value format
                modify_single_file_inplace(SSH_CONFG_TMP,['-E', "/^#?" + SSH_CONFIG_NAMES[key]+"/{h;s/.*/"+
                 kv_str + "/};${x;/^$/{s//" + kv_str + "/;H};x}"])
            else:
                syslog.syslog(syslog.LOG_ERR, "Failed to update sshd config file - wrong key {}".format(key))

        ssh_verify_res = subprocess.run(['sudo', 'sshd', '-T', '-f', SSH_CONFG_TMP], capture_output=True)
        if ssh_verify_res.returncode == 0:
            os.rename(SSH_CONFG_TMP, SSH_CONFG)
            try:
                run_cmd(['systemctl', 'restart', 'ssh'],
                        log_err=True, raise_exception=True)
            except Exception:
                syslog.syslog(syslog.LOG_ERR, f'Failed to update sshd config file')
        else:
            syslog.syslog(syslog.LOG_ERR, f'Failed to update sshd config file - sshd -T returned {ssh_verify_res.returncode} with error {ssh_verify_res.stderr.decode()}')
            os.remove(SSH_CONFG_TMP)


class KdumpCfg(object):
    def __init__(self, CfgDb):
        self.config_db = CfgDb
        self.kdump_defaults = { "enabled" : "false",
                                "memory": "0M-2G:256M,2G-4G:320M,4G-8G:384M,8G-:448M",
                                "num_dumps": "3" }

    def load(self, kdump_table):
        """
        Set the KDUMP table in CFG DB to kdump_defaults if not set by the user
        """
        syslog.syslog(syslog.LOG_INFO, "KdumpCfg init ...")
        kdump_conf = kdump_table.get("config", {})
        for row in self.kdump_defaults:
            value = self.kdump_defaults.get(row)
            if not kdump_conf.get(row):
                self.config_db.mod_entry("KDUMP", "config", {row : value})

    def kdump_update(self, key, data):
        syslog.syslog(syslog.LOG_INFO, "Kdump global configuration update")
        if key == "config":
            # Admin mode
            kdump_enabled = self.kdump_defaults["enabled"]
            if data.get("enabled") is not None:
                kdump_enabled = data.get("enabled")
            if kdump_enabled.lower() == "true":
                enabled = True
            else:
                enabled = False
            if enabled:
                run_cmd(["sonic-kdump-config", "--enable"])
            else:
                run_cmd(["sonic-kdump-config", "--disable"])

            # Memory configuration
            memory = self.kdump_defaults["memory"]
            if data.get("memory") is not None:
                memory = data.get("memory")
            run_cmd(["sonic-kdump-config", "--memory", memory])

            # Num dumps
            num_dumps = self.kdump_defaults["num_dumps"]
            if data.get("num_dumps") is not None:
                num_dumps = data.get("num_dumps")
            run_cmd(["sonic-kdump-config", "--num_dumps", num_dumps])

class NtpCfg(object):
    """
    NtpCfg Config Daemon
    1) ntp-config.service handles the configuration updates and then starts ntp.service
    2) Both of them start after all the feature services start
    3) Purpose of this daemon is to propagate runtime config changes in
       NTP, NTP_SERVER, NTP_KEY, and LOOPBACK_INTERFACE
    """
    NTP_CONF_RESTART = ['systemctl', 'restart', 'ntp-config']

    def __init__(self):
        self.cache = {}

    def load(self, ntp_global_conf: dict, ntp_server_conf: dict,
                   ntp_key_conf: dict):
        """Load initial NTP configuration

        Force load cache on init. NTP config should be taken at boot-time by
        ntp and ntp-config services. So loading whole config here.

        Args:
            ntp_global_conf:    Global configuration
            ntp_server_conf:    Servers configuration
            ntp_key_conf:       Keys configuration
        """

        syslog.syslog(syslog.LOG_INFO, "NtpCfg: load initial")

        if ntp_global_conf is None:
            ntp_global_conf = {}

        # Force load cache on init.
        # NTP config should be taken at boot-time by ntp and ntp-config
        # services.
        self.cache = {
            'global': ntp_global_conf.get('global', {}),
            'servers': ntp_server_conf,
            'keys': ntp_key_conf
        }

    def handle_ntp_source_intf_chg(self, intf_name):
        # If no ntp server configured, do nothing. Source interface will be
        # taken once any server will be configured.
        if not self.cache.get('servers'):
            return

        # check only the intf configured as source interface
        ifs = self.cache.get('global', {}).get('src_intf', '').split(';')
        if intf_name not in ifs:
            return

        # Just restart ntp config
        try:
            run_cmd(self.NTP_CONF_RESTART, True, True)
        except Exception:
            syslog.syslog(syslog.LOG_ERR, 'NtpCfg: Failed to restart '
                                          'ntp-config service')
            return

    def ntp_global_update(self, key: str, data: dict):
        """Update NTP global configuration

        The table holds NTP global configuration. It has some configs that
        require reloading only but some other require restarting ntp daemon.
        Handle each of them accordingly.

        Args:
            key:        Triggered table's key. Should be always "global"
            data:       Global configuration data
        """

        syslog.syslog(syslog.LOG_NOTICE, 'NtpCfg: Global configuration update')
        if key != 'global' or self.cache.get('global', {}) == data:
            syslog.syslog(syslog.LOG_NOTICE, 'NtpCfg: Nothing to update')
            return

        syslog.syslog(syslog.LOG_INFO, f'NtpCfg: Set global config: {data}')

        old_dhcp = self.cache.get(key, {}).get('dhcp')
        old_vrf = self.cache.get(key, {}).get('vrf')
        new_dhcp = data.get('dhcp')
        new_vrf = data.get('vrf')

        restart_ntpd = False
        if new_dhcp != old_dhcp or new_vrf != old_vrf:
            restart_ntpd = True

        # Restarting the service
        try:
            run_cmd(self.NTP_CONF_RESTART, True, True)
            if restart_ntpd:
                run_cmd(['systemctl', 'restart', 'ntp'], True, True)
        except Exception:
            syslog.syslog(syslog.LOG_ERR, f'NtpCfg: Failed to restart ntp '
                                          'services')
            return

        # Update the Local Cache
        self.cache[key] = data

    def ntp_srv_key_update(self, ntp_servers: dict, ntp_keys: dict):
        """Update NTP server/key configuration

        The tables holds only NTP servers config and/or NTP authentication keys 
        config, so any change to those tables should cause NTP config reload.
        It does not make sense to handle each of the separately. NTP config
        reload takes whole configuration, so once got to this handler - cache
        whole config.

        Args:
            ntp_servers:    Servers config table
            ntp_keys:       Keys config table
        """

        syslog.syslog(syslog.LOG_NOTICE, 'NtpCfg: Server/key configuration '
                                         'update')

        if (self.cache.get('servers', {}) == ntp_servers and
            self.cache.get('keys', {}) == ntp_keys):
            syslog.syslog(syslog.LOG_NOTICE, 'NtpCfg: Nothing to update')
            return

        # Pop keys values to print
        ntp_keys_print = copy.deepcopy(ntp_keys)
        for key in ntp_keys_print:
            ntp_keys_print[key].pop('value', None)

        syslog.syslog(syslog.LOG_INFO, f'NtpCfg: Set servers: {ntp_servers}')
        syslog.syslog(syslog.LOG_INFO, f'NtpCfg: Set keys: {ntp_keys_print}')

        # Restarting the service
        try:
            run_cmd(self.NTP_CONF_RESTART, True, True)
        except Exception:
            syslog.syslog(syslog.LOG_ERR, f'NtpCfg: Failed to restart '
                                          'ntp-config service')
            return

        # Updating the cache
        self.cache['servers'] = ntp_servers
        self.cache['keys'] = ntp_keys

class PamLimitsCfg(object):
    """
    PamLimit Config Daemon
    1) The pam_limits PAM module sets limits on the system resources that can be obtained in a user-session.
    2) Purpose of this daemon is to render pam_limits config file.
    """
    def __init__(self, config_db):
        self.config_db = config_db
        self.hwsku = ""
        self.type = ""

    # Load config from ConfigDb and render config file/
    def update_config_file(self):
        device_metadata = self.config_db.get_table('DEVICE_METADATA')
        if "localhost" not in device_metadata:
            return

        self.read_localhost_config(device_metadata["localhost"])
        self.render_conf_file()

    # Read localhost config
    def read_localhost_config(self, localhost):
        if "hwsku" in localhost:
            self.hwsku = localhost["hwsku"]
        else:
            self.hwsku = ""

        if "type" in localhost:
            self.type = localhost["type"]
        else:
            self.type = ""

    # Render pam_limits config files
    def render_conf_file(self):
        env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True)
        env.filters['sub'] = sub

        try:
            template_file = os.path.abspath(PAM_LIMITS_CONF_TEMPLATE)
            template = env.get_template(template_file)
            pam_limits_conf = template.render(
                                        hwsku=self.hwsku,
                                        type=self.type)
            with open(PAM_LIMITS_CONF, 'w') as f:
                f.write(pam_limits_conf)

            template_file = os.path.abspath(LIMITS_CONF_TEMPLATE)
            template = env.get_template(template_file)
            limits_conf = template.render(
                                        hwsku=self.hwsku,
                                        type=self.type)
            with open(LIMITS_CONF, 'w') as f:
                f.write(limits_conf)
        except Exception as e:
            syslog.syslog(syslog.LOG_ERR,
                    "modify pam_limits config file failed with exception: {}"
                    .format(e))


class DeviceMetaCfg(object):
    """
    DeviceMetaCfg Config Daemon
    Handles changes in DEVICE_METADATA table.
    1) Handle hostname change
    """

    def __init__(self):
        self.hostname = ''
        self.timezone = None

    def load(self, dev_meta={}):
        # Get hostname initial
        self.hostname = dev_meta.get('localhost', {}).get('hostname', '')
        syslog.syslog(syslog.LOG_DEBUG, f'Initial hostname: {self.hostname}')

        # Load appropriate config
        self.timezone = dev_meta.get('localhost', {}).get('timezone')

    def hostname_update(self, data):
        """
        Apply hostname handler.

        Args:
            data: Read table's key's data.
        """
        syslog.syslog(syslog.LOG_DEBUG, 'DeviceMetaCfg: hostname update')
        new_hostname = data.get('hostname')

        # Restart hostname-config service when hostname was changed.
        # Empty not allowed
        if not new_hostname:
            syslog.syslog(syslog.LOG_ERR,
                          'Hostname was not updated: Empty not allowed')
        elif new_hostname == self.hostname:
            syslog.syslog(syslog.LOG_INFO,
                          'Hostname was not updated: Already set up with the same name: {}'.format(self.hostname))
        else:
            syslog.syslog(syslog.LOG_INFO, 'DeviceMetaCfg: Set new hostname: {}'
                                           .format(new_hostname))
            self.hostname = new_hostname
            try:
                run_cmd(['sudo', 'service', 'hostname-config', 'restart'], True, True)
            except subprocess.CalledProcessError as e:
                syslog.syslog(syslog.LOG_ERR, 'DeviceMetaCfg: Failed to set new'
                                              ' hostname: {}'.format(e))
                return
            run_cmd(['sudo', 'monit', 'reload'])

    def timezone_update(self, data):
        """
        Apply timezone handler.
        Run the following command in Linux: timedatectl set-timezone <timezone>
        Args:
            data: Read table's key's data.
        """
        new_timezone = data.get('timezone')
        syslog.syslog(syslog.LOG_DEBUG,
                      f'DeviceMetaCfg: timezone update to {new_timezone}')

        if new_timezone is None:
            syslog.syslog(syslog.LOG_DEBUG,
                          f'DeviceMetaCfg: Recieved empty timezone')
            return

        if new_timezone == self.timezone:
            syslog.syslog(syslog.LOG_DEBUG,
                          f'DeviceMetaCfg: No change in timezone')
            return

        # run command will print out log error in case of error
        run_cmd(['timedatectl', 'set-timezone', new_timezone])
        self.timezone = new_timezone

        run_cmd(['systemctl', 'restart', 'rsyslog'], True, False)
        syslog.syslog(syslog.LOG_INFO, 'DeviceMetaCfg: Restart rsyslog after '
                      'changing timezone')

class MgmtIfaceCfg(object):
    """
    MgmtIfaceCfg Config Daemon
    Handles changes in MGMT_INTERFACE, MGMT_VRF_CONFIG tables.
    1) Handle change of interface ip
    2) Handle change of management VRF state
    """

    def __init__(self):
        self.iface_config_data = {}
        self.mgmt_vrf_enabled = ''

    def load(self, mgmt_iface={}, mgmt_vrf={}):
        # Get initial data
        self.iface_config_data = mgmt_iface
        self.mgmt_vrf_enabled = mgmt_vrf.get('mgmtVrfEnabled', '')
        syslog.syslog(syslog.LOG_DEBUG,
                      f'Initial mgmt interface conf: {self.iface_config_data}')
        syslog.syslog(syslog.LOG_DEBUG,
                      f'Initial mgmt VRF state: {self.mgmt_vrf_enabled}')

    def update_mgmt_iface(self, iface, key, data):
        """Handle update management interface config
        """
        syslog.syslog(syslog.LOG_DEBUG, 'MgmtIfaceCfg: mgmt iface update')

        # Restart management interface service when config was changed
        if data != self.iface_config_data.get(key):
            cfg = {key: data}
            syslog.syslog(syslog.LOG_INFO, f'MgmtIfaceCfg: Set new interface '
                                           f'config {cfg} for {iface}')
            try:
                run_cmd(['sudo', 'systemctl', 'restart', 'interfaces-config'], True, True)
                run_cmd(['sudo', 'systemctl', 'restart', 'ntp-config'], True, True)
            except subprocess.CalledProcessError:
                syslog.syslog(syslog.LOG_ERR, f'Failed to restart management '
                              'interface services')
                return

            self.iface_config_data[key] = data

    def update_mgmt_vrf(self, data):
        """Handle update management VRF state
        """
        syslog.syslog(syslog.LOG_DEBUG, 'MgmtIfaceCfg: mgmt vrf state update')

        # Restart mgmt vrf services when mgmt vrf config was changed.
        # Empty not allowed.
        enabled = data.get('mgmtVrfEnabled', '')
        if not enabled or enabled == self.mgmt_vrf_enabled:
            return

        syslog.syslog(syslog.LOG_INFO, f'Set mgmt vrf state {enabled}')

        # Restart related vrfs services
        try:
            run_cmd(['service', 'ntp', 'stop'], True, True)
            run_cmd(['systemctl', 'restart', 'interfaces-config'], True, True)
            run_cmd(['service', 'ntp', 'start'], True, True)
        except subprocess.CalledProcessError:
            syslog.syslog(syslog.LOG_ERR, f'Failed to restart management vrf '
                          'services')
            return

        # Remove mgmt if route
        if enabled == 'true':
            """
            The regular expression for grep in below cmd is to match eth0 line
            in /proc/net/route, sample file:
            $ cat /proc/net/route
            Iface   Destination     Gateway         Flags   RefCnt  Use
            eth0    00000000        01803B0A        0003    0       0
            #################### Line break here ####################
            Metric  Mask            MTU     Window  IRTT
            202     00000000        0       0       0
            """
            try:
                cmd0 = ['cat', '/proc/net/route']
                cmd1 = ['grep', '-E', r"eth0\s+00000000\s+[0-9A-Z]+\s+[0-9]+\s+[0-9]+\s+[0-9]+\s+202"]
                cmd2 = ['wc', '-l']
                run_cmd_pipe(cmd0, cmd1, cmd2, True, True)
            except subprocess.CalledProcessError:
                syslog.syslog(syslog.LOG_ERR, 'MgmtIfaceCfg: Could not delete '
                                              'eth0 route')
                return

            run_cmd(["ip", "-4", "route", "del", "default", "dev", "eth0", "metric", "202"], False)

        # Update cache
        self.mgmt_vrf_enabled = enabled


class RSyslogCfg(object):
    """Remote syslog config daemon

    Handles changes in syslog configuration tables:
        1) SYSLOG_CONFIG
        2) SYSLOG_SERVER
    """

    def __init__(self):
        self.cache = {}

    def load(self, rsyslog_config={}, rsyslog_servers={}):
        # Get initial remote syslog configuration
        self.cache = {
            'config': rsyslog_config,
            'servers': rsyslog_servers
        }
        syslog.syslog(syslog.LOG_INFO,
                      f'RSyslogCfg: Initial config: {self.cache}')

    def update_rsyslog_config(self, rsyslog_config, rsyslog_servers):
        """Apply remote syslog configuration

        The daemon restarts rsyslog-config which will regenerate rsyslog.conf
        file based on Jinja2 template and will restart rsyslogd
        Args:
            rsyslog_config:     Remote syslog global config table
            rsyslog_servers:    Remote syslog servers
        """
        syslog.syslog(syslog.LOG_DEBUG, 'RSyslogCfg: Configuration update')
        if (self.cache.get('config', {}) != rsyslog_config or
            self.cache.get('servers', {}) != rsyslog_servers):
            syslog.syslog(syslog.LOG_INFO, f'RSyslogCfg: Set config '
                          f'{rsyslog_config}, servers: {rsyslog_servers}')

            # Restarting the service
            try:
                run_cmd(['systemctl', 'reset-failed', 'rsyslog-config',
                         'rsyslog'], log_err=True, raise_exception=True)
                run_cmd(['systemctl', 'restart', 'rsyslog-config'],
                        log_err=True, raise_exception=True)
            except Exception:
                syslog.syslog(syslog.LOG_ERR,
                              f'RSyslogCfg: Failed to restart rsyslog service')
                return

        # Updating the cache
        self.cache['config'] = rsyslog_config
        self.cache['servers'] = rsyslog_servers

class DnsCfg:

    def load(self, *args, **kwargs):
        self.dns_update()

    def dns_update(self, *args, **kwargs):
        run_cmd(['systemctl', 'restart', 'resolv-config'], True, False)

class FipsCfg(object):
    """
    FipsCfg Config Daemon
    Handles the changes in FIPS table.
    """

    def __init__(self, state_db_conn):
        self.enable = False
        self.enforce = False
        self.restart_services = DEFAULT_FIPS_RESTART_SERVICES
        self.state_db_conn = state_db_conn

    def read_config(self):
        if os.path.exists(FIPS_CONFIG_FILE):
            with open(FIPS_CONFIG_FILE) as f:
                conf = json.load(f)
                self.restart_services = conf.get(RESTART_SERVICES_KEY, [])

        with open(PROC_CMDLINE) as f:
           kernel_cmdline = f.read().strip().split(' ')
        self.cur_enforced = 'sonic_fips=1' in kernel_cmdline or 'fips=1' in kernel_cmdline

    def load(self, data={}):
        common_config = data.get('global', {})
        if not common_config:
            syslog.syslog(syslog.LOG_INFO, f'FipsCfg: skipped the FIPS config, the FIPS setting is empty.')
            return
        self.read_config()
        self.enforce = is_true(common_config.get('enforce', 'false'))
        self.enable = self.enforce or is_true(common_config.get('enable', 'false'))
        self.update()

    def fips_handler(self, data):
        self.load(data)

    def update(self):
        syslog.syslog(syslog.LOG_DEBUG, f'FipsCfg: update fips option enable: {self.enable}, enforce: {self.enforce}.')
        self.update_enforce_config()
        self.update_noneenforce_config()
        self.state_db_conn.hset('FIPS_STATS|state', 'config_datetime', datetime.utcnow().isoformat())
        syslog.syslog(syslog.LOG_DEBUG, f'FipsCfg: update fips option complete.')

    def update_noneenforce_config(self):
        cur_fips_enabled = '0'
        if os.path.exists(OPENSSL_FIPS_CONFIG_FILE):
            with open(OPENSSL_FIPS_CONFIG_FILE) as f:
                cur_fips_enabled = f.read().strip()

        expected_fips_enabled = '0'
        if self.enable:
            expected_fips_enabled = '1'

        # If the runtime config is not as expected, change the config
        if cur_fips_enabled != expected_fips_enabled:
            os.makedirs(os.path.dirname(OPENSSL_FIPS_CONFIG_FILE), exist_ok=True)
            with open(OPENSSL_FIPS_CONFIG_FILE, 'w') as f:
                f.write(expected_fips_enabled)

        self.restart()

    def restart(self):
        if self.cur_enforced:
            syslog.syslog(syslog.LOG_INFO, f'FipsCfg: skipped to restart services, since FIPS enforced.')
            return

        modified_time = datetime.utcfromtimestamp(0)
        if os.path.exists(OPENSSL_FIPS_CONFIG_FILE):
            modified_time = datetime.fromtimestamp(os.path.getmtime(OPENSSL_FIPS_CONFIG_FILE))
        timestamp = self.state_db_conn.hget('FIPS_STATS|state', 'config_datetime')
        if timestamp and datetime.fromisoformat(timestamp).replace(tzinfo=None) > modified_time.replace(tzinfo=None):
            syslog.syslog(syslog.LOG_INFO, f'FipsCfg: skipped to restart services, since the services have alread been restarted.')
            return

        # Restart the services required and in the running state
        output = run_cmd_output(['sudo', 'systemctl', '-t', 'service', '--state=running', '--no-pager', '-o', 'json'])
        if not output:
            return

        services = [s['unit'] for s in json.loads(output)]
        for service in self.restart_services:
            if service in services or service + '.service' in services:
                syslog.syslog(syslog.LOG_INFO, f'FipsCfg: restart service {service}.')
                run_cmd(['sudo', 'systemctl', 'restart', service])


    def update_enforce_config(self):
        loader = bootloader.get_bootloader()
        image = loader.get_next_image()
        next_enforced = loader.get_fips(image)
        if next_enforced == self.enforce:
            syslog.syslog(syslog.LOG_INFO, f'FipsCfg: skipped to configure the enforce option {self.enforce}, since the config has already been set.')
            return
        syslog.syslog(syslog.LOG_INFO, f'FipsCfg: update the FIPS enforce option {self.enforce}.')
        loader.set_fips(image, self.enforce)

class HostConfigDaemon:
    def __init__(self):
        self.state_db_conn = DBConnector(STATE_DB, 0)
        # Wait if the Warm/Fast boot is in progress
        if swsscommon.RestartWaiter.isAdvancedBootInProgress(self.state_db_conn):
            swsscommon.RestartWaiter.waitAdvancedBootDone()
        # Just a sanity check to verify if the CONFIG_DB has been initialized
        # before moving forward
        self.config_db = ConfigDBConnector()
        self.config_db.connect(wait_for_init=True, retry_on=True)
        syslog.syslog(syslog.LOG_INFO, 'ConfigDB connect success')

        # Initialize KDump Config and set the config to default if nothing is provided
        self.kdumpCfg = KdumpCfg(self.config_db)

        # Initialize IpTables
        self.iptables = Iptables()

        # Initialize Ntp Config Handler
        self.ntpcfg = NtpCfg()

        self.is_multi_npu = device_info.is_multi_npu()

        # Initialize AAACfg
        self.aaacfg = AaaCfg()

        # Initialize PasswHardening
        self.passwcfg = PasswHardening()

        # Initialize PamLimitsCfg
        self.pamLimitsCfg = PamLimitsCfg(self.config_db)
        self.pamLimitsCfg.update_config_file()

        # Initialize DeviceMetaCfg
        self.devmetacfg = DeviceMetaCfg()

        # Initialize MgmtIfaceCfg
        self.mgmtifacecfg = MgmtIfaceCfg()

        # Initialize SshServer
        self.sshscfg = SshServer()

        # Initialize RSyslogCfg
        self.rsyslogcfg = RSyslogCfg()

        # Initialize DnsCfg
        self.dnscfg = DnsCfg()

        # Initialize FipsCfg
        self.fipscfg = FipsCfg(self.state_db_conn)

    def load(self, init_data):
        aaa = init_data['AAA']
        tacacs_global = init_data['TACPLUS']
        tacacs_server = init_data['TACPLUS_SERVER']
        radius_global = init_data['RADIUS']
        radius_server = init_data['RADIUS_SERVER']
        lpbk_table = init_data['LOOPBACK_INTERFACE']
        kdump = init_data['KDUMP']
        passwh = init_data['PASSW_HARDENING']
        ssh_server = init_data['SSH_SERVER']
        dev_meta = init_data.get(swsscommon.CFG_DEVICE_METADATA_TABLE_NAME, {})
        mgmt_ifc = init_data.get(swsscommon.CFG_MGMT_INTERFACE_TABLE_NAME, {})
        mgmt_vrf = init_data.get(swsscommon.CFG_MGMT_VRF_CONFIG_TABLE_NAME, {})
        syslog_cfg = init_data.get(swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME, {})
        syslog_srv = init_data.get(swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME, {})
        dns = init_data.get('DNS_NAMESERVER', {})
        fips_cfg = init_data.get('FIPS', {})
        ntp_global = init_data.get(swsscommon.CFG_NTP_GLOBAL_TABLE_NAME)
        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.iptables.load(lpbk_table)
        self.kdumpCfg.load(kdump)
        self.passwcfg.load(passwh)
        self.sshscfg.load(ssh_server)
        self.devmetacfg.load(dev_meta)
        self.mgmtifacecfg.load(mgmt_ifc, mgmt_vrf)

        self.rsyslogcfg.load(syslog_cfg, syslog_srv)
        self.dnscfg.load(dns)
        self.fipscfg.load(fips_cfg)
        self.ntpcfg.load(ntp_global, ntp_servers, ntp_keys)

        # Update AAA with the hostname
        self.aaacfg.hostname_update(self.devmetacfg.hostname)

    def __get_intf_name(self, key):
        if isinstance(key, tuple) and key:
            intf = key[0]
        else:
            intf = key
        return intf

    def aaa_handler(self, key, op, data):
        self.aaacfg.aaa_update(key, data)
        syslog.syslog(syslog.LOG_INFO, 'AAA Update: key: {}, op: {}, data: {}'.format(key, op, data))

    def passwh_handler(self, key, op, data):
        self.passwcfg.passw_policies_update(key, data)
        syslog.syslog(syslog.LOG_INFO, 'PASSW_HARDENING Update: key: {}, op: {}, data: {}'.format(key, op, data))

    def ssh_handler(self, key, op, data):
        self.sshscfg.policies_update(key, data)
        syslog.syslog(syslog.LOG_INFO, 'SSH Update: key: {}, op: {}, data: {}'.format(key, op, data))

    def tacacs_server_handler(self, key, op, data):
        self.aaacfg.tacacs_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, 'TACPLUS_SERVER update: key: {}, op: {}, data: {}'.format(key, op, log_data))

    def tacacs_global_handler(self, key, op, data):
        self.aaacfg.tacacs_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, 'TACPLUS Global update: key: {}, op: {}, data: {}'.format(key, op, log_data))

    def radius_server_handler(self, key, op, data):
        self.aaacfg.radius_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, 'RADIUS_SERVER update: key: {}, op: {}, data: {}'.format(key, op, log_data))

    def radius_global_handler(self, key, op, data):
        self.aaacfg.radius_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, 'RADIUS Global 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)
        self.aaacfg.handle_radius_source_intf_ip_chg(mgmt_intf_name)
        self.aaacfg.handle_radius_nas_ip_chg(mgmt_intf_name)
        self.mgmtifacecfg.update_mgmt_iface(mgmt_intf_name, key, data)

    def mgmt_vrf_handler(self, key, op, data):
        self.mgmtifacecfg.update_mgmt_vrf(data)

    def lpbk_handler(self, key, op, data):
        key = ConfigDBConnector.deserialize_key(key)
        if op == "DEL":
            add = False
        else:
            add = True

        self.iptables.iptables_handler(key, data, add)
        lpbk_name = self.__get_intf_name(key)
        self.ntpcfg.handle_ntp_source_intf_chg(lpbk_name)
        self.aaacfg.handle_radius_source_intf_ip_chg(key)

    def vlan_intf_handler(self, key, op, data):
        key = ConfigDBConnector.deserialize_key(key)
        self.aaacfg.handle_radius_source_intf_ip_chg(key)

    def vlan_sub_intf_handler(self, key, op, data):
        key = ConfigDBConnector.deserialize_key(key)
        self.aaacfg.handle_radius_source_intf_ip_chg(key)

    def portchannel_intf_handler(self, key, op, data):
        key = ConfigDBConnector.deserialize_key(key)
        self.aaacfg.handle_radius_source_intf_ip_chg(key)

    def phy_intf_handler(self, key, op, data):
        key = ConfigDBConnector.deserialize_key(key)
        self.aaacfg.handle_radius_source_intf_ip_chg(key)

    def ntp_global_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_NOTICE, 'Handling NTP global config')
        self.ntpcfg.ntp_global_update(key, data)

    def ntp_srv_key_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_NOTICE, 'Handling NTP server/key config')
        self.ntpcfg.ntp_srv_key_update(
            self.config_db.get_table(swsscommon.CFG_NTP_SERVER_TABLE_NAME),
            self.config_db.get_table(swsscommon.CFG_NTP_KEY_TABLE_NAME))

    def kdump_handler (self, key, op, data):
        syslog.syslog(syslog.LOG_INFO, 'Kdump handler...')
        self.kdumpCfg.kdump_update(key, data)

    def device_metadata_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_INFO, 'DeviceMeta handler...')
        self.devmetacfg.hostname_update(data)
        self.devmetacfg.timezone_update(data)

    def rsyslog_handler(self):
        rsyslog_config = self.config_db.get_table(
            swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME)
        rsyslog_servers = self.config_db.get_table(
            swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME)
        self.rsyslogcfg.update_rsyslog_config(rsyslog_config, rsyslog_servers)

    def rsyslog_server_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_INFO, 'SYSLOG_SERVER table handler...')
        self.rsyslog_handler()

    def rsyslog_config_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_INFO, 'SYSLOG_CONFIG table handler...')
        self.rsyslog_handler()

    def dns_nameserver_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_INFO, 'DNS nameserver handler...')
        self.dnscfg.dns_update(key, data)

    def fips_config_handler(self, key, op, data):
        syslog.syslog(syslog.LOG_INFO, 'FIPS table handler...')
        data = self.config_db.get_table("FIPS")
        self.fipscfg.fips_handler(data)

    def wait_till_system_init_done(self):
        # No need to print the output in the log file so using the "--quiet"
        # flag
        systemctl_cmd = ["sudo", "systemctl", "is-system-running", "--wait", "--quiet"]
        subprocess.call(systemctl_cmd)

    def register_callbacks(self):

        def make_callback(func):
            def callback(table, key, data):
                if data is None:
                    op = "DEL"
                    data = {}
                else:
                    op = "SET"
                return func(key, op, data)
            return callback

        self.config_db.subscribe('KDUMP', make_callback(self.kdump_handler))
        # Handle AAA, TACACS and RADIUS related tables
        self.config_db.subscribe('AAA', make_callback(self.aaa_handler))
        self.config_db.subscribe('TACPLUS', make_callback(self.tacacs_global_handler))
        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('PASSW_HARDENING', make_callback(self.passwh_handler))
        self.config_db.subscribe('SSH_SERVER', make_callback(self.ssh_handler))
        # Handle IPTables configuration
        self.config_db.subscribe('LOOPBACK_INTERFACE', make_callback(self.lpbk_handler))
        # Handle updates to src intf changes in radius
        self.config_db.subscribe('MGMT_INTERFACE', make_callback(self.mgmt_intf_handler))
        self.config_db.subscribe('VLAN_INTERFACE', make_callback(self.vlan_intf_handler))
        self.config_db.subscribe('VLAN_SUB_INTERFACE', make_callback(self.vlan_sub_intf_handler))
        self.config_db.subscribe('PORTCHANNEL_INTERFACE', make_callback(self.portchannel_intf_handler))
        self.config_db.subscribe('INTERFACE', make_callback(self.phy_intf_handler))

        # Handle DEVICE_MEATADATA changes
        self.config_db.subscribe(swsscommon.CFG_DEVICE_METADATA_TABLE_NAME,
                                 make_callback(self.device_metadata_handler))

        # Handle MGMT_VRF_CONFIG changes
        self.config_db.subscribe(swsscommon.CFG_MGMT_VRF_CONFIG_TABLE_NAME,
                                 make_callback(self.mgmt_vrf_handler))

        # Handle SYSLOG_CONFIG and SYSLOG_SERVER changes
        self.config_db.subscribe(swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME,
                                 make_callback(self.rsyslog_config_handler))
        self.config_db.subscribe(swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME,
                                 make_callback(self.rsyslog_server_handler))

        self.config_db.subscribe('DNS_NAMESERVER', make_callback(self.dns_nameserver_handler))

        # Handle FIPS changes
        self.config_db.subscribe('FIPS', make_callback(self.fips_config_handler))

        # Handle NTP, NTP_SERVER, and NTP_KEY updates
        self.config_db.subscribe(swsscommon.CFG_NTP_GLOBAL_TABLE_NAME,
                                 make_callback(self.ntp_global_handler))
        self.config_db.subscribe(swsscommon.CFG_NTP_SERVER_TABLE_NAME,
                                 make_callback(self.ntp_srv_key_handler))
        self.config_db.subscribe(swsscommon.CFG_NTP_KEY_TABLE_NAME,
                                 make_callback(self.ntp_srv_key_handler))

        syslog.syslog(syslog.LOG_INFO,
                      "Waiting for systemctl to finish initialization")
        self.wait_till_system_init_done()
        syslog.syslog(syslog.LOG_INFO,
                      "systemctl has finished initialization -- proceeding ...")

    def start(self):
        self.config_db.listen(init_data_handler=self.load)

def main():
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGHUP, signal_handler)
    daemon = HostConfigDaemon()
    daemon.register_callbacks()
    daemon.start()

if __name__ == "__main__":
    main()

