#!/usr/bin/python3
import argparse, json, logging, os, re, shutil, signal, subprocess, tempfile
import xml.etree.ElementTree as ET
from stat import *
from subprocess import PIPE
from xml.dom import minidom
import common

def run_program(args, fail_reason = ""):
    logging.debug('run: ' + str(args))
    result = subprocess.run(args, stdout=PIPE, stderr=PIPE)
    if fail_reason != "" and result.returncode == 0:
        common.fatal_error('Expected: ' + args[0] + ' to fail: ' + fail_reason)
    if fail_reason == "" and result.returncode != 0:
        common.fatal_error('Error in running: ' + args[0] + ' errors: ' + result.stdout.decode('utf-8') + ' ' + result.stderr.decode('utf-8'))
    return result.stdout.decode('utf-8')

def run_shell_program(cmd, fail_reason = ""):
    logging.debug('run: ' + cmd)
    result = subprocess.run(cmd, shell=True, stdout=PIPE, stderr=PIPE)
    if fail_reason != "" and result.returncode == 0:
        common.fatal_error('Expected: ' + cmd + ' to fail: ' + fail_reason)
    if fail_reason == "" and result.returncode != 0:
        common.fatal_error('Error in running: ' + cmd + ' errors: ' + result.stdout.decode('utf-8') + ' ' + result.stderr.decode('utf-8'))
    return result.stdout.decode('utf-8')

def systemctl_daemon_reload():
    logging.debug("Do daemon-reload")
    run_program(['systemctl', 'daemon-reload'])

def validate_environment(args):
    if not args.namespace_only and not args.disable and not os.path.exists('/sys/fs/cgroup/cgroup.controllers'):
        common.fatal_error("cgroups is not v2 on this machine")
    if os.getuid() != 0:
        common.fatal_error("this program must be run as root")
    if not args.namespace_only and not args.disable and not os.path.isfile('/etc/systemd/system.control/user.slice.d/50-IOAccounting.conf'):
        logging.debug('Activate accounting')
        run_program(['systemctl', 'set-property', 'user.slice', 'IOAccounting=yes', 'MemoryAccounting=yes', 'TasksAccounting=yes'])
        systemctl_daemon_reload()
    
def init_pgm():
    common.init_logging()
    parser = argparse.ArgumentParser(prog="setup",
                                     description='LiteSpeed Containers Setup Program')
    parser.add_argument('-l', '--log', type=int, help='set logging level, 10=Debug, 20=Info, 30=Warning, 40=Error.  Default is Info')
    parser.add_argument('-q', '--quiet', action='store_true', help='turns off all logging and only outputs what is requested.')
    parser.add_argument('-s', '--server_root', default=common.server_root(), help='the LiteSpeed SERVER_ROOT')
    parser.add_argument('-c', '--cgroups', type=int, default=2, help='The minimum value for cgroups')
    parser.add_argument('-i', '--cgroups-init', type=int, default=2, help='The cgroups value if not set currently')
    parser.add_argument('-n', '--namespace', type=int, default=2, help='The minimum value for namespace')
    parser.add_argument('-m', '--namespace-init', type=int, default=2, help='The namespace value if not set currently')
    parser.add_argument('-g', '--no-config', action='store_true', help='Skips checking of LiteSpeed Config')
    parser.add_argument('-t', '--no-subtree_control', action='store_true', help='Skips checking of system cgroup.subtree_control file')
    parser.add_argument('-u', '--no-upgrade', action='store_true', help='Does not check the version of LiteSpeed')
    parser.add_argument('-r', '--revert-config', action='store_true', help='Reverts modified LiteSpeed config file')
    parser.add_argument('-o', '--namespace-only', action='store_true', help='No cgroups, namespaces only')
    parser.add_argument('-d', '--disable', action='store_true', help='LiteSpeed Containers Disable')
    args = parser.parse_args()
    if not args.quiet or args.log != None:
        if args.log != None:
            logging.getLogger().setLevel(args.log)
        else:
            logging.getLogger().setLevel(logging.INFO)
        logging.debug("Entering setup")
    common.set_server_root(args.server_root)
    validate_environment(args)
    return args

def config_filename(lsws):
    if lsws:
        return common.server_root() + "/conf/httpd_config.xml"
    return common.server_root() + "/conf/httpd_config.conf"

