#!/usr/bin/env bash # # EasyMonitor — unified installer. # # Run this from the project root. It will: # 1. Ask whether this is a Local (dev) or Production install # 2. Collect required (and optional) configuration interactively # 3. Generate or update .env # 4. Patch docker/caddy/Caddyfile.production for production installs # 5. Build and start the docker stack # 6. Run migrations, generate keys, build assets, set up storage # # Re-running is safe: it will detect existing config and offer to keep it. # # Upgrading an existing install: # git pull && ./setup.sh --upgrade # rebuilds the containers, runs migrations, and re-applies policies without # asking any configuration questions. See UPGRADING.md. set -e # ── colors ────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' info() { printf "${BLUE}ℹ${NC} %s\n" "$1"; } ok() { printf "${GREEN}✓${NC} %s\n" "$1"; } warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; } fail() { printf "${RED}✗${NC} %s\n" "$1"; exit 1; } header() { printf "\n${BOLD}━━━ %s ━━━${NC}\n\n" "$1"; } # ── helpers ───────────────────────────────────────────────────────────────── require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" } ask() { # ask → echoes the answer local prompt="$1" local default="$2" local answer if [ -n "$default" ]; then read -p " $prompt [$default]: " answer echo "${answer:-$default}" else read -p " $prompt: " answer echo "$answer" fi } ask_secret() { # ask_secret → echoes the typed secret (hidden), no default local prompt="$1" local answer read -s -p " $prompt: " answer echo "" >&2 echo "$answer" } confirm() { # confirm → returns 0 for y, 1 for n (default n) local prompt="$1" local answer read -p " $prompt [y/N]: " answer [[ "$answer" =~ ^[Yy]$ ]] } random_string() { # 32 chars, base64-safe-ish openssl rand -base64 32 | tr -d '/+=\n' | cut -c1-32 } detect_public_ip() { # Try a few services; first one that responds wins. Empty if all fail. # Try a few IPv4-only services; first one that responds wins. # -4 forces IPv4 on the local end, and these services return IPv4 # so we don't accidentally grab an AAAA address on dual-stack hosts. for svc in "https://api.ipify.org" "https://ipv4.icanhazip.com" "https://v4.ident.me" "https://ifconfig.me" "https://ifconfig.net"; do local ip ip=$(curl -4 -fsS --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]') if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "$ip" return 0 fi done echo "" } resolve_domain() { # Resolve to an IP. Tries 'dig' then 'host' then 'getent'. local domain="$1" local ip if command -v dig >/dev/null 2>&1; then ip=$(dig +short "$domain" A | head -n1) elif command -v host >/dev/null 2>&1; then ip=$(host -t A "$domain" 2>/dev/null | awk '/has address/ {print $4; exit}') elif command -v getent >/dev/null 2>&1; then ip=$(getent hosts "$domain" | awk '{print $1; exit}') fi echo "$ip" } set_env() { # set_env — adds or updates a key in .env local key="$1" local value="$2" # escape chars dangerous to sed local escaped escaped=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g') if grep -q "^${key}=" .env 2>/dev/null; then sed -i.bak "s/^${key}=.*/${key}=${escaped}/" .env && rm -f .env.bak else echo "${key}=${value}" >> .env fi } # ── args ──────────────────────────────────────────────────────────────────── UPGRADE=false for arg in "$@"; do case "$arg" in --upgrade) UPGRADE=true ;; -h|--help) echo "Usage: ./setup.sh interactive install" echo " ./setup.sh --upgrade upgrade an existing install (run 'git pull' first)" exit 0 ;; *) fail "Unknown option: $arg (try --help)" ;; esac done # ── checks ────────────────────────────────────────────────────────────────── require_cmd docker require_cmd openssl docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 not available (run 'docker compose version' to check)" cat <<'BANNER' ╔══════════════════════════════════════════════════════════════╗ ║ EasyMonitor — Installer ║ ╚══════════════════════════════════════════════════════════════╝ BANNER # ── upgrade mode ──────────────────────────────────────────────────────────── if $UPGRADE; then header "Upgrade" [ -s .env ] || fail "No .env found — run ./setup.sh (without --upgrade) for a first-time install." info "Rebuilding images and restarting containers (this may take a few minutes)..." docker compose up -d --build ok "Containers up." info "Waiting for services to become responsive..." sleep 6 header "Application upgrade" docker compose exec -T php bash /var/www/html/docker/scripts/setup.sh info "Applying check result retention policy..." docker compose exec -T php php artisan easymonitor:retention --no-interaction \ || warn "Could not apply retention policy — run 'docker compose exec php php artisan easymonitor:retention' manually." # The php container is only recreated when its image changes; code is # bind-mounted, so long-running workers must be told to reload it. info "Restarting queue workers so they pick up the new code..." docker compose exec -T php php artisan horizon:terminate >/dev/null 2>&1 \ || warn "Could not restart Horizon — run 'docker compose exec php php artisan horizon:terminate' manually." info "Restarting probe..." docker compose up -d --force-recreate probe >/dev/null 2>&1 || true header "Done" ok "EasyMonitor upgraded." info "Check UPGRADING.md and the release notes for anything requiring manual steps." exit 0 fi # ── mode ──────────────────────────────────────────────────────────────────── header "Install mode" echo " 1) Local development" echo " 2) Production (auto-HTTPS via Let's Encrypt + on-demand TLS for status pages)" echo "" MODE="" while [[ "$MODE" != "1" && "$MODE" != "2" ]]; do read -p " Choose [1/2]: " MODE done IS_PROD=false [ "$MODE" = "2" ] && IS_PROD=true # ── .env ──────────────────────────────────────────────────────────────────── if [ -f .env ]; then warn ".env already exists." if ! confirm "Continue and update existing .env in place?"; then info "Aborting. Move .env aside and re-run if you want a clean start." exit 0 fi else cp .env.example .env ok "Created .env from .env.example" fi # ── basics ────────────────────────────────────────────────────────────────── header "Basics" APP_NAME=$(ask "App name" "EasyMonitor") set_env APP_NAME "$APP_NAME" if $IS_PROD; then info "Detecting this server's public IP address..." PUBLIC_IP=$(detect_public_ip) if [ -n "$PUBLIC_IP" ]; then ok "Server public IP: ${PUBLIC_IP}" else warn "Could not auto-detect public IP. You'll need to know it for DNS setup." fi echo "" DOMAIN=$(ask "Primary domain (e.g. monitor.example.com)" "") [ -z "$DOMAIN" ] && fail "Primary domain is required for production" # DNS sanity check — warn early if the domain doesn't already point here. DOMAIN_IP=$(resolve_domain "$DOMAIN") if [ -n "$DOMAIN_IP" ] && [ -n "$PUBLIC_IP" ]; then if [ "$DOMAIN_IP" = "$PUBLIC_IP" ]; then ok "DNS check: ${DOMAIN} resolves to ${DOMAIN_IP} (matches this server)." else warn "DNS mismatch: ${DOMAIN} resolves to ${DOMAIN_IP}, but this server is ${PUBLIC_IP}." warn "Update your DNS A record to ${PUBLIC_IP} before HTTPS will work." confirm "Continue anyway?" || fail "Aborting. Fix DNS and re-run." fi elif [ -z "$DOMAIN_IP" ]; then warn "${DOMAIN} doesn't resolve to any IP yet." warn "Add a DNS A record: ${DOMAIN} → ${PUBLIC_IP:-}" confirm "Continue anyway? (TLS will fail until DNS propagates)" || fail "Aborting. Fix DNS and re-run." fi ADMIN_EMAIL=$(ask "Admin email (for Let's Encrypt notifications)" "") [ -z "$ADMIN_EMAIL" ] && fail "Admin email is required for production" set_env APP_ENV "production" set_env APP_DEBUG "false" set_env APP_URL "https://${DOMAIN}" ok "App URL set to https://${DOMAIN}" else set_env APP_ENV "local" set_env APP_DEBUG "true" set_env APP_URL "http://localhost" ok "App URL set to http://localhost" fi # ── database ──────────────────────────────────────────────────────────────── header "Database" if $IS_PROD; then DB_PASSWORD=$(random_string) info "Generated a strong random DB password." else DB_PASSWORD=$(ask "Database password" "secret") fi set_env DB_PASSWORD "$DB_PASSWORD" # ── data retention ────────────────────────────────────────────────────────── header "Data retention" echo " Raw check results are pruned automatically to keep disk usage bounded" echo " (older data is also compressed). See DOCKER.md for details. You can" echo " change this later in .env and re-apply with: php artisan easymonitor:retention" echo "" RETENTION_DAYS="" while [[ ! "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || [ "$RETENTION_DAYS" -lt 8 ]; do RETENTION_DAYS=$(ask "Keep raw check results for how many days? (minimum 8)" "90") done set_env CHECK_RESULT_RETENTION_DAYS "$RETENTION_DAYS" ok "Check results will be kept for ${RETENTION_DAYS} days (compressed after 7 days)." # ── redis ─────────────────────────────────────────────────────────────────── header "Redis" if $IS_PROD; then REDIS_PASSWORD=$(random_string) set_env REDIS_PASSWORD "$REDIS_PASSWORD" ok "Generated Redis password (required for production)." else info "Leaving Redis without password (local dev)." REDIS_PASSWORD="" fi # ── remote probes ─────────────────────────────────────────────────────────── REMOTE_PROBES=false REMOTE_REDIS_URL="" header "Remote probe nodes" echo " EasyMonitor runs a local probe out of the box. If you want probes in" echo " other regions, they need a network path to this server's Redis." echo "" echo " Recommended approach: a VPN-style tunnel (Tailscale or Cloudflare" echo " Tunnel). Never expose Redis directly on the public internet." echo "" if confirm "Will you run probes on other machines?"; then REMOTE_PROBES=true echo "" echo " 1) Tailscale — simplest. Free for up to 100 devices." echo " 2) Cloudflare Tunnel — free with any Cloudflare account, more setup." echo " 3) I'll configure networking manually (SSH, WireGuard, etc.)" echo "" TUNNEL_CHOICE="" while [[ ! "$TUNNEL_CHOICE" =~ ^[1-3]$ ]]; do read -p " Choose [1/2/3]: " TUNNEL_CHOICE done case "$TUNNEL_CHOICE" in 1) # Tailscale if command -v tailscale >/dev/null 2>&1; then ok "Tailscale already installed." else info "Tailscale is not installed." if confirm "Install Tailscale now? (runs the official installer)"; then curl -fsSL https://tailscale.com/install.sh | sh || fail "Tailscale install failed." ok "Tailscale installed." else warn "Skipping Tailscale install. Follow https://tailscale.com/download when ready." fi fi if command -v tailscale >/dev/null 2>&1; then if ! tailscale status >/dev/null 2>&1; then info "Starting Tailscale — a browser-based login URL will open." sudo tailscale up || warn "Tailscale up failed. Run 'sudo tailscale up' manually." fi TAILSCALE_IP=$(tailscale ip -4 2>/dev/null | head -n1 || true) if [ -n "$TAILSCALE_IP" ]; then ok "Tailscale IP: $TAILSCALE_IP" set_env REDIS_BIND_HOST "$TAILSCALE_IP" REMOTE_REDIS_URL="redis://${TAILSCALE_IP}:6379/0" else warn "Couldn't read Tailscale IP. Run 'tailscale ip -4' after setup and update REDIS_BIND_HOST in .env." set_env REDIS_BIND_HOST "127.0.0.1" REMOTE_REDIS_URL="redis://:6379/0" fi else set_env REDIS_BIND_HOST "127.0.0.1" REMOTE_REDIS_URL="redis://:6379/0 (after Tailscale install)" fi ;; 2) info "Cloudflare Tunnel setup — see PROBE_NODE_SETUP.md for the full guide." info "cloudflared will run on this host and bridge a hostname to localhost:6379." set_env REDIS_BIND_HOST "127.0.0.1" REMOTE_REDIS_URL="redis://:6379/0" ;; 3) info "Manual networking. Redis will bind to 127.0.0.1:6379 by default —" info "edit REDIS_BIND_HOST in .env if you need a different interface." set_env REDIS_BIND_HOST "127.0.0.1" REMOTE_REDIS_URL="redis://:6379/0" ;; esac # Append the remote-probes compose override so REDIS_BIND_HOST takes effect. CURRENT_CF=$(grep -E '^COMPOSE_FILE=' .env 2>/dev/null | head -n1 | cut -d= -f2- || echo "") [ -z "$CURRENT_CF" ] && CURRENT_CF="docker-compose.yml" if [[ "$CURRENT_CF" != *"remote-probes"* ]]; then CURRENT_CF="${CURRENT_CF}:docker-compose.remote-probes.yml" fi set_env COMPOSE_FILE "$CURRENT_CF" fi # ── registration ──────────────────────────────────────────────────────────── header "User registration" if confirm "Allow open user registration? (No = only the very first user can register)"; then set_env REGISTRATION_ENABLED "true" else set_env REGISTRATION_ENABLED "false" info "Registration locked. The first user to sign up will be the admin." fi # ── mail ──────────────────────────────────────────────────────────────────── header "Email (alerts)" echo " 1) Skip — log emails to storage/logs (no real delivery)" echo " 2) Amazon SES" echo " 3) Generic SMTP (Mailgun, Postmark, custom, etc.)" echo "" MAIL_CHOICE="" while [[ ! "$MAIL_CHOICE" =~ ^[1-3]$ ]]; do read -p " Choose [1/2/3]: " MAIL_CHOICE done case "$MAIL_CHOICE" in 2) info "Amazon SES — make sure the from-address is on a verified identity." set_env MAIL_MAILER "ses" FROM=$(ask "From email" "alerts@${DOMAIN:-example.com}") FROM_NAME=$(ask "From name" "$APP_NAME") set_env MAIL_FROM_ADDRESS "\"$FROM\"" set_env MAIL_FROM_NAME "\"$FROM_NAME\"" AWS_KEY_INPUT=$(ask "AWS Access Key ID" "") AWS_SECRET_INPUT=$(ask_secret "AWS Secret Access Key") AWS_REGION_INPUT=$(ask "AWS region (e.g. us-east-1, eu-west-1)" "us-east-1") # AWS_* covers every AWS service (SES, S3, etc). # Cloudflare R2 has its own R2_* namespace below. set_env AWS_ACCESS_KEY_ID "$AWS_KEY_INPUT" set_env AWS_SECRET_ACCESS_KEY "$AWS_SECRET_INPUT" set_env AWS_DEFAULT_REGION "$AWS_REGION_INPUT" ok "SES configured." ;; 3) set_env MAIL_MAILER "smtp" SMTP_HOST=$(ask "SMTP host" "smtp.example.com") SMTP_PORT=$(ask "SMTP port" "587") SMTP_USER=$(ask "SMTP username" "") SMTP_PASS=$(ask_secret "SMTP password") FROM=$(ask "From email" "alerts@${DOMAIN:-example.com}") FROM_NAME=$(ask "From name" "$APP_NAME") set_env MAIL_HOST "$SMTP_HOST" set_env MAIL_PORT "$SMTP_PORT" set_env MAIL_USERNAME "$SMTP_USER" set_env MAIL_PASSWORD "$SMTP_PASS" set_env MAIL_SCHEME "tls" set_env MAIL_FROM_ADDRESS "\"$FROM\"" set_env MAIL_FROM_NAME "\"$FROM_NAME\"" ok "SMTP configured." ;; *) set_env MAIL_MAILER "log" info "Emails will be written to storage/logs/laravel.log." ;; esac # ── pushover ──────────────────────────────────────────────────────────────── header "Pushover (push notifications)" echo " Pushover delivers instant push alerts to your phone/desktop. Each user" echo " still supplies their own user key from the Notifications settings page." echo " You only need the application token here (one per EasyMonitor install)." echo "" echo " Create an app at: https://pushover.net/apps/build" echo "" if confirm "Do you already have a Pushover application token?"; then PUSHOVER_TOKEN=$(ask_secret "Pushover application token") if [ -n "$PUSHOVER_TOKEN" ]; then set_env PUSHOVER_APP_TOKEN "$PUSHOVER_TOKEN" ok "Pushover configured. Users can now add their user key at /settings/notifications." else info "Empty token — skipping. You can set PUSHOVER_APP_TOKEN in .env later." fi else info "Skipping. Email alerts still work; add PUSHOVER_APP_TOKEN to .env later to enable Pushover." fi # ── object storage (R2 / S3) ──────────────────────────────────────────────── header "Object storage (status page logos)" echo " 1) Local disk (default)" echo " 2) Cloudflare R2" echo " 3) Amazon S3" echo "" STORE_CHOICE="" while [[ ! "$STORE_CHOICE" =~ ^[1-3]$ ]]; do read -p " Choose [1/2/3]: " STORE_CHOICE done case "$STORE_CHOICE" in 2) info "Cloudflare R2 (S3-compatible)." R2_ACCOUNT_INPUT=$(ask "Cloudflare account ID" "") R2_BUCKET_INPUT=$(ask "R2 bucket name" "") R2_KEY_INPUT=$(ask "R2 access key" "") R2_SECRET_INPUT=$(ask_secret "R2 secret key") R2_PUBLIC_INPUT=$(ask "Public URL (e.g. https://cdn.example.com — leave blank if private)" "") # R2 has its own dedicated namespace so it never collides with AWS # (SES, S3, etc.) credentials. The 'r2' filesystem disk in # config/filesystems.php reads these R2_* env vars. set_env FILESYSTEM_DISK "r2" set_env R2_ACCESS_KEY "$R2_KEY_INPUT" set_env R2_SECRET_KEY "$R2_SECRET_INPUT" set_env R2_BUCKET "$R2_BUCKET_INPUT" set_env R2_ENDPOINT "https://${R2_ACCOUNT_INPUT}.r2.cloudflarestorage.com" [ -n "$R2_PUBLIC_INPUT" ] && set_env R2_URL "$R2_PUBLIC_INPUT" ok "R2 configured." ;; 3) info "Amazon S3." S3_BUCKET_INPUT=$(ask "S3 bucket name" "") # Reuse the AWS_* credentials already written by the SES flow (if any). # If not set yet, prompt now; otherwise just ask for the bucket. if ! grep -q '^AWS_ACCESS_KEY_ID=.' .env 2>/dev/null; then S3_KEY_INPUT=$(ask "AWS Access Key ID" "") S3_SECRET_INPUT=$(ask_secret "AWS Secret Access Key") S3_REGION_INPUT=$(ask "AWS region" "us-east-1") set_env AWS_ACCESS_KEY_ID "$S3_KEY_INPUT" set_env AWS_SECRET_ACCESS_KEY "$S3_SECRET_INPUT" set_env AWS_DEFAULT_REGION "$S3_REGION_INPUT" else info "Reusing AWS credentials already configured." fi set_env FILESYSTEM_DISK "s3" set_env AWS_BUCKET "$S3_BUCKET_INPUT" set_env AWS_USE_PATH_STYLE_ENDPOINT "false" ok "S3 configured." ;; *) set_env FILESYSTEM_DISK "local" info "Using local disk for uploads." ;; esac # ── patch production Caddyfile ────────────────────────────────────────────── if $IS_PROD; then header "Patching Caddyfile.production" CADDYFILE="docker/caddy/Caddyfile.production" # Replace email and domain placeholders. sed -i.bak \ -e "s/admin@your-domain.com/${ADMIN_EMAIL}/g" \ -e "s/your-domain.com/${DOMAIN}/g" \ "$CADDYFILE" rm -f "${CADDYFILE}.bak" ok "Caddyfile.production updated for ${DOMAIN}" # Ensure docker compose uses the production override automatically. # Merge with whatever is already set (e.g. remote-probes override added # earlier in this script) so we don't clobber previous choices. CURRENT_CF=$(grep -E '^COMPOSE_FILE=' .env 2>/dev/null | head -n1 | cut -d= -f2- || echo "") [ -z "$CURRENT_CF" ] && CURRENT_CF="docker-compose.yml" if [[ "$CURRENT_CF" != *"production.yml"* ]]; then # Insert production.yml right after the base compose file so its # overrides apply before any later overrides (e.g. remote-probes). CURRENT_CF=$(echo "$CURRENT_CF" | sed 's|^docker-compose.yml|docker-compose.yml:docker-compose.production.yml|') # Guard: if the base file wasn't first, just prepend production [[ "$CURRENT_CF" != *"production.yml"* ]] && CURRENT_CF="docker-compose.yml:docker-compose.production.yml:${CURRENT_CF}" fi set_env COMPOSE_FILE "$CURRENT_CF" ok "COMPOSE_FILE set so production overrides apply by default." fi # ── build & start docker ──────────────────────────────────────────────────── header "Starting Docker" info "Building images and starting containers (this may take a few minutes)..." docker compose up -d --build ok "Containers up." info "Waiting for services to become responsive..." sleep 6 # ── run inner setup ───────────────────────────────────────────────────────── header "Application setup" docker compose exec -T php bash /var/www/html/docker/scripts/setup.sh # ── apply retention policy ────────────────────────────────────────────────── # Migrations apply the policy on first install; re-running here makes sure a # changed CHECK_RESULT_RETENTION_DAYS also takes effect on existing installs. info "Applying check result retention policy..." docker compose exec -T php php artisan easymonitor:retention --no-interaction \ || warn "Could not apply retention policy — run 'docker compose exec php php artisan easymonitor:retention' manually." # ── recycle probe so it picks up the freshly-generated JWT_TOKEN ──────────── # The bundled probe container started before the inner setup wrote # PROBE_JWT_TOKEN to .env; recreate it so Docker re-reads the env file. info "Restarting probe to pick up the new token..." docker compose up -d --force-recreate probe >/dev/null 2>&1 || true ok "Probe restarted." # ── done ──────────────────────────────────────────────────────────────────── header "Done" if $IS_PROD; then ok "EasyMonitor is starting up at https://${DOMAIN}" echo "" echo " Next steps:" if [ -n "$PUBLIC_IP" ]; then echo " 1. DNS A record: ${DOMAIN} → ${PUBLIC_IP}" else echo " 1. Make sure DNS A record for ${DOMAIN} points to this server." fi echo " 2. Open https://${DOMAIN} — Caddy will provision the TLS cert on first request." echo " 3. Sign up to create the admin user (registration auto-locks after the first user)." echo " 4. Tail logs: docker compose logs -f php (or caddy / probe)" else ok "EasyMonitor is running at http://localhost" echo "" echo " Next steps:" echo " 1. Open http://localhost" echo " 2. Click Sign In → Sign up to create your account" echo " 3. Add your first monitor" fi if $REMOTE_PROBES; then echo "" echo " Remote probes:" echo " - Probe connection URL: ${REMOTE_REDIS_URL}" echo " - Generate a probe token: docker compose exec php php artisan probe:generate-token --node-id=" echo " - Full probe setup guide: PROBE_NODE_SETUP.md" fi echo ""