#!/usr/bin/env bash # # LAMP + WordPress convergent installer # https://school.efm.lol/install.sh # # Usage: # curl -fsSL https://school.efm.lol/install.sh | sudo bash # curl -fsSL https://school.efm.lol/install.sh -o install.sh && sudo bash install.sh --help # # This script CONVERGES a machine toward a working LAMP + WordPress host. # It is not a "fresh box only" script: every step first asks what is already # true, and then does the smallest thing that makes the statement true. # Running it twice is a no-op. Running it on a server that already has half # of this set up repairs the half that is wrong and leaves the rest alone. # # The three rules it will not break: # 1. It never takes the machine off the network it is being run over. # 2. It never deletes data it did not create. Anything in the way is moved # aside with a timestamp, never removed. # 3. It never blanket-denies a firewall on a box that has listeners it did # not put there. # # Everything it changes is listed at the end, and written to a report file. # INSTALL_URL="${INSTALL_URL:-https://school.efm.lol/install.sh}" # ########################################################################### # # RE-EXEC GUARD - everything above the `set` line below must be POSIX sh. # # # # # # The advertised one-liner is: # # # curl -fsSL https://school.efm.lol/install.sh | sh # # # # # # On Ubuntu /bin/sh is dash, and this script is bash throughout (arrays, # # # [[ ]], printf -v). Under dash it would not fail at the top - it would # # # fail somewhere in the middle, having already changed things. So: if we # # # are not in bash, get into bash before doing anything at all. # # # # # # When piped, "$0" is not a readable file, so there is nothing to # # # re-exec; the only way back into bash is to fetch a fresh copy. That is # # # one extra HTTP GET, and it only ever happens on the `| sh` path. # # ########################################################################### _self_is_file() { [ -n "$1" ] && [ -r "$1" ] && [ -f "$1" ] } _refetch() { if command -v curl >/dev/null 2>&1; then curl -fsSL "$INSTALL_URL" elif command -v wget >/dev/null 2>&1; then wget -qO- "$INSTALL_URL" else echo "install.sh: need curl or wget to continue." >&2 return 1 fi } if [ -z "${BASH_VERSION:-}" ] && [ -z "${LAMP_INSTALL_LIB:-}" ]; then command -v bash >/dev/null 2>&1 || { echo "install.sh: this installer requires bash. Install it: apt install bash" >&2 exit 1 } if _self_is_file "$0"; then exec bash "$0" "$@" fi # Piped: there is no file to re-exec. Drain the rest of our own source out # of stdin first, so the curl feeding us can finish writing and exit 0 # rather than reporting "(23) Failed writing body" when we stop reading. cat >/dev/null 2>&1 || true _src="$(_refetch)" || exit 1 exec bash -c "$_src" bash "$@" fi # From here on we are certainly bash. set -Eeuo pipefail VERSION="1.2.0" SELF="${BASH_SOURCE[0]:-install.sh}" # Root, the same way: re-exec rather than telling the user to type it again. # sudo reads its password straight from /dev/tty, so this still works when the # script arrived down a pipe and stdin is not a terminal. # --help and --version must never ask for a password. Anything that only # prints and exits has no business escalating first. _wants_info_only() { local a for a in "$@"; do case "$a" in -h|--help|-V|--version) return 0 ;; esac done return 1 } if [[ ${EUID:-$(id -u)} -ne 0 ]] && ! _wants_info_only "$@" && [[ -z "${LAMP_INSTALL_LIB:-}" ]]; then if ! command -v sudo >/dev/null 2>&1; then echo "install.sh: needs root, and sudo is not installed. Re-run as root." >&2 exit 1 fi echo "install.sh: needs root - re-running under sudo." if _self_is_file "${BASH_SOURCE[0]:-}"; then exec sudo -E bash "${BASH_SOURCE[0]}" "$@" fi # Piped: there is no file to re-exec. Drain the rest of our own source out # of stdin first, so the curl feeding us can finish writing and exit 0 # rather than reporting "(23) Failed writing body" when we stop reading. cat >/dev/null 2>&1 || true _src="$(_refetch)" || exit 1 exec sudo -E bash -c "$_src" bash "$@" fi # ============================================================================ # OUTPUT # ============================================================================ if [[ -t 1 ]] && [[ "${NO_COLOR:-}" == "" ]]; then C_RST=$'\033[0m'; C_B=$'\033[1m'; C_DIM=$'\033[2m' C_RED=$'\033[0;31m'; C_GRN=$'\033[0;32m'; C_YEL=$'\033[0;33m' C_BLU=$'\033[0;34m'; C_CYN=$'\033[0;36m'; C_MAG=$'\033[0;35m' else C_RST=""; C_B=""; C_DIM=""; C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_CYN=""; C_MAG="" fi LOG_FILE="/var/log/lamp-install-$(date +%Y%m%d-%H%M%S).log" STEP_N=0 _log() { { printf '%s %s ' "$(date -Is)" "$*" >>"$LOG_FILE"; } 2>/dev/null || true; } say() { printf '%s\n' "$*"; _log "OUT: $*"; } step() { STEP_N=$((STEP_N+1)); printf '\n%s%s[%d]%s %s%s%s\n' "$C_B" "$C_BLU" "$STEP_N" "$C_RST" "$C_B" "$*" "$C_RST"; _log "STEP $STEP_N: $*"; } ok() { printf ' %s✓%s %s\n' "$C_GRN" "$C_RST" "$*"; _log "OK: $*"; } skip() { printf ' %s·%s %s%s%s\n' "$C_DIM" "$C_RST" "$C_DIM" "$*" "$C_RST"; _log "SKIP: $*"; } act() { printf ' %s→%s %s\n' "$C_CYN" "$C_RST" "$*"; _log "ACT: $*"; } warn() { printf ' %s!%s %s\n' "$C_YEL" "$C_RST" "$*"; _log "WARN: $*"; WARNINGS+=("$*"); } err() { printf ' %s✗%s %s\n' "$C_RED" "$C_RST" "$*" >&2; _log "ERR: $*"; } die() { printf '\n%s%s FATAL %s %s\n\n' "$C_B$C_RED" "" "$C_RST" "$*" >&2; _log "DIE: $*" [[ -s "$LOG_FILE" ]] && printf ' Log: %s ' "$LOG_FILE" >&2 exit 1; } hr() { printf '%s%s%s\n' "$C_DIM" "────────────────────────────────────────────────────────────────" "$C_RST"; } # Clear the screen before the banner, but only when stdout really is a # terminal: with `curl | sh` stdin is the pipe while stdout is still the tty, # so this is safe there - and into a pipe or a log file it would just write # escape codes into the output. clear_screen() { $CLEAR_SCREEN || return 0 [[ -t 1 ]] || return 0 if command -v tput >/dev/null 2>&1 && tput clear 2>/dev/null; then return 0 fi # \033[3J also drops the scrollback, so the banner starts at the top. printf '\033[2J\033[3J\033[H' } banner() { cat < backup path, for the report declare -A CHECK_RESULT=() # verification results changed() { CHANGES+=("$1"); } rollback_add() { ROLLBACK+=("$1"); } TS="$(date +%Y%m%d-%H%M%S)" # ============================================================================ # ERROR HANDLING # ============================================================================ FAILED=0 on_error() { local line="$1" cmd="$2" code="$3" # In a subshell (command substitution, pipeline element) only the parent # should report; otherwise a single fault prints the whole block twice. [[ "${BASHPID:-$$}" != "$$" ]] && exit "$code" FAILED=1 printf '\n%s%s ERROR %s exit %s at line %s\n' "$C_B$C_RED" "" "$C_RST" "$code" "$line" >&2 printf ' while running: %s%s%s\n' "$C_DIM" "$cmd" "$C_RST" >&2 _log "ERROR line=$line code=$code cmd=$cmd" if ((${#ROLLBACK[@]})); then printf '\n%sRolling back %d change(s)...%s\n' "$C_YEL" "${#ROLLBACK[@]}" "$C_RST" >&2 local i for (( i=${#ROLLBACK[@]}-1; i>=0; i-- )); do printf ' ↩ %s\n' "${ROLLBACK[$i]}" >&2 eval "${ROLLBACK[$i]}" >>"$LOG_FILE" 2>&1 || true done fi printf '\n Full log: %s\n' "$LOG_FILE" >&2 printf ' Nothing else was changed. Fix the above and re-run — this script is\n' >&2 printf ' safe to run again; it will skip everything that already succeeded.\n\n' >&2 exit "$code" } trap 'on_error "$LINENO" "$BASH_COMMAND" "$?"' ERR PROBE_FILE="" DBPROBE_FILE="" cleanup() { [[ -n "${MYSQL_CNF:-}" && -f "${MYSQL_CNF:-}" ]] && shred -u "$MYSQL_CNF" 2>/dev/null || true # A probe left in the web root would be an executable file nobody put there # on purpose. Remove it even if the run died mid-check. [[ -n "${PROBE_FILE:-}" ]] && rm -f "$PROBE_FILE" 2>/dev/null || true [[ -n "${DBPROBE_FILE:-}" ]] && rm -f "$DBPROBE_FILE" 2>/dev/null || true [[ -n "${TMPDIR_SELF:-}" && -d "${TMPDIR_SELF:-}" ]] && rm -rf "$TMPDIR_SELF" || true } trap cleanup EXIT # ============================================================================ # CONFIGURATION (every one of these is overridable by flag or environment) # ============================================================================ DOMAIN="${DOMAIN:-}" # ServerName; empty = serve on the IP WP_PATH="${WP_PATH:-/srv/www/wordpress}" WP_DB_NAME="${WP_DB_NAME:-wordpress}" WP_DB_USER="${WP_DB_USER:-wp_user}" WP_DB_PASS="${WP_DB_PASS:-}" # empty = generate WP_TITLE="${WP_TITLE:-}" WP_ADMIN_EMAIL="${WP_ADMIN_EMAIL:-}" WP_ADMIN_USER="${WP_ADMIN_USER:-admin}" WP_ADMIN_PASS="${WP_ADMIN_PASS:-}" # empty = generate NO_WP_INSTALL=false # leave the browser 5-minute install AUDIT_MODE=false # report on the machine, change nothing FIX_MODE=false # ...and offer to fix what it finds UNINSTALL_MODE=false # take it all back off PURGE=false # ...including the data and the packages MYSQL_ROOT_PASS="${MYSQL_ROOT_PASS:-}" # empty = leave root auth alone STATIC_IP="${STATIC_IP:-}" # e.g. 192.168.1.10/24 (CIDR optional) GATEWAY_IP="${GATEWAY_IP:-}" DNS_IP="${DNS_IP:-}" IFACE="${IFACE:-}" SSL_EMAIL="${SSL_EMAIL:-}" TS_AUTHKEY="${TS_AUTHKEY:-}" # tailscale auth key (optional) TS_EXTRA="${TS_EXTRA:-}" # extra args for `tailscale up` ASSUME_YES=false UNATTENDED=false STEP_CONFIRM=false # ask before each module instead of once for all STEP_ALL=false # "yes to all remaining", set from inside step mode DRY_RUN=false FORCE=false KEEP_PHPINFO=false # --info-php : force it on NO_INFO_PHP=false # --no-info-php : force it off NO_UPGRADE=false CLEAR_SCREEN=true KEEP_RUNS="${KEEP_RUNS:-10}" # how many past logs/reports to keep VERSION_CHECK=true PROFILE="" # --profile NAME : reuse saved settings SAVE_PROFILE="" # --save-profile NAME : write them for next time PROFILE_DIR="/etc/lamp-install" # Modules: on/off. The ones that can hurt a running server are opt-in. declare -A MOD=( [base]=1 [firewall]=1 [apache]=1 [mysql]=1 [php]=1 [wordpress]=1 [network]=0 [ssl]=0 [tailscale]=0 [harden]=0 ) MODULE_ORDER=(base network tailscale firewall apache mysql php wordpress ssl harden) # One concrete line per module, shown when confirming step by step. "What will # this actually do to my machine" is the question being answered, so these say # what changes - not what the module is called. declare -A MOD_DESC=( [base]="apt update, apt upgrade, and install curl/wget/git if missing" [network]="rewrite netplan to a static IP (backs up the old config first)" [tailscale]="install Tailscale and join a tailnet" [firewall]="ufw: allow 22/80/443. Does not change the default policy on a box already serving" [apache]="install Apache if missing, enable rewrite/headers/ssl/expires" [mysql]="install MySQL if missing, remove anonymous users and the test db, create the WordPress db+user" [php]="install PHP and the extensions WordPress needs, set upload/memory limits" [wordpress]="download WordPress if absent, write wp-config.php, fix permissions, write the Apache vhost" [ssl]="request a Let's Encrypt certificate with certbot" [harden]="Apache security headers, unattended-upgrades, and report on SSH config" ) usage() { printf '%s\n' \ "${C_B}LAMP + WordPress convergent installer${C_RST} v${VERSION}" \ "" \ "${C_B}USAGE${C_RST}" \ " sudo bash install.sh [options]" \ " curl -fsSL https://school.efm.lol/install.sh | sudo bash -s -- [options]" \ "" \ "${C_B}MODULES${C_RST} ${C_DIM}(default)${C_RST}" \ " base apt update, essential tools, timezone ${C_GRN}on${C_RST}" \ " firewall ufw - adds rules, never blanket-denies ${C_GRN}on${C_RST}" \ " apache Apache2 + rewrite/headers/ssl modules ${C_GRN}on${C_RST}" \ " mysql MySQL server, secured, idempotent ${C_GRN}on${C_RST}" \ " php PHP + the extensions WordPress actually uses ${C_GRN}on${C_RST}" \ " wordpress download, configure, permissions, vhost ${C_GRN}on${C_RST}" \ " network static IP via netplan ${C_YEL}off${C_RST} needs --static-ip" \ " ssl Let's Encrypt certificate via certbot ${C_YEL}off${C_RST} needs --domain" \ " tailscale install + join a tailnet ${C_YEL}off${C_RST}" \ " harden SSH + Apache hardening, unattended-upgrades ${C_YEL}off${C_RST}" \ "" \ " --with M[,M...] turn modules on (--with tailscale,ssl)" \ " --without M[,M...] turn modules off (--without wordpress)" \ " --only M[,M...] run exactly these (--only mysql,wordpress)" \ "" \ "${C_B}OPTIONS${C_RST}" \ " -d, --domain NAME site domain (also used for the certificate)" \ " --wp-path PATH WordPress root [${WP_PATH}]" \ " --wp-db-name NAME database name [${WP_DB_NAME}]" \ " --wp-db-user USER database user [${WP_DB_USER}]" \ " --wp-db-pass PASS database password [generated if unset]" \ " --wp-title TITLE site title" \ " --wp-email EMAIL admin email (for the WordPress administrator)" \ " --admin-user NAME WordPress admin username [${WP_ADMIN_USER}]" \ " --admin-pass PASS WordPress admin password [generated if unset]" \ " --no-wp-install stop after wp-config; leave the browser setup screen" \ " --mysql-root-pass PW set MySQL root password [left alone if unset]" \ "" \ " --static-ip ADDR e.g. 192.168.1.10/24 (implies --with network)" \ " --gateway ADDR default route" \ " --dns ADDR resolver" \ " --interface NAME NIC to configure [auto-detected]" \ "" \ " --ssl-email EMAIL Let's Encrypt contact (implies --with ssl)" \ " --tailscale-authkey K join non-interactively (implies --with tailscale)" \ " --tailscale-args ARGS extra args for 'tailscale up'" \ "" \ " -s, --step confirm each step separately instead of once up front" \ " -y, --yes never prompt; generate what is missing" \ " -u, --unattended -y, and fail rather than ask for anything required" \ " -n, --dry-run print every action, change nothing" \ " --audit report what is on this machine and exit. Changes nothing" \ " --fix audit, then offer to fix what it found" \ " --uninstall remove what this script installs (data is dumped first)" \ " --purge with --uninstall: also delete the data and the packages" \ " -f, --force proceed past the conflict checks (see below)" \ " --info-php publish info.php - a phpinfo() page at /info.php" \ " --no-info-php remove it. Without either flag an existing one is" \ " KEPT, and a bare interactive run asks" \ " --no-clear do not clear the screen at startup" \ " --profile NAME reuse settings saved under that name" \ " --save-profile NAME save this run's settings for next time" \ " --keep-runs N keep the N most recent logs and reports [${KEEP_RUNS}]" \ " --no-version-check do not check whether a newer version is published" \ " --no-upgrade skip 'apt upgrade' (faster, less invasive)" \ " --log FILE write the log here" \ " --no-color plain output" \ " -h, --help this" \ " -V, --version print version" \ "" \ "${C_B}WHAT --force OVERRIDES${C_RST}" \ " Refusing to run because another web server (nginx) owns port 80, refusing to" \ " reconfigure the interface you are connected over, and refusing to overwrite an" \ " existing WordPress. ${C_YEL}Read the message before you use it.${C_RST}" \ "" \ "${C_B}EXAMPLES${C_RST}" \ " ${C_DIM}# interactive, on a fresh VM${C_RST}" \ " curl -fsSL https://school.efm.lol/install.sh | sudo bash" \ "" \ " ${C_DIM}# fully unattended: static IP, Tailscale, HTTPS${C_RST}" \ " sudo bash install.sh -u -d school.example.com \\" \ " --static-ip 192.168.1.10/24 --gateway 192.168.1.1 --dns 192.168.1.1 \\" \ " --tailscale-authkey tskey-auth-xxxx --ssl-email me@example.com" \ "" \ " ${C_DIM}# show what it would do, change nothing${C_RST}" \ " sudo bash install.sh --dry-run" \ "" \ " ${C_DIM}# repair just the database and WordPress on a box that already has LAMP${C_RST}" \ " sudo bash install.sh --only mysql,wordpress" } set_modules() { # $1 = csv list, $2 = 0|1 local m mods IFS=',' read -ra mods <<< "$1" for m in "${mods[@]}"; do m="${m// /}" [[ -z "$m" ]] && continue [[ -v "MOD[$m]" ]] || die "Unknown module '$m'. Known: ${!MOD[*]}" MOD[$m]=$2 done } # Pre-scan only for --profile, so its values are in place before the real # parse runs and any explicit flag can override them. preload_profile() { local i args=("$@") for (( i=0; i<${#args[@]}; i++ )); do if [[ "${args[$i]}" == "--profile" && $((i+1)) -lt ${#args[@]} ]]; then PROFILE="${args[$((i+1))]}" load_profile "$PROFILE" return 0 fi done } parse_args() { local k while (($#)); do case "$1" in -d|--domain) DOMAIN="$2"; shift 2 ;; --wp-path) WP_PATH="$2"; shift 2 ;; --wp-db-name) WP_DB_NAME="$2"; shift 2 ;; --wp-db-user) WP_DB_USER="$2"; shift 2 ;; --wp-db-pass) WP_DB_PASS="$2"; shift 2 ;; --wp-title) WP_TITLE="$2"; shift 2 ;; --wp-email) WP_ADMIN_EMAIL="$2"; shift 2 ;; --admin-user) WP_ADMIN_USER="$2"; shift 2 ;; --admin-pass) WP_ADMIN_PASS="$2"; shift 2 ;; --no-wp-install) NO_WP_INSTALL=true; shift ;; --mysql-root-pass) MYSQL_ROOT_PASS="$2"; shift 2 ;; --static-ip) STATIC_IP="$2"; MOD[network]=1; shift 2 ;; --gateway) GATEWAY_IP="$2"; shift 2 ;; --dns) DNS_IP="$2"; shift 2 ;; --interface) IFACE="$2"; shift 2 ;; --ssl-email) SSL_EMAIL="$2"; MOD[ssl]=1; shift 2 ;; --tailscale-authkey) TS_AUTHKEY="$2"; MOD[tailscale]=1; shift 2 ;; --tailscale-args) TS_EXTRA="$2"; shift 2 ;; --with) set_modules "$2" 1; shift 2 ;; --without) set_modules "$2" 0; shift 2 ;; --only) for k in "${!MOD[@]}"; do MOD[$k]=0; done set_modules "$2" 1; shift 2 ;; -s|--step) STEP_CONFIRM=true; shift ;; -y|--yes) ASSUME_YES=true; shift ;; -u|--unattended) ASSUME_YES=true; UNATTENDED=true; shift ;; -n|--dry-run) DRY_RUN=true; shift ;; --audit) AUDIT_MODE=true; shift ;; --fix) AUDIT_MODE=true; FIX_MODE=true; shift ;; --uninstall) UNINSTALL_MODE=true; shift ;; --purge) PURGE=true; shift ;; -f|--force) FORCE=true; shift ;; --info-php|--phpinfo|--keep-phpinfo) KEEP_PHPINFO=true; NO_INFO_PHP=false; shift ;; --no-info-php|--no-phpinfo) NO_INFO_PHP=true; KEEP_PHPINFO=false; shift ;; --no-clear) CLEAR_SCREEN=false; shift ;; --keep-runs) KEEP_RUNS="$2"; shift 2 ;; --no-version-check) VERSION_CHECK=false; shift ;; --profile) PROFILE="$2"; shift 2 ;; --save-profile) SAVE_PROFILE="$2"; shift 2 ;; --no-upgrade) NO_UPGRADE=true; shift ;; --log) LOG_FILE="$2"; shift 2 ;; --no-color) C_RST=""; C_B=""; C_DIM=""; C_RED=""; C_GRN="" C_YEL=""; C_BLU=""; C_CYN=""; C_MAG=""; shift ;; -h|--help) usage; exit 0 ;; -V|--version) echo "$VERSION"; exit 0 ;; --) shift; break ;; *) usage >&2; die "Unknown option: $1" ;; esac done } # ============================================================================ # PROFILES - so rebuilding the same box is one word, not eight flags # ============================================================================ # A profile is KEY=value lines, and it is PARSED, never sourced. Sourcing would # execute whatever is in the file as root - a config file is data, and a file # that can run commands is a much bigger thing to leave lying in /etc. # # Passwords are deliberately NOT saved: they are generated per run and printed # in the report. A reusable profile full of credentials is a liability, and the # one thing you genuinely should not automate away. profile_path() { local name="$1" f for f in "$PROFILE_DIR/${name}.conf" "./${name}.conf" "${name}"; do [[ -f "$f" && -r "$f" ]] && { printf '%s' "$f"; return 0; } done return 1 } load_profile() { local name="$1" f k v if ! f="$(profile_path "$name")"; then # No file: fall back to a built-in for the one case worth shipping. case "$name" in school) WP_TITLE="${WP_TITLE:-My WordPress Site}" KEEP_PHPINFO=true # coursework usually wants the proof MOD[harden]=1 say " ${C_DIM}Using the built-in 'school' profile (no ${PROFILE_DIR}/school.conf yet).${C_RST}" say " ${C_DIM}Add --save-profile school to this run to make it yours.${C_RST}" return 0 ;; *) die "No profile '${name}'. Looked in ${PROFILE_DIR}/${name}.conf and ./${name}.conf" ;; esac fi local -i bad=0 while IFS='=' read -r k v; do k="${k%%[[:space:]]*}"; k="${k##[[:space:]]}" [[ -z "$k" || "$k" == \#* ]] && continue v="${v#"${v%%[![:space:]]*}"}" # trim leading space v="${v%\"}"; v="${v#\"}" # and surrounding quotes case "$k" in DOMAIN|WP_PATH|WP_DB_NAME|WP_DB_USER|WP_TITLE|WP_ADMIN_USER|\ WP_ADMIN_EMAIL|STATIC_IP|GATEWAY_IP|DNS_IP|IFACE|SSL_EMAIL|KEEP_RUNS) printf -v "$k" '%s' "$v" ;; INFO_PHP) case "${v,,}" in yes|on|true|1) KEEP_PHPINFO=true ;; *) NO_INFO_PHP=true ;; esac ;; MODULES_ON) set_modules "$v" 1 ;; MODULES_OFF) set_modules "$v" 0 ;; *) bad+=1; _log "PROFILE: ignoring unknown key '$k'" ;; esac done < "$f" PROFILE_LOADED="$f" (( bad )) && warn "Ignored $bad unrecognised key(s) in $f" return 0 } PROFILE_LOADED="" save_profile() { local name="$1" f="$PROFILE_DIR/${name}.conf" $DRY_RUN && { act "Would save profile to $f"; return 0; } mkdir -p "$PROFILE_DIR" [[ -f "$f" ]] && backup_file "$f" >/dev/null { echo "# lamp-install profile '${name}' - written $(date -Is)" echo "# Reuse with: install.sh --profile ${name}" echo "# Passwords are NOT stored here; they are generated per run." echo echo "DOMAIN=${DOMAIN}" echo "WP_PATH=${WP_PATH}" echo "WP_DB_NAME=${WP_DB_NAME}" echo "WP_DB_USER=${WP_DB_USER}" echo "WP_TITLE=${WP_TITLE}" echo "WP_ADMIN_USER=${WP_ADMIN_USER}" echo "WP_ADMIN_EMAIL=${WP_ADMIN_EMAIL}" echo "INFO_PHP=$( [[ -n "$INFO_PHP_URL" ]] && echo yes || echo no )" local m on=() off=() for m in "${MODULE_ORDER[@]}"; do (( MOD[$m] )) && on+=("$m") || off+=("$m") done echo "MODULES_ON=$(IFS=,; echo "${on[*]}")" echo "MODULES_OFF=$(IFS=,; echo "${off[*]}")" [[ -n "$STATIC_IP" ]] && echo "STATIC_IP=${STATIC_IP}" [[ -n "$GATEWAY_IP" ]] && echo "GATEWAY_IP=${GATEWAY_IP}" [[ -n "$DNS_IP" ]] && echo "DNS_IP=${DNS_IP}" [[ -n "$SSL_EMAIL" ]] && echo "SSL_EMAIL=${SSL_EMAIL}" return 0 } > "$f" chmod 600 "$f" changed "saved profile '${name}'" ok "Profile saved: $f" say " ${C_DIM}Next rebuild: install.sh --profile ${name}${C_RST}" } # ============================================================================ # PRIMITIVES # ============================================================================ have() { command -v "$1" >/dev/null 2>&1; } is_pkg() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "ok installed"; } svc_exists(){ systemctl list-unit-files "$1.service" >/dev/null 2>&1 && \ systemctl cat "$1.service" >/dev/null 2>&1; } svc_active(){ systemctl is-active --quiet "$1" 2>/dev/null; } # Who is listening on a port, if anyone. Prints the process name or nothing. port_owner() { local p="$1" have ss || return 0 # No listener is a normal answer, not a failure: grep exits 1 when it # matches nothing and pipefail would turn that into a fatal error. ss -lntpH "sport = :$p" 2>/dev/null \ | grep -oP 'users:\(\("\K[^"]+' | head -1 || true } # run CMD... - logs it, honours --dry-run, sends output to the log run() { _log "RUN: $*" if $DRY_RUN; then printf ' %s[dry-run]%s %s\n' "$C_MAG" "$C_RST" "$*" return 0 fi "$@" >>"$LOG_FILE" 2>&1 } # runsh 'shell string' - same, for anything needing pipes or redirection runsh() { _log "RUN: $1" if $DRY_RUN; then printf ' %s[dry-run]%s %s\n' "$C_MAG" "$C_RST" "$1" return 0 fi bash -c "$1" >>"$LOG_FILE" 2>&1 } # Move a file aside instead of overwriting it. Never deletes. backup_file() { local f="$1" [[ -e "$f" ]] || return 0 local b="${f}.bak.${TS}" if $DRY_RUN; then printf ' %s[dry-run]%s backup %s -> %s\n' "$C_MAG" "$C_RST" "$f" "$b" else cp -a "$f" "$b" BACKUPS+=("$f -> $b") _log "BACKUP: $f -> $b" fi printf '%s' "$b" } gen_pass() { # a password with no shell-, MySQL- or sed-hostile characters local n="${1:-24}" raw="" # Do NOT pipe an endless /dev/urandom into `head`: head exits at the byte # limit, tr is killed by SIGPIPE, and `pipefail` turns that 141 into a fatal # error on the assignment. Make `head` the PRODUCER so it ends by itself and # tr simply reads to EOF - no signal, no failure. raw="$(LC_ALL=C tr -dc 'A-Za-z0-9_.@%+=' < <(head -c $(( n * 24 )) /dev/urandom) 2>/dev/null || true)" if (( ${#raw} < n )); then # Absurdly unlikely, but a short password must never be returned quietly. raw="$(openssl rand -base64 $(( n * 3 )) 2>/dev/null | tr -dc 'A-Za-z0-9_.@%+=' || true)" fi (( ${#raw} < n )) && die "Could not generate a random password on this machine." printf '%s ' "${raw:0:n}" } # ============================================================================ # PROMPTING (works when the script is piped from curl) # ============================================================================ # When you run `curl ... | bash`, stdin is the SCRIPT, not the keyboard, so a # plain `read` consumes the script's own remaining bytes. Reading from /dev/tty # is what makes the piped install interactive at all. TTYIN="" init_tty() { # -r on /dev/tty is not enough: the file exists in a session with no # controlling terminal (ssh without -t, cron) but opening it fails. Try to # actually open it, or we print "No such device or address" at every prompt. if [[ -t 0 ]]; then TTYIN="/dev/stdin" elif { : < /dev/tty; } 2>/dev/null; then TTYIN="/dev/tty" else TTYIN=""; fi _log "TTY: ${TTYIN:-none}" } # ask VAR "Prompt" "default" [--secret] ask() { local __var="$1" __prompt="$2" __default="${3:-}" __secret="${4:-}" __ans="" local __cur="${!__var:-}" [[ -n "$__cur" ]] && { _log "ASK $__var: preset"; return 0; } if $ASSUME_YES || [[ -z "$TTYIN" ]]; then if [[ -n "$__default" ]]; then printf -v "$__var" '%s' "$__default" _log "ASK $__var: default" return 0 fi if $UNATTENDED || [[ -z "$TTYIN" ]]; then die "--$__var is required in unattended mode (no value, no default, no terminal)" fi fi local __hint="" [[ -n "$__default" ]] && __hint=" ${C_DIM}[${__default}]${C_RST}" while :; do if [[ "$__secret" == "--secret" ]]; then printf ' %s?%s %s%s: ' "$C_CYN" "$C_RST" "$__prompt" "$__hint" read -rs __ans < "$TTYIN"; echo else printf ' %s?%s %s%s: ' "$C_CYN" "$C_RST" "$__prompt" "$__hint" read -r __ans < "$TTYIN" fi __ans="${__ans:-$__default}" [[ -n "$__ans" ]] && break warn "A value is required." done printf -v "$__var" '%s' "$__ans" } confirm() { # confirm "Question" [default-yes] local q="$1" def="${2:-n}" ans $ASSUME_YES && return 0 [[ -z "$TTYIN" ]] && return 1 local hint="[y/N]"; [[ "$def" == "y" ]] && hint="[Y/n]" printf ' %s?%s %s %s ' "$C_YEL" "$C_RST" "$q" "$hint" read -r ans < "$TTYIN" || ans="" ans="${ans:-$def}" [[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] } # ============================================================================ # IS THIS COPY CURRENT? # ============================================================================ # Only worth asking of a SAVED copy. When the script arrives through # `curl | sh` it is by definition the published one, and saying so every run # would be pure noise - so this is skipped unless we are running from a file. check_version() { $VERSION_CHECK || return 0 $DRY_RUN && return 0 _self_is_file "${BASH_SOURCE[0]:-}" || return 0 have curl || return 0 local latest latest="$(curl -fsSL --max-time 4 "${INSTALL_URL%/install.sh}/version" 2>/dev/null | tr -d '[:space:]' || true)" [[ -n "$latest" ]] || return 0 # offline, or no version file [[ "$latest" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 0 [[ "$latest" == "$VERSION" ]] && return 0 # Only mention it if the published one is actually NEWER. A local copy that # is ahead is someone editing the script, who does not need telling. local newest newest="$(printf '%s\n%s\n' "$VERSION" "$latest" | sort -V | tail -1)" [[ "$newest" == "$latest" ]] || return 0 warn "This copy is v${VERSION}; v${latest} is published." say " ${C_DIM}Update it: curl -fsSL ${INSTALL_URL} -o ${BASH_SOURCE[0]}${C_RST}" } # ============================================================================ # SURVEY - work out what is already true before changing anything # ============================================================================ OS_ID=""; OS_VER=""; OS_NAME=""; OS_CODENAME="" PHP_VER=""; WEBSERVER_CONFLICT=""; SSH_CLIENT_IP=""; SSH_IFACE="" PRIMARY_IFACE=""; PRIMARY_IP=""; PUBLIC_BOX=false MYSQL_CMD=""; MYSQL_CNF=""; TMPDIR_SELF="" FRESH_BOX=true survey() { step "Surveying the machine" # --- identity ----------------------------------------------------------- [[ -r /etc/os-release ]] || die "No /etc/os-release - this is not a Linux this script knows." # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}"; OS_VER="${VERSION_ID:-}"; OS_NAME="${PRETTY_NAME:-$OS_ID}" OS_CODENAME="${VERSION_CODENAME:-}" ok "OS: $OS_NAME (kernel $(uname -r), $(dpkg --print-architecture))" case "$OS_ID" in ubuntu) case "$OS_VER" in 24.04|24.10|25.04|22.04) : ;; *) warn "Tested on Ubuntu 22.04 and 24.04; this is $OS_VER. Continuing." ;; esac ;; debian) warn "Debian detected. Package names mostly match Ubuntu; MySQL may be MariaDB." ;; *) die "Unsupported distribution '$OS_ID'. This script is for Ubuntu/Debian." ;; esac # --- resources ---------------------------------------------------------- local mem_mb disk_mb mem_mb=$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo || true) disk_mb=$(df -Pm / | awk 'NR==2{print $4}' || true) ok "Resources: ${mem_mb} MB RAM, ${disk_mb} MB free on /" (( mem_mb < 900 )) && warn "Under 1 GB RAM. MySQL 8 is tight here - consider adding swap." (( disk_mb < 2500 )) && die "Only ${disk_mb} MB free on /. Need at least 2.5 GB." # --- network ------------------------------------------------------------ PRIMARY_IFACE="$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}' || true)" PRIMARY_IP="$(ip -4 -o addr show "${PRIMARY_IFACE:-lo}" 2>/dev/null \ | awk '{print $4; exit}' || true)" [[ -n "$PRIMARY_IFACE" ]] && ok "Network: $PRIMARY_IFACE at ${PRIMARY_IP:-no address}" # Is the address we hold a public one? Decides whether a private static IP # would be an act of self-harm. local ip_only="${PRIMARY_IP%%/*}" if [[ -n "$ip_only" ]] && ! [[ "$ip_only" =~ ^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|127\.|169\.254\.) ]]; then PUBLIC_BOX=true ok "This machine has a PUBLIC address ($ip_only)" fi # Are we sitting on the very link we might be asked to renumber? if [[ -n "${SSH_CONNECTION:-}" ]]; then SSH_CLIENT_IP="$(awk '{print $1}' <<< "$SSH_CONNECTION")" local ssh_local_ip; ssh_local_ip="$(awk '{print $3}' <<< "$SSH_CONNECTION")" SSH_IFACE="$(ip -4 -o addr show 2>/dev/null | awk -v a="$ssh_local_ip" '$4 ~ "^"a"/" {print $2; exit}' || true)" ok "Connected over SSH from $SSH_CLIENT_IP (arriving on ${SSH_IFACE:-unknown})" fi # --- internet ----------------------------------------------------------- if run_quiet getent hosts archive.ubuntu.com || run_quiet getent hosts deb.debian.org; then ok "DNS resolves the package archive" else die "Cannot resolve the package archive. Fix DNS before running this." fi # --- existing stack ----------------------------------------------------- local found=() is_pkg apache2 && found+=("apache2") is_pkg nginx && found+=("nginx") is_pkg mysql-server && found+=("mysql-server") is_pkg mariadb-server && found+=("mariadb-server") have php && found+=("php $(php -r 'echo PHP_VERSION;' 2>/dev/null)") have tailscale && found+=("tailscale") have certbot && found+=("certbot") [[ -e "$WP_PATH/wp-includes/version.php" ]] && found+=("wordpress at $WP_PATH") if ((${#found[@]})); then FRESH_BOX=false ok "Already present: ${found[*]}" say " ${C_DIM}This is not a fresh box, so nothing above will be reinstalled or reset -" say " only repaired where it is wrong.${C_RST}" else ok "Nothing from this stack is installed yet - a clean install" fi # --- port 80/443 ownership --------------------------------------------- local p80 p443 p80="$(port_owner 80)"; p443="$(port_owner 443)" [[ -n "$p80" ]] && ok "Port 80 is held by: $p80" [[ -n "$p443" ]] && ok "Port 443 is held by: $p443" if [[ -n "$p80" && "$p80" != "apache2" ]]; then WEBSERVER_CONFLICT="$p80" fi } run_quiet() { "$@" >/dev/null 2>&1; } # ============================================================================ # PREFLIGHT - the refusals # ============================================================================ preflight() { step "Preflight checks" (( EUID == 0 )) || die "Run me as root: sudo bash $SELF" ok "Running as root" if $DRY_RUN; then say " ${C_MAG}${C_B}DRY RUN${C_RST} ${C_MAG}- every action is printed, nothing is changed.${C_RST}" fi # --- another web server owns the port ---------------------------------- if [[ -n "$WEBSERVER_CONFLICT" ]] && (( MOD[apache] )) && ! $UNINSTALL_MODE; then err "Port 80 is already served by '${WEBSERVER_CONFLICT}', not Apache." say "" say " Installing Apache here would give you two web servers fighting over one" say " port. On a box that is already serving sites, that takes them all down." say "" say " Your options:" say " ${C_B}--without apache,ssl${C_RST} install the rest, keep ${WEBSERVER_CONFLICT} in front" say " ${C_B}--only mysql,wordpress${C_RST} just the database and the files" say " ${C_B}--force${C_RST} do it anyway (expect an outage)" say "" $FORCE || die "Refusing to install Apache alongside ${WEBSERVER_CONFLICT}." warn "--force given: installing Apache anyway. ${WEBSERVER_CONFLICT} may stop serving." fi # --- the network refusal ------------------------------------------------ if (( MOD[network] )) && ! $UNINSTALL_MODE; then [[ -n "$STATIC_IP" ]] || die "--with network needs --static-ip" local want="${STATIC_IP%%/*}" local target_if="${IFACE:-${PRIMARY_IFACE:-}}" if [[ -n "$SSH_IFACE" && "$target_if" == "$SSH_IFACE" ]]; then local here="${PRIMARY_IP%%/*}" if [[ "$want" != "$here" ]]; then err "You are connected over ${SSH_IFACE} (${here}), and this would change it to ${want}." say "" say " The moment netplan applies, this SSH session dies and does not come" say " back - the address you connected to no longer exists. If ${want} is" say " not reachable from where you are, the box is only recoverable from a" say " physical or virtual console." say "" if $PUBLIC_BOX && [[ "$want" =~ ^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then say " ${C_RED}${C_B}This machine has a public address and you are asking for a private" say " one. That is almost certainly a mistake.${C_RST}" say "" fi say " Use --without network to leave networking alone, or --force if you" say " have console access and mean it." say "" $FORCE || die "Refusing to renumber the interface this session is running over." warn "--force given: the network will change and this session will drop." else ok "Static IP matches the current address - no disruption" fi fi fi # --- apt must be free --------------------------------------------------- local waited=0 while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \ fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do (( waited == 0 )) && act "Waiting for another apt/dpkg process to finish..." sleep 3; waited=$((waited+3)) (( waited > 300 )) && die "apt has been locked for 5 minutes. Check for a running unattended-upgrade." done (( waited > 0 )) && ok "apt lock released after ${waited}s" || ok "apt is free" # --- log file ----------------------------------------------------------- if ! $DRY_RUN; then if ! { : > "$LOG_FILE"; } 2>/dev/null; then LOG_FILE="/tmp/lamp-install-${TS}.log" : > "$LOG_FILE" fi chmod 600 "$LOG_FILE" 2>/dev/null || true fi ok "Logging to $LOG_FILE" TMPDIR_SELF="$(mktemp -d)" } # ============================================================================ # ACTION - offer what this script can do, rather than hiding it behind flags # ============================================================================ choose_action() { # Anything explicit on the command line is the answer already; only the # bare interactive run needs asking. $AUDIT_MODE && return 0 $UNINSTALL_MODE && return 0 $ASSUME_YES && return 0 $DRY_RUN && return 0 [[ -z "$TTYIN" ]] && return 0 step "What would you like to do?" say "" say " ${C_B}1${C_RST} Install or repair LAMP + WordPress ${C_DIM}(default)${C_RST}" say " ${C_B}2${C_RST} Audit this machine ${C_DIM}report only, changes nothing${C_RST}" say " ${C_B}3${C_RST} Uninstall what this script installed ${C_DIM}database is dumped first${C_RST}" say "" local ans while :; do printf ' %s?%s Choose %s[1]%s, 2 or 3: ' "$C_CYN" "$C_RST" "$C_B" "$C_RST" read -r ans < "$TTYIN" || ans="1" case "${ans:-1}" in 1|i|install) return 0 ;; 2|a|audit) AUDIT_MODE=true; return 0 ;; 3|u|uninstall) UNINSTALL_MODE=true; return 0 ;; *) warn "Enter 1, 2 or 3." ;; esac done } # ============================================================================ # PLAN - say what will happen, then get one confirmation for all of it # ============================================================================ show_plan() { step "Plan" local m on=() for m in "${MODULE_ORDER[@]}"; do (( MOD[$m] )) && on+=("$m"); done [[ -n "$PROFILE" ]] && say " Profile: ${C_B}${PROFILE}${C_RST}${PROFILE_LOADED:+ ${C_DIM}(${PROFILE_LOADED})${C_RST}}" say " Modules: ${C_B}${on[*]}${C_RST}" say " Domain: ${DOMAIN:-${C_DIM}(none - will serve on the IP address)${C_RST}}" (( MOD[wordpress] )) && say " WordPress: $WP_PATH (db '$WP_DB_NAME' as '$WP_DB_USER')" (( MOD[network] )) && say " Network: ${IFACE:-$PRIMARY_IFACE} -> $STATIC_IP via ${GATEWAY_IP:-?}" (( MOD[ssl] )) && say " TLS: certbot for ${DOMAIN:-?} (${SSL_EMAIL:-no email yet})" (( MOD[tailscale] )) && say " Tailscale: ${TS_AUTHKEY:+pre-authorised}${TS_AUTHKEY:-interactive login}" say "" $DRY_RUN && return 0 $ASSUME_YES && return 0 if [[ -z "$TTYIN" ]]; then die "Nothing to prompt on. Re-run with -y to accept this plan, or --dry-run to preview it." fi $STEP_CONFIRM && { say " Each step will ask before it runs."; say ""; return 0; } # One blanket yes/no is a bad question when the answer is "some of it". local ans while :; do printf ' %s?%s Proceed? %s[Y]%s run all %s[s]%s step by step %s[n]%s cancel ' \ "$C_CYN" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" read -r ans < "$TTYIN" || ans="n" case "${ans,,}" in ""|y|yes) return 0 ;; s|step) STEP_CONFIRM=true say ""; say " ${C_DIM}Stepping through. Each one asks before it runs.${C_RST}" return 0 ;; n|no) say ""; say " Nothing was changed."; exit 0 ;; *) warn "Answer y, s or n." ;; esac done } # Ask about one module. Returns 1 to skip it, 2 to stop the run entirely. confirm_step() { local m="$1" ans $STEP_ALL && return 0 [[ -z "$TTYIN" ]] && return 0 printf '\n %s%s%s %s%s%s\n' "$C_B" "$m" "$C_RST" "$C_DIM" "${MOD_DESC[$m]:-}" "$C_RST" while :; do printf ' %s?%s Run it? %s[Y]%ses %s[n]%so %s[a]%sll remaining %s[q]%suit ' \ "$C_CYN" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" read -r ans < "$TTYIN" || ans="y" case "${ans,,}" in ""|y|yes) return 0 ;; n|no) return 1 ;; a|all) STEP_ALL=true; return 0 ;; q|quit) return 2 ;; *) warn "Answer y, n, a or q." ;; esac done } # ============================================================================ # MODULE: base # ============================================================================ mod_base() { step "Base system" act "Refreshing package lists" run apt-get update -qq ok "Package lists current" if $NO_UPGRADE; then skip "apt upgrade skipped (--no-upgrade)" else local n n=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst ' || true) if (( n > 0 )); then act "Upgrading $n package(s) - this is the slow part" run env DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \ -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold changed "upgraded $n packages" ok "System upgraded" else ok "All packages already current" fi fi ensure_pkgs curl wget git ca-certificates gnupg lsb-release unzip \ software-properties-common if [[ -f /var/run/reboot-required ]]; then warn "A reboot is required to finish applying updates (kernel or libc)." fi } # ensure_pkgs pkg... - installs only what is missing, one apt call ensure_pkgs() { local want=() p for p in "$@"; do is_pkg "$p" || want+=("$p"); done if ((${#want[@]} == 0)); then skip "Already installed: $*" return 0 fi # Never let one bad name abort the batch: check availability first. The # script this replaces listed php-recode, php-phalcon and php-transcodeVideo, # none of which exist - with `set -e` that killed the run part-way through. local ok_pkgs=() missing=() for p in "${want[@]}"; do if apt-cache show "$p" >/dev/null 2>&1; then ok_pkgs+=("$p"); else missing+=("$p"); fi done ((${#missing[@]})) && warn "Not in any repo, skipping: ${missing[*]}" ((${#ok_pkgs[@]} == 0)) && return 0 act "Installing: ${ok_pkgs[*]}" run env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "${ok_pkgs[@]}" changed "installed: ${ok_pkgs[*]}" ok "Installed ${#ok_pkgs[@]} package(s)" } # ============================================================================ # MODULE: network (static IP) # ============================================================================ mod_network() { step "Static IP address" local iface="${IFACE:-$PRIMARY_IFACE}" [[ -n "$iface" ]] || die "Could not work out which interface to configure. Use --interface." # arping is how we find out whether the address is spoken for. Worth the # 200 KB - the alternative is discovering the clash as an intermittent # outage on someone else's machine a week later. ensure_pkgs iputils-arping local base suggested="" attempts=0 while :; do ask STATIC_IP "Static IP for $iface (CIDR ok)" "$suggested" [[ "$STATIC_IP" == */* ]] || STATIC_IP="${STATIC_IP}/24" base="${STATIC_IP%%/*}" if ! valid_ip "$base"; then err "Not a valid IP address: $base" attempts=$(( attempts + 1 )) (( attempts > 5 )) && die "Too many invalid addresses." [[ -n "$TTYIN" ]] && ! $ASSUME_YES || die "Not a valid IP address: $base" STATIC_IP=""; continue fi # Our own address is not a conflict - it is the desired end state, and # re-running the installer must not trip over what the last run did. if ip -4 -o addr show "$iface" 2>/dev/null | grep -q " ${base}/"; then ok "$base is already this machine's address on $iface" break fi act "Checking whether $base is already in use on $iface" if ! ip_taken "$base" "$iface"; then ok "$base is free" break fi # --- occupied ------------------------------------------------------ local mac; mac="$(ip_owner_mac "$base")" err "$base is already in use on this network${mac:+ - answered by $mac}" say "" say " Two machines cannot share an address. If this one takes it, both" say " break intermittently, and the damage shows up on the OTHER machine" say " as often as on this one - which is what makes it hard to diagnose." say "" local free; free="$(suggest_free_ip "$base" "$iface" || true)" if [[ -n "$free" ]]; then say " ${C_GRN}${free}${C_RST} answered nothing and looks free." say "" fi if $ASSUME_YES || [[ -z "$TTYIN" ]]; then die "Refusing to take an address that is already in use.${free:+ Try --static-ip ${free}}" fi attempts=$(( attempts + 1 )) (( attempts > 5 )) && die "Too many addresses already in use. Re-run with a free --static-ip." STATIC_IP="" # clear it so the top of the loop asks again suggested="$free" # ...offering the free one it found as the default done ask GATEWAY_IP "Gateway" "${base%.*}.1" ask DNS_IP "DNS server" "$GATEWAY_IP" valid_ip "$GATEWAY_IP" || die "Not a valid gateway: $GATEWAY_IP" valid_ip "$DNS_IP" || die "Not a valid DNS server: $DNS_IP" [[ "$GATEWAY_IP" == "$base" ]] && die "The gateway cannot be this machine's own address." # A gateway that answers nothing is usually a typo in the subnet. Not fatal # - it may simply not do ARP from here - but it is worth saying before the # apply, not after connectivity has gone. if have arping && ! ip_taken "$GATEWAY_IP" "$iface" 2; then warn "The gateway $GATEWAY_IP does not answer on $iface. Check the subnet before applying." fi local plan="/etc/netplan/60-static-${iface}.yaml" # Every existing netplan file is copied aside first. Renumbering a box is # the one step here you cannot talk your way out of remotely. local f for f in /etc/netplan/*.yaml /etc/netplan/*.yml; do [[ -e "$f" ]] || continue backup_file "$f" >/dev/null done ok "Existing netplan config backed up (*.bak.${TS})" act "Writing $plan" if ! $DRY_RUN; then cat > "$plan" < $STATIC_IP" act "Validating" run netplan generate act "Applying (this is where a wrong answer disconnects you)" run netplan apply sleep 3 if ip -4 -o addr show "$iface" 2>/dev/null | grep -q "${base}/"; then ok "$iface now holds $base" else $DRY_RUN || warn "$iface does not report $base yet. Check 'ip addr' on the console." fi } # Is something already answering for this address on this link? # # arping -D is the kernel's own duplicate-address-detection probe: it sends an # ARP request from 0.0.0.0 and exits 0 only if NOBODY answers. That is the # right test - it does not depend on the other host replying to ICMP, and a # firewalled machine that ignores ping still answers ARP. ip_taken() { local ip="$1" iface="$2" timeout="${3:-3}" if have arping; then arping -D -q -I "$iface" -c 2 -w "$timeout" "$ip" >/dev/null 2>&1 && return 1 return 0 fi # Weaker fallback: something that answers ping is certainly there, but # silence proves nothing. Only used if iputils-arping could not install. ping -c 1 -W 1 "$ip" >/dev/null 2>&1 && return 0 return 1 } # Whatever the ARP probe just learned, so we can name the machine in the way. ip_owner_mac() { ip neigh show "$1" 2>/dev/null \ | awk '{for (i=1; i<=NF; i++) if ($i == "lladdr") { print $(i+1); exit }}' || true } # Walk the subnet for something free, so the error is not just "no". suggest_free_ip() { local base="$1" iface="$2" prefix="${1%.*}" last="${1##*.}" cand i for i in $(seq 1 12); do cand=$(( last + i )) (( cand > 254 )) && break (( cand == 1 )) && continue # conventionally the gateway ip_taken "${prefix}.${cand}" "$iface" 1 || { printf '%s' "${prefix}.${cand}"; return 0; } done return 1 } valid_ip() { local ip="$1" o x [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 IFS='.' read -ra o <<< "$ip" for x in "${o[@]}"; do (( x >= 0 && x <= 255 )) || return 1; done return 0 } # ============================================================================ # MODULE: tailscale # ============================================================================ mod_tailscale() { step "Tailscale" if have tailscale; then ok "Already installed ($(tailscale version 2>/dev/null | head -1))" else act "Adding the Tailscale repository" local codename="${OS_CODENAME:-jammy}" if ! $DRY_RUN; then install -d -m 0755 /usr/share/keyrings curl -fsSL "https://pkgs.tailscale.com/stable/${OS_ID}/${codename}.noarmor.gpg" \ > /usr/share/keyrings/tailscale-archive-keyring.gpg curl -fsSL "https://pkgs.tailscale.com/stable/${OS_ID}/${codename}.tailscale-keyring.list" \ > /etc/apt/sources.list.d/tailscale.list apt-get update -qq >>"$LOG_FILE" 2>&1 fi ensure_pkgs tailscale changed "installed tailscale" fi run systemctl enable --now tailscaled # Already on a tailnet? Then leave it exactly as it is - re-running # `tailscale up` with different flags silently changes routing. local st="" have tailscale && st="$(tailscale status --json 2>/dev/null | grep -o '"BackendState":"[^"]*"' | cut -d'"' -f4 || true)" if [[ "$st" == "Running" ]]; then ok "Already connected: $(tailscale ip -4 2>/dev/null | head -1)" return 0 fi if [[ -n "$TS_AUTHKEY" ]]; then act "Joining the tailnet with the supplied auth key" run tailscale up --authkey "$TS_AUTHKEY" --hostname "$(hostname -s)" ${TS_EXTRA} changed "joined tailnet" ok "Connected: $(tailscale ip -4 2>/dev/null | head -1)" elif [[ -n "$TTYIN" ]] && ! $ASSUME_YES; then say "" say " ${C_B}Tailscale needs you to authorise this machine once.${C_RST}" say " A URL will appear - open it, approve, and this continues." say "" $DRY_RUN || tailscale up --hostname "$(hostname -s)" ${TS_EXTRA} < "$TTYIN" || \ warn "tailscale up did not complete. Run it by hand later." else warn "Tailscale installed but not connected - no auth key and nothing to prompt on." say " ${C_DIM}Finish with: tailscale up${C_RST}" fi } # ============================================================================ # MODULE: firewall # ============================================================================ # ufw can express the same permission as a port ("22/tcp") or as an application # profile ("OpenSSH"). Checking only for the port number adds a duplicate rule # on every single run, which is how rule lists end up 15 entries long with # nothing listening behind them. ufw_allows() { local port="${1%%/*}" out out="$(ufw status 2>/dev/null)" || return 1 grep -qE "^${port}[/ ]" <<< "$out" && return 0 [[ "$port" == "22" ]] && grep -qE "^OpenSSH" <<< "$out" && return 0 return 1 } mod_firewall() { step "Firewall" ensure_pkgs ufw local was_active=false ufw status 2>/dev/null | head -1 | grep -q "active" && was_active=true # The dangerous idea in the original script was `ufw default deny incoming` # on a machine whose listeners it had never looked at. Enumerate them first # and say out loud what is about to be cut off. local listeners listeners="$(ss -lntuH 2>/dev/null | awk '{print $5}' | sed 's/.*://' \ | grep -E '^[0-9]+$' | sort -un | tr '\n' ' ' || true)" ok "Ports currently listening: ${listeners:-none}" local keep=(22 80 443) p unprotected=() for p in $listeners; do case " ${keep[*]} " in *" $p "*) continue ;; esac case "$p" in 53|68|323|5353|631|3306|11211|6379|27017) continue ;; esac (( p >= 32768 )) && continue unprotected+=("$p") done if ((${#unprotected[@]})) && ! $was_active; then warn "These ports are listening and are NOT in the allow list: ${unprotected[*]}" say " ${C_DIM}Enabling the firewall will close them to the outside. If one of them" say " matters, add it with: ufw allow /tcp${C_RST}" if ! $ASSUME_YES && ! confirm "Enable the firewall anyway?" y; then skip "Firewall left alone" return 0 fi fi # Add rules before enabling, and never touch the default policy on a box # that was already serving something. local r for r in "22/tcp:SSH" "80/tcp:HTTP" "443/tcp:HTTPS"; do local port="${r%%:*}" label="${r##*:}" if ufw_allows "$port"; then skip "$label ($port) already allowed" else act "Allowing $label ($port)" run ufw allow "$port" changed "ufw allow $port" fi done if $was_active; then ok "Firewall was already active - default policy left untouched" else act "Setting default policy (deny in, allow out) and enabling" run ufw default deny incoming run ufw default allow outgoing runsh "ufw --force enable" changed "enabled ufw" ok "Firewall active" fi } # ============================================================================ # MODULE: apache # ============================================================================ mod_apache() { step "Apache" ensure_pkgs apache2 local m changed_mod=false for m in rewrite headers ssl expires; do if apache2ctl -M 2>/dev/null | grep -q "^ ${m}_module"; then skip "mod_$m already enabled" else act "Enabling mod_$m" run a2enmod -q "$m" changed "a2enmod $m" changed_mod=true fi done if ! svc_active apache2; then act "Starting Apache" run systemctl enable --now apache2 changed "started apache2" elif $changed_mod; then act "Reloading Apache for the new modules" apache_reload fi ok "Apache $(apache2 -v 2>/dev/null | awk -F'/| ' '/version/{print $4; exit}') running" } # Test before reload, always. A failed test must never reach a reload. apache_reload() { if $DRY_RUN; then printf ' %s[dry-run]%s apache2ctl configtest && systemctl reload apache2\n' "$C_MAG" "$C_RST"; return 0; fi if apache2ctl configtest >>"$LOG_FILE" 2>&1; then systemctl reload apache2 >>"$LOG_FILE" 2>&1 || systemctl restart apache2 >>"$LOG_FILE" 2>&1 ok "Apache config valid, reloaded" else err "Apache config test FAILED - not reloading. Last lines:" apache2ctl configtest 2>&1 | tail -5 | sed 's/^/ /' >&2 return 1 fi } # ============================================================================ # MODULE: mysql # ============================================================================ mod_mysql() { step "MySQL" local flavour="mysql-server" is_pkg mariadb-server && flavour="mariadb-server" ensure_pkgs "$flavour" # Derive the unit from what we actually asked for, then fall back to # whichever of the two units really exists on this box. local unit="mysql" [[ "$flavour" == "mariadb-server" ]] && unit="mariadb" if ! svc_exists "$unit"; then if svc_exists mysql; then unit="mysql" elif svc_exists mariadb; then unit="mariadb" fi fi if ! svc_active "$unit"; then act "Starting $unit" run systemctl enable --now "$unit" changed "started $unit" fi ok "$unit is running" $DRY_RUN && { skip "Dry run - skipping SQL"; return 0; } # --- how do we get in? -------------------------------------------------- # Work this out rather than assuming. On Ubuntu, fresh MySQL 8 root uses # auth_socket and has NO password; assuming otherwise is why the original # script only ever worked once, on a brand new box. if mysql --protocol=socket -u root -e "SELECT 1" >/dev/null 2>&1; then MYSQL_CMD=(mysql --protocol=socket -u root) ok "Authenticated as root over the unix socket" elif [[ -n "$MYSQL_ROOT_PASS" ]] && mysql -u root -p"$MYSQL_ROOT_PASS" -e "SELECT 1" >/dev/null 2>&1; then write_mysql_cnf "$MYSQL_ROOT_PASS" MYSQL_CMD=(mysql --defaults-file="$MYSQL_CNF") ok "Authenticated as root with the supplied password" else if [[ -z "$MYSQL_ROOT_PASS" ]] && [[ -n "$TTYIN" ]] && ! $ASSUME_YES; then warn "root needs a password and none was given." ask MYSQL_ROOT_PASS "MySQL root password" "" --secret if mysql -u root -p"$MYSQL_ROOT_PASS" -e "SELECT 1" >/dev/null 2>&1; then write_mysql_cnf "$MYSQL_ROOT_PASS" MYSQL_CMD=(mysql --defaults-file="$MYSQL_CNF") ok "Authenticated as root" else die "That password was rejected by MySQL." fi else die "Cannot authenticate to MySQL as root. Pass --mysql-root-pass." fi fi # --- root password, only if asked -------------------------------------- # Deliberately NOT done by default. On a server that already has databases, # silently changing root's authentication breaks every app storing the old # credentials - and mysql_native_password is deprecated in MySQL 8.4 anyway. if [[ -n "$MYSQL_ROOT_PASS" ]] && [[ "${MYSQL_CMD[1]}" == "--protocol=socket" ]]; then if confirm "Set a password on the MySQL root account? (socket auth keeps working for root@localhost)" n; then act "Setting root password" mysql_run "ALTER USER 'root'@'localhost' IDENTIFIED BY '$(sql_escape "$MYSQL_ROOT_PASS")';" mysql_run "FLUSH PRIVILEGES;" write_mysql_cnf "$MYSQL_ROOT_PASS" MYSQL_CMD=(mysql --defaults-file="$MYSQL_CNF") changed "set MySQL root password" ok "root password set" else skip "root left on socket authentication" fi fi # --- the mysql_secure_installation steps, made idempotent --------------- local n n=$(mysql_val "SELECT COUNT(*) FROM mysql.user WHERE User='';") if [[ "${n:-0}" != "0" ]]; then act "Removing $n anonymous user(s)" mysql_run "DELETE FROM mysql.user WHERE User='';" changed "removed anonymous MySQL users" else skip "No anonymous users" fi n=$(mysql_val "SELECT COUNT(*) FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost','127.0.0.1','::1');") if [[ "${n:-0}" != "0" ]]; then act "Removing remote root access ($n entr(ies))" mysql_run "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost','127.0.0.1','::1');" changed "removed remote root logins" else skip "root is local-only already" fi n=$(mysql_val "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='test';") if [[ "${n:-0}" != "0" ]]; then act "Dropping the 'test' database" mysql_run "DROP DATABASE IF EXISTS test;" mysql_run "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';" changed "dropped the test database" else skip "No 'test' database" fi mysql_run "FLUSH PRIVILEGES;" # --- is MySQL listening to the world? ----------------------------------- local bind bind=$(mysql_val "SELECT @@bind_address;" || echo "") if [[ "$bind" == "0.0.0.0" || "$bind" == "*" ]]; then warn "MySQL is bound to $bind - reachable from the network. Consider 127.0.0.1." else ok "MySQL is bound to ${bind:-127.0.0.1}" fi # --- the WordPress database and user ------------------------------------ (( MOD[wordpress] )) || return 0 if [[ -z "$WP_DB_PASS" ]]; then if db_user_exists; then warn "User '$WP_DB_USER' already exists and no password was supplied." say " ${C_DIM}Its password will be RESET so wp-config.php can be written correctly.${C_RST}" fi WP_DB_PASS="$(gen_pass 28)" GENERATED_DB_PASS=true fi if [[ "$(mysql_val "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='${WP_DB_NAME}';")" == "0" ]]; then act "Creating database '$WP_DB_NAME'" mysql_run "CREATE DATABASE \`${WP_DB_NAME}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" changed "created database $WP_DB_NAME" else local tables tables=$(mysql_val "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${WP_DB_NAME}';") ok "Database '$WP_DB_NAME' already exists (${tables} tables) - left untouched" (( tables > 0 )) && EXISTING_WP_DATA=true fi act "Ensuring user '$WP_DB_USER'@'localhost'" mysql_run "CREATE USER IF NOT EXISTS '${WP_DB_USER}'@'localhost' IDENTIFIED BY '$(sql_escape "$WP_DB_PASS")';" mysql_run "ALTER USER '${WP_DB_USER}'@'localhost' IDENTIFIED BY '$(sql_escape "$WP_DB_PASS")';" # Only on its own database - not GRANT ALL ON *.* mysql_run "GRANT ALL PRIVILEGES ON \`${WP_DB_NAME}\`.* TO '${WP_DB_USER}'@'localhost';" mysql_run "FLUSH PRIVILEGES;" changed "database user $WP_DB_USER ready" if mysql -u "$WP_DB_USER" -p"$WP_DB_PASS" -e "USE \`${WP_DB_NAME}\`; SELECT 1;" >/dev/null 2>&1; then ok "Verified: '$WP_DB_USER' can reach '$WP_DB_NAME'" else die "Created the user but it cannot connect. See $LOG_FILE" fi } GENERATED_DB_PASS=false EXISTING_WP_DATA=false write_mysql_cnf() { MYSQL_CNF="$(mktemp)" chmod 600 "$MYSQL_CNF" printf '[client]\nuser=root\npassword=%s\n' "$1" > "$MYSQL_CNF" } # Passwords go in a 0600 defaults-file, never on the command line, where # `ps aux` would show them to every user on the box. mysql_run() { _log "SQL: ${1:0:60}..."; "${MYSQL_CMD[@]}" -e "$1" >>"$LOG_FILE" 2>&1; } mysql_val() { "${MYSQL_CMD[@]}" -N -B -e "$1" 2>/dev/null | head -1 || true; } sql_escape() { printf '%s' "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g"; } db_user_exists() { [[ "$(mysql_val "SELECT COUNT(*) FROM mysql.user WHERE User='${WP_DB_USER}' AND Host='localhost';")" != "0" ]] } # ============================================================================ # MODULE: php # ============================================================================ mod_php() { step "PHP" # Unversioned metapackages, so this works on 22.04 (8.1) and 24.04 (8.3) # alike. Pinning php8.3 was why the original could not run on 22.04. local pkgs=(php php-cli php-common php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl php-bcmath php-imagick php-opcache php-soap) # Only drag in the Apache module if Apache is actually part of this run. if (( MOD[apache] )); then pkgs+=(libapache2-mod-php) else skip "Apache is not in this run - not installing libapache2-mod-php" say " ${C_DIM}If a web server here should run PHP, install its SAPI (php-fpm) yourself.${C_RST}" fi ensure_pkgs "${pkgs[@]}" have php || die "PHP did not install." PHP_VER="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')" ok "PHP $PHP_VER ($(php -r 'echo PHP_VERSION;'))" # A drop-in, not an edit of php.ini: idempotent, and a package upgrade # cannot silently revert it or prompt about a modified conffile. Applied to # every SAPI that exists, so it holds whether PHP runs under Apache or fpm. local sapi sapi_dirs=() for sapi in apache2 fpm cli; do [[ -d "/etc/php/${PHP_VER}/${sapi}/conf.d" ]] && sapi_dirs+=("/etc/php/${PHP_VER}/${sapi}/conf.d") done ((${#sapi_dirs[@]} == 0)) && sapi_dirs=("/etc/php/${PHP_VER}/cli/conf.d") local ini_dir for ini_dir in "${sapi_dirs[@]}"; do local ini="${ini_dir}/99-wordpress.ini" local want="; WordPress-friendly limits - written by install.sh upload_max_filesize = 64M post_max_size = 64M memory_limit = 256M max_execution_time = 300 max_input_vars = 3000 ; Do not advertise the PHP version in response headers. expose_php = Off" if [[ -f "$ini" ]] && [[ "$(cat "$ini")" == "$want" ]]; then skip "PHP limits already set (${ini_dir##*/php/})" else act "Writing $ini" if ! $DRY_RUN; then mkdir -p "$ini_dir"; printf '%s\n' "$want" > "$ini"; fi changed "PHP limits for WordPress (${ini})" PHP_INI_CHANGED=true fi done if [[ "${PHP_INI_CHANGED:-false}" == true ]] && svc_active apache2; then apache_reload fi } PHP_INI_CHANGED=false # ============================================================================ # MODULE: wordpress # ============================================================================ mod_wordpress() { step "WordPress" local existing=false [[ -f "$WP_PATH/wp-includes/version.php" ]] && existing=true if $existing; then local ver ver=$(grep -oP "wp_version = '\K[^']+" "$WP_PATH/wp-includes/version.php" 2>/dev/null || echo "?") ok "WordPress $ver is already installed at $WP_PATH" if [[ -f "$WP_PATH/wp-config.php" ]]; then ok "wp-config.php exists - keeping it, and the credentials in it" reconcile_wp_config else act "No wp-config.php - writing one" write_wp_config fi else if [[ -e "$WP_PATH" ]] && [[ -n "$(ls -A "$WP_PATH" 2>/dev/null)" ]]; then warn "$WP_PATH exists and is not empty, but has no WordPress in it." $FORCE || confirm "Move it aside and install WordPress there?" n || \ die "Refusing to install into a non-empty directory. Use --wp-path, or --force." local aside="${WP_PATH}.moved.${TS}" act "Moving $WP_PATH -> $aside" run mv "$WP_PATH" "$aside" BACKUPS+=("$WP_PATH -> $aside") fi download_wordpress write_wp_config fi set_wp_permissions write_wp_htaccess if (( MOD[apache] )) || is_pkg apache2; then write_apache_vhost else skip "Apache is not in this run - no vhost written" say " ${C_DIM}The files are in place at ${WP_PATH}. Point your own web server at" say " them, and make sure it refuses to serve wp-config.php.${C_RST}" WP_UNPUBLISHED=true fi handle_phpinfo finish_wordpress_install } WP_UNPUBLISHED=false INFO_PHP_URL="" GENERATED_ADMIN_PASS=false WP_ALREADY_SET_UP=false download_wordpress() { act "Downloading the latest WordPress" local tgz="$TMPDIR_SELF/wordpress.tar.gz" if $DRY_RUN; then printf ' %s[dry-run]%s fetch + extract WordPress to %s\n' "$C_MAG" "$C_RST" "$WP_PATH" return 0 fi curl -fsSL --retry 3 -o "$tgz" https://wordpress.org/latest.tar.gz \ || die "Could not download WordPress." # Verify against the published checksum where one is available. This is a # tarball being unpacked as root; "it downloaded" is not the same as "it is # what wordpress.org published". local want got want="$(curl -fsSL --retry 2 https://wordpress.org/latest.tar.gz.sha1 2>/dev/null | tr -d '[:space:]' || true)" if [[ -n "$want" ]]; then got="$(sha1sum "$tgz" | awk '{print $1}')" if [[ "$want" == "$got" ]]; then ok "Checksum verified (sha1 $got)" else die "Checksum MISMATCH. Expected $want, got $got. Refusing to extract." fi else warn "wordpress.org did not serve a checksum; extracting unverified." fi act "Extracting to $WP_PATH" mkdir -p "$(dirname "$WP_PATH")" tar -xzf "$tgz" -C "$TMPDIR_SELF" mkdir -p "$WP_PATH" cp -a "$TMPDIR_SELF/wordpress/." "$WP_PATH/" changed "installed WordPress at $WP_PATH" local ver ver=$(grep -oP "wp_version = '\K[^']+" "$WP_PATH/wp-includes/version.php" 2>/dev/null || echo "?") ok "WordPress $ver unpacked" } # An existing wp-config.php is the authority on its own credentials. If the # database we just prepared does not match it, say so rather than overwriting # a working site's configuration. reconcile_wp_config() { local cfg="$WP_PATH/wp-config.php" cur_db cur_user cur_db=$(grep -oP "define\(\s*'DB_NAME'\s*,\s*'\K[^']*" "$cfg" 2>/dev/null || true) cur_user=$(grep -oP "define\(\s*'DB_USER'\s*,\s*'\K[^']*" "$cfg" 2>/dev/null || true) if [[ "$cur_db" == "$WP_DB_NAME" && "$cur_user" == "$WP_DB_USER" ]]; then if $GENERATED_DB_PASS; then act "Updating the generated password into wp-config.php" local b; b=$(backup_file "$cfg") if ! $DRY_RUN; then sed -i "s|^\(define(\s*'DB_PASSWORD'\s*,\s*'\).*\('\s*)\s*;\)|\1${WP_DB_PASS}\2|" "$cfg" fi changed "rewrote DB_PASSWORD in wp-config.php (backup: $b)" ok "Credentials in wp-config.php now match the database" else ok "wp-config.php already points at ${cur_db} as ${cur_user}" fi else warn "wp-config.php uses db '${cur_db}' as '${cur_user}', but this run prepared '${WP_DB_NAME}'/'${WP_DB_USER}'." say " ${C_DIM}Left as-is. Re-run with --wp-db-name ${cur_db:-X} --wp-db-user ${cur_user:-Y} to" say " manage the database this site is actually using.${C_RST}" fi } write_wp_config() { local cfg="$WP_PATH/wp-config.php" [[ -f "$cfg" ]] && backup_file "$cfg" >/dev/null act "Fetching fresh security salts" local salts="" salts="$(curl -fsSL --retry 2 https://api.wordpress.org/secret-key/1.1/salt/ 2>/dev/null || true)" if [[ -z "$salts" ]] || ! grep -q "AUTH_KEY" <<< "$salts"; then warn "Salt API unreachable - generating salts locally with openssl" local k salts="" for k in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY \ AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do salts+="define('${k}', '$(openssl rand -base64 48 | tr -d '\n' | tr -d "'\\\\")');"$'\n' done else ok "Salts fetched from api.wordpress.org" fi act "Writing wp-config.php" if $DRY_RUN; then return 0; fi local prefix="wp_" cat > "$cfg" </dev/null; then skip ".htaccess already has the WordPress rules" return 0 fi act "Writing .htaccess (permalinks)" $DRY_RUN && return 0 [[ -f "$ht" ]] && backup_file "$ht" >/dev/null cat > "$ht" <<'HTACCESS' # BEGIN WordPress RewriteEngine On RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] RewriteBase / RewriteRule ^index\.php$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L] # END WordPress HTACCESS chown www-data:www-data "$ht"; chmod 644 "$ht" changed "wrote .htaccess" ok "Permalink rules in place" } write_apache_vhost() { local site="/etc/apache2/sites-available/wordpress.conf" local server_name_block="" if [[ -n "$DOMAIN" ]]; then server_name_block=" ServerName ${DOMAIN} ServerAlias www.${DOMAIN}" fi act "Writing $site" if ! $DRY_RUN; then [[ -f "$site" ]] && backup_file "$site" >/dev/null cat > "$site" < ${server_name_block} DocumentRoot ${WP_PATH} Options FollowSymLinks AllowOverride All Require all granted # wp-config.php holds the database password. It must never be served, # whatever else is misconfigured. Require all denied Require all denied Require all denied # Uploads are user-supplied content. Never execute anything in there. Require all denied ErrorLog \${APACHE_LOG_DIR}/wordpress-error.log CustomLog \${APACHE_LOG_DIR}/wordpress-access.log combined VHOST fi changed "Apache vhost for WordPress" if ! a2query -s wordpress >/dev/null 2>&1; then act "Enabling the wordpress site" run a2ensite -q wordpress changed "a2ensite wordpress" else skip "wordpress site already enabled" fi # With no ServerName, ours must be the default vhost or Apache's placeholder # answers first. With a ServerName, both can coexist happily. if [[ -z "$DOMAIN" ]] && a2query -s 000-default >/dev/null 2>&1; then act "Disabling Apache's default site so WordPress answers on the IP" run a2dissite -q 000-default changed "a2dissite 000-default" fi apache_reload } handle_phpinfo() { local f="$WP_PATH/info.php" local url="http://${DOMAIN:-${PRIMARY_IP%%/*}}/info.php" # What is true right now decides the default. Re-running the installer must # never silently switch this off - an earlier version deleted an info.php # you had deliberately enabled, just because the flag was absent. local state="disabled" want [[ -f "$f" ]] && state="enabled" want="$state" if $KEEP_PHPINFO; then want="enabled" elif $NO_INFO_PHP; then want="disabled" elif ! $ASSUME_YES && ! $DRY_RUN && [[ -n "$TTYIN" ]]; then say "" if [[ "$state" == "enabled" ]]; then printf ' %sphpinfo%s currently %s%sENABLED%s %s%s%s\n' \ "$C_B" "$C_RST" "$C_B" "$C_YEL" "$C_RST" "$C_DIM" "$url" "$C_RST" say " ${C_DIM}It lists every path, module, version and setting on this box${C_RST}" say " ${C_DIM}to anyone who can reach it.${C_RST}" confirm "Keep it enabled?" y && want="enabled" || want="disabled" else printf ' %sphpinfo%s currently %sdisabled%s\n' "$C_B" "$C_RST" "$C_DIM" "$C_RST" say " ${C_DIM}A phpinfo() page is the usual way to show PHP is working. It also${C_RST}" say " ${C_DIM}publishes every path, module, version and setting on this box.${C_RST}" confirm "Enable it at /info.php?" n && want="enabled" || want="disabled" fi fi if [[ "$want" == "$state" ]]; then if [[ "$state" == "enabled" ]]; then INFO_PHP_URL="$url" skip "phpinfo left enabled at $url" warn "info.php is PUBLIC - it exposes your whole PHP configuration." else skip "phpinfo left disabled" fi return 0 fi if [[ "$want" == "enabled" ]]; then act "Publishing info.php" if ! $DRY_RUN; then printf ' "$f" chown www-data:www-data "$f" chmod 644 "$f" fi INFO_PHP_URL="$url" ok "phpinfo() published at ${url}" warn "info.php is PUBLIC - it lists every path, module, version and setting on this box." say " ${C_DIM}Turn it off later: re-run and answer no, or pass --no-info-php${C_RST}" changed "published info.php" return 0 fi if [[ -f "$f" ]]; then # Reachable because WordPress's .htaccess only rewrites requests that do # NOT resolve to a real file (RewriteCond %{REQUEST_FILENAME} !-f), and # the vhost's deny list covers wp-config.php, dotfiles and xmlrpc.php - # not this. Removing it here is what makes the default safe. act "Removing info.php" run rm -f "$f" INFO_PHP_URL="" changed "removed info.php" ok "info.php removed (it is a full disclosure of your configuration)" fi } # --------------------------------------------------------------------------- # Actually finishing the install, rather than stopping at the setup screen. # --------------------------------------------------------------------------- # Unpacking WordPress and writing wp-config.php leaves you at the "famous # five-minute install" - a browser form. For a one-command installer that is # not finished; `wp core install` is what turns files on disk into a site. # wp-cli runs as www-data, not root: it writes into wp-content, and files # created there by root are files the web server cannot later manage. wp_run() { local as="runuser -u www-data --" have runuser || as="sudo -u www-data" $as env WP_CLI_CACHE_DIR=/tmp/.wp-cli-cache \ php /usr/local/bin/wp --path="$WP_PATH" "$@" } ensure_wp_cli() { if [[ -x /usr/local/bin/wp ]]; then skip "wp-cli already installed" return 0 fi act "Installing wp-cli" $DRY_RUN && return 0 local phar="$TMPDIR_SELF/wp-cli.phar" local base="https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar" curl -fsSL --retry 3 -o "$phar" "$base/wp-cli.phar" \ || { warn "Could not download wp-cli - leaving the browser setup screen."; return 1; } # Same reasoning as the WordPress tarball: this is code about to run as a # user that can write the whole site. "It downloaded" is not "it is what # wp-cli published". local want got want="$(curl -fsSL --retry 2 "$base/wp-cli.phar.sha512" 2>/dev/null | tr -d '[:space:]' || true)" if [[ -n "$want" ]]; then got="$(sha512sum "$phar" | awk '{print $1}')" [[ "$want" == "$got" ]] || { err "wp-cli checksum mismatch - refusing to install it"; return 1; } ok "wp-cli checksum verified" else warn "No wp-cli checksum available; installing unverified." fi php "$phar" --info >/dev/null 2>&1 || { warn "The wp-cli download does not run here."; return 1; } install -m 755 "$phar" /usr/local/bin/wp changed "installed wp-cli" ok "wp-cli $(php /usr/local/bin/wp --version 2>/dev/null | awk '{print $2}' || echo '') installed" } finish_wordpress_install() { if $NO_WP_INSTALL; then skip "Leaving the browser setup screen (--no-wp-install)" return 0 fi if $WP_UNPUBLISHED; then skip "No web server in this run - skipping site creation" return 0 fi if $DRY_RUN; then act "Would install wp-cli and run 'wp core install'" return 0 fi ensure_wp_cli || return 0 # An already-configured site is the end state, not a problem. Never # re-run core install over one - it would not overwrite content, but it # would be the wrong thing to attempt. if wp_run core is-installed >/dev/null 2>&1; then WP_ALREADY_SET_UP=true local title url title="$(wp_run option get blogname 2>/dev/null || echo '?')" url="$(wp_run option get siteurl 2>/dev/null || echo '?')" ok "WordPress is already set up: \"${title}\" at ${url}" return 0 fi local url="http://${DOMAIN:-${PRIMARY_IP%%/*}}" ask WP_TITLE "Site title" "My WordPress Site" ask WP_ADMIN_EMAIL "Administrator email" "admin@${DOMAIN:-localhost}" if [[ -z "$WP_ADMIN_PASS" ]]; then WP_ADMIN_PASS="$(gen_pass 20)" GENERATED_ADMIN_PASS=true fi act "Creating the site and the administrator account" if wp_run core install \ --url="$url" --title="$WP_TITLE" \ --admin_user="$WP_ADMIN_USER" \ --admin_password="$WP_ADMIN_PASS" \ --admin_email="$WP_ADMIN_EMAIL" \ --skip-email >>"$LOG_FILE" 2>&1; then changed "WordPress site created: $WP_TITLE" ok "Site created - $url" else warn "wp core install failed; the browser setup at ${url}/wp-admin/ still works. See $LOG_FILE" return 0 fi # Pretty permalinks, since .htaccess and mod_rewrite are already in place. if wp_run rewrite structure '/%postname%/' --hard >>"$LOG_FILE" 2>&1; then ok "Permalinks set to /%postname%/" fi [[ "$WP_ADMIN_USER" == "admin" ]] && \ warn "The admin username is 'admin' - the first name every bot tries. Use --admin-user to change it." return 0 } # ============================================================================ # MODULE: ssl # ============================================================================ mod_ssl() { step "HTTPS certificate" [[ -n "$DOMAIN" ]] || { warn "--with ssl needs --domain. Skipping."; return 0; } ensure_pkgs certbot python3-certbot-apache if [[ -d "/etc/letsencrypt/live/$DOMAIN" ]]; then local days days=$(( ( $(date -d "$(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/$DOMAIN/cert.pem" 2>/dev/null | cut -d= -f2)" +%s 2>/dev/null || echo 0) - $(date +%s) ) / 86400 )) ok "Certificate for $DOMAIN already exists (${days} days left)" (( days < 30 )) && { act "Renewing"; run certbot renew --quiet; } return 0 fi # A certificate authority has to reach this name from the internet. Say so # before spending a rate-limited request finding out. local resolved # getent exits 2 for a name that does not resolve - which is precisely the # case this line is here to detect. resolved="$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1; exit}' || true)" if [[ -z "$resolved" ]]; then warn "$DOMAIN does not resolve. Let's Encrypt cannot validate it - skipping." say " ${C_DIM}Point an A record at this machine, then: certbot --apache -d $DOMAIN${C_RST}" return 0 fi ok "$DOMAIN resolves to $resolved" ask SSL_EMAIL "Email for expiry notices" "" act "Requesting a certificate for $DOMAIN" if run certbot --apache -d "$DOMAIN" -d "www.$DOMAIN" \ --non-interactive --agree-tos -m "$SSL_EMAIL" --redirect; then changed "issued certificate for $DOMAIN" ok "HTTPS live, HTTP redirects to it" else warn "certbot failed - see $LOG_FILE. The site still works over HTTP." fi } # ============================================================================ # MODULE: harden # ============================================================================ mod_harden() { step "Hardening" # Apache should not announce its version and modules on every 404. local sec="/etc/apache2/conf-available/99-hardening.conf" local want="ServerTokens Prod ServerSignature Off TraceEnable Off Header always set X-Content-Type-Options \"nosniff\" Header always set X-Frame-Options \"SAMEORIGIN\" Header always set Referrer-Policy \"strict-origin-when-cross-origin\"" if [[ -f "$sec" ]] && [[ "$(cat "$sec")" == "$want" ]]; then skip "Apache hardening already applied" else act "Writing $sec" $DRY_RUN || printf '%s\n' "$want" > "$sec" run a2enconf -q 99-hardening changed "Apache hardening" apache_reload fi ensure_pkgs unattended-upgrades if svc_active unattended-upgrades; then skip "Automatic security updates already on" else act "Enabling automatic security updates" run systemctl enable --now unattended-upgrades changed "enabled unattended-upgrades" fi # Report on SSH rather than change it: locking yourself out of a machine # you are currently logged into is exactly the failure this script exists # to avoid, and password auth may be the only way in. if grep -qE '^\s*PermitRootLogin\s+yes' /etc/ssh/sshd_config 2>/dev/null; then warn "SSH permits root login with a password. Consider 'PermitRootLogin prohibit-password'." fi if ! grep -qE '^\s*PasswordAuthentication\s+no' /etc/ssh/sshd_config 2>/dev/null; then warn "SSH accepts password authentication. Key-only is stronger, once your key works." fi ok "SSH reviewed (reported, not changed - see the warnings above)" } # ============================================================================ # MODE: --audit (report only; changes nothing, ever) # ============================================================================ a_head() { printf '\n %s%s%s\n' "$C_B" "$1" "$C_RST"; } a_row() { printf ' %-22s %s\n' "$1" "$2"; } # The verdict list is read away from the table, so each entry must carry its # own subject - "required" means nothing without "reboot". a_bad() { printf ' %s%-22s %s%s\n' "$C_RED" "$1" "$2" "$C_RST"; AUDIT_PROBLEMS+=("$1: $2"); } a_warn() { printf ' %s%-22s %s%s\n' "$C_YEL" "$1" "$2" "$C_RST"; AUDIT_PROBLEMS+=("$1: $2"); } declare -a AUDIT_PROBLEMS=() # A finding that can be repaired registers its own repair here. Reporting a # problem and then doing nothing about it is half an answer when the fix is a # single known command. declare -a FIX_DESC=() FIX_FN=() FIX_RISK=() add_fix() { FIX_DESC+=("$1"); FIX_FN+=("$2"); FIX_RISK+=("${3:-}"); } do_audit() { step "Audit" say " ${C_DIM}Read-only. Nothing on this machine is changed.${C_RST}" # --- web server --------------------------------------------------------- a_head "Web server" if is_pkg apache2; then a_row "apache2" "$(apache2 -v 2>/dev/null | awk -F'/| ' '/version/{print $4; exit}') · $(svc_active apache2 && echo running || echo STOPPED)" local sites; sites="$(ls /etc/apache2/sites-enabled/ 2>/dev/null | tr '\n' ' ' || true)" a_row "enabled sites" "${sites:-none}" apache2ctl configtest >/dev/null 2>&1 && a_row "config" "valid" || a_bad "config" "apache2ctl configtest FAILS" else a_row "apache2" "not installed" fi is_pkg nginx && a_row "nginx" "also installed ($(svc_active nginx && echo running || echo stopped))" local p80; p80="$(port_owner 80)" a_row "port 80" "${p80:-nobody}" # --- php ---------------------------------------------------------------- a_head "PHP" if have php; then a_row "version" "$(php -r 'echo PHP_VERSION;' 2>/dev/null)" local ext missing=() for ext in mysqli curl gd mbstring xml zip json intl; do php -m 2>/dev/null | grep -qix "$ext" || missing+=("$ext") done if ((${#missing[@]})); then a_warn "extensions" "missing: ${missing[*]}" FIX_PHP_EXT="${missing[*]}" add_fix "Install the missing PHP extensions (${missing[*]})" "fix_php_ext" else a_row "extensions" "all present" fi a_row "upload_max" "$(php -r 'echo ini_get("upload_max_filesize");' 2>/dev/null)" a_row "memory_limit" "$(php -r 'echo ini_get("memory_limit");' 2>/dev/null)" else a_row "php" "not installed" fi # --- database ----------------------------------------------------------- a_head "Database" local unit=mysql; svc_exists mysql || unit=mariadb if svc_exists "$unit"; then a_row "$unit" "$(svc_active "$unit" && echo running || echo STOPPED)" if mysql --protocol=socket -u root -e "SELECT 1" >/dev/null 2>&1; then MYSQL_CMD=(mysql --protocol=socket -u root) a_row "root auth" "unix socket" local dbs; dbs="$(mysql_val "SELECT GROUP_CONCAT(schema_name) FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');")" a_row "databases" "${dbs:-none}" local anon; anon="$(mysql_val "SELECT COUNT(*) FROM mysql.user WHERE User='';")" if [[ "${anon:-0}" == "0" ]]; then a_row "anonymous users" "none" else a_bad "anonymous users" "$anon present - anyone can connect without a password" add_fix "Delete the $anon anonymous MySQL user(s)" "fix_mysql_anon" fi local bind; bind="$(mysql_val "SELECT @@bind_address;")" if [[ "$bind" == "0.0.0.0" || "$bind" == "*" ]]; then a_warn "bind address" "$bind - the database is reachable from the whole network" add_fix "Bind MySQL to 127.0.0.1 only" "fix_mysql_bind" \ "restarts MySQL, and breaks anything connecting to it from another machine" else a_row "bind address" "${bind:-127.0.0.1}" fi else a_row "root auth" "password (not checked)" fi else a_row "server" "not installed" fi # --- wordpress ---------------------------------------------------------- a_head "WordPress" if [[ -f "$WP_PATH/wp-includes/version.php" ]]; then a_row "version" "$(grep -oP "wp_version = '\K[^']+" "$WP_PATH/wp-includes/version.php" 2>/dev/null || echo '?')" a_row "path" "$WP_PATH" local perm; perm="$(stat -c '%a %U:%G' "$WP_PATH/wp-config.php" 2>/dev/null || echo 'missing')" case "$perm" in *[2367]\ *) a_bad "wp-config.php" "$perm - world-readable, your DB password is exposed" add_fix "Lock wp-config.php down to 640 root:www-data" "fix_wpconfig_perm" ;; missing) a_bad "wp-config.php" "missing - the site cannot work" ;; *) a_row "wp-config.php" "$perm" ;; esac if [[ -f "$WP_PATH/info.php" ]]; then a_bad "info.php" "present - publishes your whole PHP configuration" add_fix "Remove info.php" "fix_info_php" else a_row "info.php" "absent" fi if [[ -x /usr/local/bin/wp ]]; then if wp_run core is-installed >/dev/null 2>&1; then a_row "site" "$(wp_run option get blogname 2>/dev/null) at $(wp_run option get siteurl 2>/dev/null)" a_row "plugins/themes" "$(wp_run plugin list --format=count 2>/dev/null || echo '?') / $(wp_run theme list --format=count 2>/dev/null || echo '?')" else a_warn "site" "files present but setup never completed" fi fi else a_row "wordpress" "not installed at $WP_PATH" fi # --- host --------------------------------------------------------------- a_head "Host" if have ufw; then local fw; fw="$(ufw status 2>/dev/null | awk 'NR==1{print $2}' || true)" if [[ "$fw" == "active" ]]; then a_row "firewall" "active" else a_warn "firewall" "inactive" add_fix "Enable ufw, allowing 22/80/443" "fix_firewall" \ "closes every other port that is currently reachable" fi else a_warn "firewall" "ufw not installed" fi local upd; upd="$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst ' || true)" if (( upd > 0 )); then a_warn "updates" "$upd package(s) pending" add_fix "Install the $upd pending package update(s)" "fix_updates" else a_row "updates" "current" fi [[ -f /var/run/reboot-required ]] && a_warn "reboot" "required" || a_row "reboot" "not required" grep -qE '^\s*PermitRootLogin\s+yes' /etc/ssh/sshd_config 2>/dev/null \ && a_warn "ssh root login" "permitted with a password" || a_row "ssh root login" "restricted" # --- verdict ------------------------------------------------------------ echo; hr if ((${#AUDIT_PROBLEMS[@]} == 0)); then printf '%s%s NOTHING TO FLAG%s - this machine looks healthy.\n' "$C_B" "$C_GRN" "$C_RST" else printf '%s%s %d THING(S) WORTH FIXING%s\n' "$C_B" "$C_YEL" "${#AUDIT_PROBLEMS[@]}" "$C_RST" printf ' · %s\n' "${AUDIT_PROBLEMS[@]}" fi hr offer_fixes } FIX_PHP_EXT="" fix_php_ext() { local e p=(); for e in $FIX_PHP_EXT; do p+=("php-$e"); done; ensure_pkgs "${p[@]}" svc_active apache2 && apache_reload || true; } fix_mysql_anon() { mysql_run "DELETE FROM mysql.user WHERE User='';"; mysql_run "FLUSH PRIVILEGES;" changed "removed anonymous MySQL users"; } fix_info_php() { run rm -f "$WP_PATH/info.php"; changed "removed info.php"; } fix_wpconfig_perm() { run chown root:www-data "$WP_PATH/wp-config.php" run chmod 640 "$WP_PATH/wp-config.php" changed "locked down wp-config.php" } fix_firewall() { local keep=$MOD_FIREWALL_SAVE; MOD[firewall]=1; mod_firewall; MOD[firewall]=$keep; } fix_updates() { run env DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \ -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold changed "installed pending updates"; } MOD_FIREWALL_SAVE=0 # Rebinding the database is the one repair here that can take somebody else's # working application offline. Look before doing it. fix_mysql_bind() { local remote remote="$(mysql_val "SELECT COUNT(*) FROM information_schema.processlist WHERE host NOT LIKE 'localhost%' AND host NOT LIKE '127.0.0.1%' AND host NOT LIKE '::1%';")" if [[ "${remote:-0}" != "0" ]]; then err "There are ${remote} connection(s) to this database from other machines RIGHT NOW." say " ${C_DIM}Binding to 127.0.0.1 would cut them off. Skipping this one.${C_RST}" return 1 fi local cnf="/etc/mysql/mysql.conf.d/99-bind-local.cnf" [[ -d /etc/mysql/mysql.conf.d ]] || cnf="/etc/mysql/conf.d/99-bind-local.cnf" act "Writing $cnf" $DRY_RUN || printf '[mysqld]\nbind-address = 127.0.0.1\n' > "$cnf" local unit=mysql; svc_exists mysql || unit=mariadb act "Restarting $unit" run systemctl restart "$unit" changed "bound MySQL to 127.0.0.1" } # ============================================================================ # Offering the repairs # ============================================================================ offer_fixes() { ((${#FIX_DESC[@]})) || return 0 say "" say " ${C_B}${#FIX_DESC[@]} of these can be fixed from here:${C_RST}" say "" local i for i in "${!FIX_DESC[@]}"; do printf ' %s%d%s %s\n' "$C_B" "$((i+1))" "$C_RST" "${FIX_DESC[$i]}" [[ -n "${FIX_RISK[$i]}" ]] && printf ' %s\u26a0 %s%s\n' "$C_YEL" "${FIX_RISK[$i]}" "$C_RST" done say "" local pick="" if $FIX_MODE && $ASSUME_YES; then pick="a" elif [[ -z "$TTYIN" ]]; then say " ${C_DIM}Re-run with --fix to apply these.${C_RST}" return 0 else printf ' %s?%s Fix which? %s[a]%sll, %s[n]%sone, or numbers like %s1 3%s: ' \ "$C_CYN" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" "$C_B" "$C_RST" read -r pick < "$TTYIN" || pick="n" fi local -a chosen=() case "${pick,,}" in a|all) for i in "${!FIX_DESC[@]}"; do chosen+=("$i"); done ;; ""|n|no|none) say ""; say " Nothing was changed."; return 0 ;; *) local tok for tok in $pick; do [[ "$tok" =~ ^[0-9]+$ ]] || continue (( tok >= 1 && tok <= ${#FIX_DESC[@]} )) && chosen+=("$((tok-1))") done ;; esac ((${#chosen[@]})) || { say ""; say " Nothing selected, nothing changed."; return 0; } MOD_FIREWALL_SAVE=${MOD[firewall]} local applied=0 failed=0 for i in "${chosen[@]}"; do step "${FIX_DESC[$i]}" if "${FIX_FN[$i]}"; then applied=$((applied+1)); else failed=$((failed+1)); warn "Skipped."; fi done echo; hr printf '%s%s %d FIXED%s' "$C_B" "$C_GRN" "$applied" "$C_RST" (( failed )) && printf '%s, %d skipped%s' "$C_YEL" "$failed" "$C_RST" printf '\n' hr; echo say " ${C_DIM}Run --audit again to confirm.${C_RST}" echo } # ============================================================================ # MODE: --uninstall # ============================================================================ do_uninstall() { step "Uninstall" say " This removes what this installer sets up:" say " · the WordPress files at ${C_B}${WP_PATH}${C_RST}" say " · the database ${C_B}${WP_DB_NAME}${C_RST} and the user ${C_B}${WP_DB_USER}${C_RST}" say " · the Apache vhost, the PHP drop-in, the hardening conf, wp-cli" say "" if $PURGE; then say " ${C_RED}${C_B}--purge given:${C_RST} the database dump and the moved-aside files" say " ${C_RED}will be DELETED, and apache2/mysql-server/php will be removed.${C_RST}" else say " ${C_GRN}The database is dumped to /root first, and the files are moved" say " aside rather than deleted.${C_RST} Add --purge to remove them for good." fi say "" if ! $ASSUME_YES; then [[ -z "$TTYIN" ]] && die "Refusing to uninstall without a confirmation. Add -y if you mean it." local ans printf ' %s?%s Type %sremove%s to continue: ' "$C_YEL" "$C_RST" "$C_B" "$C_RST" read -r ans < "$TTYIN" || ans="" [[ "$ans" == "remove" ]] || { say ""; say " Nothing was changed."; exit 0; } fi # --- database: dump BEFORE dropping ------------------------------------ if svc_active mysql || svc_active mariadb; then if mysql --protocol=socket -u root -e "SELECT 1" >/dev/null 2>&1; then MYSQL_CMD=(mysql --protocol=socket -u root) if [[ "$(mysql_val "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='${WP_DB_NAME}';")" != "0" ]]; then local dump="/root/${WP_DB_NAME}-before-uninstall-${TS}.sql" act "Dumping $WP_DB_NAME to $dump" if ! $DRY_RUN; then if mysqldump --protocol=socket -u root "$WP_DB_NAME" > "$dump" 2>>"$LOG_FILE"; then chmod 600 "$dump" ok "Dumped $(du -h "$dump" | cut -f1) - restore with: mysql $WP_DB_NAME < $dump" BACKUPS+=("database $WP_DB_NAME -> $dump") else die "Could not dump $WP_DB_NAME. Refusing to drop a database I could not back up." fi fi act "Dropping database $WP_DB_NAME" mysql_run "DROP DATABASE IF EXISTS \`${WP_DB_NAME}\`;" changed "dropped database $WP_DB_NAME" else skip "Database $WP_DB_NAME does not exist" fi act "Dropping user $WP_DB_USER" mysql_run "DROP USER IF EXISTS '${WP_DB_USER}'@'localhost';" mysql_run "FLUSH PRIVILEGES;" changed "dropped user $WP_DB_USER" else warn "Cannot authenticate to MySQL as root - database left alone." fi fi # --- apache ------------------------------------------------------------- if is_pkg apache2; then a2query -s wordpress >/dev/null 2>&1 && { act "Disabling the wordpress site"; run a2dissite -q wordpress; changed "a2dissite wordpress"; } a2query -s 000-default >/dev/null 2>&1 || { act "Re-enabling Apache's default site"; run a2ensite -q 000-default; } [[ -f /etc/apache2/sites-available/wordpress.conf ]] && { run rm -f /etc/apache2/sites-available/wordpress.conf; changed "removed the wordpress vhost"; } a2query -c 99-hardening >/dev/null 2>&1 && { run a2disconf -q 99-hardening; run rm -f /etc/apache2/conf-available/99-hardening.conf; changed "removed the hardening conf"; } apache_reload || true fi # --- files -------------------------------------------------------------- if [[ -d "$WP_PATH" ]]; then if $PURGE; then act "Deleting $WP_PATH" run rm -rf "$WP_PATH" changed "deleted $WP_PATH" else local aside="${WP_PATH}.removed.${TS}" act "Moving $WP_PATH -> $aside" run mv "$WP_PATH" "$aside" BACKUPS+=("$WP_PATH -> $aside") changed "moved $WP_PATH aside" fi fi # --- odds and ends ------------------------------------------------------ local f for f in /etc/php/*/apache2/conf.d/99-wordpress.ini /etc/php/*/fpm/conf.d/99-wordpress.ini /etc/php/*/cli/conf.d/99-wordpress.ini; do [[ -f "$f" ]] || continue run rm -f "$f"; changed "removed $f" done [[ -x /usr/local/bin/wp ]] && { run rm -f /usr/local/bin/wp; changed "removed wp-cli"; } # --- packages, only on --purge ----------------------------------------- if $PURGE; then act "Removing apache2, mysql-server and php" run env DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq apache2 mysql-server 'php*' || true run env DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -qq || true changed "purged packages" fi echo; hr printf '%s%s UNINSTALLED%s - %d change(s)\n' "$C_B" "$C_GRN" "$C_RST" "${#CHANGES[@]}" hr; echo ((${#BACKUPS[@]})) && { printf ' %sKept, not deleted%s\n' "$C_B" "$C_RST"; printf ' · %s\n' "${BACKUPS[@]}"; echo; } printf ' %sLog%s %s\n\n' "$C_B" "$C_RST" "$LOG_FILE" } # ============================================================================ # VERIFY - prove it works, do not assume it works # ============================================================================ # The lesson this whole section exists for: a 200 does not mean a site works, # and `systemctl is-active` does not mean the app behind it is serving. Every # check below observes an outcome, not a configuration. PASS_N=0; FAIL_N=0; WARN_N=0 check() { # check "name" "command..." -> pass/fail local name="$1"; shift if "$@" >/dev/null 2>&1; then printf ' %s✓%s %-46s %spass%s\n' "$C_GRN" "$C_RST" "$name" "$C_GRN" "$C_RST" CHECK_RESULT[$name]=pass; PASS_N=$((PASS_N+1)); return 0 else printf ' %s✗%s %-46s %sFAIL%s\n' "$C_RED" "$C_RST" "$name" "$C_RED" "$C_RST" CHECK_RESULT[$name]=fail; FAIL_N=$((FAIL_N+1)); return 1 fi } check_soft() { # same, but a failure is a warning local name="$1"; shift if "$@" >/dev/null 2>&1; then printf ' %s✓%s %-46s %spass%s\n' "$C_GRN" "$C_RST" "$name" "$C_GRN" "$C_RST" CHECK_RESULT[$name]=pass; PASS_N=$((PASS_N+1)) else printf ' %s!%s %-46s %swarn%s\n' "$C_YEL" "$C_RST" "$name" "$C_YEL" "$C_RST" CHECK_RESULT[$name]=warn; WARN_N=$((WARN_N+1)) fi } http_code() { # http_code PATH -> prints the status code local path="$1" hosthdr=() [[ -n "$DOMAIN" ]] && hosthdr=(-H "Host: $DOMAIN") curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ "${hosthdr[@]}" "http://127.0.0.1${path}" 2>/dev/null || true } http_body() { local path="$1" hosthdr=() [[ -n "$DOMAIN" ]] && hosthdr=(-H "Host: $DOMAIN") curl -s --max-time 15 "${hosthdr[@]}" "http://127.0.0.1${path}" 2>/dev/null || true } verify() { step "Verification" if $DRY_RUN; then skip "Dry run - nothing to verify"; return 0; fi if (( MOD[apache] )) || is_pkg apache2; then check "Apache service is running" svc_active apache2 check "Apache configuration is valid" apache2ctl configtest check "Something is listening on port 80" bash -c '[[ -n "$(ss -lntH "sport = :80")" ]]' fi if (( MOD[mysql] )); then local unit=mysql; svc_exists mysql || unit=mariadb svc_exists "$unit" || unit=mysql check "Database service is running" svc_active "$unit" fi if (( MOD[php] )); then check "PHP CLI runs" php -r 'exit(0);' local ext missing=() for ext in mysqli curl gd mbstring xml zip json; do php -m 2>/dev/null | grep -qix "$ext" || missing+=("$ext") done check "PHP has every extension WordPress needs" bash -c "[[ ${#missing[@]} -eq 0 ]]" ((${#missing[@]})) && warn "Missing PHP extensions: ${missing[*]}" fi if (( MOD[wordpress] )); then # Does Apache actually EXECUTE php, or hand it over as a download? A # missing libapache2-mod-php gives you a browser downloading index.php. # NOT dot-prefixed: the vhost written above denies "^\." outright, so a # dotfile probe is refused before PHP ever sees it and a working install # reports a failure. Random suffix so it cannot collide or be guessed. local pname="wp-probe-$$-${RANDOM}.php" local probe="$WP_PATH/$pname" PROBE_FILE="$probe" printf ' "$probe" 2>/dev/null || true chown www-data:www-data "$probe" 2>/dev/null || true chmod 644 "$probe" 2>/dev/null || true if [[ "$(http_body "/$pname")" == "PHP_EXECUTES_OK" ]]; then printf ' %s✓%s %-46s %spass%s\n' "$C_GRN" "$C_RST" "Apache executes PHP (not downloading it)" "$C_GRN" "$C_RST" CHECK_RESULT["Apache executes PHP (not downloading it)"]=pass PASS_N=$((PASS_N+1)) else printf ' %s✗%s %-46s %sFAIL%s\n' "$C_RED" "$C_RST" "Apache executes PHP (not downloading it)" "$C_RED" "$C_RST" CHECK_RESULT["Apache executes PHP (not downloading it)"]=fail FAIL_N=$((FAIL_N+1)) fi rm -f "$probe"; PROBE_FILE="" check "WordPress files are present" test -f "$WP_PATH/wp-includes/version.php" check "wp-config.php exists" test -f "$WP_PATH/wp-config.php" check "wp-config.php is not world-readable" bash -c "[[ \$(stat -c '%a' '$WP_PATH/wp-config.php') != *[2367] ]]" # 200 alone is not proof. Ask whether the body is WordPress. local code body code="$(http_code /)" body="$(http_body / | head -c 4000 || true)" if [[ "$code" == "200" || "$code" == "302" ]] && \ grep -qiE 'wp-content|wp-includes|WordPress' <<< "$body"; then printf ' %s✓%s %-46s %spass%s %s(HTTP %s)%s\n' "$C_GRN" "$C_RST" "The site serves WordPress" "$C_GRN" "$C_RST" "$C_DIM" "$code" "$C_RST" CHECK_RESULT["The site serves WordPress"]=pass PASS_N=$((PASS_N+1)) else printf ' %s✗%s %-46s %sFAIL%s %s(HTTP %s)%s\n' "$C_RED" "$C_RST" "The site serves WordPress" "$C_RED" "$C_RST" "$C_DIM" "$code" "$C_RST" CHECK_RESULT["The site serves WordPress"]=fail FAIL_N=$((FAIL_N+1)) WORDPRESS_BODY_HINT="$(head -c 200 <<< "$body")" fi # The database is reachable from PHP, not just from the shell. local dbtest="$WP_PATH/wp-dbprobe-$$-${RANDOM}.php" DBPROBE_FILE="$dbtest" cat > "$dbtest" <<'DBPROBE' connect_error ? "DB_FAIL" : "DB_OK"; DBPROBE chown www-data:www-data "$dbtest" 2>/dev/null || true chmod 640 "$dbtest" 2>/dev/null || true if [[ "$(sudo -u www-data php "$dbtest" 2>/dev/null)" == "DB_OK" ]]; then printf ' %s✓%s %-46s %spass%s\n' "$C_GRN" "$C_RST" "PHP can reach the database" "$C_GRN" "$C_RST" CHECK_RESULT["PHP can reach the database"]=pass PASS_N=$((PASS_N+1)) else printf ' %s✗%s %-46s %sFAIL%s\n' "$C_RED" "$C_RST" "PHP can reach the database" "$C_RED" "$C_RST" CHECK_RESULT["PHP can reach the database"]=fail FAIL_N=$((FAIL_N+1)) fi rm -f "$dbtest"; DBPROBE_FILE="" # The one that matters: the database password must not be downloadable. local wpc; wpc="$(http_code /wp-config.php)" if [[ "$wpc" == "403" || "$wpc" == "404" ]]; then printf ' %s✓%s %-46s %spass%s %s(HTTP %s)%s\n' "$C_GRN" "$C_RST" "wp-config.php is not downloadable" "$C_GRN" "$C_RST" "$C_DIM" "$wpc" "$C_RST" CHECK_RESULT["wp-config.php is not downloadable"]=pass PASS_N=$((PASS_N+1)) else printf ' %s✗%s %-46s %sFAIL%s %s(HTTP %s)%s\n' "$C_RED" "$C_RST" "wp-config.php is not downloadable" "$C_RED" "$C_RST" "$C_DIM" "$wpc" "$C_RST" CHECK_RESULT["wp-config.php is not downloadable"]=fail FAIL_N=$((FAIL_N+1)) err "Your database password may be fetchable over HTTP. Fix before exposing this box." fi check_soft "Permalink rewrites are enabled" bash -c "apache2ctl -M 2>/dev/null | grep -q rewrite_module" if ! $NO_WP_INSTALL; then # An unfinished WordPress redirects everything to install.php. That # is the difference between "files are served" and "there is a site". if http_body / | grep -qi "wp-admin/install.php"; then printf ' %s!%s %-46s %swarn%s ' "$C_YEL" "$C_RST" "The site is set up, not the setup screen" "$C_YEL" "$C_RST" CHECK_RESULT["The site is set up, not the setup screen"]=warn WARN_N=$((WARN_N+1)) else printf ' %s✓%s %-46s %spass%s ' "$C_GRN" "$C_RST" "The site is set up, not the setup screen" "$C_GRN" "$C_RST" CHECK_RESULT["The site is set up, not the setup screen"]=pass PASS_N=$((PASS_N+1)) fi fi fi if (( MOD[ssl] )) && [[ -n "$DOMAIN" ]]; then check_soft "Certificate present for $DOMAIN" test -d "/etc/letsencrypt/live/$DOMAIN" fi if (( MOD[tailscale] )); then check_soft "Tailscale is connected" bash -c 'tailscale status >/dev/null 2>&1' fi if (( MOD[firewall] )); then check_soft "Firewall is active" bash -c 'ufw status | head -1 | grep -q active' fi } # ============================================================================ # REPORT # ============================================================================ write_report() { local rpt="/root/lamp-install-${TS}.txt" $DRY_RUN && return 0 { echo "LAMP + WordPress install report" echo "Generated: $(date -Is)" echo "Host: $(hostname -f 2>/dev/null || hostname) ($OS_NAME)" echo "Script: v${VERSION}" echo echo "== ACCESS ==" echo "Site: http://${DOMAIN:-${PRIMARY_IP%%/*}}/" echo "Admin: http://${DOMAIN:-${PRIMARY_IP%%/*}}/wp-admin/" echo "WP root: $WP_PATH" [[ -n "$INFO_PHP_URL" ]] && echo "phpinfo: $INFO_PHP_URL (PUBLIC - delete when done)" echo if (( MOD[wordpress] )) && ! $NO_WP_INSTALL && ! $WP_ALREADY_SET_UP; then echo "== WORDPRESS ADMINISTRATOR ==" echo "Username: $WP_ADMIN_USER" echo "Password: $WP_ADMIN_PASS" echo "Email: $WP_ADMIN_EMAIL" echo fi if (( MOD[wordpress] )); then echo "== DATABASE ==" echo "Name: $WP_DB_NAME" echo "User: $WP_DB_USER" echo "Password: $WP_DB_PASS" echo "Host: localhost" echo fi if [[ -n "$MYSQL_ROOT_PASS" ]]; then echo "MySQL root password: $MYSQL_ROOT_PASS" echo fi echo "== VERIFICATION ==" if ((${#CHECK_RESULT[@]})); then local _k for _k in "${!CHECK_RESULT[@]}"; do printf ' [%-4s] %s\n' "${CHECK_RESULT[$_k]}" "$_k" done | sort -r echo " ${PASS_N} passed, ${FAIL_N} failed, ${WARN_N} warned" else echo " (not run)" fi echo echo "== CHANGED THIS RUN (${#CHANGES[@]}) ==" if ((${#CHANGES[@]})); then printf ' - %s\n' "${CHANGES[@]}"; else echo " (nothing - already converged)"; fi echo echo "== BACKED UP (${#BACKUPS[@]}) ==" if ((${#BACKUPS[@]})); then printf ' - %s\n' "${BACKUPS[@]}"; else echo " (none)"; fi echo echo "== WARNINGS (${#WARNINGS[@]}) ==" if ((${#WARNINGS[@]})); then printf ' - %s\n' "${WARNINGS[@]}"; else echo " (none)"; fi echo echo "Log: $LOG_FILE" } > "$rpt" chmod 600 "$rpt" REPORT_FILE="$rpt" } REPORT_FILE="" # Every run leaves a log and a report, and the reports contain passwords. An # unbounded pile of 0600 files full of credentials is a slow-growing liability, # not just clutter. prune_old_runs() { $DRY_RUN && return 0 [[ "$KEEP_RUNS" =~ ^[0-9]+$ ]] || return 0 (( KEEP_RUNS < 1 )) && return 0 local removed=0 f local -a old # The filenames carry a sortable YYYYmmdd-HHMMSS stamp, so a reverse sort # puts the newest first and this run is never in the tail. mapfile -t old < <(ls -1 /var/log/lamp-install-*.log 2>/dev/null | sort -r | tail -n +$((KEEP_RUNS+1)) || true) for f in "${old[@]:-}"; do [[ -n "$f" && -f "$f" ]] && rm -f "$f" && removed=$((removed+1)); done mapfile -t old < <(ls -1 /root/lamp-install-*.txt 2>/dev/null | sort -r | tail -n +$((KEEP_RUNS+1)) || true) for f in "${old[@]:-}"; do [[ -n "$f" && -f "$f" ]] && rm -f "$f" && removed=$((removed+1)); done (( removed )) && _log "PRUNE: removed $removed old log/report file(s)" PRUNED_N=$removed return 0 } PRUNED_N=0 summary() { local url="http://${DOMAIN:-${PRIMARY_IP%%/*}}" echo hr if $DRY_RUN; then printf '%s%s DRY RUN COMPLETE%s - nothing on this machine was changed.\n' "$C_B" "$C_MAG" "$C_RST" hr; echo; return 0 fi if (( FAIL_N > 0 )); then printf '%s%s FINISHED WITH %d FAILED CHECK(S)%s\n' "$C_B" "$C_RED" "$FAIL_N" "$C_RST" elif ((${#CHANGES[@]} == 0)); then printf '%s%s ALREADY CONVERGED%s - everything was in place; nothing needed changing.\n' "$C_B" "$C_GRN" "$C_RST" else printf '%s%s DONE%s - %d change(s), %d check(s) passed.\n' "$C_B" "$C_GRN" "$C_RST" "${#CHANGES[@]}" "$PASS_N" fi hr echo if (( MOD[wordpress] )) && $WP_UNPUBLISHED; then printf ' %sWordPress files are installed but not served%s\n' "$C_B" "$C_RST" printf ' %s\n' "$WP_PATH" printf ' %sNo Apache vhost was written - point your web server here yourself.%s\n' "$C_DIM" "$C_RST" echo elif (( MOD[wordpress] )); then printf ' %sYour site%s\n' "$C_B" "$C_RST" printf ' %s/\n' "$url" printf ' %s/wp-admin/ %s← finish setup here%s\n' "$url" "$C_DIM" "$C_RST" [[ -n "$INFO_PHP_URL" ]] && \ printf ' %s %sphpinfo, public - delete when done%s\n' "$INFO_PHP_URL" "$C_YEL" "$C_RST" echo if ! $NO_WP_INSTALL && ! $WP_ALREADY_SET_UP && [[ -n "$WP_ADMIN_PASS" ]]; then printf ' %sLog in%s %s / %s\n' "$C_B" "$C_RST" "$WP_ADMIN_USER" "$WP_ADMIN_EMAIL" printf ' password %s%s%s%s\n' "$C_B$C_YEL" "$WP_ADMIN_PASS" "$C_RST" \ "$( $GENERATED_ADMIN_PASS && printf ' %s(generated)%s' "$C_DIM" "$C_RST" )" echo fi printf ' %sDatabase%s %s / %s\n' "$C_B" "$C_RST" "$WP_DB_NAME" "$WP_DB_USER" if $GENERATED_DB_PASS; then printf ' password %s%s%s %s(generated)%s\n' "$C_B$C_YEL" "$WP_DB_PASS" "$C_RST" "$C_DIM" "$C_RST" fi echo if $EXISTING_WP_DATA; then printf ' %sThis database already had tables%s - your existing site and its content\n' "$C_YEL" "$C_RST" printf ' were left exactly as they were.\n\n' fi fi if ((${#CHANGES[@]})); then printf ' %sChanged (%d)%s\n' "$C_B" "${#CHANGES[@]}" "$C_RST" printf ' · %s\n' "${CHANGES[@]}" echo fi if ((${#BACKUPS[@]})); then printf ' %sMoved aside, never deleted (%d)%s\n' "$C_B" "${#BACKUPS[@]}" "$C_RST" printf ' · %s\n' "${BACKUPS[@]}" echo fi if ((${#WARNINGS[@]})); then printf ' %sWorth reading (%d)%s\n' "$C_B$C_YEL" "${#WARNINGS[@]}" "$C_RST" printf ' ! %s\n' "${WARNINGS[@]}" echo fi [[ -n "$REPORT_FILE" ]] && printf ' %sReport%s %s %s(0600, includes passwords)%s\n' "$C_B" "$C_RST" "$REPORT_FILE" "$C_DIM" "$C_RST" (( PRUNED_N )) && printf ' %sPruned%s %d old log/report file(s), keeping the newest %s\n' "$C_B" "$C_RST" "$PRUNED_N" "$KEEP_RUNS" printf ' %sLog%s %s\n' "$C_B" "$C_RST" "$LOG_FILE" echo if (( FAIL_N > 0 )); then printf ' %sSomething is not right. The failed checks above name it exactly;%s\n' "$C_YEL" "$C_RST" printf ' %sthe log has the detail. Re-running this script is safe.%s\n\n' "$C_YEL" "$C_RST" fi } # ============================================================================ # MAIN # ============================================================================ main() { preload_profile "$@" parse_args "$@" init_tty clear_screen banner check_version survey choose_action if $AUDIT_MODE; then do_audit exit 0 fi preflight if $UNINSTALL_MODE; then do_uninstall exit 0 fi show_plan local m rc for m in "${MODULE_ORDER[@]}"; do (( MOD[$m] )) || continue if $STEP_CONFIRM; then rc=0; confirm_step "$m" || rc=$? if (( rc == 1 )); then MOD[$m]=0 # so verify() does not test what we skipped step "$m"; skip "Skipped at your request" continue elif (( rc == 2 )); then say ""; say " ${C_YEL}Stopped here at your request.${C_RST} Everything already done is kept." break fi fi "mod_${m}" done verify [[ -n "$SAVE_PROFILE" ]] && save_profile "$SAVE_PROFILE" write_report prune_old_runs summary (( FAIL_N > 0 )) && exit 1 exit 0 } # `LAMP_INSTALL_LIB=1 source install.sh` defines every function and runs # nothing. test/selftest.sh uses that to exercise the functions directly - # which is how the SIGPIPE and pipefail crashes are now caught before release. if [[ -z "${LAMP_INSTALL_LIB:-}" ]]; then main "$@" fi