#!/usr/bin/env bash # # paas — minimal deployment platform CLI (dotnews-paas). # The ONLY writer of server state: app registry, Caddy/Varnish config, # PM2 processes, releases. The web panel shells out to this. # # Layout: # /srv/paas/apps.d/.conf registry (KEY=VALUE) # /srv/paas/caddy/app-*.caddy generated Caddy site blocks (imported) # /srv/paas/vcl/paas-*.vcl generated Varnish backends/routing (included) # /srv/apps//releases/ one dir per deploy, current -> active release # /srv/apps//shared/.env app environment (sourced by run.sh / read by Laravel) # /srv/apps//static/ accumulated public/ assets, served by Caddy (node apps) # /srv/paas/php//*.conf generated PHP-FPM pools (included), RUNTIME=laravel apps # /srv/paas/db-admin.cnf MariaDB admin credentials for 'paas db' (WITH_LARAVEL servers) # # Runtimes (registry RUNTIME=, default node): # node PM2 process on 127.0.0.1:PORT (Nuxt/Nitro default) # laravel PHP-FPM pool + Caddy php_fastcgi (public site) and a loopback # http://127.0.0.1:PORT listener (health/monitoring), no PM2 # # Deployment records go to sqlite (/srv/paas/paas.db, table deployments). # Env contract with the panel: DEPLOY_ID (row to update), PAAS_SOURCE, PAAS_COMMIT. set -euo pipefail PAAS_ROOT="${PAAS_ROOT:-/srv/paas}" APPS_ROOT="${APPS_ROOT:-/srv/apps}" # platform-wide settings (SYSTEM_DOMAIN_BASE for auto *.base wildcard domains) # shellcheck disable=SC1091 [[ -f "$PAAS_ROOT/paas.conf" ]] && source "$PAAS_ROOT/paas.conf" REG_DIR="$PAAS_ROOT/apps.d" CADDY_DIR="$PAAS_ROOT/caddy" VCL_DIR="$PAAS_ROOT/vcl" LOG_DIR="$PAAS_ROOT/logs" DB="${PAAS_DB:-$PAAS_ROOT/paas.db}" VARNISH_ADDR="${VARNISH_ADDR:-127.0.0.1:6081}" # prefer the xcaddy-built binary (includes maxmind_geolocation) for validation CADDY_BIN="/usr/local/bin/caddy"; [[ -x "$CADDY_BIN" ]] || CADDY_BIN="caddy" KEEP_RELEASES="${KEEP_RELEASES:-5}" HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-45}" # laravel runtime + database provisioning (only present on WITH_LARAVEL servers) PHP_DIR="$PAAS_ROOT/php" DB_ADMIN_CNF="$PAAS_ROOT/db-admin.cnf" DB_BACKUP_DIR="$PAAS_ROOT/backups/db" DB_BACKUP_KEEP="${DB_BACKUP_KEEP:-7}" PHP_DEFAULT="${PHP_DEFAULT:-8.3}" DEFAULT_QUEUE_CMD="php artisan queue:work --sleep=3 --tries=3 --max-time=3600" log() { printf '[paas] %s\n' "$*"; } die() { printf '[paas] ERROR: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' paas — minimal deployment platform Usage: paas create --domains d1[,d2] [--cache] [--noindex] [--start "node server/index.mjs"] [--health /api/_health] [--pass-paths ""] [--warm-paths "/,/news"] [--port N] [--warm-sitemap /news-sitemap.xml] # warm its URLs after deploy/rollback [--runtime node|laravel] [--php 8.3] # laravel: PHP-FPM, no Varnish, health /up paas deploy # node: a Nuxt .output; laravel: the built checkout paas rollback paas restart # node: pm2 restart; laravel: re-cache config + fpm reload paas php --set # select PHP version (/srv/paas/php/), laravel apps paas db create|show|drop|backup|shell # MariaDB per app (credentials -> shared/.env) paas db backup --all # all apps with a database (nightly timer) paas domains --add | --remove paas domain-rules --set-json | --clear | --show # restrict a domain to a path allowlist and/or noindex it paas ban # purge this app's Varnish cache paas warm # pre-render WARM_PATHS (+ WARM_SITEMAP locs) into the cache paas ssl # retry cert issuance + report per-domain status paas node --set # select Node.js version (/opt/node/vNN) paas set [--cache on|off] [--noindex on|off] [--health ] [--warm-paths ] [--warm-sitemap ] [--geo-exempt-google on|off] # registry flags + regen paas enable|disable # public 503 toggle (app keeps running) paas protect --user U --password P # basic-auth in front of all domains (--off removes) paas list paas info paas logs # tail pm2 logs paas remove [--purge] [--yes] paas regen # regenerate + reload all configs EOF exit "${1:-0}" } # ---------------------------------------------------------------- helpers ---- require_app_name() { [[ "$1" =~ ^[a-z][a-z0-9-]{1,30}$ ]] || die "invalid app name '$1' (use ^[a-z][a-z0-9-]{1,30}$)" } conf_path() { printf '%s/%s.conf' "$REG_DIR" "$1"; } load_app() { APP="$1" require_app_name "$APP" local conf; conf="$(conf_path "$APP")" [[ -f "$conf" ]] || die "unknown app '$APP' (no $conf)" # registry files are machine-written KEY=VALUE, safe to source # shellcheck disable=SC1090 source "$conf" APP_DIR="$APPS_ROOT/$APP" } sq() { # escape a string for use inside single-quoted SQL printf '%s' "${1//\'/\'\'}" } db_exec() { command -v sqlite3 >/dev/null || return 0 [[ -f "$DB" ]] || return 0 sqlite3 "$DB" "$1" 2>/dev/null || log "warning: sqlite write failed (non-fatal)" } db_query() { sqlite3 "$DB" "$1" 2>/dev/null || true } next_free_port() { local used p=3001 used="$(grep -hs '^PORT=' "$REG_DIR"/*.conf 2>/dev/null | cut -d= -f2 || true)" while grep -qx "$p" <<<"$used" || [[ "$p" -eq 3900 ]]; do p=$((p+1)); done printf '%s' "$p" } primary_domain() { # explicit MAIN_DOMAIN wins; else first custom; else system if [[ -n "${MAIN_DOMAIN:-}" ]]; then printf '%s' "$MAIN_DOMAIN" elif [[ -n "${DOMAINS:-}" ]]; then cut -d, -f1 <<<"$DOMAINS" else printf '%s' "${SYSTEM_DOMAIN:-}"; fi } all_domains() { # custom domains + the always-available system domain local d="${DOMAINS:-}" [[ -n "${SYSTEM_DOMAIN:-}" ]] && d="${d:+$d,}$SYSTEM_DOMAIN" printf '%s' "$d" } reset_app_vars() { # prevent key leakage between registry files in loops APP=''; PORT=''; DOMAINS=''; CACHE=off; NOINDEX=off; START_CMD='' HEALTH_PATH=/; PASS_PATHS=''; WARM_PATHS=/; WARM_SITEMAP=''; SYSTEM_DOMAIN=''; REDIRECT_DOMAINS='' MAIN_DOMAIN=''; NODE_VERSION=''; ENABLED=on; BASIC_AUTH_USER=''; BASIC_AUTH_HASH='' RUNTIME=node; PHP_VERSION=''; MIGRATE=on; QUEUE_WORKER=off; QUEUE_CMD=''; SCHEDULER=off FPM_MAX_CHILDREN=8; GEO_EXEMPT_GOOGLE=on } # ------------------------------------------------------- runtime helpers ---- is_laravel() { [[ "${RUNTIME:-node}" == "laravel" ]]; } server_runtimes() { printf '%s' "${RUNTIMES:-node}"; } # from paas.conf (bootstrap WITH_LARAVEL=1) require_runtime_support() { # $1 = runtime [[ "$1" == "node" ]] && return 0 tr ',' '\n' <<<"$(server_runtimes)" | grep -qx "$1" \ || die "runtime '$1' is not enabled on this server (RUNTIMES=$(server_runtimes) in $PAAS_ROOT/paas.conf — bootstrap with WITH_LARAVEL=1)" } php_bin() { # the app's PHP CLI (exact version, never the update-alternatives default) local v="${PHP_VERSION:-$PHP_DEFAULT}" [[ -x "/usr/bin/php$v" ]] || die "PHP $v is not installed (/usr/bin/php$v missing)" printf '/usr/bin/php%s' "$v" } php_path_prefix() { printf '%s/bin/%s' "$PHP_DIR" "${PHP_VERSION:-$PHP_DEFAULT}"; } artisan() { # $1 = release/app dir, rest = artisan args — runs as the deploy user local dir="$1"; shift local php; php="$(php_bin)" ( cd "$dir" && PATH="$(php_path_prefix):$PATH" "$php" artisan "$@" --no-interaction --no-ansi ) } fpm_socket() { printf '/run/php/paas-%s.sock' "$1"; } fpm_unit() { printf 'php%s-fpm' "${PHP_VERSION:-$PHP_DEFAULT}"; } fpm_state() { # online | stopped — socket present and the fpm master for this version running if [[ -S "$(fpm_socket "$APP")" ]] && systemctl is-active --quiet "$(fpm_unit)"; then printf 'online' else printf 'stopped' fi } # .env editing (KEY=VALUE lines, comments preserved, 0600 kept). Values are # written verbatim: callers pass only [A-Za-z0-9_./:@+=-] content. env_get_key() { # $1 file, $2 KEY [[ -f "$1" ]] || return 0 sed -n "s/^$2=//p" "$1" | tail -1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/" } env_set_key() { # $1 file, $2 KEY, $3 VALUE — replace in place or append local file="$1" key="$2" val="$3" tmp [[ -f "$file" ]] || { touch "$file"; chmod 600 "$file"; } tmp="$(mktemp "$file.XXXXXX")" awk -v k="$key" -v v="$val" ' BEGIN { done = 0 } index($0, k "=") == 1 { if (!done) { print k "=" v; done = 1 } ; next } { print } END { if (!done) print k "=" v } ' "$file" >"$tmp" chmod 600 "$tmp" mv "$tmp" "$file" } GEOIP_DB="${GEOIP_DB:-$PAAS_ROOT/geoip/country.mmdb}" # Google's official crawler/AdsBot IP ranges (special-crawlers.json + # common-crawlers.json, merged) — refreshed daily by paas-google-crawlers-refresh. GOOGLE_CRAWLER_IPS="${GOOGLE_CRAWLER_IPS:-$PAAS_ROOT/geoip/google-crawlers.cidr}" # Emit Caddy geo matchers + 403 responders for an app's geo.json (if any). # Modes: "block" = 403 for listed countries; "allow-only" = 403 for everyone # not listed (UNK/private IPs — health checks, warming, snapshots — always pass). # Google's published crawler/AdsBot IP ranges (GOOGLE_CRAWLER_IPS, if present) are # exempt too, per-app (GEO_EXEMPT_GOOGLE, default on — geo-blocking a market # shouldn't also break Google's indexing or ad-verification crawls of it, but # an app can opt back in to blocking Google's crawlers like anyone else). emit_geo_directives() { # $1 = geo.json path [[ -f "$1" && -f "$GEOIP_DB" ]] || return 0 python3 - "$1" "$GEOIP_DB" "$GOOGLE_CRAWLER_IPS" "${GEO_EXEMPT_GOOGLE:-on}" <<'PY' import json, re, sys path, db, crawler_ips_path, exempt_google = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] try: rules = json.load(open(path)).get("rules", []) except Exception: sys.exit(0) cc = re.compile(r"^[A-Z]{2}$") cidr_re = re.compile(r"^[0-9a-fA-F:.]+/[0-9]{1,3}$") crawler_ips = [] if exempt_google != "off": try: with open(crawler_ips_path) as f: crawler_ips = [line.strip() for line in f if cidr_re.match(line.strip())] except OSError: pass for i, r in enumerate(rules): countries = [c for c in r.get("countries", []) if cc.match(str(c))] paths = [p for p in r.get("paths", []) if str(p).startswith("/") and re.match(r"^[A-Za-z0-9_\-./*]+$", str(p))] mode = r.get("mode", "block") if not countries: continue print(f" @paas_geo_{i} {{") if paths: print(f" path {' '.join(paths)}") if crawler_ips: print(f" not remote_ip {' '.join(crawler_ips)}") print(" maxmind_geolocation {") print(f" db_path {db}") if mode == "allow-only": print(f" deny_countries {' '.join(countries)} UNK") else: print(f" allow_countries {' '.join(countries)}") print(" }") print(" }") # handle blocks run in order of appearance — geo handles are emitted # before the static/proxy handles, so a match wins with a 403 print(f" handle @paas_geo_{i} {{") print(' respond "Not available in your region" 403') print(" }") PY } # Emit Caddy 404 blocks for an app's domain-rules.json (if any): a domain # with a non-empty `paths` allowlist serves ONLY those paths — everything # else on that host 404s here, before the static/reverse_proxy handles ever # see it. Domains with no rule, and non-listed paths on other domains, are # untouched and fall through to the normal chain. emit_domain_path_blocks() { # $1 = domain-rules.json path [[ -f "$1" ]] || return 0 python3 - "$1" <<'PY' import json, re, sys path = sys.argv[1] try: rules = json.load(open(path)).get("rules", []) except Exception: sys.exit(0) for i, r in enumerate(rules): domain = r.get("domain", "") paths = [p for p in r.get("paths", []) if str(p).startswith("/") and re.match(r"^[A-Za-z0-9_\-./*]+$", str(p))] if not domain or not paths: continue print(f" @paas_domrule_{i} {{") print(f" host {domain}") print(f" not path {' '.join(paths)}") print(" }") print(f" handle @paas_domrule_{i} {{") print(" respond 404") print(" }") PY } # Emit per-domain X-Robots-Tag headers for an app's domain-rules.json (if # any) — independent of the app-wide NOINDEX flag and the system domain's # forced noindex. emit_domain_noindex_headers() { # $1 = domain-rules.json path [[ -f "$1" ]] || return 0 python3 - "$1" <<'PY' import json, sys path = sys.argv[1] try: rules = json.load(open(path)).get("rules", []) except Exception: sys.exit(0) for i, r in enumerate(rules): domain = r.get("domain", "") if not domain or not r.get("noindex"): continue print(f" @paas_domrule_noindex_{i} {{") print(f" host {domain}") print(" }") print(f' header @paas_domrule_noindex_{i} X-Robots-Tag "noindex, nofollow"') PY } # --------------------------------------------------------- config generation ---- # Caddy lines for a PHP document root. resolve_root_symlink: Caddy resolves # current/ -> releases/ per request and passes the REAL path as # SCRIPT_FILENAME, so opcache keys on the release — a symlink flip is live on # the next request and no php-fpm reload is ever needed for a deploy. emit_caddy_php_site() { # $1 = docroot, $2 = indent (default 4 spaces) local root="$1" i="${2:- }" echo "${i}root * $root" echo "${i}php_fastcgi unix/$(fpm_socket "$APP") {" echo "${i} resolve_root_symlink" echo "${i}}" echo "${i}file_server {" echo "${i} hide .htaccess" echo "${i}}" } fpm_pool_path() { printf '%s/%s/paas-%s.conf' "$PHP_DIR" "$1" "$2"; } # $1 ver, $2 app write_fpm_pool() { # stdout; uses the loaded app vars local max="${FPM_MAX_CHILDREN:-8}" [[ "$max" =~ ^[0-9]+$ ]] || max=8 cat < php-fpm -t (root, via # sudoers) -> commit -> reload. A pool that moves to another PHP version is # removed from the losing master FIRST (it unlinks the socket on reload), # then the gaining one binds it. Never called from deploy (nothing changes). regen_fpm() { [[ -d "$PHP_DIR" ]] || return 0 # node-only server local vers=() v conf for v in "$PHP_DIR"/[0-9]*.[0-9]*; do [[ -d "$v" ]] && vers+=("$(basename "$v")"); done [[ ${#vers[@]} -gt 0 ]] || return 0 for v in "${vers[@]}"; do rm -rf "$PHP_DIR/$v/.staged"; mkdir -p "$PHP_DIR/$v/.staged"; done for conf in "$REG_DIR"/*.conf; do [[ -e "$conf" ]] || continue # shellcheck disable=SC1090 ( reset_app_vars source "$conf" is_laravel || exit 0 pv="${PHP_VERSION:-$PHP_DEFAULT}" [[ -d "$PHP_DIR/$pv" ]] || die "app $APP wants PHP $pv which is not installed under $PHP_DIR" write_fpm_pool >"$PHP_DIR/$pv/.staged/paas-$APP.conf" ) done # swap in, remember what changed per version local -A changed=() removed=() for v in "${vers[@]}"; do local d="$PHP_DIR/$v" bak="$PHP_DIR/$v/.bak" f rm -rf "$bak"; mkdir -p "$bak" find "$d" -maxdepth 1 -name 'paas-*.conf' -exec mv {} "$bak"/ \; if compgen -G "$d/.staged/paas-*.conf" >/dev/null; then mv "$d"/.staged/paas-*.conf "$d"/; fi rm -rf "$d/.staged" if ! diff -rq "$bak" "$d" -x '.bak' -x '.staged' >/dev/null 2>&1; then changed[$v]=1; fi for f in "$bak"/paas-*.conf; do [[ -e "$f" ]] || continue [[ -e "$d/$(basename "$f")" ]] || removed[$v]=1 done done local failed="" for v in "${vers[@]}"; do [[ -n "${changed[$v]:-}" ]] || continue if ! sudo -n "/usr/sbin/php-fpm$v" -t >/dev/null 2>"$PHP_DIR/$v/.validate.err"; then failed="$v"; break fi done if [[ -n "$failed" ]]; then local msg; msg="$(tail -3 "$PHP_DIR/$failed/.validate.err" 2>/dev/null)" for v in "${vers[@]}"; do local d="$PHP_DIR/$v" bak="$PHP_DIR/$v/.bak" find "$d" -maxdepth 1 -name 'paas-*.conf' -delete if compgen -G "$bak/paas-*.conf" >/dev/null; then mv "$bak"/paas-*.conf "$d"/; fi rm -rf "$bak" "$d/.validate.err" done die "generated php-fpm pool config failed validation (PHP $failed) — old config kept. $msg" fi for v in "${vers[@]}"; do rm -rf "$PHP_DIR/$v/.bak" "$PHP_DIR/$v/.validate.err"; done # losing side first (frees the socket path), then the gaining side local any_removed=0 for v in "${vers[@]}"; do [[ -n "${changed[$v]:-}" && -n "${removed[$v]:-}" ]] || continue sudo -n systemctl reload "php$v-fpm"; any_removed=1 done [[ "$any_removed" -eq 1 ]] && sleep 1 for v in "${vers[@]}"; do [[ -n "${changed[$v]:-}" && -z "${removed[$v]:-}" ]] || continue sudo -n systemctl reload "php$v-fpm" done log "php-fpm pools regenerated ($(find "$PHP_DIR" -maxdepth 2 -name 'paas-*.conf' | wc -l | tr -d ' ') laravel apps)" } vcl_name() { printf 'b_%s' "${1//-/_}"; } regen_vcl() { local backends="$VCL_DIR/paas-backends.vcl.new" routing="$VCL_DIR/paas-routing.vcl.new" local have_cached=0 rm -f "$backends.frag" "$routing.frag" { echo "# GENERATED by paas $(date -u +%FT%TZ) — do not edit." } >"$backends" { echo "# GENERATED by paas $(date -u +%FT%TZ) — do not edit." } >"$routing" local conf for conf in "$REG_DIR"/*.conf; do [[ -e "$conf" ]] || continue # shellcheck disable=SC1090 ( reset_app_vars source "$conf" [[ "${CACHE:-off}" == "on" ]] || exit 0 local_name="$(vcl_name "$APP")" { echo "probe p_${local_name#b_} {" echo " .url = \"${HEALTH_PATH:-/}\";" echo " .interval = 5s; .timeout = 2s; .window = 5; .threshold = 3; .initial = 3;" echo "}" echo "backend $local_name {" echo " .host = \"127.0.0.1\"; .port = \"$PORT\";" echo " .probe = p_${local_name#b_};" echo " .connect_timeout = 3s; .first_byte_timeout = 60s; .between_bytes_timeout = 30s;" echo "}" } >>"$backends.frag" host_cond="" IFS=',' read -ra doms <<<"$(all_domains)" for d in "${doms[@]}"; do [[ -n "$host_cond" ]] && host_cond+=" || " host_cond+="req.http.host == \"$d\"" done { echo "if ($host_cond) {" echo " set req.backend_hint = $local_name;" if [[ -n "${PASS_PATHS:-}" ]]; then echo " if (req.url ~ \"$PASS_PATHS\") { return (pass); }" fi echo "}" echo "else" } >>"$routing.frag" ) done if [[ -s "$backends.frag" ]]; then have_cached=1 cat "$backends.frag" >>"$backends" else echo "backend b_paas_none none;" >>"$backends" fi if [[ -s "$routing.frag" ]]; then # fragments are "if (host…) { … }\nelse" — concatenated they chain, # and the final else branch handles unknown hosts cat "$routing.frag" >>"$routing" echo "{ return (synth(421, \"Misdirected Request\")); }" >>"$routing" else echo "return (synth(421, \"Misdirected Request\"));" >>"$routing" fi rm -f "$backends.frag" "$routing.frag" # stage -> validate -> commit (restore on failure) local b_old="$VCL_DIR/paas-backends.vcl" r_old="$VCL_DIR/paas-routing.vcl" cp -a "$b_old" "$b_old.bak" 2>/dev/null || true cp -a "$r_old" "$r_old.bak" 2>/dev/null || true mv "$backends" "$b_old" mv "$routing" "$r_old" if ! sudo /usr/sbin/varnishd -C -f /etc/varnish/default.vcl >/dev/null 2>"$VCL_DIR/.validate.err"; then [[ -f "$b_old.bak" ]] && mv "$b_old.bak" "$b_old" [[ -f "$r_old.bak" ]] && mv "$r_old.bak" "$r_old" die "generated VCL failed validation — old config kept. $(tail -3 "$VCL_DIR/.validate.err" 2>/dev/null)" fi rm -f "$b_old.bak" "$r_old.bak" "$VCL_DIR/.validate.err" sudo systemctl reload varnish log "varnish config regenerated ($(grep -c '^backend b_' "$b_old" 2>/dev/null || echo 0) cached app backends)" } regen_caddy() { local staged="$CADDY_DIR/.staged" rm -rf "$staged"; mkdir -p "$staged" local conf for conf in "$REG_DIR"/*.conf; do [[ -e "$conf" ]] || continue # shellcheck disable=SC1090 ( reset_app_vars source "$conf" upstream="127.0.0.1:$PORT" [[ "${CACHE:-off}" == "on" ]] && upstream="$VARNISH_ADDR" alldoms="$(all_domains)" domains_spaced="${alldoms//,/, }" { echo "# GENERATED by paas — do not edit. App: $APP" if [[ -n "${REDIRECT_DOMAINS:-}" ]]; then primary="$(primary_domain)" IFS=',' read -ra rdoms <<<"$REDIRECT_DOMAINS" for rd in "${rdoms[@]}"; do echo "$rd {" echo " redir https://$primary{uri} 308" echo "}" done fi if is_laravel; then # loopback listeners (never public): PORT = what wait_healthy, # the Checkmk health probe and the panel status hit — same # contract as a PM2 app; PORT+1000 = the deploy health-gate # target (root only exists while a deploy is in flight). # No log block: the public site already logs every request # (a second log would double-count in paas-traffic-rollup). echo "http://:$PORT {" echo " bind 127.0.0.1" emit_caddy_php_site "$APPS_ROOT/$APP/current/public" echo "}" echo "http://:$((PORT + 1000)) {" echo " bind 127.0.0.1" emit_caddy_php_site "$APPS_ROOT/$APP/staging/public" echo "}" fi if [[ "${ENABLED:-on}" == "off" ]]; then echo "$domains_spaced {" echo " respond \"Liftoff: this project is currently deactivated\" 503" echo "}" exit 0 fi echo "$domains_spaced {" if [[ -n "${BASIC_AUTH_USER:-}" && -n "${BASIC_AUTH_HASH:-}" ]]; then echo " basic_auth {" echo " $BASIC_AUTH_USER $BASIC_AUTH_HASH" echo " }" fi # robots.txt deny: X-Robots-Tag only stops indexing — this stops # the crawl itself. Whole app when NOINDEX=on, else only the # system domain (custom production domains keep the app's robots). if [[ "${NOINDEX:-off}" == "on" ]]; then echo " handle /robots.txt {" echo " header Content-Type \"text/plain; charset=utf-8\"" echo " respond <"$staged/app-$APP.caddy" ) done # stage -> validate -> commit local bak="$CADDY_DIR/.bak" rm -rf "$bak"; mkdir -p "$bak" find "$CADDY_DIR" -maxdepth 1 -name 'app-*.caddy' -exec mv {} "$bak"/ \; if compgen -G "$staged/app-*.caddy" >/dev/null; then mv "$staged"/app-*.caddy "$CADDY_DIR"/ fi rm -rf "$staged" if ! "$CADDY_BIN" validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>"$CADDY_DIR/.validate.err"; then find "$CADDY_DIR" -maxdepth 1 -name 'app-*.caddy' -delete if compgen -G "$bak/app-*.caddy" >/dev/null; then mv "$bak"/app-*.caddy "$CADDY_DIR"/; fi rm -rf "$bak" die "generated Caddy config failed validation — old config kept. $(tail -3 "$CADDY_DIR/.validate.err" 2>/dev/null)" fi rm -rf "$bak" "$CADDY_DIR/.validate.err" sudo systemctl reload caddy log "caddy config regenerated" } # ----------------------------------------------------------------- runtime ---- write_run_sh() { cat >"$APP_DIR/run.sh" </dev/null 2>&1; then pm2 restart "$APP" --update-env >/dev/null else pm2 start "$APP_DIR/run.sh" --name "$APP" --interpreter bash --time >/dev/null fi pm2 save >/dev/null 2>&1 || true } # ---- laravel runtime ---- queue_name() { printf '%s-queue' "$APP"; } write_queue_run_sh() { # pm2 wrapper for the queue worker # pm2 stops with SIGINT, which Laravel's worker does not trap (SIGTERM it # does) — relay so a redeploy/stop lets the current job finish. cat >"$APP_DIR/run-queue.sh" </dev/null' INT TERM wait "\$child" EOF chmod +x "$APP_DIR/run-queue.sh" } queue_apply() { # reconcile the pm2 worker with QUEUE_WORKER local q; q="$(queue_name)" if [[ "${QUEUE_WORKER:-off}" == "on" ]]; then [[ -e "$APP_DIR/current" ]] || { log "queue worker: no release yet — starts with the first deploy"; return 0; } write_queue_run_sh if pm2 describe "$q" >/dev/null 2>&1; then pm2 restart "$q" --update-env >/dev/null else pm2 start "$APP_DIR/run-queue.sh" --name "$q" --interpreter bash --time --kill-timeout 60000 >/dev/null fi else pm2 delete "$q" >/dev/null 2>&1 || true rm -f "$APP_DIR/run-queue.sh" fi pm2 save >/dev/null 2>&1 || true } laravel_signal_queue() { # after a release switch: workers pick up the new code gracefully [[ "${QUEUE_WORKER:-off}" == "on" ]] || return 0 if pm2 describe "$(queue_name)" >/dev/null 2>&1; then artisan "$APP_DIR/current" queue:restart >/dev/null 2>&1 \ || log "warning: 'artisan queue:restart' failed (cache store not reachable?) — worker keeps old code until its --max-time" else queue_apply fi } ensure_laravel_shared() { # storage/ + .env live outside the releases local s="$APP_DIR/shared/storage" mkdir -p "$s/app/public" "$s/framework/cache/data" "$s/framework/sessions" \ "$s/framework/views" "$s/framework/testing" "$s/logs" [[ -f "$APP_DIR/shared/.env" ]] || touch "$APP_DIR/shared/.env" chmod 600 "$APP_DIR/shared/.env" } link_laravel_release() { # $1 = release dir local rel="$1" rm -rf "$rel/storage" "$rel/.env" "$rel/public/storage" ln -sfn "$APP_DIR/shared/storage" "$rel/storage" ln -sfn "$APP_DIR/shared/.env" "$rel/.env" ln -sfn "$APP_DIR/shared/storage/app/public" "$rel/public/storage" mkdir -p "$rel/bootstrap/cache" } laravel_build_caches() { # $1 = release dir — also the "does it boot" smoke test local rel="$1" c for c in config:cache route:cache view:cache event:cache; do log "artisan $c" artisan "$rel" "$c" || return 1 done } laravel_seed_env() { # first-time defaults so config:cache works before the user edits anything local env="$APP_DIR/shared/.env" [[ -n "$(env_get_key "$env" APP_KEY)" ]] || env_set_key "$env" APP_KEY "base64:$(openssl rand -base64 32)" [[ -n "$(env_get_key "$env" APP_ENV)" ]] || env_set_key "$env" APP_ENV production [[ -n "$(env_get_key "$env" APP_DEBUG)" ]] || env_set_key "$env" APP_DEBUG false [[ -n "$(env_get_key "$env" APP_URL)" ]] || env_set_key "$env" APP_URL "https://$(primary_domain)" [[ -n "$(env_get_key "$env" LOG_CHANNEL)" ]] || env_set_key "$env" LOG_CHANNEL daily } # ---- release activation (both runtimes) ---- runtime_activate() { # make `current` the served release if is_laravel; then laravel_signal_queue # fpm needs nothing: resolve_root_symlink + per-release opcache keys else write_run_sh pm2_apply fi } activate_release() { # $1 = release, $2 = previous release (may be empty) — returns 1 if reverted local rel="$1" prev="${2:-}" ln -sfn "$rel" "$APP_DIR/current" runtime_activate if wait_healthy "$PORT" "$HEALTH_TIMEOUT"; then return 0 fi log "app unhealthy after activation — rolling back symlink" if [[ -n "$prev" && -d "$prev" ]]; then ln -sfn "$prev" "$APP_DIR/current" runtime_activate fi return 1 } wait_healthy() { # $1 = port, $2 = timeout seconds local port="$1" timeout="$2" path="${HEALTH_PATH:-/}" i=0 while (( i < timeout )); do if curl -fsS -o /dev/null --max-time 2 "http://127.0.0.1:$port$path"; then return 0 fi sleep 1; i=$((i+1)) done return 1 } ban_app() { [[ "${CACHE:-off}" == "on" ]] || return 0 local d IFS=',' read -ra doms <<<"$(all_domains)" for d in "${doms[@]}"; do # negative lookahead spares /api/img objects: their sources are # content-hashed (immutable, 1y TTL) and rebuilding them after every # deploy re-fetched + re-encoded every teaser image for nothing. # For apps without /api/img the regex degrades to a full host ban. curl -fsS -o /dev/null -X BAN -H "X-Ban-Host: $d" -H 'X-Ban-Path: ^/(?!api/img)' "http://$VARNISH_ADDR/" \ && log "cache purged for $d (kept /api/img)" \ || log "warning: cache purge failed for $d (varnish down?)" done } warm_app() { # re-populate the cache so the next visitor gets a HIT [[ "${CACHE:-off}" == "on" ]] || return 0 local d p i ok IFS=',' read -ra doms <<<"$(all_domains)" IFS=',' read -ra paths <<<"${WARM_PATHS:-/}" for d in "${doms[@]}"; do for p in "${paths[@]}"; do # retry: right after a deploy Varnish may still be probing the # fresh backend (sick -> 503 for up to ~15s) ok=0 for i in 1 2 3 4 5; do if curl -fsS -o /dev/null --max-time 30 -H "Host: $d" "http://$VARNISH_ADDR$p" 2>/dev/null; then ok=1; break fi [[ "$i" -lt 5 ]] && sleep 4 done if [[ "$ok" -eq 1 ]]; then log "warmed $d$p" else log "warning: warm failed for $d$p (after $i attempts)" fi done done # never let warming fail a deploy that already activated warm_from_sitemap || true } # Sitemap-driven warm (WARM_SITEMAP=/news-sitemap.xml): the deploy ban wipes # pages whose app-side TTLs are effectively "until purged" — without this, # WARM_PATHS (default /) re-rendered only the homepage and every real visitor # after a deploy paid a cold SSR miss. Warms the primary domain only; the # system domain sees no organic traffic worth pre-rendering. warm_from_sitemap() { [[ -n "${WARM_SITEMAP:-}" ]] || return 0 local prim xml n prim="$(primary_domain)" [[ -n "$prim" ]] || return 0 xml="$(curl -fsS --max-time 30 -H "Host: $prim" "http://$VARNISH_ADDR$WARM_SITEMAP" 2>/dev/null || true)" if [[ -z "$xml" ]]; then log "warning: sitemap warm skipped ($prim$WARM_SITEMAP unreachable)" return 0 fi # `|| true` everywhere: an EMPTY sitemap is normal (ran/can news sitemaps # carry only syndicated content) — grep exits 1 on no match and under # `set -euo pipefail` that aborted the whole deploy AFTER activation, # recording a live release as failed. n="$( { grep -o '[^<]*' <<<"$xml" \ | sed -e 's|||' -e 's|||' -e 's|&|\&|g' -E -e 's|^https?://[^/]+||' -e 's|^$|/|' \ | sort -u | head -n "${WARM_SITEMAP_LIMIT:-50}" \ | xargs -r -P 3 -I{} sh -c \ "curl -fsS -o /dev/null --max-time 30 -H 'Host: $prim' 'http://$VARNISH_ADDR{}' 2>/dev/null && echo ok" \ | wc -l | tr -d ' '; } 2>/dev/null || true)" log "warmed ${n:-0} sitemap URLs for $prim (source $WARM_SITEMAP)" } deploy_record_update() { # $1 = status, $2 = step, $3 = release_dir (optional) local extra="" [[ -n "${3:-}" ]] && extra=", release_dir='$(sq "$3")'" [[ "$1" == "live" || "$1" == "failed" ]] && extra="$extra, finished_at=datetime('now')" db_exec "UPDATE deployments SET status='$(sq "$1")', step='$(sq "$2")'$extra WHERE id=$DEPLOY_ID;" } # ---------------------------------------------------------------- commands ---- cmd_create() { local app="" domains="" cache=off noindex=off port="" start_cmd="node server/index.mjs" local health="" pass_paths="" warm_paths="/" warm_sitemap="" redirect_domains="" system_domain="" node_version="" local runtime="node" php_version="" app="${1:-}"; shift || true [[ -n "$app" ]] || usage 1 require_app_name "$app" while [[ $# -gt 0 ]]; do case "$1" in --domains) domains="$2"; shift 2 ;; --redirect-domains) redirect_domains="$2"; shift 2 ;; --cache) cache=on; shift ;; --noindex) noindex=on; shift ;; --port) port="$2"; shift 2 ;; --start) start_cmd="$2"; shift 2 ;; --health) health="$2"; shift 2 ;; --pass-paths) pass_paths="$2"; shift 2 ;; --warm-paths) warm_paths="$2"; shift 2 ;; --warm-sitemap) warm_sitemap="$2"; shift 2 ;; --node) node_version="$2"; shift 2 ;; --runtime) runtime="$2"; shift 2 ;; --php) php_version="$2"; shift 2 ;; *) die "unknown option '$1'" ;; esac done [[ -f "$(conf_path "$app")" ]] && die "app '$app' already exists" [[ -n "$port" ]] || port="$(next_free_port)" [[ "$runtime" =~ ^(node|laravel)$ ]] || die "--runtime must be node or laravel" require_runtime_support "$runtime" if [[ "$runtime" == "laravel" ]]; then # base.vcl strips cookies on cacheable paths — fatal for Laravel sessions/CSRF [[ "$cache" == "off" ]] || die "--cache is not supported for the laravel runtime (Varnish strips cookies)" php_version="${php_version:-$PHP_DEFAULT}" [[ "$php_version" =~ ^[0-9]+\.[0-9]+$ ]] || die "invalid --php '$php_version' (e.g. 8.3)" [[ -d "$PHP_DIR/$php_version" && -x "/usr/bin/php$php_version" ]] || die "PHP $php_version not installed ($PHP_DIR/$php_version missing)" [[ -n "$health" ]] || health="/up" # Laravel 11+ ships /up; older apps: --health / start_cmd="" else php_version="" [[ -n "$health" ]] || health="/" fi # always-available system domain via the *.SYSTEM_DOMAIN_BASE wildcard if [[ -n "${SYSTEM_DOMAIN_BASE:-}" ]]; then system_domain="$app-$(openssl rand -hex 4).$SYSTEM_DOMAIN_BASE" fi [[ -n "$domains$system_domain" ]] || die "--domains is required (no SYSTEM_DOMAIN_BASE configured)" if [[ -n "$node_version" && "$node_version" != "system" ]]; then [[ -d "/opt/node/v$node_version" ]] || die "node version $node_version not installed (/opt/node/v$node_version missing)" fi [[ "$node_version" == "system" ]] && node_version="" mkdir -p "$APPS_ROOT/$app"/{releases,shared,static} touch "$APPS_ROOT/$app/shared/.env" cat >"$(conf_path "$app")" <>"$(conf_path "$app")" <'" else log "next: point DNS for custom domains at this server, then 'paas deploy $app '" fi } cmd_deploy() { load_app "${1:-}" ; local src="${2:-}" [[ -n "$src" && -d "$src" ]] || die "usage: paas deploy " if is_laravel; then [[ -f "$src/artisan" && -d "$src/vendor" && -d "$src/public" ]] \ || die "$src is not a built Laravel checkout (need artisan, vendor/, public/ — run composer install first)" else [[ -e "$src/server/index.mjs" ]] || log "note: $src has no server/index.mjs — make sure START_CMD matches" fi local source_tag="${PAAS_SOURCE:-manual}" sha="${PAAS_COMMIT:-}" if [[ -z "${DEPLOY_ID:-}" ]]; then local logfile logfile="$LOG_DIR/deploy-$APP-$(date +%s).log" exec > >(tee -a "$logfile") 2>&1 DEPLOY_ID="$(db_query "INSERT INTO deployments (app, source, commit_sha, status, step, log_path) VALUES ('$(sq "$APP")','$(sq "$source_tag")','$(sq "$sha")','deploying','starting','$(sq "$logfile")'); SELECT last_insert_rowid();")" DEPLOY_ID="${DEPLOY_ID:-0}" fi [[ "$DEPLOY_ID" =~ ^[0-9]+$ ]] || DEPLOY_ID=0 local ts rel prev ts="$(date +%Y%m%d%H%M%S)" rel="$APP_DIR/releases/$ts-${sha:0:8}" [[ -n "$sha" ]] || rel="$APP_DIR/releases/$ts" prev="$(readlink -f "$APP_DIR/current" 2>/dev/null || true)" deploy_record_update deploying "copying release" log "creating release $rel" mkdir -p "$rel" local test_port=$((PORT + 1000)) if is_laravel; then # whole built checkout; storage/ and .env are shared, env-specific # caches are rebuilt below (packages.php/services.php stay: they come # from composer's package:discover and are environment-independent) rsync -a --delete-excluded \ --exclude=/.git --exclude=/node_modules --exclude=/storage --exclude=/.env \ --exclude=/tests --exclude='/bootstrap/cache/config.php' \ --exclude='/bootstrap/cache/routes-*.php' --exclude='/bootstrap/cache/events.php' \ "$src"/ "$rel"/ ensure_laravel_shared link_laravel_release "$rel" deploy_record_update deploying "building laravel caches" if ! laravel_build_caches "$rel"; then deploy_record_update failed "artisan cache build failed (config/route/view/event)" die "artisan cache build failed — the release cannot boot; current version untouched" fi # health-gate through the REAL fpm pool: Caddy's loopback site on # PORT+1000 serves staging/public deploy_record_update deploying "health-gating new release" log "health-gating on port $test_port ($HEALTH_PATH) via staging symlink" ln -sfn "$rel" "$APP_DIR/staging" if ! wait_healthy "$test_port" "$HEALTH_TIMEOUT"; then rm -f "$APP_DIR/staging" deploy_record_update failed "health-gate failed (see shared/storage/logs/laravel.log)" die "new release failed the health-gate on port $test_port — current version untouched. See $APP_DIR/shared/storage/logs/laravel.log and php-fpm.log" fi rm -f "$APP_DIR/staging" if [[ "${MIGRATE:-on}" == "on" ]]; then deploy_record_update deploying "running migrations" log "artisan migrate --force" if ! artisan "$rel" migrate --force; then deploy_record_update failed "migrations failed (current release untouched; schema changes are NOT rolled back)" die "migrations failed — current version untouched (applied migrations stay applied)" fi fi else rsync -a "$src"/ "$rel"/ if [[ -d "$rel/public" ]]; then log "syncing static assets (additive)" rsync -a "$rel/public/" "$APP_DIR/static/" fi # health-gate the new release on a test port before it goes live deploy_record_update deploying "health-gating new release" log "health-gating on port $test_port ($HEALTH_PATH)" ( set -a [ -f "$APP_DIR/shared/.env" ] && . "$APP_DIR/shared/.env" set +a [[ -n "${NODE_VERSION:-}" ]] && export PATH="/opt/node/v$NODE_VERSION/bin:$PATH" export PORT="$test_port" NITRO_PORT="$test_port" HOST=127.0.0.1 NITRO_HOST=127.0.0.1 NODE_ENV=production cd "$rel" exec bash -c "exec $START_CMD" ) >"$rel/.healthcheck.log" 2>&1 & local test_pid=$! if ! wait_healthy "$test_port" "$HEALTH_TIMEOUT"; then kill "$test_pid" 2>/dev/null || true deploy_record_update failed "health-gate failed (see release .healthcheck.log)" die "new release failed the health-gate on port $test_port — current version untouched. Log: $rel/.healthcheck.log" fi kill "$test_pid" 2>/dev/null || true wait "$test_pid" 2>/dev/null || true fi # go live deploy_record_update deploying "activating release" if ! activate_release "$rel" "$prev"; then deploy_record_update failed "unhealthy after activation, reverted" die "deploy failed after activation; previous release restored" fi ban_app warm_app # prune old releases (never the active one) local keep_list r keep_list="$(ls -1dt "$APP_DIR/releases"/*/ 2>/dev/null | head -n "$KEEP_RELEASES")" for r in "$APP_DIR/releases"/*/; do r="${r%/}" if ! grep -q "^${r}/$" <<<"$keep_list" && [[ "$(readlink -f "$APP_DIR/current")" != "$r" ]]; then rm -rf "$r" fi done deploy_record_update live "deployed" "$rel" log "deploy OK — $APP is live ($(primary_domain))" } cmd_rollback() { load_app "${1:-}" local cur target cur="$(readlink -f "$APP_DIR/current" 2>/dev/null || true)" [[ -n "$cur" ]] || die "no current release" target="$(ls -1dt "$APP_DIR/releases"/*/ | sed 's:/$::' | awk -v c="$cur" 'found{print;exit} $0==c{found=1}')" [[ -n "$target" ]] || die "no older release to roll back to" log "rolling back: $cur -> $target" activate_release "$target" "$cur" || die "rollback target unhealthy — restored $cur" ban_app warm_app db_exec "INSERT INTO deployments (app, source, status, step, release_dir, finished_at) VALUES ('$(sq "$APP")','manual','live','rollback -> $(sq "$(basename "$target")")','$(sq "$target")',datetime('now'));" log "rollback OK — now serving $(basename "$target")" } cmd_domains() { load_app "${1:-}"; shift local conf; conf="$(conf_path "$APP")" require_domain() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || die "invalid domain '$1'"; } case "${1:-}" in --add) [[ -n "${2:-}" ]] || die "usage: paas domains --add " require_domain "$2" tr ',' '\n' <<<"$DOMAINS" | grep -qx "$2" && die "domain already present" sed -i "s|^DOMAINS=.*|DOMAINS=${DOMAINS:+$DOMAINS,}$2|" "$conf" ;; --remove) [[ -n "${2:-}" ]] || die "usage: paas domains --remove " local new # grep -vx legitimately exits 1 when the removed domain was the only # entry (nothing survives to select) — under set -e/pipefail that would # otherwise abort the whole function before the empty-result check below # ever runs. The `|| true` only neutralizes the propagated exit status; # captured stdout (the filtered list) is unaffected. new="$(tr ',' '\n' <<<"$DOMAINS" | grep -vx "$2" | paste -sd, -)" || true [[ -n "$new" || -n "${SYSTEM_DOMAIN:-}" ]] || die "cannot remove the last domain (no system domain exists)" sed -i "s|^DOMAINS=.*|DOMAINS=$new|" "$conf" # a removed domain cannot stay main if [[ "${MAIN_DOMAIN:-}" == "$2" ]]; then if grep -q '^MAIN_DOMAIN=' "$conf"; then sed -i "s|^MAIN_DOMAIN=.*|MAIN_DOMAIN=|" "$conf"; fi fi # a removed domain cannot keep a domain-rules.json entry either local rules_file="$APP_DIR/shared/domain-rules.json" if [[ -f "$rules_file" ]]; then python3 - "$rules_file" "$2" <<'PY' || true import json, sys path, removed = sys.argv[1], sys.argv[2] try: d = json.load(open(path)) except Exception: sys.exit(0) d["rules"] = [r for r in d.get("rules", []) if r.get("domain") != removed] json.dump(d, open(path, "w")) PY fi ;; --set-main) [[ -n "${2:-}" ]] || die "usage: paas domains --set-main " tr ',' '\n' <<<"$(all_domains)" | grep -qx "$2" || die "'$2' is not a configured domain of $APP" if grep -q '^MAIN_DOMAIN=' "$conf"; then sed -i "s|^MAIN_DOMAIN=.*|MAIN_DOMAIN=$2|" "$conf" else echo "MAIN_DOMAIN=$2" >>"$conf" fi ;; --add-redirect) [[ -n "${2:-}" ]] || die "usage: paas domains --add-redirect " require_domain "$2" tr ',' '\n' <<<"${REDIRECT_DOMAINS:-}" | grep -qx "$2" && die "redirect already present" if grep -q '^REDIRECT_DOMAINS=' "$conf"; then sed -i "s|^REDIRECT_DOMAINS=.*|REDIRECT_DOMAINS=${REDIRECT_DOMAINS:+$REDIRECT_DOMAINS,}$2|" "$conf" else echo "REDIRECT_DOMAINS=$2" >>"$conf" fi ;; --remove-redirect) [[ -n "${2:-}" ]] || die "usage: paas domains --remove-redirect " local newr # same set -e/pipefail pitfall as --remove above newr="$(tr ',' '\n' <<<"${REDIRECT_DOMAINS:-}" | grep -vx "$2" | paste -sd, -)" || true sed -i "s|^REDIRECT_DOMAINS=.*|REDIRECT_DOMAINS=$newr|" "$conf" ;; *) die "usage: paas domains --add|--remove|--set-main|--add-redirect|--remove-redirect " ;; esac _keep="$APP"; reset_app_vars; load_app "$_keep" regen_caddy regen_vcl log "domains now: $(all_domains) redirects: ${REDIRECT_DOMAINS:-—}" } cmd_ban() { load_app "${1:-}"; CACHE=on ban_app; } cmd_warm() { load_app "${1:-}"; CACHE=on warm_app; } cmd_list() { printf '%-16s %-8s %-6s %-6s %-14s %s\n' APP RUNTIME PORT CACHE STATUS DOMAINS local conf status jlist jlist="$(pm2 jlist 2>/dev/null || echo '[]')" for conf in "$REG_DIR"/*.conf; do [[ -e "$conf" ]] || continue # shellcheck disable=SC1090 ( reset_app_vars source "$conf" if is_laravel; then status="fpm:$(fpm_state)" if [[ "${QUEUE_WORKER:-off}" == "on" ]]; then qs="$(jq -r --arg n "$APP-queue" '[.[]|select(.name==$n)][0].pm2_env.status // "-"' <<<"$jlist" 2>/dev/null || echo '-')" status="$status+q:$qs" fi else status="$(jq -r --arg n "$APP" '[.[]|select(.name==$n)][0].pm2_env.status // "-"' <<<"$jlist" 2>/dev/null || echo '-')" fi printf '%-16s %-8s %-6s %-6s %-14s %s\n' "$APP" "${RUNTIME:-node}" "$PORT" "$CACHE" "$status" "$DOMAINS" ) done } cmd_info() { load_app "${1:-}"; cat "$(conf_path "$APP")"; } cmd_logs() { load_app "${1:-}" if is_laravel; then local files=() f for f in "$APP_DIR/shared/storage/logs/laravel.log" "$APP_DIR/shared/storage/logs/php-fpm.log" \ "$APP_DIR/shared/storage/logs/scheduler.log"; do [[ -f "$f" ]] && files+=("$f") done # daily channel: laravel-YYYY-MM-DD.log — include the newest local daily; daily="$(ls -1t "$APP_DIR"/shared/storage/logs/laravel-*.log 2>/dev/null | head -1 || true)" [[ -n "$daily" ]] && files+=("$daily") [[ ${#files[@]} -gt 0 ]] || die "no logs yet under $APP_DIR/shared/storage/logs" if [[ "${QUEUE_WORKER:-off}" == "on" ]]; then pm2 logs "$(queue_name)" --lines 50 --nostream 2>/dev/null || true fi exec tail -n 200 -F "${files[@]}" fi exec pm2 logs "$APP" } cmd_restart() { # apply env changes: node = pm2 restart; laravel = re-cache config + reload fpm load_app "${1:-}" if is_laravel; then [[ -e "$APP_DIR/current" ]] || die "no current release" laravel_build_caches "$APP_DIR/current" || die "artisan cache rebuild failed — app still runs the previous config cache" sudo -n systemctl reload "$(fpm_unit)" laravel_signal_queue [[ "${QUEUE_WORKER:-off}" == "on" ]] && pm2 restart "$(queue_name)" --update-env >/dev/null 2>&1 || true wait_healthy "$PORT" "$HEALTH_TIMEOUT" || die "app unhealthy after restart — check 'paas logs $APP'" log "$APP restarted (config cache rebuilt, php-fpm reloaded)" else write_run_sh pm2_apply wait_healthy "$PORT" "$HEALTH_TIMEOUT" || die "app unhealthy after restart — check 'paas logs $APP'" log "$APP restarted (pm2)" fi } cmd_remove() { local app="${1:-}" purge=0 yes=0; shift || true while [[ $# -gt 0 ]]; do case "$1" in --purge) purge=1; shift ;; --yes) yes=1; shift ;; *) die "unknown option '$1'" ;; esac done load_app "$app" if [[ "$yes" -ne 1 ]]; then read -rp "[paas] remove app '$APP' (domains: $DOMAINS)? [y/N] " a [[ "$a" == "y" || "$a" == "Y" ]] || die "aborted" fi # capture domains now: after conf removal they are gone local _all _redirects; _all="$(all_domains)"; _redirects="${REDIRECT_DOMAINS:-}" # purge any cached pages for this app's hosts before the config vanishes CACHE=on ban_app || true pm2 delete "$APP" >/dev/null 2>&1 || true pm2 delete "$(queue_name)" >/dev/null 2>&1 || true pm2 save >/dev/null 2>&1 || true local _was_laravel=0; is_laravel && _was_laravel=1 rm -f "$(conf_path "$APP")" regen_caddy regen_vcl [[ "$_was_laravel" -eq 1 ]] && regen_fpm if [[ "$purge" -eq 1 ]]; then # database (if any): backup first, the dump survives the purge if [[ -f "$DB_ADMIN_CNF" ]] && db_exists "$(db_name_for "$APP")"; then db_backup_app "$APP" "$(db_name_for "$APP")" || log "warning: database backup failed — dropping anyway" db_drop_app "$APP" "$(db_name_for "$APP")" fi [[ -n "$APP" && -d "$APPS_ROOT/$APP" ]] && rm -rf "${APPS_ROOT:?}/${APP:?}" log "purged $APP_DIR" # drop caddy's stored certificates for exactly this app's domains local _d for _d in $(tr ',' ' ' <<<"$_all${_redirects:+,$_redirects}"); do if [[ $EUID -eq 0 ]]; then /usr/local/bin/paas-cert-prune "$_d" >/dev/null 2>&1 && log "removed certificate storage for $_d" || true else sudo -n /usr/local/bin/paas-cert-prune "$_d" >/dev/null 2>&1 && log "removed certificate storage for $_d" || true fi done fi log "app '$APP' removed" } cmd_ssl() { # force Caddy to re-attempt certificate issuance (resets ACME backoff) load_app "${1:-}" local only="${2:-}" list list="$(all_domains)${REDIRECT_DOMAINS:+,$REDIRECT_DOMAINS}" if [[ -n "$only" ]]; then tr ',' '\n' <<<"$list" | grep -qx "$only" || die "'$only' is not a domain of $APP" list="$only" fi log "forcing caddy re-provision to retry certificate issuance" # only names WITHOUT a valid cert are (re)attempted — existing certs stay untouched sudo systemctl reload caddy # ExecReload uses --force -> re-provisions, retries missing certs sleep 6 # issuance is async; give the first attempt a moment local d end IFS=',' read -ra doms <<<"$list" for d in "${doms[@]}"; do end=$(echo | timeout 5 openssl s_client -servername "$d" -connect 127.0.0.1:443 2>/dev/null \ | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) if [[ -n "$end" ]]; then log "OK: $d (valid until $end)" else log "PENDING: $d — no cert yet; if DNS points here, retry in a minute" fi done } cmd_geo() { load_app "${1:-}"; shift local geo_file="$APP_DIR/shared/geo.json" case "${1:-}" in --set-json) local src="${2:--}" tmp tmp="$(mktemp)" if [[ "$src" == "-" ]]; then cat >"$tmp"; else cat "$src" >"$tmp"; fi python3 - "$tmp" <<'PY' || { rm -f "$tmp"; die "invalid geo rules JSON"; } import json, re, sys d = json.load(open(sys.argv[1])) assert isinstance(d.get("rules"), list) for r in d["rules"]: assert r.get("mode") in ("block", "allow-only") assert isinstance(r.get("countries"), list) and r["countries"] for c in r["countries"]: assert re.match(r"^[A-Z]{2}$", str(c)) for p in r.get("paths", []): assert str(p).startswith("/") PY install -m 644 "$tmp" "$geo_file"; rm -f "$tmp" ;; --clear) rm -f "$geo_file" ;; --show) cat "$geo_file" 2>/dev/null || echo '{"rules": []}'; return 0 ;; *) die "usage: paas geo --set-json | --clear | --show" ;; esac regen_caddy log "geo rules updated for $APP" } # Restrict specific custom domains to a path allowlist (everything else # 404s on that host) and/or mark them noindex independently of the # app-wide NOINDEX flag. See emit_domain_path_blocks/emit_domain_noindex_headers. cmd_domain_rules() { load_app "${1:-}"; shift local rules_file="$APP_DIR/shared/domain-rules.json" case "${1:-}" in --set-json) local src="${2:--}" tmp tmp="$(mktemp)" if [[ "$src" == "-" ]]; then cat >"$tmp"; else cat "$src" >"$tmp"; fi DOMAIN_RULES_KNOWN_DOMAINS="$DOMAINS" python3 - "$tmp" <<'PY' || { rm -f "$tmp"; die "invalid domain rules JSON"; } import json, os, re, sys path = sys.argv[1] known = set(d for d in os.environ.get("DOMAIN_RULES_KNOWN_DOMAINS", "").split(",") if d) d = json.load(open(path)) assert isinstance(d.get("rules"), list) seen = set() for r in d["rules"]: dom = r.get("domain") assert isinstance(dom, str) and dom, "each rule needs a domain" assert dom not in seen, f"duplicate rule for domain '{dom}'" assert dom in known, f"domain '{dom}' is not a configured DOMAINS entry for this app" seen.add(dom) paths = r.get("paths", []) assert isinstance(paths, list) and len(paths) <= 20 for p in paths: assert re.match(r"^/[A-Za-z0-9_\-./*]+$", str(p)), f"invalid path '{p}'" assert isinstance(r.get("noindex", False), bool) PY install -m 644 "$tmp" "$rules_file"; rm -f "$tmp" ;; --clear) rm -f "$rules_file" ;; --show) cat "$rules_file" 2>/dev/null || echo '{"rules": []}'; return 0 ;; *) die "usage: paas domain-rules --set-json | --clear | --show" ;; esac regen_caddy log "domain rules updated for $APP" } cmd_node() { # select the Node.js version an app builds/runs with load_app "${1:-}"; shift [[ "${1:-}" == "--set" && -n "${2:-}" ]] || die "usage: paas node --set " local v="$2" if [[ "$v" == "system" ]]; then v=""; else [[ -d "/opt/node/v$v" ]] || die "node version $v not installed (/opt/node/v$v missing)" fi local conf; conf="$(conf_path "$APP")" if grep -q '^NODE_VERSION=' "$conf"; then sed -i "s|^NODE_VERSION=.*|NODE_VERSION=$v|" "$conf" else echo "NODE_VERSION=$v" >>"$conf" fi _keep="$APP"; reset_app_vars; load_app "$_keep" if is_laravel; then log "node version set to ${v:-system} — used for the asset build (Vite) on the next deploy" else write_run_sh log "node version set to ${v:-system} — takes effect on next deploy or 'pm2 restart $APP'" fi } cmd_php() { # select the PHP version a laravel app builds/runs with load_app "${1:-}"; shift is_laravel || die "$APP is not a laravel app" [[ "${1:-}" == "--set" && -n "${2:-}" ]] || die "usage: paas php --set " local v="$2" [[ "$v" =~ ^[0-9]+\.[0-9]+$ && -d "$PHP_DIR/$v" && -x "/usr/bin/php$v" ]] || die "PHP $v not installed ($PHP_DIR/$v missing)" set_conf_key "$(conf_path "$APP")" PHP_VERSION "$v" _keep="$APP"; reset_app_vars; load_app "$_keep" regen_fpm # pool moves to the new master; socket path unchanged if [[ "${QUEUE_WORKER:-off}" == "on" && -e "$APP_DIR/current" ]]; then write_queue_run_sh; queue_apply fi log "PHP version set to $v — live now (fpm pool moved); rebuild caches with 'paas restart $APP' or the next deploy" } set_conf_key() { # $1 conf, $2 key, $3 value if grep -q "^$2=" "$1"; then sed -i "s|^$2=.*|$2=$3|" "$1"; else echo "$2=$3" >>"$1"; fi } cmd_set() { # toggle per-app registry flags; CACHE affects caddy upstream + vcl, NOINDEX affects caddy local set_usage="usage: paas set [--cache on|off] [--noindex on|off] [--health ] [--warm-paths ] [--warm-sitemap ] [--geo-exempt-google on|off] [--migrate on|off] [--queue on|off] [--queue-cmd ] [--scheduler on|off] [--max-children N] (laravel)" load_app "${1:-}"; shift local conf changed=0 fpm_changed=0 queue_changed=0; conf="$(conf_path "$APP")" while [[ $# -gt 0 ]]; do case "$1" in --cache) [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage" if [[ "$2" == "on" ]] && is_laravel; then die "Varnish cache is not supported for laravel apps (base.vcl strips cookies)"; fi set_conf_key "$conf" CACHE "$2"; changed=1; shift 2 ;; --migrate|--scheduler|--queue) is_laravel || die "$1 only applies to laravel apps" [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage" case "$1" in --migrate) set_conf_key "$conf" MIGRATE "$2" ;; --scheduler) set_conf_key "$conf" SCHEDULER "$2" ;; --queue) set_conf_key "$conf" QUEUE_WORKER "$2"; queue_changed=1 ;; esac changed=1; shift 2 ;; --queue-cmd) is_laravel || die "$1 only applies to laravel apps" [[ -n "${2:-}" && "$2" != *'"'* ]] || die "$set_usage" set_conf_key "$conf" QUEUE_CMD "\"$2\""; changed=1; queue_changed=1; shift 2 ;; --max-children) is_laravel || die "$1 only applies to laravel apps" [[ "${2:-}" =~ ^[0-9]{1,3}$ && "$2" -ge 1 ]] || die "$set_usage" set_conf_key "$conf" FPM_MAX_CHILDREN "$2"; changed=1; fpm_changed=1; shift 2 ;; --noindex) [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage" set_conf_key "$conf" NOINDEX "$2"; changed=1; shift 2 ;; --geo-exempt-google) [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage" set_conf_key "$conf" GEO_EXEMPT_GOOGLE "$2"; changed=1; shift 2 ;; --health) # health probes hit this on the app port every 5s — a cheap # endpoint (e.g. /api/_health) beats a full SSR render of / [[ "${2:-}" == /* ]] || die "$set_usage" set_conf_key "$conf" HEALTH_PATH "$2"; changed=1; shift 2 ;; --warm-paths) [[ -n "${2:-}" ]] || die "$set_usage" set_conf_key "$conf" WARM_PATHS "\"$2\""; changed=1; shift 2 ;; --warm-sitemap) # empty string clears it set_conf_key "$conf" WARM_SITEMAP "\"${2:-}\""; changed=1; shift 2 ;; *) die "$set_usage" ;; esac done [[ "$changed" == "1" ]] || die "$set_usage" _keep="$APP"; reset_app_vars; load_app "$_keep" regen_caddy; regen_vcl [[ "$fpm_changed" == "1" ]] && regen_fpm [[ "$queue_changed" == "1" ]] && queue_apply if is_laravel; then log "$APP flags now NOINDEX=${NOINDEX:-off} HEALTH=${HEALTH_PATH:-/} MIGRATE=${MIGRATE:-on} QUEUE_WORKER=${QUEUE_WORKER:-off} SCHEDULER=${SCHEDULER:-off} FPM_MAX_CHILDREN=${FPM_MAX_CHILDREN:-8} GEO_EXEMPT_GOOGLE=${GEO_EXEMPT_GOOGLE:-on}" else log "$APP flags now CACHE=${CACHE:-off} NOINDEX=${NOINDEX:-off} HEALTH=${HEALTH_PATH:-/} WARM_SITEMAP=${WARM_SITEMAP:-} GEO_EXEMPT_GOOGLE=${GEO_EXEMPT_GOOGLE:-on} — caddy+varnish regenerated" fi } cmd_enable() { load_app "${1:-}" set_conf_key "$(conf_path "$APP")" ENABLED on _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy log "$APP enabled — publicly reachable again" } cmd_disable() { load_app "${1:-}" set_conf_key "$(conf_path "$APP")" ENABLED off _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy log "$APP disabled — all domains answer 503 (app keeps running internally)" } cmd_protect() { load_app "${1:-}"; shift local conf; conf="$(conf_path "$APP")" if [[ "${1:-}" == "--off" ]]; then set_conf_key "$conf" BASIC_AUTH_USER "" set_conf_key "$conf" BASIC_AUTH_HASH "" _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy log "$APP protection removed — public again" return 0 fi local buser="" bpass="" while [[ $# -gt 0 ]]; do case "$1" in --user) buser="$2"; shift 2 ;; --password) bpass="$2"; shift 2 ;; *) die "usage: paas protect --user --password

