diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..2cee757 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,21 @@ +name: ShellCheck + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Syntax check + run: for f in ./*.sh; do bash -n "$f"; done + - name: ShellCheck (preinstalled on GitHub runners) + run: shellcheck --severity=style ./*.sh + - name: Dry run + run: LOG_FILE=/tmp/provision.log ./provision.sh --dry-run diff --git a/README.md b/README.md index 778caab..b7f38f7 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,57 @@ # Linux Server Provisioning -Bash automation scripts to provision and monitor an Ubuntu web server on AWS EC2. +Bash scripts that turn a fresh Ubuntu/Debian server (e.g. an AWS EC2 instance) into an nginx web server behind a UFW firewall, plus a health-check script with monitoring-friendly exit codes. ## Scripts -### provision.sh -Automates full server setup: -- Updates system packages -- Installs and configures nginx -- Sets up UFW firewall (allows SSH and HTTP) -- Deploys a custom homepage +### `provision.sh` -### healthcheck.sh -Monitors server health: -- Disk usage -- Memory usage -- Nginx service status +| Step | What it does | +|---|---| +| Preflight | Requires root (except `--dry-run`), checks the OS is Ubuntu/Debian, validates `SSH_SOURCE_CIDR` | +| Packages | `apt-get update/upgrade`, installs `nginx`, `ufw`, `curl` only if missing | +| Firewall | Default deny incoming. Allows SSH **before** enabling UFW, so the current session is not locked out. SSH can be restricted to one CIDR. HTTP 80 open | +| Homepage | Writes `/var/www/html/index.html` atomically; skipped if already up to date | +| nginx | Validates config with `nginx -t`, enables and reloads the service | +| Verify | Checks that `http://127.0.0.1/` responds | + +Built for safe repeated runs: `set -Eeuo pipefail`, an error trap that logs the failing line and command, timestamped logs to `/var/log/provision.log`, and idempotent steps. + +### `healthcheck.sh` + +Checks root disk usage, memory (based on `MemAvailable`), systemd services and an HTTP endpoint. Thresholds are configurable. Exit codes: `0` OK, `1` WARNING, `2` CRITICAL, so it can run from cron or a monitoring agent. ## Usage + ```bash -chmod +x provision.sh -./provision.sh +chmod +x provision.sh healthcheck.sh + +sudo ./provision.sh --dry-run # show what would happen +sudo SSH_SOURCE_CIDR=203.0.113.4/32 ./provision.sh # SSH only from your IP +./healthcheck.sh +DISK_WARN=70 SERVICES="nginx ssh" ./healthcheck.sh ``` +| Variable | Default | Script | +|---|---|---| +| `SSH_SOURCE_CIDR` | empty = SSH from anywhere (logs a warning) | provision | +| `LOG_FILE` | `/var/log/provision.log` | provision | +| `DISK_WARN` / `DISK_CRIT` | 80 / 90 (%) | healthcheck | +| `MEM_WARN` / `MEM_CRIT` | 85 / 95 (%) | healthcheck | +| `SERVICES` | `nginx` | healthcheck | +| `HTTP_URL` | `http://127.0.0.1/` (empty = skip) | healthcheck | + +## Security notes + +- Restrict SSH with `SSH_SOURCE_CIDR`, or better, use AWS Systems Manager Session Manager and close port 22 completely (see my [aws-terraform-infrastructure](https://github.com/MultiCloudEng/aws-terraform-infrastructure) project). +- Only HTTP (80) is opened. For production, add TLS (e.g. certbot) and allow 443. + +## What was tested + +- The original version was run on an Ubuntu EC2 instance. +- The current version was tested on Ubuntu 24.04 (not EC2): `--dry-run` as a non-root user, rejection of an invalid CIDR, an unknown argument and a non-root real run, and `healthcheck.sh` including forced WARNING/CRITICAL thresholds. ShellCheck reports no findings. +- A full real provisioning run of the current version (package install, UFW, systemd) has not been repeated yet. Run it on a fresh EC2 instance before relying on it. + ## Skills -Linux administration, bash scripting, nginx, UFW firewall, AWS EC2, automation. + +Linux administration, Bash (strict mode, traps, idempotency), nginx, UFW, systemd, monitoring basics. diff --git a/healthcheck.sh b/healthcheck.sh index b3d8d1e..0c0a7ee 100755 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -1,17 +1,94 @@ -#!/bin/bash +#!/usr/bin/env bash +# Server health check: disk, memory, services and HTTP. +# Exit codes (usable from cron or a monitoring agent): +# 0 = OK, 1 = WARNING, 2 = CRITICAL +# +# Usage: +# ./healthcheck.sh +# DISK_WARN=70 SERVICES="nginx ssh" ./healthcheck.sh +# +# Environment variables (defaults in brackets): +# DISK_WARN [80] / DISK_CRIT [90] root filesystem usage in % +# MEM_WARN [85] / MEM_CRIT [95] memory usage in % (based on MemAvailable) +# SERVICES [nginx] systemd services that must be active +# HTTP_URL [http://127.0.0.1/] URL that must return HTTP 2xx/3xx (empty = skip) -echo "=== Server Health Check ===" -echo "Date: $(date)" +set -Euo pipefail -echo "" -echo "--- Disk Usage ---" -df -h / +DISK_WARN="${DISK_WARN:-80}" +DISK_CRIT="${DISK_CRIT:-90}" +MEM_WARN="${MEM_WARN:-85}" +MEM_CRIT="${MEM_CRIT:-95}" +SERVICES="${SERVICES:-nginx}" +HTTP_URL="${HTTP_URL-http://127.0.0.1/}" -echo "" -echo "--- Memory Usage ---" -free -h +status=0 +report() { + # $1 = level (OK/WARN/CRIT/UNKNOWN), rest = message + local level="$1"; shift + printf '%-8s %s\n' "[$level]" "$*" + case "$level" in + WARN|UNKNOWN) (( status < 1 )) && status=1 ;; + CRIT) status=2 ;; + esac +} -echo "" -echo "--- Nginx Status ---" -systemctl is-active nginx +check_threshold() { + # $1 = name, $2 = value, $3 = warn, $4 = crit + if (( $2 >= $4 )); then report CRIT "$1 at $2% (critical >= $4%)" + elif (( $2 >= $3 )); then report WARN "$1 at $2% (warning >= $3%)" + else report OK "$1 at $2%" + fi +} +check_disk() { + local used + used="$(df -P / | awk 'NR==2 {gsub("%","",$5); print $5}')" + [[ "$used" =~ ^[0-9]+$ ]] || { report UNKNOWN "could not read disk usage"; return; } + check_threshold "Disk /" "$used" "$DISK_WARN" "$DISK_CRIT" +} + +check_memory() { + local total avail used + total="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)" + avail="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)" + [[ -n "$total" && -n "$avail" && "$total" -gt 0 ]] || { report UNKNOWN "could not read memory usage"; return; } + used=$(( (total - avail) * 100 / total )) + check_threshold "Memory" "$used" "$MEM_WARN" "$MEM_CRIT" +} + +check_services() { + local svc state + if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then + report UNKNOWN "systemd not available; cannot check services ($SERVICES)" + return + fi + for svc in $SERVICES; do + state="$(systemctl is-active "$svc" 2>/dev/null || true)" + if [[ "$state" == "active" ]]; then report OK "Service $svc is active" + else report CRIT "Service $svc is ${state:-unknown}" + fi + done +} + +check_http() { + [[ -z "$HTTP_URL" ]] && return + local code + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HTTP_URL" || true)" + if [[ "$code" =~ ^[23][0-9][0-9]$ ]]; then report OK "HTTP $HTTP_URL returned $code" + else report CRIT "HTTP $HTTP_URL returned ${code:-no response}" + fi +} + +echo "=== Server health check: $(hostname) $(date '+%Y-%m-%d %H:%M:%S') ===" +check_disk +check_memory +check_services +check_http +case "$status" in + 0) overall=OK ;; + 1) overall=WARNING ;; + *) overall=CRITICAL ;; +esac +echo "=== Overall: $overall ===" +exit "$status" diff --git a/provision.sh b/provision.sh index cd97f40..b93ff5c 100755 --- a/provision.sh +++ b/provision.sh @@ -1,17 +1,162 @@ -#!/bin/bash -echo "Starting server provisioning..." -sudo apt update -sudo apt install nginx -y +#!/usr/bin/env bash +# Provision an Ubuntu/Debian web server: system updates, nginx, UFW firewall, +# custom homepage. Safe to run more than once (idempotent). +# +# Usage: +# sudo ./provision.sh # provision +# sudo ./provision.sh --dry-run # print what would be done, change nothing +# sudo SSH_SOURCE_CIDR=203.0.113.4/32 ./provision.sh # allow SSH only from one IP +# +# Environment variables: +# SSH_SOURCE_CIDR CIDR allowed to reach SSH (port 22). Default: anywhere (with a warning). +# LOG_FILE Log file path. Default: /var/log/provision.log +set -Eeuo pipefail -echo "Configuring firewall..." -sudo ufw allow 22 -sudo ufw allow 80 -sudo ufw --force enable +LOG_FILE="${LOG_FILE:-/var/log/provision.log}" +SSH_SOURCE_CIDR="${SSH_SOURCE_CIDR:-}" +WEB_ROOT="/var/www/html" +HOMEPAGE="${WEB_ROOT}/index.html" +DRY_RUN=false +log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2" | tee -a "$LOG_FILE" >&2; } +info() { log INFO "$*"; } +warn() { log WARN "$*"; } +die() { log ERROR "$*"; exit 1; } -echo "Creating custom homepage..." -echo "

Provisioned by Asadbek's script

" | sudo tee /var/www/html/index.nginx-debian.html +# Print the failing command and line number instead of exiting silently. +trap 'die "Command failed (exit $?) at line ${LINENO}: ${BASH_COMMAND}"' ERR +# Run a command, or only print it in dry-run mode. +run() { + if "$DRY_RUN"; then + info "[dry-run] $*" + else + info "+ $*" + "$@" + fi +} -echo "Provisioning complete!" +usage() { + sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) usage; die "Unknown argument: $1" ;; + esac + shift + done +} + +preflight() { + if "$DRY_RUN"; then + # Dry run must work without root, so log to a temp file if needed. + if ! touch "$LOG_FILE" 2>/dev/null; then LOG_FILE="$(mktemp -t provision.XXXX.log)"; fi + else + [[ $EUID -eq 0 ]] || { echo "Run as root: sudo $0" >&2; exit 1; } + touch "$LOG_FILE" + chmod 640 "$LOG_FILE" + fi + + [[ -r /etc/os-release ]] || die "Cannot detect the OS (/etc/os-release missing)." + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}" in + ubuntu|debian) info "Detected ${PRETTY_NAME:-$ID}" ;; + *) die "Unsupported OS '${ID:-unknown}'. This script supports Ubuntu and Debian." ;; + esac + + if [[ -n "$SSH_SOURCE_CIDR" ]] && ! [[ "$SSH_SOURCE_CIDR" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; then + die "SSH_SOURCE_CIDR '$SSH_SOURCE_CIDR' is not a valid IPv4 CIDR (e.g. 203.0.113.4/32)." + fi +} + +install_packages() { + export DEBIAN_FRONTEND=noninteractive + run apt-get update -y + run apt-get upgrade -y + local pkg missing=() + for pkg in nginx ufw curl; do + if dpkg -s "$pkg" >/dev/null 2>&1; then + info "$pkg already installed" + else + missing+=("$pkg") + fi + done + if [[ ${#missing[@]} -gt 0 ]]; then + run apt-get install -y --no-install-recommends "${missing[@]}" + fi +} + +configure_firewall() { + run ufw default deny incoming + run ufw default allow outgoing + + # Allow SSH BEFORE enabling the firewall, otherwise the current session is locked out. + if [[ -n "$SSH_SOURCE_CIDR" ]]; then + run ufw allow from "$SSH_SOURCE_CIDR" to any port 22 proto tcp comment 'SSH (restricted)' + else + warn "SSH_SOURCE_CIDR not set: SSH (22) is allowed from anywhere. Restrict it in production." + run ufw allow 22/tcp comment 'SSH' + fi + run ufw allow 80/tcp comment 'HTTP' + run ufw --force enable +} + +deploy_homepage() { + local content + content=" +Provisioned +

Provisioned by Asadbek's script

Host: $(hostname)

" + + if [[ -f "$HOMEPAGE" ]] && [[ "$(cat "$HOMEPAGE")" == "$content" ]]; then + info "Homepage already up to date" + return + fi + if "$DRY_RUN"; then + info "[dry-run] write $HOMEPAGE" + else + install -d -m 755 "$WEB_ROOT" + printf '%s\n' "$content" > "${HOMEPAGE}.tmp" + chmod 644 "${HOMEPAGE}.tmp" + mv "${HOMEPAGE}.tmp" "$HOMEPAGE" # atomic replace + info "Homepage written to $HOMEPAGE" + fi +} + +start_nginx() { + run nginx -t # validate config before (re)starting + run systemctl enable --now nginx + run systemctl reload nginx +} + +verify() { + "$DRY_RUN" && { info "[dry-run] skip verification"; return; } + local _ + for _ in 1 2 3 4 5; do + if curl -fsS --max-time 3 http://127.0.0.1/ >/dev/null; then + info "nginx responds on http://127.0.0.1/" + return + fi + sleep 1 + done + die "nginx is not responding on port 80" +} + +main() { + parse_args "$@" + preflight + info "Starting provisioning (dry-run=$DRY_RUN, log=$LOG_FILE)" + install_packages + configure_firewall + deploy_homepage + start_nginx + verify + info "Provisioning complete" +} + +main "$@"