Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74648ba34b | ||
|
|
86a88c87db | ||
|
|
02d93f4d1a | ||
|
|
94a73ea8ab | ||
|
|
0ffccac4b8 | ||
|
|
eaf616eb30 | ||
|
|
14761597bb | ||
|
|
db19ee24c3 | ||
|
|
3841b18820 | ||
|
|
48b714447f |
No files matched your search
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- trunk
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Shell gate. Runs FIRST and costs seconds; the images below do not depend on
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
cloud-hosting-platform/cloud-apache-container
|
||||
@@ -40,6 +40,7 @@ COPY ./configs/phpinfo.php /var/www/html/
|
||||
COPY ./configs/mariadb.repo /etc/yum.repos.d/
|
||||
COPY ./configs/index.php /var/www/html/
|
||||
COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
|
||||
COPY ./configs/whp-security.conf /etc/httpd/conf.d/
|
||||
|
||||
# Set up cron job in a single layer
|
||||
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
|
||||
|
||||
@@ -13,6 +13,7 @@ COPY ./scripts/detect-memory.sh /scripts/detect-memory.sh
|
||||
COPY ./scripts/create-apache-mpm-config.sh /scripts/create-apache-mpm-config.sh
|
||||
COPY ./scripts/log-rotate.sh /scripts/log-rotate.sh
|
||||
COPY ./scripts/entrypoint-shared-httpd.sh /scripts/entrypoint-shared-httpd.sh
|
||||
COPY ./scripts/stop-services.sh /scripts/stop-services.sh
|
||||
COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh
|
||||
RUN chmod +x /scripts/*
|
||||
|
||||
|
||||
@@ -72,6 +72,18 @@ virtualHostConfig {
|
||||
addDefaultCharset off
|
||||
}
|
||||
|
||||
## SECURITY: never serve config / VCS dotfiles over HTTP. Same named list as
|
||||
## WHP's shared-ols vhconf template (first `context exp:` there) and
|
||||
## shared-httpd's 000-whp-security.conf. Measured on .148: this tier served
|
||||
## /.env even at mode 0640, because OLS runs as the customer uid here. An
|
||||
## `exp:` regex context wins over the `context /` prefix context regardless
|
||||
## of order (measured on OLS 1.8.4 for shared-ols), so a customer .htaccess
|
||||
## cannot reopen it. Named, not blanket "^\.": /.well-known/ (ACME,
|
||||
## security.txt, app-site-association) must stay reachable.
|
||||
context exp:^/(.*/)?\.(user\.ini|env|git|svn|hg|bzr|ht|whp|site-builder) {
|
||||
allowBrowse 0
|
||||
}
|
||||
|
||||
rewrite {
|
||||
enable 1
|
||||
autoLoadHtaccess 1
|
||||
|
||||
@@ -23,10 +23,22 @@ useIpInProxyHeader 1
|
||||
|
||||
## LSCache enabled at MODULE scope for the whole tier (dedicated cache volume,
|
||||
## ephemeral across rebuilds; OLS auto-keys a per-vhost subdir under storagePath).
|
||||
## PUBLIC (anonymous) caching ONLY: enableCache 1 + checkPublicCache 1 let OLS
|
||||
## serve cacheable, non-logged-in responses marked by the LiteSpeed Cache WP
|
||||
## PUBLIC (anonymous) caching ONLY: checkPublicCache 1 lets OLS serve cacheable,
|
||||
## non-logged-in responses that are EXPLICITLY marked by the LiteSpeed Cache WP
|
||||
## plugin's X-LiteSpeed-Cache-Control headers (ignoreRespCacheCtrl=0 honors them).
|
||||
##
|
||||
## enableCache is 0 ON PURPOSE. At module scope enableCache 1 means "cache every
|
||||
## cacheable-looking public response by default", INCLUDING ones that carry no
|
||||
## cache header at all. That bit us on Divi: LSCWP's Divi compat calls
|
||||
## litespeed_disable_all for ?et_fb= / ?et_pb_preview= / ?p=N&preview=true, which
|
||||
## switches the plugin off for the request, so it sends NO header (and its own
|
||||
## "Do Not Cache Query Strings" never runs) — and OLS stored the Visual Builder
|
||||
## page under enableCache 1. With enableCache 0 only responses that opt in via
|
||||
## X-LiteSpeed-Cache-Control: public are stored; a plugin-disabled request is
|
||||
## simply not cached. Measured on OLS 1.8.4 (2026-09-21): headerless response =
|
||||
## miss,hit under 1; not cached under 0; explicit `public` header cached under
|
||||
## both. Matches the dedicated cac-litespeed tier (configs/litespeed/site-template.tpl).
|
||||
##
|
||||
## PRIVATE caching is intentionally OFF (enablePrivateCache 0 + checkPrivateCache 0).
|
||||
## Logged-in / cookie-bearing pages must NEVER be cached at the tier. We previously
|
||||
## left enablePrivateCache=1 assuming "no plugin -> nothing cached," but that was
|
||||
@@ -45,7 +57,7 @@ module cache {
|
||||
respCookieCache 1
|
||||
ignoreReqCacheCtrl 0
|
||||
ignoreRespCacheCtrl 0
|
||||
enableCache 1
|
||||
enableCache 0
|
||||
enablePrivateCache 0
|
||||
}
|
||||
## ---- end shared-ols server append ----
|
||||
@@ -0,0 +1,29 @@
|
||||
## SECURITY: never serve config / VCS dotfiles over HTTP (standalone cac).
|
||||
##
|
||||
## Same named list as WHP's shared-httpd drop-in (web-files/configs/
|
||||
## shared-httpd-security.conf, 000-whp-security.conf on every shared-httpd host),
|
||||
## re-rooted from /mnt/users to /home because here the docroot is
|
||||
## /home/<user>/public_html. Standalone cac never got that drop-in, and measured
|
||||
## on .148 it served /.env with contents.
|
||||
##
|
||||
## <FilesMatch> matches the FILENAME resolved from the filesystem path, not the
|
||||
## request URL, so it cannot be side-stepped by URL-encoding. httpd applies
|
||||
## <Directory> sections (and .htaccess) first and <Files>/<FilesMatch> after
|
||||
## them, so this deny wins over create-vhost.sh's `Require all granted`.
|
||||
##
|
||||
## A NAMED LIST, NOT A BLANKET "^\." DENY: /.well-known/ (ACME, security.txt,
|
||||
## apple-app-site-association, assetlinks.json, payment-domain verification)
|
||||
## must stay reachable. FilesMatch tests the final path component, and for
|
||||
## /.well-known/acme-challenge/<token> that is <token>, not a dotfile.
|
||||
##
|
||||
## HTTP only: PHP still reads .user.ini and .env off the filesystem as before.
|
||||
<Directory "/home">
|
||||
<FilesMatch "^\.(user\.ini|env|git|svn|hg|bzr|ht|whp|site-builder)">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</Directory>
|
||||
## Directory form: reaches the files INSIDE .git/ etc. (e.g. /.git/config,
|
||||
## whose own name is not a dotfile).
|
||||
<DirectoryMatch "^/home/.*/\.(git|svn|hg|bzr|whp|site-builder)(/|$)">
|
||||
Require all denied
|
||||
</DirectoryMatch>
|
||||
+58
-24
@@ -14,8 +14,18 @@ export FPM_LISTEN=${FPM_LISTEN:-9000}
|
||||
|
||||
adduser -u $uid $user
|
||||
|
||||
mkdir -p /home/$user/public_html
|
||||
mkdir -p /home/$user/logs/php-fpm
|
||||
# Root never follows customer symlinks under /home/$user: see safe-paths.sh.
|
||||
source /scripts/safe-paths.sh
|
||||
secure_home
|
||||
|
||||
user_mkdir /home/$user/public_html /home/$user/logs/php-fpm
|
||||
# Create the pool's error_log (php_admin_value[error_log] in
|
||||
# create-php-config.sh) AS THE CUSTOMER, before php-fpm starts. It used to be
|
||||
# `touch`ed as root at the very end, after the chown -R, so on a site's first
|
||||
# start the pool (customer uid) could not open it and PHP errors fell back to
|
||||
# FastCGI stderr -> shared-httpd's error log until the next restart's chown -R
|
||||
# fixed the owner. user_touch never writes or chowns through a symlink.
|
||||
user_touch /home/$user/logs/php-fpm/error.log
|
||||
|
||||
ln -sf /home/$user/logs/php-fpm /var/log/php-fpm
|
||||
|
||||
@@ -25,13 +35,27 @@ echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_
|
||||
/scripts/create-php-config.sh
|
||||
|
||||
mkdir -p /run/php-fpm/
|
||||
# php-fpm opens its slowlog under /home/$user/logs as root: last fail-closed
|
||||
# check immediately before it starts (safe-paths.sh).
|
||||
require_safe_logs /home/$user/logs/php-fpm
|
||||
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
||||
# GNU chown -R defaults to -P: symlinks are lchown()ed, never followed.
|
||||
chown -R $user:$user /home/$user
|
||||
chmod -R 755 /home/$user
|
||||
# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's
|
||||
# bind-mounted site directory; a recursive 755 on every start made every file
|
||||
# (wp-config.php included) world-readable AND executable, and reverted any
|
||||
# tighter permissions the customer had set. Apache (shared-httpd, or the
|
||||
# bundled httpd) runs as `apache`, not the customer, so it only needs to
|
||||
# traverse directories to serve static files. Normalise that and nothing else:
|
||||
# open the docroot itself, and add o+x to directories that lack it. Regular
|
||||
# files are never touched, so execute is never added and wp-config.php is
|
||||
# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only
|
||||
# reach what the customer already owns.
|
||||
open_dir_traversal
|
||||
|
||||
if [[ $environment == 'DEV' ]]; then
|
||||
echo "Starting Dev Deployment (FPM-only mode)"
|
||||
mkdir -p /home/$user/_db_backups
|
||||
user_mkdir /home/$user/_db_backups
|
||||
if ! command -v microdnf &> /dev/null; then
|
||||
echo "microdnf not found, installing with dnf..."
|
||||
dnf install -y microdnf && dnf clean all
|
||||
@@ -49,13 +73,12 @@ if [[ $environment == 'DEV' ]]; then
|
||||
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
||||
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
||||
mysql -e "FLUSH PRIVILEGES;"
|
||||
echo "# User crontab for $user" > /home/$user/crontab
|
||||
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
|
||||
chown $user:$user /home/$user/crontab
|
||||
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
|
||||
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
|
||||
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
|
||||
cat /home/$user/mysql_creds
|
||||
# Written AS THE CUSTOMER (as_user tee), never by root through a customer path.
|
||||
drop_symlink /home/$user/crontab
|
||||
printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \
|
||||
| as_user tee /home/$user/crontab >/dev/null
|
||||
printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \
|
||||
| as_user tee /home/$user/mysql_creds
|
||||
fi
|
||||
/usr/bin/memcached -d -u $user
|
||||
fi
|
||||
@@ -66,21 +89,32 @@ if [[ $environment == 'PROD' ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set up user crontab
|
||||
if [ ! -f /home/$user/crontab ]; then
|
||||
echo "# User crontab for $user" > /home/$user/crontab
|
||||
echo "# Add your cron jobs here" >> /home/$user/crontab
|
||||
echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
|
||||
chown $user:$user /home/$user/crontab
|
||||
fi
|
||||
|
||||
# Load user crontab
|
||||
crontab -u $user /home/$user/crontab
|
||||
# Set up + load the user crontab, created and read as the customer
|
||||
# (safe-paths.sh). Root reading/writing/chowning it followed symlinks.
|
||||
load_user_crontab
|
||||
|
||||
/usr/sbin/crond
|
||||
|
||||
# Tail PHP-FPM logs (becomes PID 1 process)
|
||||
touch /home/$user/logs/php-fpm/error.log
|
||||
tail -f /home/$user/logs/php-fpm/*
|
||||
# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it
|
||||
# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped:
|
||||
# docker waited the full 10s and SIGKILLed php-fpm mid-request (exit 137).
|
||||
# php-fpm QUIT = graceful (workers finish their current request), then crond.
|
||||
source /scripts/stop-services.sh
|
||||
TAIL_PID=""
|
||||
shutdown() {
|
||||
trap - TERM INT
|
||||
echo "entrypoint: received stop signal, shutting down"
|
||||
stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 6 'php-fpm: master'
|
||||
stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond'
|
||||
[ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap shutdown TERM INT
|
||||
|
||||
# Tail PHP-FPM logs to docker logs. Backgrounded + `wait` (instead of a
|
||||
# foreground tail) so bash can run the trap as soon as a signal arrives.
|
||||
tail -f /home/$user/logs/php-fpm/* &
|
||||
TAIL_PID=$!
|
||||
wait "$TAIL_PID"
|
||||
|
||||
exit 0
|
||||
@@ -59,11 +59,30 @@ fi
|
||||
# Start cron for log rotation
|
||||
/usr/sbin/crond
|
||||
|
||||
# Tail Apache logs (becomes PID 1 process)
|
||||
# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it
|
||||
# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped:
|
||||
# docker waited the full 10s and SIGKILLed apache mid-request (exit 137), for
|
||||
# every site on the host at once. graceful-stop (WINCH) lets in-flight requests
|
||||
# finish; after 7s it falls back to TERM. Timeouts sum to < 10s so this
|
||||
# completes inside docker's default stop timeout.
|
||||
source /scripts/stop-services.sh
|
||||
TAIL_PID=""
|
||||
SLEEP_PID=""
|
||||
shutdown() {
|
||||
trap - TERM INT
|
||||
echo "entrypoint-shared-httpd: received stop signal, shutting down"
|
||||
stop_service httpd /etc/httpd/run/httpd.pid WINCH 7 '^/usr/sbin/httpd'
|
||||
stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond'
|
||||
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
|
||||
[ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap shutdown TERM INT
|
||||
|
||||
# Tail Apache logs to docker logs.
|
||||
# Use a loop to pick up new log files as vhosts are added.
|
||||
# tail -f only watches files that exist at start time.
|
||||
touch /var/log/httpd/error_log
|
||||
TAIL_PID=""
|
||||
while true; do
|
||||
LOG_FILES=$(find /var/log/httpd/ -name '*.log' -o -name '*_log' 2>/dev/null | sort)
|
||||
if [ -n "$TAIL_PID" ]; then
|
||||
@@ -72,6 +91,10 @@ while true; do
|
||||
fi
|
||||
tail -f $LOG_FILES &
|
||||
TAIL_PID=$!
|
||||
# Re-check for new log files every 60 seconds
|
||||
sleep 60
|
||||
# Re-check for new log files every 60 seconds. Background + `wait` (not a
|
||||
# foreground sleep) so a stop signal runs the trap immediately instead of
|
||||
# after up to 60s.
|
||||
sleep 60 &
|
||||
SLEEP_PID=$!
|
||||
wait "$SLEEP_PID"
|
||||
done
|
||||
+57
-23
@@ -10,8 +10,11 @@ fi
|
||||
|
||||
adduser -u $uid $user
|
||||
|
||||
mkdir -p /home/$user/public_html
|
||||
mkdir -p /home/$user/logs/{apache,php-fpm}
|
||||
# Root never follows customer symlinks under /home/$user: see safe-paths.sh.
|
||||
source /scripts/safe-paths.sh
|
||||
secure_home
|
||||
|
||||
user_mkdir /home/$user/public_html /home/$user/logs/apache /home/$user/logs/php-fpm
|
||||
|
||||
mv /var/log/httpd /var/log/httpd.bak
|
||||
|
||||
@@ -34,14 +37,28 @@ if [ -f /etc/httpd/conf.d/ssl.conf ]; then
|
||||
mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
|
||||
fi
|
||||
|
||||
# httpd and php-fpm open their logs under /home/$user/logs as root: last
|
||||
# fail-closed check immediately before they start (safe-paths.sh).
|
||||
require_safe_logs /home/$user/logs/apache /home/$user/logs/php-fpm
|
||||
/usr/sbin/httpd -k start
|
||||
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
||||
# GNU chown -R defaults to -P: symlinks are lchown()ed, never followed.
|
||||
chown -R $user:$user /home/$user
|
||||
chmod -R 755 /home/$user
|
||||
# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's
|
||||
# bind-mounted site directory; a recursive 755 on every start made every file
|
||||
# (wp-config.php included) world-readable AND executable, and reverted any
|
||||
# tighter permissions the customer had set. Apache (shared-httpd, or the
|
||||
# bundled httpd) runs as `apache`, not the customer, so it only needs to
|
||||
# traverse directories to serve static files. Normalise that and nothing else:
|
||||
# open the docroot itself, and add o+x to directories that lack it. Regular
|
||||
# files are never touched, so execute is never added and wp-config.php is
|
||||
# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only
|
||||
# reach what the customer already owns.
|
||||
open_dir_traversal
|
||||
|
||||
if [[ $environment == 'DEV' ]]; then
|
||||
echo "Starting Dev Deployment"
|
||||
mkdir -p /home/$user/_db_backups
|
||||
user_mkdir /home/$user/_db_backups
|
||||
# Ensure microdnf is available for installing MariaDB and memcached in DEV mode
|
||||
if ! command -v microdnf &> /dev/null; then
|
||||
echo "microdnf not found, installing with dnf..."
|
||||
@@ -60,14 +77,13 @@ if [[ $environment == 'DEV' ]]; then
|
||||
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
||||
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
||||
mysql -e "FLUSH PRIVILEGES;"
|
||||
# Create user crontab with MySQL backup job
|
||||
echo "# User crontab for $user" > /home/$user/crontab
|
||||
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
|
||||
chown $user:$user /home/$user/crontab
|
||||
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
|
||||
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
|
||||
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
|
||||
cat /home/$user/mysql_creds
|
||||
# Create user crontab with MySQL backup job. Written AS THE CUSTOMER
|
||||
# (as_user tee), never by root through a customer path.
|
||||
drop_symlink /home/$user/crontab
|
||||
printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \
|
||||
| as_user tee /home/$user/crontab >/dev/null
|
||||
printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \
|
||||
| as_user tee /home/$user/mysql_creds
|
||||
|
||||
fi
|
||||
/usr/bin/memcached -d -u $user
|
||||
@@ -78,19 +94,37 @@ if [[ $environment == 'PROD' ]]; then
|
||||
sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
|
||||
fi
|
||||
|
||||
# Set up user crontab
|
||||
if [ ! -f /home/$user/crontab ]; then
|
||||
echo "# User crontab for $user" > /home/$user/crontab
|
||||
echo "# Add your cron jobs here" >> /home/$user/crontab
|
||||
echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
|
||||
chown $user:$user /home/$user/crontab
|
||||
fi
|
||||
|
||||
# Load user crontab
|
||||
crontab -u $user /home/$user/crontab
|
||||
# Set up + load the user crontab, created and read as the customer
|
||||
# (safe-paths.sh). Root reading/writing/chowning it followed symlinks.
|
||||
load_user_crontab
|
||||
|
||||
/usr/sbin/crond
|
||||
tail -f /var/log/httpd/*
|
||||
|
||||
# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it
|
||||
# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped:
|
||||
# docker waited the full 10s and SIGKILLed apache and php-fpm mid-request
|
||||
# (exit 137). Stop apache first (graceful-stop: finish in-flight requests,
|
||||
# accept no new ones), then php-fpm (QUIT: workers finish their current
|
||||
# request), then crond. Timeouts sum to < 10s so this completes inside
|
||||
# docker's default stop timeout.
|
||||
source /scripts/stop-services.sh
|
||||
TAIL_PID=""
|
||||
shutdown() {
|
||||
trap - TERM INT
|
||||
echo "entrypoint: received stop signal, shutting down"
|
||||
stop_service httpd /etc/httpd/run/httpd.pid WINCH 4 '^/usr/sbin/httpd'
|
||||
stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 3 'php-fpm: master'
|
||||
stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond'
|
||||
[ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap shutdown TERM INT
|
||||
|
||||
# Stream apache logs to docker logs. Backgrounded + `wait` (instead of a
|
||||
# foreground tail) so bash can run the trap as soon as a signal arrives.
|
||||
tail -f /var/log/httpd/* &
|
||||
TAIL_PID=$!
|
||||
wait "$TAIL_PID"
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2154 # $uid / $user come from the container environment
|
||||
## safe-paths.sh — sourced by entrypoint.sh and entrypoint-fpm.sh. Not executed directly.
|
||||
##
|
||||
## /home/$user is the customer's bind-mounted site directory: the customer can
|
||||
## create anything in it, including symlinks, from SFTP/FTP, cron, or PHP. The
|
||||
## entrypoints run as root, and root following a customer symlink is a
|
||||
## privilege escalation: `touch`/`chown`/`>` on logs/php-fpm/error.log ->
|
||||
## /etc/shadow hands the customer /etc/shadow (container root).
|
||||
##
|
||||
## Rules the entrypoints follow:
|
||||
## * /home/$user itself is the bind-mount point and must be a real directory.
|
||||
## * logs/ is WHP-owned and apache / php-fpm open files in it as root, so it
|
||||
## must be a real directory and every symlink under it is removed + logged.
|
||||
## * Files and dirs are CREATED as the customer (as_user), so a symlink or a
|
||||
## swapped path can only reach what the customer could reach anyway.
|
||||
## * Ownership changes use `chown -h` / `chown -R` (GNU default -P: never
|
||||
## follows symlinks, lchown()s them), never a dereferencing chown.
|
||||
## * Customer files are READ as the customer (crontab), never as root.
|
||||
|
||||
# as_user CMD... : run CMD as the customer uid/gid (no root, no caps).
|
||||
as_user() {
|
||||
if command -v setpriv >/dev/null 2>&1; then
|
||||
setpriv --reuid="$uid" --regid="$uid" --init-groups -- "$@"
|
||||
else
|
||||
runuser -u "$user" -- "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# die MSG : refuse to start. Only the customer who planted the path is hurt,
|
||||
# which is preferred to root following it.
|
||||
die() {
|
||||
echo "entrypoint: FATAL: $* - refusing to start" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# drop_symlink PATH : if PATH is a symlink, unlink it AS THE CUSTOMER (rm on
|
||||
# the final component never touches the link target, and if a parent dir is
|
||||
# swapped for a symlink mid-way the customer can only delete what they already
|
||||
# could) and log it. Fails closed: still a symlink afterwards -> refuse to start.
|
||||
drop_symlink() {
|
||||
if [ -L "$1" ]; then
|
||||
echo "entrypoint: SECURITY: $1 was a symlink to '$(readlink "$1")' - removing" >&2
|
||||
as_user rm -f -- "$1"
|
||||
[ -L "$1" ] && die "could not remove symlink $1"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# secure_home : run once, right after adduser, before anything else touches
|
||||
# /home/$user.
|
||||
secure_home() {
|
||||
local h="/home/$user"
|
||||
if [ -L "$h" ] || [ ! -d "$h" ]; then
|
||||
die "$h is not a real directory (symlink or missing)"
|
||||
fi
|
||||
# The mount point itself: owned by the customer so as_user can create in it.
|
||||
chown -h "$user:$user" "$h"
|
||||
drop_symlink "$h/logs"
|
||||
# public_html may legitimately be a symlink (release-dir deploys); it is left
|
||||
# alone and every root-side operation on it runs as the customer instead.
|
||||
if [ -d "$h/logs" ]; then
|
||||
# As the customer: find without -L never follows symlinks, -delete unlinks
|
||||
# the link itself, and a raced parent swap can only reach what the customer
|
||||
# could already delete. Errors are not fatal HERE: require_safe_logs
|
||||
# re-checks immediately before the daemons start and fails closed.
|
||||
as_user find "$h/logs" -xdev -type l \
|
||||
-printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# user_mkdir DIR... : create directories as the customer.
|
||||
user_mkdir() {
|
||||
as_user mkdir -p -- "$@"
|
||||
}
|
||||
|
||||
# user_touch FILE : create FILE as the customer (a symlink there is dropped
|
||||
# first, fail-closed). Root never touches/chowns it: if it already exists
|
||||
# root-owned (left by an older image) the touch fails quietly and the
|
||||
# entrypoint's chown -R, which never follows symlinks, fixes the owner. Must end
|
||||
# up a regular file, or refuse to start.
|
||||
user_touch() {
|
||||
drop_symlink "$1"
|
||||
as_user touch -- "$1" 2>/dev/null || true
|
||||
[ -f "$1" ] && [ ! -L "$1" ] || die "$1 is not a regular file"
|
||||
}
|
||||
|
||||
# require_safe_logs DIR... : fail-closed gate, run immediately before starting
|
||||
# the daemons that open files under /home/$user/logs AS ROOT (httpd, php-fpm).
|
||||
# Each DIR must be a real directory, and nothing under logs/ may be anything
|
||||
# but a regular file or directory (no symlink, fifo, socket, device).
|
||||
require_safe_logs() {
|
||||
local d bad
|
||||
for d in "/home/$user/logs" "$@"; do
|
||||
[ -d "$d" ] && [ ! -L "$d" ] || die "$d is not a real directory"
|
||||
done
|
||||
bad=$(find "/home/$user/logs" -xdev ! -type f ! -type d -print -quit 2>&1)
|
||||
[ -z "$bad" ] || die "unsafe entry under /home/$user/logs: $bad"
|
||||
return 0
|
||||
}
|
||||
|
||||
# load_user_crontab : install /home/$user/crontab, read AS THE CUSTOMER. Root
|
||||
# reading it (`crontab -u $user /home/$user/crontab`) would follow a symlink to
|
||||
# any root-readable file and install it where the customer can `crontab -l` it.
|
||||
load_user_crontab() {
|
||||
local f="/home/$user/crontab"
|
||||
if [ ! -e "$f" ] && [ ! -L "$f" ]; then
|
||||
as_user sh -c 'printf "%s\n" "# User crontab for $1" "# Add your cron jobs here" "# Example: */5 * * * * /home/$1/scripts/my-script.sh" > "$2"' sh "$user" "$f"
|
||||
fi
|
||||
# No temp file: root writing a predictable /tmp path is the same bug class.
|
||||
local content
|
||||
if content=$(as_user cat -- "$f" 2>/dev/null); then
|
||||
printf '%s\n' "$content" | crontab -u "$user" -
|
||||
else
|
||||
echo "entrypoint: WARNING: $f not readable as $user - user crontab not loaded" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# open_dir_traversal : chmod 755 the home dir, then add o+x to every directory
|
||||
# lacking it (apache/nginx run as their own user and must traverse). Run AS THE
|
||||
# CUSTOMER: `find -type d` without -L skips symlinks, but chmod would follow a
|
||||
# dir swapped for a symlink between find and chmod; as the customer that can
|
||||
# only reach what the customer already owns.
|
||||
open_dir_traversal() {
|
||||
chmod 755 "/home/$user"
|
||||
as_user find "/home/$user" -type d ! -perm -o=x -exec chmod o+x {} + 2>/dev/null || true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
## stop-services.sh — sourced by the bash PID-1 entrypoints (entrypoint.sh,
|
||||
## entrypoint-fpm.sh, entrypoint-shared-httpd.sh). Not executed directly.
|
||||
##
|
||||
## Why this exists: those entrypoints end with bash as PID 1 tailing logs.
|
||||
## The kernel never delivers a signal to PID 1 unless PID 1 installed a handler
|
||||
## for it, so `docker stop`'s SIGTERM was silently dropped. Docker waited the
|
||||
## full stop timeout (10s) and then SIGKILLed the whole container, killing
|
||||
## apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT
|
||||
## and call stop_service for each daemon they started, then exit 0.
|
||||
##
|
||||
## Budget: docker's default stop timeout is 10s. Callers pass per-service
|
||||
## timeouts that sum to < 10s so the graceful path always completes before
|
||||
## docker gives up and SIGKILLs.
|
||||
|
||||
# A pid counts as alive if it exists and is not a zombie. bash (PID 1) reaps
|
||||
# the daemons it adopted, but between exit and reap a zombie still answers
|
||||
# `kill -0`, so check /proc state too.
|
||||
_svc_alive() {
|
||||
[ -n "$1" ] || return 1
|
||||
kill -0 "$1" 2>/dev/null || return 1
|
||||
! grep -qs '^State:[[:space:]]*Z' "/proc/$1/status"
|
||||
}
|
||||
|
||||
# stop_service NAME PIDFILE GRACEFUL_SIGNAL GRACE_SECONDS [PGREP_PATTERN]
|
||||
# Sends GRACEFUL_SIGNAL, waits up to GRACE_SECONDS for the process to exit,
|
||||
# then sends TERM and waits 1s more, then KILL. Pid comes from PIDFILE, or
|
||||
# from `pgrep -o -f PGREP_PATTERN` when the pidfile is missing/stale.
|
||||
stop_service() {
|
||||
local name=$1 pidfile=$2 sig=$3 grace=$4 pat=${5:-} pid="" i
|
||||
[ -r "$pidfile" ] && pid=$(tr -dc '0-9' < "$pidfile")
|
||||
if ! _svc_alive "$pid" && [ -n "$pat" ]; then
|
||||
pid=$(pgrep -o -f "$pat" 2>/dev/null)
|
||||
fi
|
||||
if ! _svc_alive "$pid"; then
|
||||
echo "entrypoint: $name not running"
|
||||
return 0
|
||||
fi
|
||||
echo "entrypoint: stopping $name (pid $pid) with SIG$sig"
|
||||
kill -s "$sig" "$pid" 2>/dev/null
|
||||
for ((i = 0; i < grace * 10; i++)); do
|
||||
_svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; }
|
||||
sleep 0.1
|
||||
done
|
||||
echo "entrypoint: $name still running after ${grace}s, sending SIGTERM"
|
||||
kill -TERM "$pid" 2>/dev/null
|
||||
for ((i = 0; i < 10; i++)); do
|
||||
_svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; }
|
||||
sleep 0.1
|
||||
done
|
||||
echo "entrypoint: $name still running, sending SIGKILL"
|
||||
kill -KILL "$pid" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
Reference in new issue
Block a user