| --off" ;; esac done [[ -n "$buser" && -n "$bpass" ]] || die "usage: paas protect --user --password

| --off" [[ "$buser" =~ ^[A-Za-z0-9_.-]{2,40}$ ]] || die "invalid user" local hash hash="$("$CADDY_BIN" hash-password --plaintext "$bpass")" || die "hashing failed" set_conf_key "$conf" BASIC_AUTH_USER "$buser" set_conf_key "$conf" BASIC_AUTH_HASH "'$hash'" _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy log "$APP protected — browser login: $buser / " } # ---------------------------------------------------------- database (MariaDB) ---- # One database + one user per app, loopback only. Admin access goes through # /srv/paas/db-admin.cnf (paas_admin, written by bootstrap WITH_LARAVEL=1) — # no sudo, no root shell. Credentials live ONLY in the app's shared/.env. DB_RESERVED="mysql information_schema performance_schema sys test" # explicit list (not ALL): paas_admin holds exactly these with GRANT OPTION — # "GRANT ALL" would fail because ALL also implies privileges we never gave it DB_APP_PRIVS="SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES, LOCK TABLES, CREATE VIEW, SHOW VIEW, TRIGGER, EVENT, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, CREATE TEMPORARY TABLES" require_db_server() { [[ -f "$DB_ADMIN_CNF" ]] || die "no database server on this host (bootstrap with WITH_LARAVEL=1 to install MariaDB)" command -v mariadb >/dev/null || command -v mysql >/dev/null || die "mariadb client not installed" } db_admin() { # run the mariadb client as paas_admin local bin=mariadb; command -v mariadb >/dev/null || bin=mysql "$bin" --defaults-extra-file="$DB_ADMIN_CNF" "$@" } db_dump_bin() { command -v mariadb-dump >/dev/null && echo mariadb-dump || echo mysqldump; } db_name_for() { printf '%s' "${1//-/_}"; } # app names are ^[a-z][a-z0-9-]{1,30}$ → safe identifiers db_guard_name() { # $1 = db name local r for r in $DB_RESERVED; do [[ "$1" == "$r" ]] && die "'$1' is a reserved database name"; done return 0 } db_exists() { # $1 = db name [[ -n "$(db_admin -N -B -e "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$1'" 2>/dev/null)" ]] } db_backup_app() { # $1 = app, $2 = db name local app="$1" name="$2" ts out mkdir -p "$DB_BACKUP_DIR"; chmod 700 "$DB_BACKUP_DIR" ts="$(date +%Y%m%d%H%M%S)" out="$DB_BACKUP_DIR/$app-$ts.sql.gz" if ! "$(db_dump_bin)" --defaults-extra-file="$DB_ADMIN_CNF" --single-transaction --quick \ --routines --triggers --events "$name" | gzip >"$out"; then rm -f "$out" return 1 fi chmod 600 "$out" log "database backup: $out ($(du -h "$out" | cut -f1))" # keep the newest N per app ls -1t "$DB_BACKUP_DIR/$app"-*.sql.gz 2>/dev/null | tail -n +"$((DB_BACKUP_KEEP + 1))" | xargs -r rm -f } db_drop_app() { # $1 = app, $2 = db name — DROP db + user, forget creds in .env local app="$1" name="$2" k local env="$APPS_ROOT/$app/shared/.env" db_admin -e "DROP DATABASE IF EXISTS \`$name\`; DROP USER IF EXISTS '$name'@'localhost'; DROP USER IF EXISTS '$name'@'127.0.0.1'; FLUSH PRIVILEGES;" if [[ -f "$env" && "$(env_get_key "$env" DB_DATABASE)" == "$name" ]]; then for k in DB_CONNECTION DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD DATABASE_URL; do sed -i "/^$k=/d" "$env" done fi log "database $name and user dropped" } db_print_creds() { # $1 = db/user name, $2 = password cat </dev/null || hostname) (then connect to localhost:3306) EOF } db_write_env() { # $1 = app, $2 = name, $3 = password, $4 = force (1|0) local env="$APPS_ROOT/$1/shared/.env" force="$4" k local -A want=( [DB_CONNECTION]=mysql [DB_HOST]=127.0.0.1 [DB_PORT]=3306 [DB_DATABASE]="$2" [DB_USERNAME]="$2" [DB_PASSWORD]="$3" ) local skipped=() for k in DB_CONNECTION DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD; do if [[ "$force" != "1" && -n "$(env_get_key "$env" "$k")" && "$(env_get_key "$env" "$k")" != "${want[$k]}" ]]; then skipped+=("$k"); continue fi env_set_key "$env" "$k" "${want[$k]}" done # node apps: many libraries want a single URL (Prisma, knex, Strapi DATABASE_URL) if ! is_laravel; then if [[ "$force" == "1" || -z "$(env_get_key "$env" DATABASE_URL)" ]]; then env_set_key "$env" DATABASE_URL "mysql://$2:$3@127.0.0.1:3306/$2" else skipped+=(DATABASE_URL) fi fi if [[ ${#skipped[@]} -gt 0 ]]; then log "warning: kept existing ${skipped[*]} in shared/.env (differs from the provisioned values; use --force-env to overwrite)" fi } cmd_db() { local action="${1:-}"; shift || true case "$action" in backup) require_db_server if [[ "${1:-}" == "--all" ]]; then local conf n rc=0 for conf in "$REG_DIR"/*.conf; do [[ -e "$conf" ]] || continue # shellcheck disable=SC1090 ( reset_app_vars; source "$conf" n="$(db_name_for "$APP")" db_exists "$n" || exit 0 db_backup_app "$APP" "$n" ) || rc=1 done [[ "$rc" -eq 0 ]] || die "one or more backups failed" return 0 fi load_app "${1:-}" local n; n="$(db_name_for "$APP")" db_exists "$n" || die "$APP has no database" db_backup_app "$APP" "$n" || die "backup failed" ;; create) require_db_server local force=0 load_app "${1:-}"; shift || true [[ "${1:-}" == "--force-env" ]] && force=1 local n env pw; n="$(db_name_for "$APP")"; env="$APP_DIR/shared/.env" db_guard_name "$n" if db_exists "$n"; then if [[ "$force" != "1" && "$(env_get_key "$env" DB_DATABASE)" == "$n" && -n "$(env_get_key "$env" DB_PASSWORD)" ]]; then log "database $n already exists — current credentials:" db_print_creds "$n" "$(env_get_key "$env" DB_PASSWORD)" return 0 fi log "database $n exists but shared/.env has no matching credentials — rotating the password" fi pw="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)" # two host entries: apps connect over TCP to 127.0.0.1 (with # skip-name-resolve that never matches 'localhost'), tooling may use the socket db_admin -e "CREATE DATABASE IF NOT EXISTS \`$n\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS '$n'@'127.0.0.1' IDENTIFIED BY '$pw'; CREATE USER IF NOT EXISTS '$n'@'localhost' IDENTIFIED BY '$pw'; ALTER USER '$n'@'127.0.0.1' IDENTIFIED BY '$pw'; ALTER USER '$n'@'localhost' IDENTIFIED BY '$pw'; GRANT $DB_APP_PRIVS ON \`$n\`.* TO '$n'@'127.0.0.1'; GRANT $DB_APP_PRIVS ON \`$n\`.* TO '$n'@'localhost'; FLUSH PRIVILEGES;" || die "creating database $n failed" db_write_env "$APP" "$n" "$pw" "$force" log "database $n ready — credentials written to $env:" db_print_creds "$n" "$pw" is_laravel && log "apply with 'paas restart $APP' (config cache) or the next deploy" return 0 ;; show) require_db_server load_app "${1:-}" local n env; n="$(db_name_for "$APP")"; env="$APP_DIR/shared/.env" db_exists "$n" || die "$APP has no database (paas db create $APP)" [[ "$(env_get_key "$env" DB_DATABASE)" == "$n" ]] || die "database $n exists but shared/.env has no credentials — 'paas db create $APP' rotates them" db_print_creds "$n" "$(env_get_key "$env" DB_PASSWORD)" ;; drop) require_db_server local yes=0 load_app "${1:-}"; shift || true [[ "${1:-}" == "--yes" ]] && yes=1 local n; n="$(db_name_for "$APP")" db_exists "$n" || die "$APP has no database" if [[ "$yes" -ne 1 ]]; then read -rp "[paas] DROP database '$n' of $APP (a backup is taken first)? [y/N] " a [[ "$a" == "y" || "$a" == "Y" ]] || die "aborted" fi db_backup_app "$APP" "$n" || die "backup failed — database NOT dropped" db_drop_app "$APP" "$n" ;; shell) require_db_server load_app "${1:-}" local n env; n="$(db_name_for "$APP")"; env="$APP_DIR/shared/.env" [[ "$(env_get_key "$env" DB_DATABASE)" == "$n" ]] || die "$APP has no database credentials in shared/.env" local bin=mariadb; command -v mariadb >/dev/null || bin=mysql MYSQL_PWD="$(env_get_key "$env" DB_PASSWORD)" exec "$bin" -h 127.0.0.1 -u "$n" "$n" ;; *) die "usage: paas db create [--force-env] | show | drop [--yes] | backup |--all | shell " ;; esac } cmd_regen() { regen_fpm; regen_caddy; regen_vcl; } # ------------------------------------------------------------------- main ---- [[ "$(id -un)" == "root" ]] && die "run paas as the 'deploy' user, not root" mkdir -p "$REG_DIR" "$CADDY_DIR" "$VCL_DIR" "$LOG_DIR" case "${1:-}" in create) shift; cmd_create "$@" ;; deploy) shift; cmd_deploy "$@" ;; rollback) shift; cmd_rollback "$@" ;; domains) shift; cmd_domains "$@" ;; domain-rules) shift; cmd_domain_rules "$@" ;; ban) shift; cmd_ban "$@" ;; warm) shift; cmd_warm "$@" ;; geo) shift; cmd_geo "$@" ;; node) shift; cmd_node "$@" ;; php) shift; cmd_php "$@" ;; restart) shift; cmd_restart "$@" ;; db) shift; cmd_db "$@" ;; set) shift; cmd_set "$@" ;; enable) shift; cmd_enable "$@" ;; disable) shift; cmd_disable "$@" ;; protect) shift; cmd_protect "$@" ;; ssl) shift; cmd_ssl "$@" ;; list) shift; cmd_list "$@" ;; info) shift; cmd_info "$@" ;; logs) shift; cmd_logs "$@" ;; remove) shift; cmd_remove "$@" ;; regen) shift; cmd_regen "$@" ;; help|-h|--help|"") usage 0 ;; *) die "unknown command '${1}' (see 'paas help')" ;; esac