[Unit]
# WHP cPanel import worker - isolated per-import instance.
#
# Instance name is the import_id (a uniqid()-based string, e.g. "import_6634a1f0a3b21").
# The panel writes the per-import parameter file
#   /var/lib/whp/cpanel-imports/%i.params
# before invoking `systemctl start whp-cpanel-import@%i.service`. That file
# defines IMPORT_ID, IMPORT_USERNAME, IMPORT_BACKUP_FILE for the worker.
#
# Why a systemd unit instead of a backgrounded shell-out? History: a hand-rolled
# recursive deleter in CpanelBackupImporter::removeDirectory() followed symlinks
# planted by a cPanel tarball and walked out of /tmp/cpanel_import_<id> into
# /etc and /usr/bin, unlink()ing every file (see commit f7e4cbc). The PHP-level
# fixes there close the specific bug, but the worker runs as root and a future
# refactor could re-open the class. systemd's namespace isolation enforces the
# blast radius at the kernel layer: even if the worker code tries to write /etc
# or /usr or /bin, the syscall fails with EROFS regardless of credentials.
#
# Full audit: /tmp/cpanel-import-audit.md (2026-05-29).
Description=WHP cPanel Import Worker (%i) - sandboxed
Documentation=file:///root/whp/configs/whp-cpanel-import@.service
After=network.target docker.service
Wants=docker.service

[Service]
Type=oneshot
# We want `systemctl start` to return immediately so the panel does not block;
# the worker can run for many minutes (large cpmove tarballs). RemainAfterExit
# is false so the unit transitions to inactive when the worker exits, which
# means a re-import with the same import_id will not trip "unit already loaded".
RemainAfterExit=no

# Per-import parameters written by the panel before `systemctl start`.
# Variables: IMPORT_ID, IMPORT_USERNAME, IMPORT_BACKUP_FILE, IMPORT_MODE
# (analyze | resume | legacy — see run-cpanel-import.php).
EnvironmentFile=/var/lib/whp/cpanel-imports/%i.params

# Worker runs as root because the import touches /docker/users/<u>/, calls
# mysql/docker, and reads /root/.my.cnf (config.php) - same posture as the
# existing backgrounded invocation. Isolation below limits what root can DO,
# not what user it runs as.
User=root
Group=root

ExecStart=/usr/bin/php /docker/whp/web/scripts/run-cpanel-import.php ${IMPORT_ID} ${IMPORT_USERNAME} ${IMPORT_BACKUP_FILE} ${IMPORT_MODE}

# ---------------------------------------------------------------------------
# Filesystem isolation
# ---------------------------------------------------------------------------
#
# ProtectSystem=strict mounts /, /usr, /boot, /efi, and /etc as read-only.
# Combined with ProtectHome=read-only this covers /home and /root too.
# ReadWritePaths punches explicit RW holes for the paths the worker
# legitimately needs to mutate. Anything not listed is read-only at the
# kernel layer - a malicious cpmove cannot delete /etc even if the PHP
# code is tricked into trying.
ProtectSystem=strict
ProtectHome=read-only

# Paths the worker writes to. Each MUST exist on disk before the unit
# starts (systemd bind-mounts them; missing paths cause a start failure):
#
#   /docker/users          - customer site files (importSiteFiles destination)
#   /var/log               - /var/log/cpanel-import-<id>.log (this unit's stdout)
#   /var/log/whp           - assorted WHP background logs (announcement-fetch etc.)
#   /var/lib/whp           - /var/lib/whp/cpanel-imports/<id>.json summary writer
#   /tmp                   - /tmp/cpanel_processing_<uniqid> and
#                            /tmp/cpanel_import_<uniqid> (extract tempdir).
#                            Globs are not supported by ReadWritePaths and these
#                            dirs are created per-import via mkdir(), so the
#                            grant has to be on /tmp itself.
#   /docker/powerdns/logs  - pdns-integration logZoneOperation/logRecordOperation
#                            append zone- and record-operation lines here when
#                            the importer creates DNS zones.
ReadWritePaths=/docker/users /var/log /var/log/whp /var/lib/whp /tmp /docker/powerdns/logs

# ---------------------------------------------------------------------------
# Kernel / privilege isolation
# ---------------------------------------------------------------------------
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible

# Drop the ability to gain new privileges via setuid binaries; also strip
# the bits from anything the worker runs (rsync, tar, mysql, docker).
NoNewPrivileges=true
RestrictSUIDSGID=true
RestrictRealtime=true

# Block user namespace creation (would let the worker re-gain capabilities
# via unshare(2) inside a userns). Other namespace types are allowed because
# docker CLI uses some of them.
RestrictNamespaces=~user

LockPersonality=true
MemoryDenyWriteExecute=true

# PrivateTmp=false (NOT the default for hardened services). The importer's
# tempdirs (/tmp/cpanel_processing_<id>, /tmp/cpanel_import_<id>) need to
# share the host /tmp namespace so operator debugging and any out-of-band
# inspection can see them. With PrivateTmp=true the dirs would live in a
# per-service /tmp/systemd-private-...; the worker still works but post-mortem
# is harder.
PrivateTmp=false

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
# The panel's old backgrounded invocation redirected stdout/stderr to
# /var/log/cpanel-import-<id>.log. Preserve that file format and path so the
# panel's tail/poll behaviour and operator muscle memory still work.
StandardOutput=append:/var/log/cpanel-import-%i.log
StandardError=append:/var/log/cpanel-import-%i.log

# Workers can run a long time on big cpmove tarballs; no service-level timeout
# applied - the importer's internal progress reporting is the upper bound.
TimeoutStartSec=infinity

[Install]
# This is a template; instances are activated on demand by the panel via
# `systemctl start whp-cpanel-import@<importid>.service`. No WantedBy needed.
