File: //sbin/wp-toolkit-clone-awp-settings
#!/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()