def config_filename_revert(lsws):
    return config_filename(lsws) + '_lssetup'

def is_lsws():
    if (os.path.isfile(config_filename(True))):
        return True
    if (os.path.isfile(config_filename(False))):
        return False
    common.fatal_error("Can not identify the LiteSpeed system type")

def copy_file_details(source, dest):
    s = os.stat(source)
    shutil.copystat(source, dest)
    os.chown(dest, s.st_uid, s.st_gid)

def _append_subelement(parent, tag, text):
    """ET.SubElement-like append that preserves the parent's existing
    indentation so the new element sits on its own line rather than being
    jammed against the parent's closing tag.

    In ElementTree, the whitespace that appears in the serialized XML between
    elements lives in `.text` (before the first child) and `.tail` (after each
    child). When we append a new child we have to redistribute these so:
      - the element that used to be the last child keeps a sibling-indent in
        its tail (so the new child sits one level in), and
      - the new child gets the closing-tag-indent (so the parent's close stays
        where it was)."""
    if len(parent) == 0:
        # No existing children: synthesize a reasonable indent.
        sibling_indent = (parent.text or '\n') + '  '
        closing_indent = parent.text or '\n'
        parent.text = sibling_indent
        new = ET.SubElement(parent, tag)
        new.text = text
        new.tail = closing_indent
        return new

    # The previous-last child's tail currently sits between it and the parent's
    # closing tag — that's the closing-tag indent. We need to repurpose it as
    # the new child's tail (so the parent's close stays put) and give the
    # previous-last child a true sibling-indent.
    closing_indent = parent[-1].tail or '\n'
    if len(parent) >= 2:
        # The element BEFORE the previous-last child has the canonical
        # sibling-indent in its tail.
        sibling_indent = parent[-2].tail or closing_indent
    else:
        # Only one prior child — use parent.text (which is the indent before
        # the first child, also a sibling-indent).
        sibling_indent = parent.text or closing_indent

    parent[-1].tail = sibling_indent
    new = ET.SubElement(parent, tag)
    new.text = text
    new.tail = closing_indent
    return new

def set_cgroups(root, disable, val, init):
    valSet = False
    findLimit = root.find('./security/CGIRLimit')
    if findLimit == None:
        common.fatal_error('Unexpected missing element CGIRLimit in configuration file')
    cgroups = findLimit.find('cgroups')
    if disable:
        if cgroups != None and int(cgroups.text) > 0:
            logging.debug('cgroups needs disabling')
            cgroups.text = str(0)
            valSet = True
    elif cgroups == None:
        logging.debug('cgroups not in LSWS file')
        cgroups = _append_subelement(findLimit, 'cgroups', str(init))
        valSet = True
    elif disable and int(cgroups.text) > 0:
        logging.debug('cgroups needs disabling')
        cgroups.text = str(0)
        valSet = True
    elif int(cgroups.text) < val:
        logging.debug('cgroups bad value in LSWS file')
        cgroups.text = str(val)
        valSet = True
    if valSet:
        logging.debug('cgroups set')
    else:
        logging.debug('cgroups ok')
        
    return valSet    

def set_namespace(root, disable, val, init):
    valSet = False
    findSecurity = root.find('./security')
    if findSecurity == None:
        common.fatal_error('Unexpected missing element Security in configuration file')
    namespace = findSecurity.find('namespace')
    if disable:
        if namespace != None and int(namespace.text) > 0:
            logging.debug('namespace needs disabling')
            namespace.text = str(0)
            valSet = True
    elif namespace == None:
        logging.debug('namespace not in LSWS file')
        namespace = _append_subelement(findSecurity, 'namespace', str(init))
        valSet = True
    elif int(namespace.text) < val:
        logging.debug('namespace bad value in LSWS file')
        namespace.text = str(val)
        valSet = True
    if valSet:
        logging.debug('namespace set')
    else:
        logging.debug('namespace ok')
        
    return valSet    

