| Current Path : /opt/cloudlinux/venv/lib64/python3.11/site-packages/xray/internal/ |
| Current File : //opt/cloudlinux/venv/lib64/python3.11/site-packages/xray/internal/types.py |
# -*- coding: utf-8 -*-
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
"""
This module contains classes for X-Ray internal objects
"""
import errno
import hashlib
import logging
import os
import re
import pwd
import stat
import subprocess
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from fnmatch import fnmatchcase
from typing import Iterator, List, Optional, NamedTuple
from urllib.parse import urlparse
from clcommon.const import Feature
from clcommon.cpapi import is_panel_feature_supported
from xray import gettext as _
from .constants import (
is_allowed_ini_path,
request_data_storage,
api_server,
tasks_base_storage,
part_delimiter,
task_delimiter,
)
from .exceptions import XRayManagerError, XRayManagerExit
from .utils import (
timestamp,
skeleton_update,
dbm_storage_update,
cagefsctl_get_prefix,
_selectorctl_get_version,
_is_selector_phpd_location_set,
_is_cagefs_enabled,
_cagefsctl_remount,
set_privileges,
)
from ..reconfiguration.global_ini import is_global_ini_mode
from ..reconfiguration.website_isolation import (
remove_website_isolation_ini,
update_website_isolation_ini,
)
logger = logging.getLogger("types")
# Upper bound for an xray.ini / xray.tasks read. A legitimate file is a few
# lines; the cap defends a TOCTOU race where the inode grows between fstat and
# read. Matches website_isolation.MAX_INI_SIZE.
_MAX_READ_SIZE = 64 * 1024
# Roots the daemon itself writes to besides the PHP ini trees: the per-uid
# tasks storage (tasks_base_storage=/usr/share/alt-php-xray-tasks/<uid>, i.e.
# xray.tasks and fake-id/real-id files) and the daemon-local state under
# /usr/share/alt-php-xray (local_tasks_storage, continuous_storage,
# request_data_storage, ...).
_DAEMON_STORAGE_ROOTS = (tasks_base_storage, "/usr/share/alt-php-xray")
def _is_allowed_write_dir(path: str) -> bool:
"""True if the daemon may write into ``path``: an allowlisted PHP ini
directory (is_allowed_ini_path) or one of the daemon's own root-owned
storage roots (xray.tasks / fake-id files, continuous-tracing and
request-data state).
``path`` is the CANONICAL path of an already-pinned directory fd (read
back from /proc/self/fd by unified_write/unified_erase), so the prefix
checks below are race-free: they describe exactly the inode the
subsequent openat/renameat/unlinkat operate on.
"""
if is_allowed_ini_path(path):
return True
for root in _DAEMON_STORAGE_ROOTS:
root = root.rstrip("/")
if path == root or path.startswith(root + os.sep):
return True
return False
class URL(NamedTuple):
"""
Representation of an URL
"""
domain_name: str
uri_path: str
class ContinuousTask(NamedTuple):
"""
Representation of a continuous tracing task
"""
creation_time: int
domain: str
original_url: str
email: str
execution_count: int
status: int
@dataclass
class User:
"""
Representation of a user entity
"""
name: str
uid: int
gid: int
path: str = field(init=False)
def __post_init__(self):
self.path = os.path.join(tasks_base_storage, str(self.uid))
def url_split(url: str) -> URL:
"""
Split URL into domain_name and uripath including query string
:param url: URL of format protocol://domain/path;parameters?query#fragment
:return: namedtuple URL(domain_name, uripath)
"""
fragments = urlparse(url)
qs = f"?{fragments.query}" if fragments.query else ""
uri = f"{fragments.path}{qs}" if fragments.path else "/"
if fragments.netloc.startswith("www."):
_no_www_netloc = fragments.netloc[4:]
else:
_no_www_netloc = fragments.netloc
_no_port_netloc = _no_www_netloc.split(":")[0]
logger.info("Parsed %s into %s:%s", url, _no_port_netloc, uri)
return URL(_no_port_netloc, uri)
class DomainInfo:
"""
Simple container class for storing information about domain.
Allows to add whatever attributes.
Implements two properties:
- version set in CL selector
- direct ini path in CageFS
"""
def __init__(self, **kwargs):
self.name = None
self.user = None
self.panel_php_version = None
self.saved_selector_php_version = "native"
self.panel_fpm = None
self.is_selector_applied: bool = False
self.php_ini_scan_dir: Optional[str] = None
for k, v in kwargs.items():
self.__setattr__(k, v)
def __repr__(self):
return str(self.__dict__)
def __str__(self):
return f"Domain {self.name}"
def add_cagefs_dirpath(self, basename: str) -> Optional[str]:
"""
Get path to ini location directory in CageFS
"""
prefix = cagefsctl_get_prefix(self.user)
if prefix is None:
raise ValueError(_("CageFS prefix resolved as None, but should be a number"))
return f"/var/cagefs/{prefix}/{self.user}/etc/cl.php.d/{basename}"
@property
def selector_php_version(self) -> Optional[str]:
"""
Get PHP version set in selector
:param domain_user: user of domain
:return: PHP version in format of 'alt-phpXY'
"""
if self.saved_selector_php_version == "native":
result = _selectorctl_get_version(self.user)
if result is not None:
out, err = result
not_err_result = "ERROR" not in err and "ERROR" not in out
if not_err_result and "native" not in out:
short_version = out.split()[0].strip()
self.saved_selector_php_version = f"alt-php{''.join(short_version.split('.'))}"
else:
# selectorctl success, but returned ERROR or native version
self.saved_selector_php_version = None
else:
# selectorctl command failed or just returned non-zero retcode
self.saved_selector_php_version = None
return self.saved_selector_php_version
@property
def selector_ini_path(self) -> Optional[str]:
"""
Resolve direct ini path in CageFS
"""
return self.add_cagefs_dirpath(self.selector_php_version)
@property
def phpd_location_ini_path(self) -> Optional[str]:
"""
Resolve direct ini path for the case of php.d.location = selector.
This, if set, affects only alt-php versions with enabled CageFS.
Also, consider that FPM is not compatible with selector behaviour
"""
alt_php_in_cage = "alt-php" in self.panel_php_version and _is_cagefs_enabled(self.user)
if _is_selector_phpd_location_set() and alt_php_in_cage and not self.panel_fpm:
return self.add_cagefs_dirpath(self.panel_php_version)
class Task:
"""
Container class for storing task data
"""
cron_file = "/etc/cron.d/xray-manager"
def __init__(
self,
*,
url: str,
client_ip: str,
tracing_by: str,
tracing_count: int,
ini_location: str = "unknown",
task_id: str = "unknown",
status: str = "unknown",
starttime: int = -1,
initial_count: int = 0,
request_count: int = 0,
auto_task: bool = False,
user: str = None,
domain_owner: str = None,
):
self.url = url
self.client_ip = client_ip
self.tracing_by = tracing_by
self.tracing_count = tracing_count
self.ini_location = ini_location
self.task_id = task_id
self.status = status
self.starttime = starttime
self.request_count = request_count
self.initial_count = tracing_count if initial_count == 0 else initial_count
self.auto_task = auto_task
self.user = user
self.domain_owner = domain_owner
self.owner_data = None
logger.debug("Instantiate Task %s: %s", self.task_id, self.as_dict())
def update_with_local_data(self, *, next_request_id):
self.request_count = next_request_id - 1
if self.tracing_by != "time":
self.tracing_count = self.initial_count - self.request_count
def __repr__(self):
return str(self.__dict__)
def __str__(self):
return f"Task {self.task_id}"
@property
def _owner(self) -> User:
""""""
if self.owner_data is None:
if self.domain_owner is not None:
user_data = pwd.getpwnam(self.domain_owner)
self.owner_data = User(name=self.domain_owner, uid=user_data.pw_uid, gid=user_data.pw_gid)
else:
raise XRayManagerError(_("Unable to operate with tracing task, because domain owner is not set"))
return self.owner_data
@property
def fake_id(self) -> str:
return hashlib.blake2b(self.task_id.encode(), digest_size=10).hexdigest()
@property
def shared_link(self) -> str:
if self.auto_task:
return f"https://{api_server}/static/xray/reports/?tracing_task_id={self.task_id}"
raise XRayManagerError(_("No shared link could be obtained for manual task"))
@property
def is_manual(self) -> bool:
"""If task is a manual one"""
return not self.auto_task
@property
def is_continuous(self) -> bool:
"""If task is a continuous one"""
return self.auto_task and self.user != "*autotracing*"
@property
def is_autotracing(self) -> bool:
"""If task is an autotracing one"""
return self.auto_task and self.user == "*autotracing*"
def as_dict(self) -> dict:
"""
Represent task as a regular dictionary without task_id field
:return: dict
"""
return {k: v for k, v in self.__dict__.items() if k not in ("task_id", "owner_data")}
def set_domain_owner(self, username: str) -> None:
"""
Update domain_owner attribute if it is not already set
"""
if self.domain_owner is None:
self.domain_owner = username
def generate(self) -> str:
"""
Generate task for writing into xray.ini file.
:return: domain_name:uripath:client_ip:tracing_task_id
"""
# encode only " chars within URL
# since we wrap the value of xray.tasks into them
url_data = part_delimiter.join(url_split(self.url)).replace('"', "%22")
task_view = part_delimiter.join((url_data, self.client_ip, self.fake_id))
logger.info("Generated task %s", task_view)
return task_view
def is_path_available(self, p: str) -> bool:
"""
Check if given path is available in user environment
"""
if not is_panel_feature_supported(Feature.CAGEFS):
# this check is skipped in case if CageFS is not available
# since in such a case cagefs_enter_user is available, but throws
# 'error while loading shared libraries: liblve.so.0'
return True
cmd = ["/sbin/cagefs_enter_user", self._owner.name, "/usr/bin/stat", p]
try:
subprocess.run(cmd, capture_output=True, check=True)
except subprocess.CalledProcessError:
logger.error("Mount %s missing", p)
return False
except (OSError, ValueError, subprocess.SubprocessError) as e:
logger.error("External command `%s` failed: %s", cmd, str(e))
return False
return True
def ini_file(self) -> str:
"""
Full path to xray.ini file
:return: xray.ini full path
"""
return os.path.join(self.ini_location, "xray.ini")
def check_xray_dir(self):
if not os.path.isdir(self._owner.path):
with set_privileges(target_uid=0, target_gid=0, mask=0o066):
os.makedirs(self._owner.path)
_cagefsctl_remount(self._owner.name)
def tasks_file(self) -> str:
"""
Full path to xray.tasks file
Create location on the fly
:return: xray.tasks full path
"""
self.check_xray_dir()
return os.path.join(self._owner.path, "xray.tasks")
def fake_id_real_id_file(self) -> str:
self.check_xray_dir()
return os.path.join(self._owner.path, self.fake_id)
@staticmethod
def _sanitize_task_id(task_id: str) -> str:
"""Validate task_id contains only safe characters to prevent
path traversal and shell injection in cron files"""
from xray.internal.constants import safe_id_pattern
if not safe_id_pattern.match(str(task_id)):
raise XRayManagerError(f'Invalid task_id format: {task_id}')
return str(task_id)
def set_cronjob(self, system_id: str) -> None:
"""
Set a cron job for stop task by time
:param system_id: a unique system ID
"""
if self.tracing_by == "time":
safe_task_id = self._sanitize_task_id(self.task_id)
safe_system_id = self._sanitize_task_id(system_id)
stop_at = datetime.fromtimestamp(self.starttime) + timedelta(minutes=self.tracing_count + 1)
shceduled = f"{stop_at.minute} {stop_at.hour} {stop_at.day} {stop_at.month} *"
cmd = "root /usr/sbin/cloudlinux-xray-manager stop"
params = f"--system_id {safe_system_id} --tracing_task_id {safe_task_id}"
cron_line = f"{shceduled}\t{cmd} {params}\n"
individual_cron_file = self.cron_file + f"_{safe_task_id}"
logger.info(
"Generating cron file %s with command %s",
individual_cron_file,
cron_line,
)
with open(individual_cron_file, "w") as cron:
cron.write(cron_line)
def drop_cronjob(self) -> None:
"""
Drop existing cron job file for current task
"""
safe_task_id = self._sanitize_task_id(self.task_id)
job = self.cron_file + f"_{safe_task_id}"
logger.info("Removing cron job %s", job)
try:
os.unlink(job)
except FileNotFoundError:
logger.info("No such job")
@staticmethod
def read_file(filepath, expected_uid: int = None) -> str:
"""
Read contents of given file.
The xray.ini parent directory may live inside the user's CageFS jail, so
the user can replace xray.ini with a symlink to a root-readable file
(e.g. /etc/shadow) and have the root daemon slurp its bytes. The read
is therefore opened with O_NOFOLLOW (the symlink is never followed) and
O_NONBLOCK (so a tenant-planted FIFO/device at the path returns from
open() at once instead of blocking the root daemon forever waiting for a
writer — the open must not hang before the inode-type check can reject
it), and the opened inode is fstat-checked: it must be a regular file
and, when the caller passes ``expected_uid``, owned by that uid. The caller pins
``expected_uid`` to the owner of the ini_location DIRECTORY (the same
uid unified_write writes the file as), so a legitimately-written ini is
always accepted — user-owned in per-user/CageFS mode, root-owned in
default mode — while a file whose owner differs from its dir's owner is
an anomaly and rejected. Privilege is NOT derived from the path's stat()
target. Mirrors the write side (unified_write's _nofollow_opener) and the
per-website read in website_isolation.regenerate_ini_for_website_isolation.
Raise XRayManagerError if the file does not exist, is a symlink, is not
a regular file, or is not owned by the expected account.
:return: contents of the file with trailing newlines stripped
"""
if not os.path.exists(filepath):
raise XRayManagerError("Failed to find %s" % os.path.basename(filepath))
def _nofollow_opener(path, flags):
# O_NONBLOCK: a FIFO/device opened read-only without it blocks until a
# writer appears, so a tenant who plants a FIFO at the ini path would
# hang the root daemon indefinitely (a DoS) before the S_ISREG check
# below ever runs. With O_NONBLOCK the open returns at once and the
# fstat check rejects the non-regular inode. On a regular file
# O_NONBLOCK is a no-op for the subsequent read.
return os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
try:
with open(filepath, opener=_nofollow_opener) as existing:
st = os.fstat(existing.fileno())
if not stat.S_ISREG(st.st_mode):
raise XRayManagerError("Refusing to read non-regular file %s" % os.path.basename(filepath))
if expected_uid is not None and st.st_uid != expected_uid:
raise XRayManagerError(
"Refusing to read %s: owner uid %d != expected uid %d"
% (os.path.basename(filepath), st.st_uid, expected_uid)
)
# Bound the read explicitly against a TOCTOU grow-after-fstat.
return existing.read(_MAX_READ_SIZE).strip("\n")
except OSError as e:
if e.errno == errno.ELOOP:
logger.warning("Refusing to follow symlink at %s", filepath)
raise XRayManagerError("Failed to read %s" % os.path.basename(filepath)) from e
def read_ini(self) -> List[str]:
"""
Read contents of xray.ini
Raise XRayError in case if xray.ini does not exists
:return: list of lines contained in xray.ini
"""
# xray.ini is written by unified_write(target_path=self.ini_location):
# with no explicit target_uid, set_privileges adopts the ini_location
# DIRECTORY's owner, so the legit file owner == the dir owner. That is
# the user in CageFS/per-user mode, but root in default mode where the
# ini_location is a root-owned dir (e.g. /opt/alt/phpXX/link/conf).
# Pin the read to that same dir owner: a file whose owner differs from
# its containing dir's owner is an anomaly and is rejected. O_NOFOLLOW
# in read_file is what closes the symlink-follow read; this owner pin is
# a consistency / defense-in-depth check that holds for both modes.
try:
expected_uid = os.stat(self.ini_location).st_uid
except OSError:
expected_uid = None
return self.read_file(self.ini_file(), expected_uid=expected_uid).split("\n")
@staticmethod
def read_tasks_file(file) -> List[str]:
"""
Read contents of xray.tasks
:return: list tasks records
"""
try:
tasks = Task.read_file(file)
split_pattern = r"(?<=:[0-9A-Za-z]{20}),(?=[a-zA-Z0-9\.-]+:)"
tasks_list = re.split(split_pattern, tasks)
return tasks_list
except XRayManagerError:
return list()
@staticmethod
def ini_tasks_count(xray_tasks: str) -> int:
"""
Retrieve the list of existing tracing tasks from given xray.tasks line
"""
return int(xray_tasks[12:].strip('"\n'))
@staticmethod
def generate_tasks_line(*args: str) -> str:
"""
Create an xray.tasks line with given args like:
args0,args1,args2,...argsN\n
"""
return task_delimiter.join(args)
def _tasks(self) -> List[str]:
"""
Retrieve the list of existing tracing tasks from xray.tasks file
"""
return self.read_tasks_file(self.tasks_file())
def tasks_parsed(self) -> Iterator[URL]:
"""
Iterate over URL of each task in xray.tasks file
:return: list of URLs
"""
for task in self._tasks():
info = task.split(":")
yield URL(info[0], info[1])
def count_tasks_for_domain(self, domain: str) -> int:
"""
Count the number of tasks in xray.tasks file that belong to a specific domain.
This is used for per-website xray.ini to track domain-specific task count.
:param domain: Domain name to count tasks for
:return: Number of tasks for the specified domain
"""
count = 0
for task_url in self.tasks_parsed():
if task_url.domain_name == domain:
count += 1
return count
def is_a_duplicate(self):
"""
Check if task is duplicated by another tasks
already present in current xray.ini
:return: True if task is a duplicate, False otherwise
"""
domain, uri = url_split(self.url)
def check(t):
"""
Match domain name and uri separately, but return joined result
:param t: task
:return: True if both domain and uri matches task, False otherwise
"""
direct_match = domain == t.domain_name and uri == t.uri_path
wildcard_match = fnmatchcase(domain, t.domain_name) and fnmatchcase(uri, t.uri_path)
return direct_match or wildcard_match
return any([check(task) for task in self.tasks_parsed()])
@staticmethod
def so_path(php_ver: str = None) -> str:
"""
Ensure full path for versions, which are not among known.6
In such a case version is expected as two numbers, e.g. 56 or 74
"""
if php_ver is None or len(php_ver) > 2:
return "xray.so"
return f"/opt/alt/php{php_ver}/usr/lib64/php/modules/xray.so"
def _ini_dir_validator(self):
"""Build the pinned-directory binding for the xray.ini write/erase.
Returns ``None`` when no domain owner is known (no binding available).
Otherwise returns ``validate(pinned_dir, st)`` that unified_write /
unified_erase run against the ALREADY-PINNED inode.
The binding preserves the identity of the SPECIFIC directory the
ini_location was authorized as (``self.ini_location`` is canonical --
phpinfo resolves absolute_ini_scan_dir, custom._validate_ini_path
returns realpath):
* If the authorized location is a PER-TENANT dir (the owner's own CageFS
subtree or /etc/users/<owner>), the pinned inode MUST stay inside that
owner's own subtree. A tenant symlink-swap of the authorized dir lands
the pinned inode on a root-owned global (e.g. /etc/php.d) or a foreign
tenant's dir -- outside the owner subtree -- so it is rejected. This is
what a bare ``st_uid in (0, owner)`` check missed: it accepted any
root-owned allowlisted dir.
* Otherwise the authorized location is an operator-controlled GLOBAL dir
whose path components are root-owned (a tenant cannot swap them); the
pinned inode must be root-owned.
"""
if not self.domain_owner:
return None
# Local import avoids any import-time coupling between the internal
# modules; phpinfo_utils does not import types, so this is one-way.
from . import phpinfo_utils
owner = self.domain_owner
authorized = self.ini_location
def _own_subtree(path):
return phpinfo_utils._is_own_cagefs_subtree(owner, path) or phpinfo_utils._is_within(
phpinfo_utils._USERS_INI_ROOT + owner, path
)
authorized_is_per_tenant = _own_subtree(authorized)
def validate(pinned_dir, st):
if authorized_is_per_tenant:
return _own_subtree(pinned_dir)
return st.st_uid == 0
return validate
def update_ini(self, version: str = None, with_decrement: bool = False) -> None:
"""
Update xray.ini tasks counter.
The per-user xray.ini tracks total tasks for the user.
The per-website xray.ini (for website isolation) tracks tasks for that specific domain only.
"""
# Re-check the allowlist on every read/write of ini_location. The remove
# path reaches this without going through create_ini_location(), so this
# is the only gate guarding ini_location on stop/decrement.
if not is_allowed_ini_path(self.ini_location):
raise XRayManagerError(_('ini_location outside allowed paths: %s') % self.ini_location)
# Write-time binding to the authorized ini directory (see unified_write /
# _ini_dir_validator). Carries the authorized location's identity so a
# post-check directory rename cannot redirect the root xray.ini write.
ini_validator = self._ini_dir_validator()
new_counter = 0
existing_contents = None
try:
existing_contents = self.read_ini()
except XRayManagerError:
ini_contents = f"""extension={self.so_path(version)}
;xray.tasks=1\n"""
new_counter = 1
else:
def update():
"""
A generator function aimed to update xray.task field
"""
nonlocal new_counter
for line in existing_contents:
if "xray.tasks" in line:
current = self.ini_tasks_count(line)
new_counter = current - 1 if with_decrement else current + 1
yield f";xray.tasks={new_counter}\n"
else:
yield line + "\n"
ini_contents = "".join(list(update()))
# Extract domain from task URL for website isolation
domain = url_split(self.url).domain_name
# Calculate per-domain task count for website isolation
# This is different from the per-user counter (new_counter)
# The xray.tasks file is already updated by add_task/remove_task before update_ini is called,
# so it reflects the current state after the task change
# Note: we can only count domain tasks if domain_owner is set (needed to access xray.tasks file)
domain_task_count = 0
if domain and self.domain_owner:
domain_task_count = self.count_tasks_for_domain(domain)
if new_counter <= 0 and not is_global_ini_mode():
# this indicates that there are no tasks left for this user,
# thus the whole xray.ini should be deleted
self.unified_erase(self.ini_file(), dir_validator=ini_validator)
# Also remove per-website copy if website isolation is enabled
if domain and self.domain_owner:
remove_website_isolation_ini(self._owner.name, domain)
else:
# in case when global ini mode is enabled
# or counter still reports some tasks
# keep the file and update counter
self.unified_write(
self.ini_file(), ini_contents, target_path=self.ini_location, dir_validator=ini_validator
)
# Handle per-website ini separately based on domain-specific task count
if domain and self.domain_owner:
if domain_task_count <= 0:
# No tasks left for this specific domain - remove per-website ini
remove_website_isolation_ini(self._owner.name, domain)
else:
# Update per-website ini with domain-specific task counter
update_website_isolation_ini(
self._owner.name,
self._owner.uid,
self._owner.gid,
domain,
domain_task_count,
existing_contents,
version,
)
def add_task(self) -> None:
"""
Add a task into xray.tasks
"""
_file = self.tasks_file()
if not self.is_path_available(self._owner.path):
# check to see if mount is missing (generally cagefs related check)
_cagefsctl_remount(self._owner.name)
raise XRayManagerError(
_(
"Creation of tracing task failed due to filesystem problem. Please, try to recreate the task. If the problem persists, contact support for resolution"
)
)
tasks_contents = self.generate_tasks_line(*self._tasks(), self.generate())
self.unified_write(_file, tasks_contents, target_uid=0, target_gid=self._owner.gid, mask=0o137)
self.generate_fake_real_id_file()
def remove_task(self) -> None:
"""
Remove a task from xray.tasks
"""
if os.path.exists(self.fake_id_real_id_file()):
self.unified_erase(self.fake_id_real_id_file())
tasks_contents = self.generate_tasks_line(*[task for task in self._tasks() if self.generate() != task])
if not tasks_contents.strip():
# this indicates that there are no tasks left,
# thus the xray.tasks file should be deleted
self.unified_erase(self.tasks_file())
else:
self.unified_write(
self.tasks_file(),
tasks_contents,
target_uid=0,
target_gid=self._owner.gid,
mask=0o137,
)
def generate_fake_real_id_file(self):
"""
/usr/share/alt-php-xray-tasks/1004/<fake_id>
used by x-ray extension to obtain real task id
"""
self.unified_write(
self.fake_id_real_id_file(),
self.task_id,
target_uid=0,
target_gid=self._owner.gid,
mask=0o137,
)
@staticmethod
def unified_write(
filepath: str,
contents: str,
target_uid: int = None,
target_gid: int = None,
target_path=".",
mask: int = None,
dir_validator=None,
) -> None:
"""
Unified writing files method.
Includes writing into temporary file and
then atomically renaming temporary to original path.
O_NOFOLLOW on the leaf alone is not enough: the DIRECTORY component of
filepath may live inside the user's CageFS jail, so a tenant can swap it
(or any ancestor) for a symlink to a root-owned target (e.g.
/etc/cron.d) and win a TOCTOU race against the allowlist check,
redirecting the root write. To close that, the parent directory is
opened ONCE into a pinned file descriptor -- FOLLOWING symlinks, because
legit root-owned links such as /opt/alt/phpNN/link/conf ->
/opt/alt/phpNN/etc/php.d must keep resolving -- and the pinned inode's
canonical path is then read back via /proc/self/fd and validated with
_is_allowed_write_dir. That post-pin check is race-free (it reflects
exactly the inode the subsequent openat/renameat use, no matter how any
path component is swapped afterwards) and rejects BOTH final- and
ancestor-component symlink escapes, while legit ini dirs and the
daemon's own storage roots pass. The tmp write and the rename are then
performed relative to that fd -- the pathname is never re-resolved
after the pin.
"""
working_path = filepath + ".tmp"
target_dir = os.path.dirname(filepath)
base = os.path.basename(filepath)
tmp_base = base + ".tmp"
logger.info("Writing %s with contents %s", working_path, contents)
try:
dir_fd = os.open(
target_dir,
os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC,
)
except OSError as e:
logger.error(
"Failed to generate %s %s",
base,
str(e),
extra={"reason": str(e)},
)
raise XRayManagerExit(_("Failed to generate {}. {}".format(base, str(e.strerror)))) from e
# dir_fd is pinned to a concrete inode; its canonical path (read back
# via /proc/self/fd) reflects exactly where the subsequent
# openat/renameat operate, regardless of any post-open symlink swap of
# target_dir or of any ancestor. Reject the fd unless it landed in an
# allowlisted ini tree or the daemon's own storage roots: a tenant
# symlink resolving to e.g. /etc/cron.d is refused, while legit
# root-owned symlinks (alt-php link/conf) resolve into an allowlisted
# dir and pass. Close the fd exactly once on the reject path -- the
# raise happens BEFORE the try/finally below takes ownership of it.
pinned_dir = os.readlink("/proc/self/fd/%d" % dir_fd)
if not _is_allowed_write_dir(pinned_dir):
os.close(dir_fd)
raise XRayManagerError(_('ini_location outside allowed paths: %s') % pinned_dir)
try:
st = os.fstat(dir_fd)
# Bind the write to the SPECIFIC authorized directory (race-free: the
# validator runs against the PINNED inode, the exact one the
# openat/renameat below use). _is_allowed_write_dir above only
# confirms the pinned dir is SOME allowlisted ini tree or daemon
# storage root -- and that allowlist deliberately spans this owner's
# own trees, OTHER tenants' trees, and root-owned global dirs. A uid
# class check is not enough: a tenant can symlink their authorized
# per-tenant ini dir onto a root-owned GLOBAL dir (e.g. /etc/php.d),
# which is both allowlisted and root-owned. dir_validator carries the
# authorized location's identity (per-tenant vs global) and rejects a
# pinned inode that escapes it -- see Task._ini_dir_validator.
if dir_validator is not None and not dir_validator(pinned_dir, st):
raise XRayManagerError(
_('ini_location write target not bound to the authorized directory: %s') % pinned_dir
)
# Derive the write identity from the PINNED inode, not a re-resolved
# pathname. Explicit caller uid/gid win; otherwise adopt the pinned
# dir owner -- equivalent to the previous stat(ini_location) result
# because target_dir is exactly that directory.
eff_uid = target_uid if target_uid is not None else st.st_uid
eff_gid = target_gid if target_gid is not None else st.st_gid
def _dirfd_opener(path, flags):
# Create the tmp file relative to the pinned dir_fd; O_NOFOLLOW
# still guards the leaf against a pre-planted symlink.
return os.open(
tmp_base,
flags | os.O_NOFOLLOW | os.O_CLOEXEC,
0o644,
dir_fd=dir_fd,
)
with set_privileges(eff_uid, eff_gid, target_path, mask):
try:
with open(working_path, "w", opener=_dirfd_opener) as _file:
_file.write(contents)
except OSError as e:
logger.error(
"Failed to generate %s %s",
os.path.basename(filepath),
str(e),
extra={"reason": str(e)},
)
base_path = os.path.basename(filepath)
raise XRayManagerExit(_("Failed to generate {}. {}".format(base_path, str(e.strerror)))) from e
else:
# renameat within the pinned dir -- never re-resolves a
# pathname, so the swapped-directory attack cannot redirect
# it. src and dst share dir_fd, so the rename stays atomic.
os.replace(tmp_base, base, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
finally:
os.close(dir_fd)
@staticmethod
def unified_erase(filepath: str, dir_validator=None) -> None:
"""
Unified erase method.
Unlinks target file.
Like unified_write, the parent directory is opened ONCE into a pinned
file descriptor (following symlinks, so legit root-owned links such as
alt-php's link/conf resolve), the pinned inode's canonical path is
validated with _is_allowed_write_dir, and the unlink is performed
relative to the fd -- so a swapped symlinked directory component (final
or ancestor) cannot redirect the root unlink outside the allowed trees.
"""
logger.info("Remove %s: no tasks left", os.path.basename(filepath))
target_dir = os.path.dirname(filepath)
base = os.path.basename(filepath)
try:
dir_fd = os.open(
target_dir,
os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC,
)
except OSError as e:
logger.warning(
"Failed to unlink %s file",
filepath,
extra={"file": filepath, "err": str(e)},
)
return
# dir_fd is pinned to a concrete inode; validate its canonical path
# (via /proc/self/fd) against the union of the ini allowlist and the
# daemon's storage roots. Race-free: it reflects exactly the inode the
# unlinkat below operates on. Close the fd exactly once on the reject
# path -- the raise happens BEFORE the try/finally takes ownership.
pinned_dir = os.readlink("/proc/self/fd/%d" % dir_fd)
if not _is_allowed_write_dir(pinned_dir):
os.close(dir_fd)
raise XRayManagerError(_('ini_location outside allowed paths: %s') % pinned_dir)
try:
st = os.fstat(dir_fd)
# Same directory binding as unified_write: reject a pinned inode that
# escapes the authorized location (e.g. a symlink redirect from a
# per-tenant dir onto a root-owned global or a foreign tenant), so a
# post-check rename cannot redirect the root unlink.
if dir_validator is not None and not dir_validator(pinned_dir, st):
raise XRayManagerError(
_('ini_location write target not bound to the authorized directory: %s') % pinned_dir
)
# Unlink requires write on the directory; adopt the pinned dir
# owner as the identity (matches the file owner in per-user/CageFS
# mode, root in default mode).
with set_privileges(st.st_uid, st.st_gid, target_path=target_dir):
try:
os.unlink(base, dir_fd=dir_fd)
except OSError as e:
logger.warning(
"Failed to unlink %s file",
filepath,
extra={"file": filepath, "err": str(e)},
)
finally:
os.close(dir_fd)
def create_ini_location(self) -> None:
"""
Create ini location dir if it does not exist.
Due to troubles with additional ini scan dir on DirectAdmin
we are to create ini_location path before generating xray.ini
"""
resolved = os.path.realpath(self.ini_location)
if not is_allowed_ini_path(resolved):
raise XRayManagerError(_('ini_location outside allowed paths: %s') % self.ini_location)
if not os.path.exists(resolved):
try:
os.mkdir(resolved)
except FileExistsError:
pass
@skeleton_update
@dbm_storage_update
def add(self, php_version: str = None) -> None:
"""
Add task with primary check for duplicates
Adding task includes modifying two files:
- xray.ini file with xray extension and task count
- xray.tasks file per each user with list of tasks
"""
if self.is_a_duplicate():
raise XRayManagerError(_("Task is duplicated by URL"), flag="warning")
self.create_ini_location()
self.add_task()
self.update_ini(version=php_version)
self.starttime = timestamp()
logger.info("Starttime set %s", self.starttime)
@skeleton_update
@dbm_storage_update
def remove(self) -> bool:
"""
If task is present in xray.tasks file,
remove task from xray.tasks file and
decrement tasks counter in ini.
:return: True if the task entry was actually removed,
False if there was nothing to remove (already drained)
"""
if self.generate() not in self._tasks():
return False
self.remove_task()
self.update_ini(with_decrement=True)
return True
def recalculate_counts(self) -> int:
"""
Recalculates remaining number of time|request_qty counts
:return: remaining number of counts
"""
if self.tracing_by == "time":
current = timestamp()
if current < self.starttime:
logger.error(
"Failed to recalculate time count: current timestamp appears to be in the past",
extra={"currenttime": current, "starttime": self.starttime},
)
raise XRayManagerError(
_(
"Failed to recalculate time count: current timestamp appears to be in the past relatively to task start time"
)
)
remaining = (current - self.starttime) // 60
recalculated = self.tracing_count - remaining
logger.info("Remaining time recalculated: %s left", recalculated)
return recalculated
elif self.tracing_by == "request_qty":
return self.initial_count - self.request_count
raise XRayManagerError(_("Unknown tracing marker: %s") % self.tracing_by)
def erase_request_id_storage(self) -> None:
"""
Unlink request ID file (by fake ID of tracing_task)
"""
req_id_file = os.path.join(request_data_storage, self.fake_id)
logger.info("Erasing storage %s", req_id_file)
try:
os.unlink(req_id_file)
except OSError as e:
logger.warning(
"Failed to unlink request_id file",
extra={"file": req_id_file, "err": str(e)},
)