#!/usr/bin/env bash set -Eeuo pipefail PROJECT_NAME="berloga-dns" STACK_DIR="/opt/stacks/adguard-doh" DOH_DOMAIN="" ADMIN_DOMAIN="" ACME_EMAIL="" ADMIN_USER="admin" ADMIN_PASSWORD="" ALLOW_DNS_MISMATCH=0 ADOPT_EXISTING=0 DRY_RUN=0 log() { printf '\033[1;34m[DNS]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[ERROR]\033[0m %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Usage: install.sh [options] --doh-domain DOMAIN Public DoH domain, for example dns.example.com --admin-domain DOMAIN HTTPS admin domain, for example node.example.com --email EMAIL Optional ACME account email --admin-user USER AdGuard administrator name (default: admin) --stack-dir PATH Installation directory (default: /opt/stacks/adguard-doh) --allow-dns-mismatch Continue when DNS does not yet point to this VPS --adopt-existing Adopt a compatible unmarked stack directory --check Validate only; do not write or start containers -h, --help Show this help The administrator password is requested from /dev/tty and is never written to shell history. If left empty, a random password is generated and shown once. EOF } while (($#)); do case "$1" in --doh-domain) DOH_DOMAIN="${2:-}"; shift 2 ;; --admin-domain) ADMIN_DOMAIN="${2:-}"; shift 2 ;; --email) ACME_EMAIL="${2:-}"; shift 2 ;; --admin-user) ADMIN_USER="${2:-}"; shift 2 ;; --stack-dir) STACK_DIR="${2:-}"; shift 2 ;; --allow-dns-mismatch) ALLOW_DNS_MISMATCH=1; shift ;; --adopt-existing) ADOPT_EXISTING=1; shift ;; --check) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown option: $1" ;; esac done [[ ${EUID:-$(id -u)} -eq 0 ]] || die "Run as root (sudo)." read_tty() { local prompt="$1" value="" [[ -r /dev/tty ]] || die "No terminal available; pass domains as arguments." printf '%s' "$prompt" > /dev/tty IFS= read -r value < /dev/tty printf '%s' "$value" } [[ -n "$DOH_DOMAIN" ]] || DOH_DOMAIN="$(read_tty 'DoH domain (dns.example.com): ')" [[ -n "$ADMIN_DOMAIN" ]] || ADMIN_DOMAIN="$(read_tty 'Admin domain (node.example.com): ')" valid_domain() { [[ "$1" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] } valid_domain "$DOH_DOMAIN" || die "Invalid DoH domain: $DOH_DOMAIN" valid_domain "$ADMIN_DOMAIN" || die "Invalid admin domain: $ADMIN_DOMAIN" [[ "$DOH_DOMAIN" != "$ADMIN_DOMAIN" ]] || die "DoH and admin domains must be different." [[ "$ADMIN_USER" =~ ^[A-Za-z0-9._-]{1,64}$ ]] || die "Invalid administrator name." [[ "$STACK_DIR" == /* && "$STACK_DIR" != "/" ]] || die "Stack directory must be an absolute non-root path." if [[ -e "$STACK_DIR" && ! -f "$STACK_DIR/.berloga-adguard-installer" ]]; then if ((ADOPT_EXISTING)) && [[ -f "$STACK_DIR/compose.yaml" && -f "$STACK_DIR/conf/AdGuardHome.yaml" ]]; then warn "Adopting compatible existing stack: $STACK_DIR" else die "$STACK_DIR already exists and is not managed by this installer. Nothing was changed. Use --adopt-existing only after reviewing it." fi fi if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Image}} {{.Names}}' | grep -qi 'adguard/adguardhome'; then if [[ ! -f "$STACK_DIR/.berloga-adguard-installer" && $ADOPT_EXISTING -eq 0 ]]; then die "Another AdGuard Home container is already running. Nothing was changed." fi fi command -v ss >/dev/null 2>&1 || die "The 'ss' utility is required (package: iproute2). Nothing was changed." command -v curl >/dev/null 2>&1 || die "The 'curl' utility is required. Nothing was changed." command -v getent >/dev/null 2>&1 || die "The 'getent' utility is required (package: libc-bin). Nothing was changed." port_busy() { ss -H -ltn "sport = :$1" 2>/dev/null | grep -q .; } our_stack_running() { command -v docker >/dev/null 2>&1 && docker ps --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.ID}}' | grep -q . } for port in 80 443; do if port_busy "$port" && ! our_stack_running; then ss -ltnp "sport = :$port" >&2 || true die "TCP port $port is already occupied by another service. Nothing was changed. Integrate with the existing reverse proxy instead of forcing takeover." fi done PUBLIC_IP="$(curl -4fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)" for domain in "$DOH_DOMAIN" "$ADMIN_DOMAIN"; do resolved="$(getent ahostsv4 "$domain" 2>/dev/null | awk 'NR==1{print $1}')" if [[ -z "$resolved" || (-n "$PUBLIC_IP" && "$resolved" != "$PUBLIC_IP") ]]; then if ((ALLOW_DNS_MISMATCH)); then warn "$domain currently resolves to '${resolved:-nothing}', VPS public IP is '${PUBLIC_IP:-unknown}'. Caddy will retry certificate issuance." else die "$domain does not point to this VPS (${PUBLIC_IP:-unknown}). Add A records first or use --allow-dns-mismatch. Nothing was changed." fi fi done if ((DRY_RUN)); then log "Checks passed. Ports are available and domains are valid. No changes made." exit 0 fi if ! command -v docker >/dev/null 2>&1; then command -v apt-get >/dev/null 2>&1 || die "Automatic Docker installation supports Debian/Ubuntu only." log "Docker is missing; installing distribution packages." export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y ca-certificates curl openssl docker.io apt-get install -y docker-compose-v2 || apt-get install -y docker-compose-plugin systemctl enable --now docker fi docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required." new_install=0 [[ -f "$STACK_DIR/conf/AdGuardHome.yaml" ]] || new_install=1 if ((new_install)); then if [[ -r /dev/tty ]]; then printf 'Administrator password (leave empty to generate): ' > /dev/tty IFS= read -rs ADMIN_PASSWORD < /dev/tty printf '\n' > /dev/tty fi if [[ -z "$ADMIN_PASSWORD" ]]; then ADMIN_PASSWORD="$(openssl rand -hex 16)" fi ((${#ADMIN_PASSWORD} >= 12)) || die "Administrator password must contain at least 12 characters." log "Creating password hash locally in the Caddy container." ADMIN_HASH="$(printf '%s\n%s\n' "$ADMIN_PASSWORD" "$ADMIN_PASSWORD" | docker run --rm -i caddy:2-alpine caddy hash-password)" fi timestamp="$(date -u +%Y%m%dT%H%M%SZ)" backup_dir="" if [[ -d "$STACK_DIR" ]]; then backup_dir="$STACK_DIR/backups/$timestamp" mkdir -p "$backup_dir" for item in compose.yaml Caddyfile .env site/index.html conf/AdGuardHome.yaml; do [[ -e "$STACK_DIR/$item" ]] || continue mkdir -p "$backup_dir/$(dirname "$item")" cp -a "$STACK_DIR/$item" "$backup_dir/$item" done log "Backup created: $backup_dir" fi mkdir -p "$STACK_DIR"/{conf,work,caddy-data,caddy-config,site,backups} touch "$STACK_DIR/.berloga-adguard-installer" cat > "$STACK_DIR/.env" < "$STACK_DIR/compose.yaml" <<'EOF' services: adguardhome: image: adguard/adguardhome:v0.107.78 restart: unless-stopped volumes: - ./work:/opt/adguardhome/work - ./conf:/opt/adguardhome/conf expose: - "53/tcp" - "53/udp" - "3000/tcp" ports: - "127.0.0.1:3000:3000/tcp" mem_limit: 256m oom_score_adj: -500 networks: - dns-internal caddy: image: caddy:2-alpine restart: unless-stopped depends_on: - adguardhome ports: - "80:80/tcp" - "443:443/tcp" - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./site:/srv/cover:ro - ./caddy-data:/data - ./caddy-config:/config mem_limit: 96m oom_score_adj: -500 networks: - dns-internal networks: dns-internal: driver: bridge EOF global_options="" [[ -n "$ACME_EMAIL" ]] && global_options="{ email $ACME_EMAIL } " cat > "$STACK_DIR/Caddyfile" < "$STACK_DIR/site/index.html" <<'EOF' Network Services

Network services

This endpoint provides infrastructure services for authorized clients.

Service operational
EOF if ((new_install)); then cat > "$STACK_DIR/conf/AdGuardHome.yaml" <