def lsws_restart():
    # Canonical LiteSpeed PID file. If it's missing, LiteSpeed isn't running.
    pidfile = '/tmp/lshttpd/lshttpd.pid'
    if not os.path.isfile(pidfile):
        logging.debug("LiteSpeed not running")
        return
    try:
        with open(pidfile, 'r') as pf:
            pid_str = pf.readline().strip()
    except OSError as err:
        common.fatal_error("Cannot read LiteSpeed pid file %s: %s" % (pidfile, err))
    if not pid_str:
        logging.debug("LiteSpeed not running")
        return
    try:
        pid = int(pid_str)
    except ValueError:
        common.fatal_error("Bad LiteSpeed pid value: %r" % pid_str)
    # Sanity-check: the pid we're about to SIGUSR1 must actually be the
    # LiteSpeed server, owned by root. Prevents racing into a recycled pid
    # that belongs to a non-LiteSpeed process (e.g. an unprivileged user's
    # process). Match on exe basename prefix, since the actual binary name
    # varies: OpenLiteSpeed uses 'openlitespeed'; LiteSpeed Enterprise uses
    # a versioned 'lshttpd.<ver>' (e.g. 'lshttpd.6.3.5'); some builds use
    # 'litespeed'/'lshttpd'. The 'lshttpd' label in the process comm/args is
    # not the executable path.
    lsws_exe_prefixes = ('openlitespeed', 'litespeed', 'lshttpd')
    try:
        exe = os.readlink('/proc/%d/exe' % pid)
        st = os.stat('/proc/%d' % pid)
    except OSError as err:
        common.fatal_error("Cannot verify LiteSpeed pid %d: %s" % (pid, err))
    if st.st_uid != 0 or not os.path.basename(exe).startswith(lsws_exe_prefixes):
        common.fatal_error(
            "Refusing to signal pid %d: exe=%r uid=%d (not a root-owned lshttpd)"
            % (pid, exe, st.st_uid))
    try:
        os.kill(pid, signal.SIGUSR1)
    except OSError as err:
        common.fatal_error("Error sending graceful restart to ListSpeed: %s" % err)
    logging.debug("LiteSpeed restarted")
    
def lsws_config(args):
    file = config_filename(True)
    try:
        tree = ET.parse(file)
    except Exception as err:
        common.fatal_error('Error parsing configuration: %s' % err)
    revert = config_filename_revert(True)
    shutil.copy2(file, revert)
    copy_file_details(file, revert)
    if not args.namespace_only:
        valSet = set_cgroups(tree.getroot(), args.disable, args.cgroups, args.cgroups_init)
    if set_namespace(tree.getroot(), args.disable, args.namespace, args.namespace_init):
        valSet = True
    if valSet:
        try:
            #ET.indent(tree, space='  ')
            # Atomic, symlink-safe write: write to a randomized temp file in the
            # same directory, then rename. Defeats a planted symlink at `file`.
            tmp_fd, tmp_path = tempfile.mkstemp(
                prefix='.httpd_config.', suffix='.xml',
                dir=os.path.dirname(file))
            try:
                with os.fdopen(tmp_fd, 'wb') as tmp_w:
                    tree.write(tmp_w, encoding="UTF-8", xml_declaration=True, short_empty_elements=False)
                copy_file_details(file, tmp_path)
                os.replace(tmp_path, file)
            except Exception:
                try:
                    os.unlink(tmp_path)
                except OSError:
                    pass
                raise
        except Exception as err:
            common.fatal_error('Error updating configuration: %s' % err)
        lsws_restart()
            
def lsws_validate(args):
    lsws_config(args)
        
