[Unit]
# WHP bulk-sites worker - isolated per-job instance.
#
# Instance name is the bulk job_id (action-prefixed, e.g.
# "recreate_containers-20260530-153012-a1b2c3d4"). The panel writes the
# per-job parameter file
#   /var/lib/whp/bulk-sites/%i.params
# before invoking `systemctl start whp-bulk-sites@%i.service`. That file
# defines BULK_JOB_ID for the worker; the full job spec (site_ids, action
# args, csrf-stripped POST payload) lives next to the JSONL progress stream
# at /docker/whp/sql/bulk-jobs/%i.json.
#
# Why a systemd unit instead of running the work inline in api/sites-bulk.php:
# the inline path blocks the HTTP request for N x per-op duration. With the
# WHP_BULK_MAX_SITES cap of 200 sites and per-op durations that can hit ~30s
# (container recreate + image pull, ssl_renew with certbot), the request can
# trivially exceed php-fpm's max execution time and any upstream proxy idle
# timeout (httpd, haproxy). The worker model mirrors whp-cpanel-import@.service
# - panel returns 202 immediately, JSONL stream is polled, kernel-layer
# sandbox enforces the blast radius regardless of how the handler code evolves.
#
# Pattern mirrors configs/whp-cpanel-import@.service (commit f7e4cbc audit).
Description=WHP Bulk-Sites Worker (%i) - sandboxed
Documentation=file:///root/whp/configs/whp-bulk-sites@.service
After=network.target docker.service
Wants=docker.service

[Service]
Type=oneshot
# `systemctl start` returns immediately and the unit transitions back to
# inactive when the worker exits, so re-using the same job_id (should it
# ever happen — they're action+timestamp+random hex, collision-free in
# practice) does not trip "unit already loaded".
RemainAfterExit=no

# Per-job parameters written by the panel before `systemctl start`.
# The only required variable today is BULK_JOB_ID; the spec JSON carries
# everything else. Kept as an env-file (not just argv) so future expansions
# can add knobs without changing the unit file.
EnvironmentFile=/var/lib/whp/bulk-sites/%i.params

# Worker runs as root because the work touches /docker/users/<u>/, calls
# mysql/docker/haproxy-manager, runs certbot for SSL renewals, and restarts
# httpd/proftpd post-SSL. Same posture as the inline pre-async path and the
# cpanel-import worker. Isolation below limits what root can DO, not what
# user it runs as.
User=root
Group=root

ExecStart=/usr/bin/php /root/whp/scripts/run-bulk-sites.php ${BULK_JOB_ID}

# ---------------------------------------------------------------------------
# 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.
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 start failure):
#
#   /docker/users          - delete_sites prunes site files / container cleanup
#                            scratch may write here transiently
#   /docker/whp/sql        - per-job JSONL progress stream at
#                            /docker/whp/sql/bulk-jobs/<job_id>.jsonl plus the
#                            spec JSON at .../<job_id>.json
#   /var/log               - /var/log/whp-bulk-sites-<job_id>.log (stdout)
#   /var/log/whp           - assorted WHP background logs
#   /var/lib/whp           - /var/lib/whp/bulk-sites/<job_id>.params lives here
#   /docker/powerdns/logs  - delete_sites can trigger pdns-integration zone
#                            removal which appends here
#   /etc/letsencrypt       - ssl_renew runs certbot which writes here
#   /etc/httpd/conf.d      - ssl_manager rewrites per-site ssl includes here
#   /run                   - docker socket path (/run/docker.sock symlink on
#                            some hosts), php-fpm sockets are not used by
#                            the worker but the bind is parent-dir-wide
#   /var/run               - /var/run/docker.sock for docker API calls
#   /tmp                   - PHP CLI's sys_get_temp_dir + session save path;
#                            php opens lock files there even when the script
#                            never starts a session (e.g. tempnam for file
#                            operations). Without this the unit dies before
#                            run-bulk-sites.php's main() ever runs.
ReadWritePaths=/docker/users /docker/whp/sql /var/log /var/log/whp /var/lib/whp /docker/powerdns/logs /etc/letsencrypt /etc/httpd/conf.d /run /var/run /tmp

# ---------------------------------------------------------------------------
# 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 (certbot, systemctl, docker socket
# clients).
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
# certbot / systemctl restart paths may use them.
RestrictNamespaces=~user

LockPersonality=true
MemoryDenyWriteExecute=true

# PrivateTmp=false (NOT the default for hardened services). SSL renewals
# and other operator-debuggable flows benefit from /tmp being the host /tmp.
PrivateTmp=false

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
# Stdout/stderr go to a per-job log file so operators can tail a specific
# job's output (mirrors the cpanel-import-<id>.log convention).
StandardOutput=append:/var/log/whp-bulk-sites-%i.log
StandardError=append:/var/log/whp-bulk-sites-%i.log

# Workers can run minutes on a 200-site job (200 * ssl_renew at ~10s each
# is ~30 min worst case). No service-level timeout; the worker's own
# per-site loop and progress reporting are the upper bound.
TimeoutStartSec=infinity

# Graceful SIGTERM: the worker installs a pcntl_signal handler that flushes
# a "cancelled" JSONL line before exit. Give it 30 seconds to write that
# line before systemd escalates to SIGKILL.
TimeoutStopSec=30
KillSignal=SIGTERM
SendSIGKILL=yes

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