From 35b35add368e1d643877a6221155e3203954b643 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:24:51 +0000 Subject: [PATCH 1/2] fix: make bootstrap/revert reliable end to end (verified on Ubuntu 24.04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes of "it doesn't work": - generate_password used `tr … | head -c N` under pipefail (SIGPIPE, exit 141) and killed LEMP right after MySQL installed - MySQL hardening ran with `2>/dev/null || true`, then wrote /root/.my.cnf with a password that was never applied; root also kept auth_socket - no `set -E`, so the ERR trap never fired inside functions - every module prompted on stdin and ignored flags (hung non-interactive) - redis maxmemory sed used `\|` with `|` delimiter and never matched - php_tune overwrote opcache.ini, dropping its zend_extension line - nginx default site / RHEL stock server{} shadowed the PulseDeploy site - fail2ban jails referenced missing logs/backends and crashed the service - RHEL base step installed+enabled firewalld before ports were opened - docker cleanup cron ran `prune --volumes` (data loss); Compose v1 via pip - revert.sh called dnf on Debian and apt on RHEL, aborting mid-run Changes: - scripts/lib/common.sh + web.sh: shared logging, validation, retry, apt lock handling, package/service helpers, idempotent conf_set, PHP layout, nginx/apache activation with `-t` validation and rollback, HTTP->PHP health check - new services/mysql.sh: distro packages only, real password auth, checks before saving credentials, DB/user creation with validated identifiers - bootstrap.sh: strict flag parsing (--flag=value, unknown flags error), input validation before any change, PID-file lock, preflight checks, guarded --disable-root-ssh, hostname/timezone handling, tee logging - firewall never resets rules and always allows every SSH port first - swap/redis/docker/certbot/php_tune rewritten to be idempotent and to verify that the service actually works before reporting success - revert.sh rewritten OS-aware; keeps data unless --purge-data - config templates: client_max_body_size, try_files guard, dotfile blocks - tests/run.sh (75 unit tests) and CI jobs for lint/tests/structure Tested on Ubuntu 24.04: LEMP, LAMP, Node (NodeSource stubbed), Redis, swap, PHP tuning, fail2ban, SSH hardening, hostname/timezone, wizard via PTY, re-run idempotency and revert. Debian, RHEL-family and Amazon Linux paths follow the same design but are not yet run on real machines. Claude-Session: https://claude.ai/code/session_01WF2SiSxEVxKmDEdmffxPbT --- .github/workflows/ci.yml | 22 +- CONTRIBUTING.md | 16 +- README.md | 42 ++- bootstrap.sh | 685 +++++++++++++++++++++++----------- config/apache/vhost.conf | 4 + config/nginx/default.conf | 31 +- revert.sh | 458 +++++++++++++++-------- scripts/lib/common.sh | 289 ++++++++++++++ scripts/lib/web.sh | 278 ++++++++++++++ scripts/os/amazon_linux.sh | 75 ++-- scripts/os/centos_rocky.sh | 83 ++-- scripts/os/debian.sh | 61 ++- scripts/os/ubuntu.sh | 29 +- scripts/services/certbot.sh | 93 +++-- scripts/services/docker.sh | 177 +++++---- scripts/services/firewall.sh | 214 ++++++++--- scripts/services/mysql.sh | 176 +++++++++ scripts/services/php_tune.sh | 177 +++++---- scripts/services/redis.sh | 120 +++--- scripts/services/swap.sh | 82 ++-- scripts/stacks/lamp.sh | 128 +++---- scripts/stacks/lemp.sh | 88 ++--- scripts/stacks/node.sh | 112 ++++-- tests/fixtures/nginx-el8.conf | 42 +++ tests/run.sh | 165 ++++++++ 25 files changed, 2657 insertions(+), 990 deletions(-) mode change 100644 => 100755 bootstrap.sh mode change 100644 => 100755 revert.sh create mode 100755 scripts/lib/common.sh create mode 100755 scripts/lib/web.sh mode change 100644 => 100755 scripts/os/amazon_linux.sh mode change 100644 => 100755 scripts/os/centos_rocky.sh mode change 100644 => 100755 scripts/os/debian.sh mode change 100644 => 100755 scripts/os/ubuntu.sh mode change 100644 => 100755 scripts/services/certbot.sh mode change 100644 => 100755 scripts/services/docker.sh mode change 100644 => 100755 scripts/services/firewall.sh create mode 100755 scripts/services/mysql.sh mode change 100644 => 100755 scripts/services/php_tune.sh mode change 100644 => 100755 scripts/services/redis.sh mode change 100644 => 100755 scripts/services/swap.sh mode change 100644 => 100755 scripts/stacks/lamp.sh mode change 100644 => 100755 scripts/stacks/lemp.sh mode change 100644 => 100755 scripts/stacks/node.sh create mode 100644 tests/fixtures/nginx-el8.conf create mode 100755 tests/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b2060f..45fdd43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,12 +19,21 @@ jobs: - name: Install ShellCheck run: sudo apt-get install -y shellcheck - - name: Lint bootstrap.sh - run: shellcheck -x bootstrap.sh + - name: Lint entry points + run: shellcheck -x bootstrap.sh revert.sh - - name: Lint all service scripts + - name: Lint library, module and test scripts run: | - find scripts/ -name "*.sh" | xargs shellcheck -x + find scripts/ tests/ -name "*.sh" | xargs shellcheck -x + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run unit tests + run: bash tests/run.sh validate-configs: name: Validate Config Templates @@ -71,6 +80,11 @@ jobs: "scripts/os/debian.sh" "scripts/os/amazon_linux.sh" "scripts/os/centos_rocky.sh" + "revert.sh" + "tests/run.sh" + "scripts/lib/common.sh" + "scripts/lib/web.sh" + "scripts/services/mysql.sh" "scripts/stacks/lemp.sh" "scripts/stacks/lamp.sh" "scripts/stacks/node.sh" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ef5952..45e258f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,16 +51,22 @@ docs/what-you-are-documenting 1. **Run ShellCheck** on any `.sh` files you modified: ```bash - shellcheck -x bootstrap.sh - find scripts/ -name "*.sh" | xargs shellcheck -x + shellcheck -x bootstrap.sh revert.sh + find scripts/ tests/ -name "*.sh" | xargs shellcheck -x ``` 2. **Follow the module pattern** — every service or OS module should: - Be independently sourceable (no hard dependencies on bootstrap.sh state beyond `$PKG_MANAGER` and `$OS_ID`) - Use the `os_pkg_install`, `os_svc_enable`, `os_firewall_cmd` abstractions - - Use the `log()`, `warn()`, `error()`, `info()`, `section()` helpers from bootstrap.sh + - Use the `log()`, `warn()`, `error()`, `info()`, `section()` helpers from `scripts/lib/common.sh` (source it at the top of every module) + - Read settings from plain variables with defaults (`${PHP_VER:-8.2}`), and **never prompt** — prompting belongs in `bootstrap.sh`'s wizard so `--non-interactive` never hangs + - Verify that what you installed really works before logging success + - Be idempotent: a second run must not duplicate config, overwrite user files, or rotate credentials + - Edit config with `conf_set` / drop-in files and `backup_file`, never by overwriting package-owned files -3. **Test on a real VM** if possible — at minimum Ubuntu 22.04 or Debian 12 +3. **Run the unit tests**: `bash tests/run.sh` + +4. **Test on a real VM** if possible — at minimum Ubuntu 22.04 or Debian 12. Run your change twice to prove it is idempotent, then `revert.sh` to prove it undoes cleanly. ### PR checklist @@ -74,10 +80,12 @@ docs/what-you-are-documenting ## 📁 Project Structure Recap ``` +scripts/lib/ # Shared helpers: logging, validation, packages, services, config editing scripts/os/ # One file per distro — handles pkg manager, repos, firewall abstraction scripts/stacks/ # Stack installers (lemp, lamp, node) scripts/services/ # Individual service installers (redis, docker, certbot, etc.) config/ # Config file templates (nginx, apache) +tests/run.sh # Unit tests (no root/network needed) ``` --- diff --git a/README.md b/README.md index 83f3c4a..949169f 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,14 @@ Built by a developer who personally recovered from 502 storms, 8GB database bloa | Category | What's included | |----------------|----------------| | **Stacks** | LEMP (Nginx + PHP-FPM + MySQL), LAMP (Apache + PHP + MySQL), Node.js + PM2 + Nginx reverse proxy | -| **PHP** | Version selector (8.1 / 8.2 / 8.3), OPcache JIT, PHP-FPM pool auto-tuning based on RAM | +| **PHP** | Version selector (8.1 – 8.4; third-party repo added only if your distro lacks it), OPcache JIT, PHP-FPM pool auto-tuning based on RAM | | **Security** | UFW / firewalld, fail2ban (SSH + Nginx + Apache rules), secure file blocking in web configs | | **SSL** | Certbot (Let's Encrypt) with auto-renewal cron | | **Caching** | Redis with Unix socket or TCP, maxmemory auto-calculated, allkeys-lru policy | | **Containers** | Docker Engine + Docker Compose v2, log rotation, weekly prune cron | | **Swap** | Auto-sized swap file based on detected RAM, swappiness=10 tuning | -| **Logging** | Full install log saved to `/var/log/pulsedeploy.log` | +| **Reliability**| Idempotent (safe to re-run), every step verified (health checks, not just exit codes), strict flag/input validation, config changes validated and rolled back on failure | +| **Logging** | Full install log saved to `/var/log/server-bootstrap.log` | --- @@ -57,7 +58,7 @@ Built by a developer who personally recovered from 502 storms, 8GB database bloa |--------------------------------------|-----------------|-------| | Ubuntu 20.04 / 22.04 / 24.04 | apt | Full support | | Debian 11 (Bullseye) / 12 (Bookworm)| apt | Full support | -| Amazon Linux 2 / 2023 | dnf | AWS-aware: Security Group hints, IMDSv2 detection | +| Amazon Linux 2023 (AL2 best effort, EOL) | dnf / yum | AWS-aware: Security Group hints, IMDSv2 detection | | CentOS 8 / Rocky Linux 8 & 9 | dnf | SELinux awareness, Remi repo for PHP | --- @@ -83,6 +84,10 @@ The interactive wizard walks you through: 3. **Services toggle** — Redis · Docker · Firewall · SSL · Swap · PHP tuning 4. **Summary + confirmation** — review everything before a single package is installed +Everything the wizard asks can also be given as a flag (`--stack`, `--php`, `--node`, `--services`, `--domain`, `--email`, `--db-name`, `--db-user`, `--swap-size`, `--redis-conn`, `--open-ports`, `--ssh-port`, `--hostname`, `--timezone`, `--disable-root-ssh`, `--non-interactive`) or `PULSE_*` environment variable. See `bash bootstrap.sh --help`. + +To undo an installation: `sudo bash revert.sh --list`, then `sudo bash revert.sh --yes` (dry-run without `--yes`; databases and Docker data are kept unless `--purge-data`). + --- ## 📁 Project Structure @@ -90,7 +95,12 @@ The interactive wizard walks you through: ``` PulseDeploy/ ├── bootstrap.sh # Main entry point & interactive wizard +├── revert.sh # Roll back what bootstrap.sh installed (dry-run by default) +├── tests/run.sh # Unit tests for helpers + CLI validation ├── scripts/ +│ ├── lib/ # Shared helpers (logging, validation, pkg/service/config) +│ │ ├── common.sh +│ │ └── web.sh # nginx/apache/PHP layout, health checks │ ├── os/ # OS-specific package management │ │ ├── ubuntu.sh │ │ ├── debian.sh @@ -106,7 +116,8 @@ PulseDeploy/ │ ├── docker.sh # Docker Engine + Compose v2 │ ├── certbot.sh # Let's Encrypt SSL + auto-renewal │ ├── swap.sh # Auto-sized swap file -│ └── php_tune.sh # PHP-FPM + OPcache + php.ini tuning +│ ├── mysql.sh # MySQL/MariaDB install + hardening + DB/user creation +│ └── php_tune.sh # PHP-FPM + OPcache + php.ini tuning (drop-in files) ├── config/ │ ├── nginx/default.conf # Production Nginx template │ └── apache/vhost.conf # Production Apache vhost template @@ -122,7 +133,7 @@ When running on Amazon Linux 2 / 2023, PulseDeploy automatically: - Detects the EC2 instance via **IMDSv2** - Warns you to open **ports 80/443/22** in your **Security Group** (OS-level firewall rules alone are not enough on AWS) - Uses `firewalld` instead of `ufw` -- Falls back to MariaDB if the MySQL repo is unavailable +- Uses the distribution's MariaDB/MySQL packages (no third-party repo required) **Recommended EC2 setup before running:** @@ -154,7 +165,7 @@ Every stack is deployed with hardened defaults out of the box: ## 🧩 Running Individual Modules -Every module is independently sourceable — no need to run the full wizard: +Every module is independently sourceable — no need to run the full wizard. Settings are plain variables (`PHP_VER`, `SWAP_SIZE`, `REDIS_CONN`, `DOMAIN`, …); run as root: ```bash # Install only Redis on an existing server @@ -177,8 +188,8 @@ install_docker ## 📋 Post-Install Checklist -- [ ] Delete `/var/www/html/info.php` after verifying PHP works -- [ ] Run `certbot --nginx -d yourdomain.com` to issue SSL certificate +- [ ] SSL: pass `--domain` + `--email` with the `certbot` service, or run `certbot --nginx -d yourdomain.com` +- [ ] Upload your site to `/var/www/html` (a placeholder page is there until you do) - [ ] Point your domain DNS A record to your server IP - [ ] Review fail2ban: `fail2ban-client status sshd` - [ ] Check firewall rules: `ufw status` or `firewall-cmd --list-all` @@ -187,9 +198,22 @@ install_docker --- +## 🛡️ Reliability Notes + +- **Verified, not assumed.** LEMP/LAMP run an end-to-end HTTP → PHP check (using a throw-away file, no `phpinfo()` is left exposed); Redis, Docker, MySQL and swap are each confirmed working before being reported as done. +- **Safe defaults.** MySQL root gets a random password (saved to `/root/.my.cnf`, `chmod 600`) and *requires* it; the firewall always keeps every SSH port open and never resets your existing rules; `--disable-root-ssh` refuses unless another sudo user with an SSH key exists; Docker's weekly cleanup never touches volumes. +- **Fails loudly.** Any failure prints the function, file:line and command, then exits non-zero. Unknown flags and invalid values are rejected before anything is changed. +- **Non-interactive by design.** Flags/env vars cover every choice the wizard asks; with no terminal (cloud-init) it switches to non-interactive automatically. +- **Requirements.** A real VM/VPS with systemd (not a Docker container), 2 GB free disk, root. Port 80 must not be held by another web server. + +### Testing status + +Unit tests: `bash tests/run.sh` (no root, no network). The Ubuntu path (LEMP, LAMP, Node, Redis, swap, PHP tuning, fail2ban, SSH hardening, revert) has been exercised end to end on Ubuntu 24.04. **Debian, Rocky/Alma/RHEL and Amazon Linux code paths follow the same design but have not been run on real machines yet** — please report issues using the bug template. + +--- + ## 🗺️ Roadmap -- [ ] `--non-interactive` flag mode for cloud-init / user-data bootstrapping - [ ] WordPress fast-deploy module (on top of LEMP) - [ ] `healthcheck.sh` — audit an existing server's config and services - [ ] PostgreSQL stack option diff --git a/bootstrap.sh b/bootstrap.sh old mode 100644 new mode 100755 index 34a572a..629b25b --- a/bootstrap.sh +++ b/bootstrap.sh @@ -5,50 +5,50 @@ # Repo : https://github.com/Xbot-me/PulseDeploy # License : MIT # ============================================================================= -set -euo pipefail +# -E makes the ERR trap fire inside functions too — without it failures in any +# function exit silently, which is exactly how earlier versions "just stopped". +set -Eeuo pipefail + +if ((BASH_VERSINFO[0] < 4)); then + echo "PulseDeploy needs bash 4 or newer (found ${BASH_VERSION})." >&2 + exit 1 +fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOG_FILE="/var/log/server-bootstrap.log" -# shellcheck disable=SC2034 # Used in print_banner below -BOOTSTRAP_VERSION="1.1.1" - -# ── Colours ─────────────────────────────────────────────────────────────────── -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' - -# ── Logging ─────────────────────────────────────────────────────────────────── -log() { echo -e "${GREEN}[✔]${RESET} $*" | tee -a "$LOG_FILE"; } -warn() { echo -e "${YELLOW}[⚠]${RESET} $*" | tee -a "$LOG_FILE"; } -error() { echo -e "${RED}[✘]${RESET} $*" | tee -a "$LOG_FILE"; exit 1; } -info() { echo -e "${CYAN}[i]${RESET} $*" | tee -a "$LOG_FILE"; } -section() { echo -e "\n${BOLD}${BLUE}━━━ $* ━━━${RESET}\n" | tee -a "$LOG_FILE"; } - -# ── Global error trap ────────────────────────────────────────────────────────── -# Fires on ANY command failure that would trigger `set -e` (i.e. exactly the -# ones that used to kill the script silently). Prints what failed, where, and -# in which function, both to the terminal and to the log file, before exiting. +LOG_FILE="${PULSE_LOG_FILE:-/var/log/server-bootstrap.log}" +BOOTSTRAP_VERSION="1.2.0" + +if [[ ! -f "$SCRIPT_DIR/scripts/lib/common.sh" ]]; then + echo "PulseDeploy must be run from a full checkout (scripts/ directory not found next to bootstrap.sh)." >&2 + echo " git clone https://github.com/Xbot-me/PulseDeploy.git && cd PulseDeploy && sudo bash bootstrap.sh" >&2 + exit 1 +fi +# shellcheck source=scripts/lib/common.sh +source "$SCRIPT_DIR/scripts/lib/common.sh" + +# ── Global error trap ───────────────────────────────────────────────────────── +# Prints what failed, where, and in which function, then exits with that code. handle_error() { - local exit_code="$1" - local line_no="$2" - local last_cmd="$3" - local func_name="${FUNCNAME[2]:-main}" - - echo -e "\n${RED}${BOLD}✘ PulseDeploy failed${RESET}" | tee -a "$LOG_FILE" - echo -e "${RED} Function :${RESET} $func_name" | tee -a "$LOG_FILE" - echo -e "${RED} Line :${RESET} $line_no" | tee -a "$LOG_FILE" - echo -e "${RED} Command :${RESET} $last_cmd" | tee -a "$LOG_FILE" - echo -e "${RED} Exit code :${RESET} $exit_code" | tee -a "$LOG_FILE" - echo -e "${RED} Log file :${RESET} $LOG_FILE" | tee -a "$LOG_FILE" - echo -e "${YELLOW} This is where the script actually stopped — nothing after this line ran.${RESET}\n" - + local exit_code="$1" file="$2" line_no="$3" last_cmd="$4" + local func_name="${FUNCNAME[1]:-main}" + trap - ERR + { + echo -e "\n${RED}${BOLD}✘ PulseDeploy failed${RESET}" + echo -e "${RED} Function :${RESET} $func_name" + echo -e "${RED} Location :${RESET} ${file}:${line_no}" + echo -e "${RED} Command :${RESET} $last_cmd" + echo -e "${RED} Exit code :${RESET} $exit_code" + echo -e "${RED} Log file :${RESET} $LOG_FILE" + echo -e "${YELLOW} Nothing after this point ran. Fix the cause and re-run — PulseDeploy is safe to run again.${RESET}\n" + } >&2 exit "$exit_code" } -trap 'handle_error $? $LINENO "$BASH_COMMAND"' ERR +trap 'handle_error $? "${BASH_SOURCE[0]##*/}" $LINENO "$BASH_COMMAND"' ERR # ── Defaults (overridden by flags / env vars) ───────────────────────────────── STACK="${PULSE_STACK:-}" PHP_VER="${PULSE_PHP:-8.2}" -NODE_VER="${PULSE_NODE:-20}" +NODE_VER="${PULSE_NODE:-22}" SERVICES_RAW="${PULSE_SERVICES:-}" SWAP_SIZE="${PULSE_SWAP_SIZE:-}" APP_PORT="${PULSE_APP_PORT:-3000}" @@ -61,8 +61,18 @@ TIMEZONE="${PULSE_TIMEZONE:-}" HOSTNAME_VAL="${PULSE_HOSTNAME:-}" DISABLE_ROOT_SSH="${PULSE_DISABLE_ROOT_SSH:-0}" NON_INTERACTIVE="${PULSE_NON_INTERACTIVE:-0}" - -declare -gA SERVICES=( +OPEN_PORTS="${PULSE_OPEN_PORTS:-}" +REDIS_CONN="${PULSE_REDIS_CONN:-}" + +# Which values were given explicitly (so the wizard does not ask again) +PHP_SET=0; [[ -n "${PULSE_PHP:-}" ]] && PHP_SET=1 +NODE_SET=0; [[ -n "${PULSE_NODE:-}" ]] && NODE_SET=1 +PORT_SET=0; [[ -n "${PULSE_APP_PORT:-}" ]] && PORT_SET=1 +SWAP_SET=0; [[ -n "${PULSE_SWAP_SIZE:-}" ]] && SWAP_SET=1 +REDIS_SET=0; [[ -n "${PULSE_REDIS_CONN:-}" ]] && REDIS_SET=1 +PORTS_SET=0; [[ -n "${PULSE_OPEN_PORTS:-}" ]] && PORTS_SET=1 + +declare -A SERVICES=( [redis]=0 [docker]=0 [firewall]=0 [certbot]=0 [swap]=0 [phptune]=0 ) @@ -86,6 +96,7 @@ print_banner() { VPS & AWS Server Automation v${BOOTSTRAP_VERSION} · by @Xbot-me EOF echo -e "${RESET}" + return 0 } # ── Help ────────────────────────────────────────────────────────────────────── @@ -97,14 +108,15 @@ ${BOLD}USAGE${RESET} ${BOLD}DESCRIPTION${RESET} PulseDeploy is a modular Bash toolkit for spinning up production-ready Linux servers on VPS providers and AWS EC2. Run with no flags for the interactive - wizard, or pass flags for fully automated / CI deployments. + wizard, or pass flags for fully automated / CI deployments. Options may be + written --flag value or --flag=value. Unknown flags are an error. ${BOLD}STACK OPTIONS${RESET} -s, --stack Stack to install: lemp | lamp | node | none Env: PULSE_STACK - -P, --php PHP version: 8.1 | 8.2 | 8.3 (default: 8.2) + -P, --php PHP version: 8.1 | 8.2 | 8.3 | 8.4 (default: 8.2) Env: PULSE_PHP - -N, --node Node.js LTS version: 18 | 20 | 22 (default: 20) + -N, --node Node.js version: 18 | 20 | 22 | 24 (default: 22) Env: PULSE_NODE ${BOLD}SERVICE FLAGS${RESET} @@ -112,9 +124,13 @@ ${BOLD}SERVICE FLAGS${RESET} redis, docker, firewall, certbot, swap, phptune Env: PULSE_SERVICES Example: --services redis,firewall,swap,certbot + --redis-conn Redis access: socket | tcp (default: socket) + Env: PULSE_REDIS_CONN + --open-ports Extra TCP ports for the firewall, e.g. 8080,9000 + Env: PULSE_OPEN_PORTS ${BOLD}SERVER CONFIGURATION${RESET} - --domain Primary domain name (used by Certbot & vhost) + --domain Primary domain (nginx/apache server_name + Certbot) Env: PULSE_DOMAIN --email Email for Certbot SSL notifications Env: PULSE_EMAIL @@ -122,28 +138,32 @@ ${BOLD}SERVER CONFIGURATION${RESET} Env: PULSE_HOSTNAME --timezone Set system timezone (e.g. Asia/Dhaka, UTC) Env: PULSE_TIMEZONE - --ssh-port SSH port to allow through firewall (default: 22) + --ssh-port SSH port the firewall must keep open (default: 22; + the port sshd actually listens on is always kept) Env: PULSE_SSH_PORT - --disable-root-ssh Disable root SSH login (PermitRootLogin no) + --disable-root-ssh Disable root SSH login (refused unless another + sudo user with an SSH key exists) Env: PULSE_DISABLE_ROOT_SSH=1 --app-port Node.js app port for Nginx proxy (default: 3000) Env: PULSE_APP_PORT ${BOLD}DATABASE${RESET} - --db-name Create a database with this name after MySQL install + --db-name Create a database with this name (LEMP/LAMP) Env: PULSE_DB_NAME - --db-user Create a DB user with this name (random password) + --db-user Create a DB user for it (random password, saved to + /root/.my.cnf). Requires --db-name. Env: PULSE_DB_USER ${BOLD}SWAP${RESET} - --swap-size Swap file size, e.g. 2G, 4G (default: auto) + --swap-size Swap file size, e.g. 512M, 2G (default: auto) Env: PULSE_SWAP_SIZE ${BOLD}BEHAVIOUR${RESET} -y, --non-interactive Skip all prompts; use flag values or defaults + (enabled automatically when stdin is not a terminal) Env: PULSE_NON_INTERACTIVE=1 -h, --help Show this help message and exit - -v, --version Show version and exit + -v, --version Show version and exit ${BOLD}EXAMPLES${RESET} # Interactive wizard (default) @@ -159,7 +179,7 @@ ${BOLD}EXAMPLES${RESET} --disable-root-ssh --non-interactive # Node.js server, non-interactive - sudo bash bootstrap.sh -s node -N 20 -S firewall,swap,docker -y + sudo bash bootstrap.sh -s node -N 22 -S firewall,swap,docker -y # Via environment variables (AWS EC2 user-data / cloud-init) export PULSE_STACK=lemp @@ -170,166 +190,332 @@ ${BOLD}EXAMPLES${RESET} export PULSE_NON_INTERACTIVE=1 sudo -E bash bootstrap.sh -${BOLD}MAN PAGE${RESET} - After installing the man page (docs/install-man.sh): - man pulsedeploy - ${BOLD}DOCS & SOURCE${RESET} https://github.com/Xbot-me/PulseDeploy https://github.com/Xbot-me/PulseDeploy/wiki EOF + return 0 } # ── Argument parsing ────────────────────────────────────────────────────────── +need_value() { + [[ $# -ge 2 && -n "$2" ]] || error "Option $1 requires a value — run --help for usage" + return 0 +} + parse_args() { + # Accept --flag=value as well as --flag value + local -a args=() + local a + for a in "$@"; do + if [[ "$a" == --*=* ]]; then args+=("${a%%=*}" "${a#*=}"); else args+=("$a"); fi + done + set -- "${args[@]+"${args[@]}"}" + while [[ $# -gt 0 ]]; do case "$1" in - -s|--stack) STACK="$2"; shift 2 ;; - -P|--php) PHP_VER="$2"; shift 2 ;; - -N|--node) NODE_VER="$2"; shift 2 ;; - -S|--services) SERVICES_RAW="$2"; shift 2 ;; - --domain) DOMAIN="$2"; shift 2 ;; - --email) EMAIL="$2"; shift 2 ;; - --hostname) HOSTNAME_VAL="$2"; shift 2 ;; - --timezone) TIMEZONE="$2"; shift 2 ;; - --ssh-port) SSH_PORT="$2"; shift 2 ;; - --disable-root-ssh) DISABLE_ROOT_SSH=1; shift ;; - --app-port) APP_PORT="$2"; shift 2 ;; - --db-name) DB_NAME="$2"; shift 2 ;; - --db-user) DB_USER="$2"; shift 2 ;; - --swap-size) SWAP_SIZE="$2"; shift 2 ;; - -y|--non-interactive) NON_INTERACTIVE=1; shift ;; - -h|--help) print_help; exit 0 ;; - -v|--version) echo "PulseDeploy v${BOOTSTRAP_VERSION}"; exit 0 ;; - *) warn "Unknown flag: $1 — run --help for usage"; shift ;; + -s|--stack) need_value "$@"; STACK="$2"; shift 2 ;; + -P|--php) need_value "$@"; PHP_VER="$2"; PHP_SET=1; shift 2 ;; + -N|--node) need_value "$@"; NODE_VER="$2"; NODE_SET=1; shift 2 ;; + -S|--services) need_value "$@"; SERVICES_RAW="$2"; shift 2 ;; + --domain) need_value "$@"; DOMAIN="$2"; shift 2 ;; + --email) need_value "$@"; EMAIL="$2"; shift 2 ;; + --hostname) need_value "$@"; HOSTNAME_VAL="$2"; shift 2 ;; + --timezone) need_value "$@"; TIMEZONE="$2"; shift 2 ;; + --ssh-port) need_value "$@"; SSH_PORT="$2"; shift 2 ;; + --disable-root-ssh) DISABLE_ROOT_SSH=1; shift ;; + --app-port) need_value "$@"; APP_PORT="$2"; PORT_SET=1; shift 2 ;; + --db-name) need_value "$@"; DB_NAME="$2"; shift 2 ;; + --db-user) need_value "$@"; DB_USER="$2"; shift 2 ;; + --swap-size) need_value "$@"; SWAP_SIZE="$2"; SWAP_SET=1; shift 2 ;; + --open-ports) need_value "$@"; OPEN_PORTS="$2"; PORTS_SET=1; shift 2 ;; + --redis-conn) need_value "$@"; REDIS_CONN="$2"; REDIS_SET=1; shift 2 ;; + -y|--non-interactive) NON_INTERACTIVE=1; shift ;; + -h|--help) print_help; exit 0 ;; + -v|--version) echo "PulseDeploy v${BOOTSTRAP_VERSION}"; exit 0 ;; + *) error "Unknown option: $1 — run --help for usage" ;; esac done + STACK="${STACK,,}" + SERVICES_RAW="${SERVICES_RAW,,}" + REDIS_CONN="${REDIS_CONN,,}" + SWAP_SIZE="${SWAP_SIZE^^}" + case "${DISABLE_ROOT_SSH,,}" in 1|true|yes) DISABLE_ROOT_SSH=1 ;; *) DISABLE_ROOT_SSH=0 ;; esac + case "${NON_INTERACTIVE,,}" in 1|true|yes) NON_INTERACTIVE=1 ;; *) NON_INTERACTIVE=0 ;; esac + return 0 +} + +# ── Validation ──────────────────────────────────────────────────────────────── +valid_php_ver() { [[ "$1" =~ ^8\.[1-4]$ ]]; } +valid_node_ver() { [[ "$1" =~ ^(18|20|22|24)$ ]]; } +valid_redis_conn() { [[ "$1" == "socket" || "$1" == "tcp" ]]; } +valid_port_list() { + local p + local -a items + [[ -z "$1" ]] && return 0 + IFS=',' read -ra items <<<"$1" + for p in "${items[@]}"; do + p="${p// /}" + valid_port "$p" || return 1 + done + return 0 +} + +# Fail fast on bad flags/env before touching the system. +validate_inputs() { + case "$STACK" in lemp|lamp|node|none|"") ;; *) error "Invalid --stack '$STACK' (lemp | lamp | node | none)" ;; esac + valid_php_ver "$PHP_VER" || error "Invalid --php '$PHP_VER' (8.1 | 8.2 | 8.3 | 8.4)" + valid_node_ver "$NODE_VER" || error "Invalid --node '$NODE_VER' (18 | 20 | 22 | 24)" + valid_port "$APP_PORT" || error "Invalid --app-port '$APP_PORT' (1-65535)" + valid_port "$SSH_PORT" || error "Invalid --ssh-port '$SSH_PORT' (1-65535)" + valid_port_list "$OPEN_PORTS" || error "Invalid --open-ports '$OPEN_PORTS' (comma-separated ports, 1-65535)" + [[ -z "$DOMAIN" ]] || valid_domain "$DOMAIN" || error "Invalid --domain '$DOMAIN'" + [[ -z "$EMAIL" ]] || valid_email "$EMAIL" || error "Invalid --email '$EMAIL'" + [[ -z "$HOSTNAME_VAL" ]] || valid_hostname "$HOSTNAME_VAL" || error "Invalid --hostname '$HOSTNAME_VAL'" + [[ -z "$TIMEZONE" ]] || valid_timezone "$TIMEZONE" || error "Unknown --timezone '$TIMEZONE' (see: timedatectl list-timezones)" + [[ -z "$SWAP_SIZE" ]] || valid_swap_size "$SWAP_SIZE" || error "Invalid --swap-size '$SWAP_SIZE' (examples: 512M, 2G)" + [[ -z "$DB_NAME" ]] || valid_db_name "$DB_NAME" || error "Invalid --db-name '$DB_NAME' (letters, digits, underscore; max 64)" + [[ -z "$DB_USER" ]] || valid_db_user "$DB_USER" || error "Invalid --db-user '$DB_USER' (letters, digits, underscore; max 32)" + [[ -z "$REDIS_CONN" ]] || valid_redis_conn "$REDIS_CONN" || error "Invalid --redis-conn '$REDIS_CONN' (socket | tcp)" + [[ -z "$DB_USER" || -n "$DB_NAME" ]] || error "--db-user requires --db-name" return 0 } # ── Parse services list ─────────────────────────────────────────────────────── parse_services() { [[ -z "$SERVICES_RAW" ]] && return 0 - IFS=',' read -ra SVC_LIST <<< "$SERVICES_RAW" - for svc in "${SVC_LIST[@]}"; do - svc="${svc// /}" # trim spaces + local svc + local -a svc_list + IFS=',' read -ra svc_list <<<"$SERVICES_RAW" + for svc in "${svc_list[@]}"; do + svc="${svc// /}" + [[ -z "$svc" ]] && continue case "$svc" in redis|docker|firewall|certbot|swap|phptune) SERVICES[$svc]=1 ;; - *) warn "Unknown service: '$svc'. Valid: redis,docker,firewall,certbot,swap,phptune" ;; + *) error "Unknown service '$svc'. Valid: redis,docker,firewall,certbot,swap,phptune" ;; esac done return 0 } -# ── Apply server config flags ───────────────────────────────────────────────── -apply_server_config() { - # Hostname - if [[ -n "$HOSTNAME_VAL" ]]; then - hostnamectl set-hostname "$HOSTNAME_VAL" 2>/dev/null || hostname "$HOSTNAME_VAL" - log "Hostname set to: $HOSTNAME_VAL" +# ── Safe server configuration ───────────────────────────────────────────────── +set_hostname() { + if ! { command -v hostnamectl &>/dev/null && hostnamectl set-hostname "$HOSTNAME_VAL" 2>/dev/null; }; then + hostname "$HOSTNAME_VAL" + echo "$HOSTNAME_VAL" >/etc/hostname fi - - # Timezone - if [[ -n "$TIMEZONE" ]]; then - timedatectl set-timezone "$TIMEZONE" 2>/dev/null || \ - ln -sf "/usr/share/zoneinfo/$TIMEZONE" /etc/localtime - log "Timezone set to: $TIMEZONE" + # Keep the name resolvable locally so sudo does not complain + local escaped="${HOSTNAME_VAL//./\\.}" + if ! grep -Eq "^127\.0\.1\.1[[:space:]]+${escaped}([[:space:]]|\$)" /etc/hosts; then + if grep -q '^127\.0\.1\.1' /etc/hosts; then + sed -i "s|^127\.0\.1\.1.*|127.0.1.1 ${HOSTNAME_VAL}|" /etc/hosts + else + echo "127.0.1.1 ${HOSTNAME_VAL}" >>/etc/hosts + fi fi + log "Hostname set to: $HOSTNAME_VAL" + return 0 +} - # Disable root SSH - if [[ "$DISABLE_ROOT_SSH" -eq 1 ]]; then - sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config - systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true - log "Root SSH login disabled" - warn "Ensure you have a sudo user before your next SSH session!" +set_timezone() { + if ! { command -v timedatectl &>/dev/null && timedatectl set-timezone "$TIMEZONE" 2>/dev/null; }; then + ln -sf "/usr/share/zoneinfo/$TIMEZONE" /etc/localtime + [[ -f /etc/timezone ]] && echo "$TIMEZONE" >/etc/timezone fi + log "Timezone set to: $TIMEZONE" return 0 } -# ── Create DB + user after MySQL install ────────────────────────────────────── -create_database() { - [[ -z "$DB_NAME" && -z "$DB_USER" ]] && return 0 - - local DB_PASS - DB_PASS=$(tr -dc 'A-Za-z0-9!@#$%' = 1000 && uid < 60000)) || continue + [[ "$shell" =~ (nologin|false)$ ]] && continue + [[ " $(id -nG "$u" 2>/dev/null) " =~ \ (sudo|wheel|admin)\ ]] || continue + [[ -s "$home/.ssh/authorized_keys" ]] || continue + found="$u" + break + done /dev/null - log "Database created: $DB_NAME" + local cfg="/etc/ssh/sshd_config" dropdir="/etc/ssh/sshd_config.d" target restore="" + if grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/' "$cfg" && [[ -d "$dropdir" ]]; then + target="$dropdir/00-pulsedeploy.conf" # first value wins, so 00- beats cloud-init's 50- + printf 'PermitRootLogin no\n' >"$target" + restore="rm -f $target" + else + target="$cfg" + backup_file "$cfg" + if grep -Eq '^[[:space:]]*#?[[:space:]]*PermitRootLogin' "$cfg"; then + conf_set "$cfg" PermitRootLogin no " " + else + sed -i '1i PermitRootLogin no' "$cfg" # top of file: never inside a Match block + fi + restore="cp -a ${cfg}.pulsedeploy.bak $cfg" fi - if [[ -n "$DB_USER" && -n "$DB_NAME" ]]; then - mysql -u root </dev/null -CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}'; -GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'localhost'; -FLUSH PRIVILEGES; -SQL - # Save credentials - cat >> /root/.my.cnf </dev/null && ! sshd -t; then + eval "$restore" + warn "sshd rejected the new configuration; it was rolled back. Root login unchanged." + return 0 + fi + systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || warn "Could not reload sshd — change applies after the next restart." + local effective="" + effective="$(sshd -T 2>/dev/null | awk 'tolower($1) == "permitrootlogin" { print $2 }')" || true + if [[ "$effective" == "no" || -z "$effective" ]]; then + log "Root SSH login disabled (admin user with key: $found)" + else + warn "sshd still reports PermitRootLogin=$effective — another config file overrides it." fi return 0 } -# ── Root check ──────────────────────────────────────────────────────────────── +apply_server_config() { + [[ -n "$HOSTNAME_VAL" ]] && set_hostname + [[ -n "$TIMEZONE" ]] && set_timezone + [[ "$DISABLE_ROOT_SSH" -eq 1 ]] && disable_root_ssh + return 0 +} + +# ── Pre-flight checks ───────────────────────────────────────────────────────── check_root() { [[ $EUID -eq 0 ]] || error "This script must be run as root. Use: sudo bash bootstrap.sh" return 0 } +setup_logging() { + if ! touch "$LOG_FILE" 2>/dev/null; then + LOG_FILE="/tmp/server-bootstrap.log" + touch "$LOG_FILE" + fi + chmod 600 "$LOG_FILE" + exec > >(tee -a "$LOG_FILE") 2>&1 + return 0 +} + +# PID-file lock. (Not flock on a file descriptor: daemons started during the +# install — PM2, database servers under a non-systemd init — would inherit the +# descriptor and keep the lock held forever.) +LOCK_FILE="/run/lock/pulsedeploy.pid" +release_lock() { + if [[ -f "$LOCK_FILE" && "$(cat "$LOCK_FILE" 2>/dev/null)" == "$$" ]]; then + rm -f "$LOCK_FILE" + fi + return 0 +} + +acquire_lock() { + [[ -d /run/lock ]] || LOCK_FILE="/tmp/pulsedeploy.pid" + local pid + for _ in 1 2; do + if (set -o noclobber; echo "$$" >"$LOCK_FILE") 2>/dev/null; then + trap release_lock EXIT + return 0 + fi + pid="$(cat "$LOCK_FILE" 2>/dev/null || true)" + if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null && + [[ "$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null)" == *bootstrap.sh* ]]; then + error "Another PulseDeploy run is already in progress (PID $pid)." + fi + rm -f "$LOCK_FILE" # stale lock from a crashed run + done + error "Could not acquire the lock file $LOCK_FILE." +} + +preflight() { + if [[ "${PULSE_SKIP_PREFLIGHT:-0}" == "1" ]]; then + warn "Pre-flight checks skipped (PULSE_SKIP_PREFLIGHT=1)" + return 0 + fi + if ! has_systemd && [[ "${PULSE_ALLOW_NO_SYSTEMD:-0}" != "1" ]]; then + error "systemd is not running as PID 1 (container or WSL1?). PulseDeploy manages services with systemctl and needs a real VM/VPS." + fi + local free_mb ram_mb arch + free_mb="$(df -Pm / | awk 'NR == 2 { print $4 }')" + ((free_mb >= 2048)) || error "Only ${free_mb}MB free on / — at least 2048MB is required." + ram_mb="$(total_ram_mb)" + if ((ram_mb < 900)) && [[ "${SERVICES[swap]}" -eq 0 && "$NON_INTERACTIVE" -eq 1 ]]; then + warn "Only ${ram_mb}MB RAM and swap is not selected — package installs can be OOM-killed. Consider --services swap." + fi + arch="$(uname -m)" + [[ "$arch" == "x86_64" || "$arch" == "aarch64" ]] || warn "Untested CPU architecture: $arch" + return 0 +} + # ── OS Detection ────────────────────────────────────────────────────────────── detect_os() { - if [[ -f /etc/os-release ]]; then - # shellcheck disable=SC1091 - source /etc/os-release - OS_ID="${ID}" - OS_VERSION="${VERSION_ID:-unknown}" - else - error "Cannot detect OS. /etc/os-release not found." - fi + [[ -r /etc/os-release ]] || error "Cannot detect OS. /etc/os-release not found." + OS_ID="$(os_release_value ID)" + OS_VERSION="$(os_release_value VERSION_ID)" + OS_VERSION="${OS_VERSION:-unknown}" + local major="${OS_VERSION%%.*}" module="" - # shellcheck disable=SC2034 # PKG_MANAGER used by sourced OS modules case "$OS_ID" in ubuntu) - PKG_MANAGER="apt" - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/scripts/os/ubuntu.sh" + module="ubuntu" + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 20 ]] || error "Ubuntu $OS_VERSION is too old (need 20.04+)." ;; debian) - PKG_MANAGER="apt" - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/scripts/os/debian.sh" + module="debian" + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 11 ]] || error "Debian $OS_VERSION is not supported (need 11+)." ;; amzn) - PKG_MANAGER="dnf" - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/scripts/os/amazon_linux.sh" + module="amazon_linux" + [[ "$OS_VERSION" == "2" || "$OS_VERSION" == "2023" ]] || error "Amazon Linux $OS_VERSION is not supported (2 or 2023)." + [[ "$OS_VERSION" == "2" ]] && warn "Amazon Linux 2 is end-of-life; support is best effort. Prefer Amazon Linux 2023." ;; centos|rocky|rhel|almalinux) - PKG_MANAGER="dnf" - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/scripts/os/centos_rocky.sh" + module="centos_rocky" + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 8 ]] || error "$OS_ID $OS_VERSION is not supported (need 8+)." ;; *) - error "Unsupported OS: $OS_ID. Supported: Ubuntu, Debian, Amazon Linux, CentOS/Rocky." + error "Unsupported OS: $OS_ID. Supported: Ubuntu, Debian, Amazon Linux, CentOS/Rocky/Alma/RHEL." ;; esac - - log "Detected OS: $OS_ID $OS_VERSION" + # shellcheck source=/dev/null + source "$SCRIPT_DIR/scripts/os/${module}.sh" + log "Detected OS: $OS_ID $OS_VERSION (package manager: $PKG_MANAGER)" return 0 } +# ── Interactive helpers ─────────────────────────────────────────────────────── +# prompt