def rewrite_ols_config(args, cgroupsAt, pastCGIRLimit, cgroupsVal, namespaceAt, lineNo, namespaceVal):
    stop1 = 0
    replace1 = False
    if not args.namespace_only:
        if cgroupsAt != 0:
            if (args.disable and int(cgroupsVal) > 0) or (not args.disable and int(cgroupsVal) <= args.cgroups):
                stop1 = cgroupsAt
                replace1 = True
                if args.disable:
                    setCgroups = 0
                else:
                    setCgroups = args.cgroups
        elif not args.disable:
            stop1 = pastCGIRLimit
            setCgroups = args.cgroups_init
    stop2 = 0
    replace2 = False
    if namespaceAt != 0:
        if (args.disable and int(namespaceVal) > 0) or (not args.disable and int(namespaceVal) <= args.namespace):
            stop2 = namespaceAt
            replace2 = True
            if args.disable:
                setNS = 0
            else:
                setNS = args.namespace
    elif not args.disable:
        stop2 = lineNo
        setNS = args.namespace_init
    lineNo = 0
    file = config_filename(False)
    try:
        f = open(file, 'r')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (file, err))
    # Atomic, symlink-safe rewrite: mkstemp in same dir, write, fsync-replace.
    # Previous predictable `httpd_config.out` path was vulnerable to a planted
    # symlink redirecting the write to an arbitrary file.
    try:
        fileout_fd, fileout = tempfile.mkstemp(
            prefix='.httpd_config.', suffix='.out',
            dir=os.path.dirname(file))
        w = os.fdopen(fileout_fd, 'w')
    except Exception as err:
        common.fatal_error('Error opening temp file in %s: %s' % (os.path.dirname(file), err))
    try:
        for line in f:
            replace = False
            if lineNo > 0:
                if stop1 != 0 and lineNo == stop1:
                    w.write('  cgroups %d\n' % setCgroups)
                    replace = replace1
                    logging.debug('writing cgroups %d' % setCgroups)
                elif lineNo == stop2:
                    w.write('namespace %d\n'% setNS)
                    replace = replace2
                    logging.debug('writing namespace %d' % setNS)
            if not replace:
                w.write(line)
            lineNo += 1
        f.close()
        w.close()
        copy_file_details(file, fileout)
        os.replace(fileout, file)
    except Exception as err:
        try:
            os.unlink(fileout)
        except OSError:
            pass
        common.fatal_error('Error writing OLS config file: %s' % err)

def ols_config(args):
    logging.debug('OLS config file')
    file = config_filename(False)
    try:
        f = open(file, 'r')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (file, err))
    revert = config_filename_revert(False)
    shutil.copy2(file, revert)
    copy_file_details(file, revert)
    # Optimize for the do nothing case
    inCGIRLimit = False
    pastCGIRLimit = 0
    cgroupsAt = 0
    cgroupsLine = ''
    namespaceAt = 0
    namespaceLine = ''
    lineNo = 0
    cgroupsVal = 0
    namespaceVal = 0
    for line in f:
        if pastCGIRLimit == 0:
            if not inCGIRLimit:
                if line.startswith('CGIRLimit'):
                    inCGIRLimit = True
            elif line.find('}') != -1:
                pastCGIRLimit = lineNo
            elif not args.namespace_only and line.startswith('  cgroups'):
                cgroupsLine = line
                cgroupsAt = lineNo
        elif line.startswith('namespace'):
            namespaceLine = line
            namespaceAt = lineNo
            break
        elif line.find('{') != -1:
            break
        lineNo += 1
    f.close()
    if not args.namespace_only and cgroupsLine != '':
        cgroupsVal = re.search(r'[\d]+', cgroupsLine).group()
        logging.debug('OLS cgroups val: %d' % int(cgroupsVal))   
    if namespaceLine != '':
        namespaceVal = re.search(r'[\d]+', namespaceLine).group()
        logging.debug('OLS namespace val: %d' % int(namespaceVal))   
    if args.disable:
        if (args.namespace_only or cgroupsLine == '' or int(cgroupsVal) == 0) and (namespaceLine == '' or int(namespaceVal) == 0):
            logging.debug('OLS config file needs no mod')
            return
    elif (args.namespace_only or (cgroupsLine != '' and int(cgroupsVal) >= args.cgroups)) and namespaceLine != '' and int(namespaceVal) >= args.namespace:
        logging.debug('OLS config file needs no mod')   
        return
    logging.debug('OLS config file needs mod')   
    rewrite_ols_config(args, cgroupsAt, pastCGIRLimit, cgroupsVal, namespaceAt, lineNo, namespaceVal)
    lsws_restart()
        
def ols_validate(args):
    ols_config(args)

def read_oneline_file(file):
    try:
        f = open(file, 'r')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (file, err))
    ln = f.readline()
    line = ln.rstrip()
    f.close()
    return line

def read_subtree_control():
    line = read_oneline_file('/sys/fs/cgroup/cgroup.subtree_control')
    pieces = line.split(' ')
    logging.debug('Read line: %s got %s' % (line, ' '.join(pieces)))
    return pieces

