From 53b46c900125f6e28b521f815310a6d8b4856bab Mon Sep 17 00:00:00 2001 From: mwwalters Date: Sat, 25 Jul 2026 15:39:10 -0400 Subject: [PATCH] initial commit --- docker/container-snippet-docker-compose.yml | 14 ++ docker/example-docker-compose.yml | 54 +++++ npm-maintenance/index.html | 36 +++ npm-maintenance/npm-maintenance.sh | 237 ++++++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 docker/container-snippet-docker-compose.yml create mode 100644 docker/example-docker-compose.yml create mode 100644 npm-maintenance/index.html create mode 100644 npm-maintenance/npm-maintenance.sh diff --git a/docker/container-snippet-docker-compose.yml b/docker/container-snippet-docker-compose.yml new file mode 100644 index 0000000..fdc7149 --- /dev/null +++ b/docker/container-snippet-docker-compose.yml @@ -0,0 +1,14 @@ +# --- add this snippet to your existing docker-compose.yml file --- +# --- this will define a small container that will serve the maintenance page you define in /maintenance/index.html--- +# --- this assumes you have a docker network by the name nginxproxymanager --- +# --- this container must have access to the same docker network as your nginx proxy manager container to function --- + + # --- Maintenance page container --- + maintenance: + image: nginx:alpine + restart: unless-stopped + volumes: + - ./maintenance:/usr/share/nginx/html:ro + networks: + - nginxproxymanager + # ---------------------------------- \ No newline at end of file diff --git a/docker/example-docker-compose.yml b/docker/example-docker-compose.yml new file mode 100644 index 0000000..d3acb1a --- /dev/null +++ b/docker/example-docker-compose.yml @@ -0,0 +1,54 @@ +services: + app: + build: . + image: jc21/nginx-proxy-manager:custom + restart: unless-stopped + ports: + - '80:80' # Public HTTP Port + - '443:443' # Public HTTPS Port + - '81:81' # Admin Web Port + environment: + DB_MYSQL_HOST: 'db' + DB_MYSQL_PORT: '3306' + DB_MYSQL_USER: 'npm' + DB_MYSQL_PASSWORD: 'your_secure_password' # Replace with your secure password + DB_MYSQL_NAME: 'npm' + TZ: 'your_timezone_here' # Set the timezone + # Uncomment this if IPv6 is not enabled on your host + # DISABLE_IPV6: 'true' + volumes: + - ./data:/data + - ./letsencrypt:/etc/letsencrypt + - /root/nginx-proxy-manager/logs:/data/logs # Bind host logs directory + depends_on: + - db + networks: + - nginxproxymanager + + db: + image: 'jc21/mariadb-aria:latest' + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: 'your_secure_root_password' # Replace with your secure root password + MYSQL_DATABASE: 'npm' + MYSQL_USER: 'npm' + MYSQL_PASSWORD: 'your_secure_password' # Replace with your secure password + MARIADB_AUTO_UPGRADE: '1' + volumes: + - ./mysql:/var/lib/mysql + networks: + - nginxproxymanager + + # --- Maintenance page container --- + maintenance: + image: nginx:alpine + restart: unless-stopped + volumes: + - ./maintenance:/usr/share/nginx/html:ro + networks: + - nginxproxymanager + # ---------------------------------- + +networks: + nginxproxymanager: + external: true diff --git a/npm-maintenance/index.html b/npm-maintenance/index.html new file mode 100644 index 0000000..851c832 --- /dev/null +++ b/npm-maintenance/index.html @@ -0,0 +1,36 @@ + + + + + + Down for Maintenance + + + +
+

Down for Maintenance

+

This resource is currently unavailable while we perform scheduled maintenance.

+

Please contact the administrator with any questions.

