#!/opt/cloudlinux/venv/bin/python3
# -*- coding: utf-8 -*-

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2025 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

import argparse
import errno
import json
import logging
import os
import pwd
import re
import stat
import subprocess
import sys
from urllib.parse import urlparse

from clwpos.logsetup import setup_logging
from clwpos.optimization_features import Feature

# Set up logger
logger = setup_logging(
    caller_name='wp-toolkit-clone-awp-settings',
    console_level=logging.INFO,
    file_level=logging.DEBUG,
    logfile_path='/var/log/clwpos/wp-toolkit-clone.log'
)

# Any whitespace or control character (incl. NUL / newline) in a domain or
# wp_path is malformed and has no place in a cloudlinux-awp-user argument.
_UNSAFE_CHARS_RE = re.compile(r'[\x00-\x1f\x7f\s]')


def is_safe_argv_value(value):
    """True if value is safe to pass to cloudlinux-awp-user as a domain/wp_path.

    Values are passed in the ``--opt=value`` form, so a leading '-' cannot be
    re-parsed as a separate option; we still reject option-like and malformed
    inputs as defense-in-depth:
      * must be a non-empty string
      * must not start with '-' (option-like)
      * must not contain whitespace or control characters

    This intentionally accepts everything extract_site_details() can legitimately
    produce from a urlparse netloc/path -- hostnames with ports ('host:8443'),
    IPv6 literals ('[::1]', '[::1]:8443'), percent-encoded path segments
    ('blog%20site') and IDN/Unicode domains -- which the previous strict ASCII
    allowlist wrongly refused.
    """
    if not isinstance(value, str) or not value:
        return False
    if value.startswith('-'):
        return False
    return not _UNSAFE_CHARS_RE.search(value)

def get_wp_toolkit_instance_info(instance_id):
    """Get WP Toolkit instance information

    Returns JSON data from WP Toolkit API in the following format:
    {
      "id": 3,
      "siteUrl": "https://us17.com/wp1",
      "fullPath": "/var/www/vhosts/us17.com/httpdocs/wp1",
      "mainDomainId": 3,
      "path": "/httpdocs/wp1",
      "ownerId": 4,
      "name": "Exquisite Goods",
      "version": "6.8.1",
      "alive": true,
      "stateText": "Working",
      ...  // other fields
    }

    Key fields used by this script:
    - id: WordPress instance ID (for validation)
    - siteUrl: Full URL of the WordPress site (used to extract domain and wp_path)  
    - fullPath: Absolute filesystem path (used to determine site owner)

    Args:
        instance_id (str|int): WP Toolkit instance ID

    Returns:
        dict|None: Instance data on success, None on failure
    """
    logger.debug(f"Getting WP Toolkit instance info for ID: {instance_id}")

    try:
        # Simple panel detection - check if Plesk binary exists
        if os.path.exists('/usr/sbin/plesk'):
            cmd = ['/usr/sbin/plesk', 'ext', 'wp-toolkit']
        else:
            cmd = ['/usr/local/bin/wp-toolkit']

        cmd.extend(['-info', '-instance-id', str(instance_id), '-format', 'json'])

        result = subprocess.run(cmd, capture_output=True, text=True, check=True)

        instance = json.loads(result.stdout)

        if isinstance(instance, dict) and 'id' in instance:
            if str(instance.get('id')) == str(instance_id):
                return instance
            else:
                logger.error(f"Instance ID mismatch: expected {instance_id}, got {instance.get('id')}")
                return None
        else:
            logger.error(f"Invalid instance data structure: {type(instance)}")
            return None

    except subprocess.CalledProcessError as e:
        logger.error(f"WP Toolkit command failed with return code {e.returncode}")
        logger.debug(f"Command stderr: {e.stderr}")
        return None
    except json.JSONDecodeError as e:
        logger.error(f"Failed to parse WP Toolkit JSON output: {e}")
        return None
    except Exception as e:
        logger.error(f"Unexpected error getting instance info: {e}")
        return None

def extract_site_details(instance):
    """Extract domain and wp_path from WP Toolkit instance data"""
    if not instance or not isinstance(instance, dict):
        logger.error(f"Invalid instance data: {type(instance)}")
        return None, None

    try:
        site_url = instance.get('siteUrl', '')

        if not site_url:
            logger.error("Missing siteUrl in instance data")
            return None, None

        # Parse URL properly using standard library
        parsed = urlparse(site_url)
        domain = parsed.netloc

        # Get path and normalize it
        wp_path = parsed.path.strip('/')
        if not wp_path:
            wp_path = '/'

        return domain, wp_path

    except Exception as e:
        logger.error(f"Error extracting site details: {e}")
        return None, None