def build_delegate(controllers):
    dirname = '/etc/systemd/system/user@.service.d'
    filename = dirname + '/delegate.conf'
    desired = "[Service]\nDelegate=%s\n" % ' '.join(controllers)

    # Idempotent: if the file already exists with the exact contents we'd
    # write, this is a re-run of a successful Setup — return silently and
    # don't bother systemd with a daemon-reload. If it exists with
    # different contents, overwrite (the controller set may have changed,
    # or a prior run left a partial/stale file). Only the create path
    # needs the mkdir attempt.
    if os.path.exists(filename):
        try:
            with open(filename, 'r') as r:
                existing = r.read()
        except Exception as err:
            common.fatal_error('Error reading %s: %s' % (filename, err))
        if existing == desired:
            logging.debug(
                '%s already matches desired controller set; nothing to do'
                % filename
            )
            return
        logging.debug(
            '%s exists with stale contents; overwriting' % filename
        )
    else:
        try:
            os.mkdir(dirname)
        except Exception as err:
            logging.debug("Error creating service directory: %s" % err)

    # Atomic, symlink-safe write of delegate.conf in systemd drop-in dir.
    try:
        tmp_fd, tmp_path = tempfile.mkstemp(
            prefix='.delegate.', suffix='.conf', dir=dirname)
        try:
            with os.fdopen(tmp_fd, 'w') as w:
                w.write(desired)
            os.chmod(tmp_path, 0o644)
            os.replace(tmp_path, filename)
        except Exception:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (filename, err))
    systemctl_daemon_reload()

def subtree_control():
    pieces = read_subtree_control()
    controllers = ['cpu', 'cpuset', 'io', 'memory', 'pids']
    missing = []
    for controller in controllers:
        if not controller in pieces:
            missing.append(controller)
    if len(missing) == 0 or (len(missing) == 1 and missing[0] == 'cpuset'):
        logging.debug('No controller missing')
        return
    logging.debug('Missing the %s controllers' % ' '.join(missing))
    build_delegate(controllers)

def compare_versions(verstr, minver):
    verlist = verstr.split('.')
    minlist = minver.split('.')
    for index, min in enumerate(minlist):
        if index > len(verlist):
            logging.debug('Old version %s < %s (short)' % (verstr, minver))
            return True
        dash_index = verlist[index].find('-')
        if dash_index != -1:
            verlist[index] = verlist[index][0:dash_index]
        if int(min) > int(verlist[index]):
            logging.debug('Old version %s < %s' % (verstr, minver))
            return True
        if int(min) < int(verlist[index]):
            logging.debug('Newer version %s > %s' % (verstr, minver))
            return False
    logging.debug('Equal version %s == %s' % (verstr, minver))
    return False

def get_minver():
    if is_lsws():
        return '6.3'
    return '1.7'
    
def needs_upgrade():
    verstr = read_oneline_file(common.server_root() + '/VERSION')
    return compare_versions(verstr, get_minver())
    
def upgrade():
    autostr = read_oneline_file(common.server_root() + '/autoupdate/release')
    upgrade_pgm = common.server_root() + '/admin/misc/lsup.sh'
    logging.info('Doing LiteSpeed upgrade')
    if not compare_versions(autostr, get_minver()):
        run_program([upgrade_pgm, '-f'])
    else:
        run_program([upgrade_pgm, '-d', '-f', '-v', get_minver()])
        
def revert_config():
    lsws = is_lsws()
    if not os.path.isfile(config_filename_revert(lsws)):
        common.fatal_error("You do not have a config file to revert")
    logging.info('Doing LiteSpeed config file revert')
    os.replace(config_filename_revert(lsws), config_filename(lsws))
    lsws_restart()

def do_pgm(args):
    logging.debug("Entering setup")
    if args.revert_config:
        revert_config()
        return;
    if not args.no_upgrade:
        if needs_upgrade():
            upgrade()
    if not args.no_config:
        if is_lsws():
            lsws_validate(args)
        else:
            ols_validate(args)
    else:
        logging.debug('no-config specified')
    if not args.disable and not args.no_subtree_control and not args.namespace_only:
        subtree_control()
    else:
        logging.debug('no-subtree_control specified')

def main():
    args = init_pgm()
    return do_pgm(args)
  
if __name__ == "__main__":
    main()
    
