diff --git a/Dockerfile b/Dockerfile index 676bddc..e999c5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.shared-httpd b/Dockerfile.shared-httpd index 1c4dd3c..5e0c157 100644 --- a/Dockerfile.shared-httpd +++ b/Dockerfile.shared-httpd @@ -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/* diff --git a/configs/litespeed/site-template.tpl b/configs/litespeed/site-template.tpl index b508e54..5db442d 100644 --- a/configs/litespeed/site-template.tpl +++ b/configs/litespeed/site-template.tpl @@ -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 diff --git a/configs/whp-security.conf b/configs/whp-security.conf new file mode 100644 index 0000000..b492680 --- /dev/null +++ b/configs/whp-security.conf @@ -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//public_html. Standalone cac never got that drop-in, and measured +## on .148 it served /.env with contents. +## +## matches the FILENAME resolved from the filesystem path, not the +## request URL, so it cannot be side-stepped by URL-encoding. httpd applies +## sections (and .htaccess) first and / 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/ that is , not a dotfile. +## +## HTTP only: PHP still reads .user.ini and .env off the filesystem as before. + + + Require all denied + + +## Directory form: reaches the files INSIDE .git/ etc. (e.g. /.git/config, +## whose own name is not a dotfile). + + Require all denied + diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh index ae2d445..36fa0af 100755 --- a/scripts/entrypoint-fpm.sh +++ b/scripts/entrypoint-fpm.sh @@ -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 diff --git a/scripts/entrypoint-shared-httpd.sh b/scripts/entrypoint-shared-httpd.sh index 8f54dbb..cf36cc8 100755 --- a/scripts/entrypoint-shared-httpd.sh +++ b/scripts/entrypoint-shared-httpd.sh @@ -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 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 77acc7e..96eee39 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -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 diff --git a/scripts/safe-paths.sh b/scripts/safe-paths.sh new file mode 100644 index 0000000..f453d30 --- /dev/null +++ b/scripts/safe-paths.sh @@ -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 +} diff --git a/scripts/stop-services.sh b/scripts/stop-services.sh new file mode 100644 index 0000000..37cac71 --- /dev/null +++ b/scripts/stop-services.sh @@ -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 +}