# Maximum number of symlink expansions before we give up, mirroring the
# kernel's ELOOP guard so a cyclic root-owned link chain cannot spin forever.
_SYMLINK_EXPANSION_CAP = 40


def _stat_no_symlink(full_path):
    """Stat an absolute path, refusing only attacker-plantable symlinks.

    Walks each component from the filesystem root with O_NOFOLLOW, keeping a
    worklist of components still to resolve. When a component is a symlink the
    open fails with ELOOP; we then lstat the link itself (relative to its
    parent dir_fd, so the owner is read from the same object the kernel just
    refused) and decide by ownership:

    - root-owned (uid 0) symlinks are trusted platform/vendor links — e.g.
      cPanel's root-owned ``/home -> /home2`` partition link — an
      unprivileged user cannot create one. Instead of re-opening the link
      WITHOUT O_NOFOLLOW (which would let the kernel resolve the link's whole
      stored target in one shot, silently following any symlink embedded
      inside that target), we read the target with ``os.readlink`` and push
      its components back onto the worklist so each one is re-validated
      through this SAME gate. An absolute target restarts resolution from
      ``/``; a relative target continues from the symlink's parent dir_fd;
    - non-root-owned symlinks are attacker-plantable; refuse them
      (raise the original OSError).

    Every component reached — including those inside a trusted link's target —
    passes the per-component O_NOFOLLOW + root-owner gate, and ``..``/``.`` is
    refused everywhere. A symlink-expansion counter caps the total number of
    link expansions (``_SYMLINK_EXPANSION_CAP``) so a cyclic root-owned link
    chain raises OSError rather than looping forever.

    Returns the os.stat_result of the final component, read from the open
    fd to stay TOCTOU-safe. Raises OSError if any component is an
    untrusted symlink, a traversal segment, the expansion cap is exceeded,
    or the path cannot be resolved.
    """
    parent_fd = os.open('/', os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
    try:
        # Worklist of components still to resolve, head-first. We append the
        # original path's components and consume from the front; a trusted
        # symlink prepends its target's components so they are walked next.
        pending = [c for c in full_path.split('/') if c]
        expansions = 0
        while pending:
            component = pending.pop(0)
            # Refuse '..' (and defensively '.'): a '..' is not a symlink, so
            # O_NOFOLLOW does not stop the walk climbing out of the attacker's
            # own tree into a victim's directory. Reject rather than normalize
            # — a legitimate WP Toolkit docroot is a clean absolute path. This
            # gate also applies to components pulled from a symlink target.
            if component in ('..', '.'):
                raise OSError(errno.ELOOP, 'path traversal component refused', component)
            # A component is the final one only when nothing else is pending;
            # a symlink target may extend the path, so recompute each round.
            is_last = not pending
            # O_NONBLOCK so opening an attacker-planted FIFO or device as the
            # final component returns immediately instead of blocking this
            # root process until a writer appears: O_NOFOLLOW refuses symlinks
            # but not named pipes, and the last component omits O_DIRECTORY, so
            # without O_NONBLOCK a terminal FIFO would hang the clone hook (base
            # code used the never-blocking os.stat). It is a no-op for regular
            # files and directories and does not alter O_NOFOLLOW's ELOOP-on-
            # symlink behaviour the walk relies on below.
            flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
            if not is_last:
                flags |= os.O_DIRECTORY
            try:
                fd = os.open(component, flags, dir_fd=parent_fd)
            except OSError as exc:
                # O_NOFOLLOW on a symlink yields ELOOP, or ENOTDIR when
                # combined with O_DIRECTORY and the link points at a dir.
                # Confirm it really is a symlink via lstat before treating
                # the failure as a refusable link; re-raise anything else
                # (genuine ENOTDIR, ENOENT, EACCES, ...).
                if exc.errno not in (errno.ELOOP, errno.ENOTDIR):
                    raise
                link_stat = os.lstat(component, dir_fd=parent_fd)
                if not stat.S_ISLNK(link_stat.st_mode):
                    raise
                # The component is a symlink. Trust it only if it is
                # root-owned, i.e. a platform symlink no unprivileged user
                # could have planted; otherwise it is attacker-plantable.
                if link_stat.st_uid != 0:
                    raise
                expansions += 1
                if expansions > _SYMLINK_EXPANSION_CAP:
                    raise OSError(errno.ELOOP, 'too many symlink expansions', component)
                # Re-walk the trusted link's target per component through this
                # same gate rather than following the whole target string in
                # one O_NOFOLLOW-less open(). An absolute target restarts from
                # '/'; a relative target continues from the symlink's parent.
                target = os.readlink(component, dir_fd=parent_fd)
                target_components = [c for c in target.split('/') if c]
                if target.startswith('/'):
                    os.close(parent_fd)
                    parent_fd = os.open(
                        '/', os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
                pending[:0] = target_components
                continue
            os.close(parent_fd)
            parent_fd = fd
        return os.fstat(parent_fd)
    finally:
        os.close(parent_fd)


def get_site_owner(instance):
    """Get site owner by examining filesystem ownership using fullPath from WP Toolkit"""
    full_path = instance.get('fullPath', '')
    domain = instance.get('siteUrl', '').replace('https://', '').replace('http://', '').split('/')[0]

    try:
        if not full_path or not os.path.isabs(full_path):
            logger.error(f"Full path does not exist: {full_path}")
            return None

        # full_path is an attacker-writable docroot; resolve ownership with a
        # per-component O_NOFOLLOW walk so a symlink planted at any component
        # (final or intermediate) is refused rather than spoofing the owner.
        stat_info = _stat_no_symlink(full_path)
        owner_uid = stat_info.st_uid
        owner = pwd.getpwuid(owner_uid).pw_name
        return owner

    except FileNotFoundError:
        logger.error(f"Full path does not exist: {full_path}")
        return None

    except OSError as e:
        logger.error(f"Refusing to resolve owner of path {full_path}: {e}")
        return None

    except Exception as e:
        logger.error(f"Error getting site owner: {e}")
        return None

def get_awp_features(domain, wp_path, username):
    """Get AccelerateWP features for a site"""
    if not is_safe_argv_value(domain) or not is_safe_argv_value(wp_path):
        logger.error(f"Refusing unsafe domain/wp_path: {domain!r} {wp_path!r}")
        return None
    try:
        # '--opt=value' form keeps option-like values from being parsed as flags
        cmd = [
            'cloudlinux-awp-user',
            '--user', username,
            'get',
            f'--domain={domain}',
            f'--wp-path={wp_path}'
        ]

        result = subprocess.run(cmd, capture_output=True, text=True, check=True)

        features_data = json.loads(result.stdout)
        return features_data

    except subprocess.CalledProcessError as e:
        logger.error(f"cloudlinux-awp-user get failed: {e.stderr}")
        return None
    except json.JSONDecodeError as e:
        logger.error(f"Failed to parse AWP features JSON: {e}")
        return None
    except Exception as e:
        logger.error(f"Unexpected error getting AWP features: {e}")
        return None

def copy_awp_features(source_features, source_domain, source_wp_path, target_domain, target_wp_path, target_username):
    """Copy AccelerateWP features from source to target"""
    if not source_features:
        logger.error("No source features data")
        return False

    if not is_safe_argv_value(target_domain) or not is_safe_argv_value(target_wp_path):
        logger.error(
            f"Refusing unsafe target domain/wp_path: {target_domain!r} {target_wp_path!r}"
        )
        return False

    if not isinstance(source_features, dict):
        logger.error(f"Source features is not a dict: {type(source_features)}")
        return False

    if 'docroots' not in source_features:
        logger.error("Missing 'docroots' key in source features data")
        return False

    # Normalize wp_path for comparison (both '/' and '' mean root WordPress)
    normalized_source_path = '' if source_wp_path == '/' else source_wp_path

    # Find the features for the specific WordPress site that is being cloned
    wp_features = None

    for i, docroot in enumerate(source_features.get('docroots', [])):
        # Check if this docroot contains our source domain
        docroot_domains = docroot.get('domains', [])
        logger.debug(f"Docroot {i}: domains={docroot_domains}")

        if source_domain not in docroot_domains:
            logger.debug(f"Skipping docroot {i} - does not contain domain {source_domain}")
            continue

        logger.debug(f"Docroot {i} contains source domain, checking WordPress sites...")
        wps = docroot.get('wps', [])
        logger.debug(f"Found {len(wps)} WordPress sites in this docroot")

        for j, wp_site in enumerate(wps):
            site_path = wp_site.get('path', '')
            # Normalize site_path for comparison (both '/' and '' mean root WordPress)
            normalized_site_path = '' if site_path == '/' else site_path
            logger.debug(f"  WP site {j}: path='{site_path}' (normalized: '{normalized_site_path}') - comparing with '{normalized_source_path}'")

            # Check if this is exactly the WordPress site that is being cloned
            if normalized_site_path == normalized_source_path:
                wp_features = wp_site.get('features', {})
                logger.debug(f"Found source site: {source_domain}:{source_wp_path}")
                logger.debug(f"Features available: {list(wp_features.keys()) if wp_features else 'None'}")
                break

        if wp_features is not None:
            break

    if wp_features is None:
        logger.error(f"Could not find source WordPress site: {source_domain}:{source_wp_path}")
        logger.error("Available sites in docroots:")
        for i, docroot in enumerate(source_features.get('docroots', [])):
            domains = docroot.get('domains', [])
            logger.error(f"  Docroot {i}: domains={domains}")
            for j, wp_site in enumerate(docroot.get('wps', [])):
                path = wp_site.get('path', '')
                logger.error(f"    WP site {j}: path='{path}'")
        return False

    if not wp_features:
        logger.info("No features configured on source site")
        return True

    features_to_copy = []

    # Use centralized feature mapping from Feature class
    for feature_name, feature_data in wp_features.items():
        if isinstance(feature_data, dict) and feature_data.get('enabled'):
            try:
                # Use centralized mapping from Feature class
                feature_obj = Feature(feature_name)
                cli_feature_name = feature_obj.to_interface_name()
                features_to_copy.append(cli_feature_name)
            except Exception as e:
                logger.warning(f"Unknown feature '{feature_name}': {e}")
                continue

    if not features_to_copy:
        logger.info("No features enabled on source site")
        return True

    logger.info(f"Copying features: {features_to_copy}")

    # Enable features on target
    success = True
    for feature in features_to_copy:
        try:
            # '--opt=value' form keeps option-like values from being parsed as flags
            cmd = [
                'cloudlinux-awp-user',
                '--user', target_username,
                'enable',
                '--feature', feature,
                f'--domain={target_domain}',
                f'--wp-path={target_wp_path}',
                '--ignore-errors'
            ]

            result = subprocess.run(cmd, capture_output=True, text=True, check=True)
            logger.info(f"Enabled {feature}")

        except subprocess.CalledProcessError as e:
            logger.error(f"Failed to enable {feature}: {str(e)}")
            success = False
        except Exception as e:
            logger.error(f"Unexpected error enabling {feature}: {e}")
            success = False

    return success

def main():
    parser = argparse.ArgumentParser(description='Copy AccelerateWP settings from parent site to clone')
    parser.add_argument('--source-instance-id', required=True, help='Source WP Toolkit instance ID')
    parser.add_argument('--target-instance-id', required=True, help='Target WP Toolkit instance ID')
    parser.add_argument('--quiet', action='store_true', help='Suppress output messages')

    args, unknown = parser.parse_known_args()

    # Unknown arguments are normal in some contexts (e.g., WP Toolkit hook for cPanel)
    if unknown:
        logger.info(f"Unknown arguments: {unknown}")

    # Adjust logging level if quiet mode requested
    if args.quiet:
        logger.setLevel(logging.ERROR)
        for handler in logger.handlers:
            handler.setLevel(logging.ERROR)

    logger.info(f"Starting AWP copy: {args.source_instance_id} → {args.target_instance_id}")

    # Get source instance info
    source_instance = get_wp_toolkit_instance_info(args.source_instance_id)
    if not source_instance:
        logger.error("Failed to get source instance information")
        sys.exit(1)

    # Get target instance info
    target_instance = get_wp_toolkit_instance_info(args.target_instance_id)
    if not target_instance:
        logger.error("Failed to get target instance information")
        sys.exit(1)

    # Extract site details
    source_domain, source_wp_path = extract_site_details(source_instance)
    target_domain, target_wp_path = extract_site_details(target_instance)

    if not all([source_domain, target_domain]):
        logger.error("Failed to extract site details")
        sys.exit(1)

    # Get site owners
    source_username = get_site_owner(source_instance)
    target_username = get_site_owner(target_instance)

    if not all([source_username, target_username]):
        logger.error("Failed to determine site owners")
        sys.exit(1)

    logger.info(f"Source: {source_domain}:{source_wp_path} ({source_username})")
    logger.info(f"Target: {target_domain}:{target_wp_path} ({target_username})")

    # Get source AWP features
    source_features = get_awp_features(source_domain, source_wp_path, source_username)
    if not source_features:
        logger.error("Failed to get source AWP features")
        sys.exit(1)

    # Copy features to target
    if copy_awp_features(source_features, source_domain, source_wp_path, target_domain, target_wp_path, target_username):
        logger.info("AWP settings copied successfully")
        sys.exit(0)
    else:
        logger.error("Failed to copy AWP settings")
        sys.exit(1)

if __name__ == '__main__':
    main() 