+
+ + diff --git a/npm-maintenance/npm-maintenance.sh b/npm-maintenance/npm-maintenance.sh new file mode 100644 index 0000000..9471d8f --- /dev/null +++ b/npm-maintenance/npm-maintenance.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# npm-maintenance.sh +# Toggles maintenance mode across all NPM proxy hosts via the NPM REST API + +set -euo pipefail + +# ============================================================ +# Configuration +# ============================================================ +NPM_URL="http://localhost:81" +NPM_USER="username" +NPM_PASS="password" + +MAINTENANCE_HOST="maintenance" +MAINTENANCE_PORT=80 +MAINTENANCE_SCHEME="http" + +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +BACKUP_FILE="${SCRIPT_DIR}/proxy_backup.json" +LOG_DIR="${SCRIPT_DIR}" +LOG_OUT="${LOG_DIR}/npm-maintenance.out" +LOG_ERR="${LOG_DIR}/npm-maintenance.err" + +TRIGGERED_BY="$(whoami)" + +# ============================================================ +# Logging +# ============================================================ +log_separator() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [----] ----------------------------------------" | tee -a "$LOG_OUT" +} + +log_info() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $1" | tee -a "$LOG_OUT" +} + +log_warn() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $1" | tee -a "$LOG_OUT" >&2 +} + +log_error() { + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $1" + echo "$msg" | tee -a "$LOG_OUT" + echo "$msg" >> "$LOG_ERR" +} + +# ============================================================ +# Preflight checks +# ============================================================ +preflight_checks() { + if [ ! -d "$LOG_DIR" ]; then + mkdir -p "$LOG_DIR" + log_info "[PREFLIGHT] Log directory not found, created: $LOG_DIR" + fi + + if [ ! -f "$LOG_OUT" ]; then + touch "$LOG_OUT" + log_info "[PREFLIGHT] Log file not found, created: $LOG_OUT" + fi + + if [ ! -f "$LOG_ERR" ]; then + touch "$LOG_ERR" + log_info "[PREFLIGHT] Error log file not found, created: $LOG_ERR" + fi + + if [ ! -f "$BACKUP_FILE" ]; then + echo "[]" > "$BACKUP_FILE" + log_info "[PREFLIGHT] Backup file not found, created empty: $BACKUP_FILE" + fi +} + +# ============================================================ +# API helpers +# ============================================================ +get_token() { + local response + response=$(curl -s -X POST "${NPM_URL}/api/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"${NPM_USER}\",\"secret\":\"${NPM_PASS}\"}") + + local token + token=$(echo "$response" | jq -r '.token // empty') + + if [ -z "$token" ]; then + log_error "Failed to authenticate with NPM API. Check NPM_USER and NPM_PASS." + exit 1 + fi + + echo "$token" +} + +get_proxy_hosts() { + local token="$1" + curl -s -X GET "${NPM_URL}/api/nginx/proxy-hosts" \ + -H "Authorization: Bearer ${token}" +} + +update_proxy_host() { + local token="$1" + local id="$2" + local body="$3" + + curl -s -X PUT "${NPM_URL}/api/nginx/proxy-hosts/${id}" \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + -d "$body" +} + +# ============================================================ +# Enable maintenance +# ============================================================ +enable_maintenance() { + log_separator + log_info "START: Enabling maintenance mode (triggered by ${TRIGGERED_BY})" + + local token + token=$(get_token) + + local hosts + hosts=$(get_proxy_hosts "$token") + + local total + total=$(echo "$hosts" | jq length) + + if [ "$total" -eq 0 ]; then + log_warn "No proxy hosts found. Nothing to do." + log_separator + exit 0 + fi + + # Save backup: id, forward_host, forward_port, forward_scheme + echo "$hosts" | jq '[.[] | {id, forward_host, forward_port, forward_scheme}]' > "$BACKUP_FILE" + log_info "Backed up $total proxy host(s) to $BACKUP_FILE" + + local success=0 + local failed=0 + + for i in $(seq 0 $((total - 1))); do + local id domain body response + id=$(echo "$hosts" | jq -r ".[$i].id") + domain=$(echo "$hosts" | jq -r ".[$i].domain_names[0]") + + # Build update payload from existing host, only swapping forward fields + body=$(echo "$hosts" | jq -c \ + ".[$i] | del(.id, .created_on, .modified_on, .owner_user_id) | .forward_host = \"${MAINTENANCE_HOST}\" | .forward_port = ${MAINTENANCE_PORT} | .forward_scheme = \"${MAINTENANCE_SCHEME}\"") + + response=$(update_proxy_host "$token" "$id" "$body") + + if echo "$response" | jq -e '.id' > /dev/null 2>&1; then + log_info " ✓ [$((i+1))/$total] $domain (id: $id) → ${MAINTENANCE_HOST}:${MAINTENANCE_PORT}" + success=$((success + 1)) + else + log_error " ✗ [$((i+1))/$total] $domain (id: $id) — API error: $(echo "$response" | jq -r '.error.message // .message // "unknown"')" + fi + done + + log_info "DONE: $success succeeded, $failed failed out of $total host(s)" + log_separator +} + +# ============================================================ +# Disable maintenance +# ============================================================ +disable_maintenance() { + log_separator + log_info "START: Disabling maintenance mode (triggered by ${TRIGGERED_BY})" + + local count + count=$(jq length "$BACKUP_FILE") + + if [ "$count" -eq 0 ]; then + log_error "Backup file exists at $BACKUP_FILE but contains no hosts. Was enable ever run? Aborting." + log_separator + exit 1 + fi + + local token + token=$(get_token) + + local hosts + hosts=$(get_proxy_hosts "$token") + + local success=0 + local failed=0 + + for i in $(seq 0 $((count - 1))); do + local id fwd_host fwd_port fwd_scheme current_host domain body response + id=$(jq -r ".[$i].id" "$BACKUP_FILE") + fwd_host=$(jq -r ".[$i].forward_host" "$BACKUP_FILE") + fwd_port=$(jq -r ".[$i].forward_port" "$BACKUP_FILE") + fwd_scheme=$(jq -r ".[$i].forward_scheme" "$BACKUP_FILE") + + # Get the current live config for this host and restore only the forward fields + current_host=$(echo "$hosts" | jq -c ".[] | select(.id == $id)") + domain=$(echo "$current_host" | jq -r '.domain_names[0]') + + body=$(echo "$current_host" | jq -c \ + "del(.id, .created_on, .modified_on, .owner_user_id) | .forward_host = \"${fwd_host}\" | .forward_port = ${fwd_port} | .forward_scheme = \"${fwd_scheme}\"") + + response=$(update_proxy_host "$token" "$id" "$body") + + if echo "$response" | jq -e '.id' > /dev/null 2>&1; then + log_info " ✓ [$((i+1))/$count] $domain (id: $id) → ${fwd_host}:${fwd_port}" + success=$((success + 1)) + else + log_error " ✗ [$((i+1))/$total] $domain (id: $id) — API error: $(echo "$response" | jq -r '.error.message // .message // "unknown"')" + fi + done + + if [ "$failed" -eq 0 ]; then + echo "[]" > "$BACKUP_FILE" + log_info "Backup cleared after full successful restore" + else + log_warn "Backup retained at $BACKUP_FILE due to $failed failure(s)" + fi + + log_info "DONE: $success succeeded, $failed failed out of $count host(s)" + log_separator +} + +# ============================================================ +# Entry point +# ============================================================ +case "$1" in + enable) + preflight_checks + enable_maintenance + ;; + disable) + preflight_checks + disable_maintenance + ;; + *) + log_error "Invalid or missing argument. Usage: npm-maintenance {enable|disable}" + exit 1 + ;; +esac