From d97726aa91c94703f4acbf9e18b743bd72b7c723 Mon Sep 17 00:00:00 2001 From: AlexChard Date: Sat, 29 Aug 2026 19:16:00 +0400 Subject: [PATCH] Add safe AdGuard DoH installer --- .gitignore | 8 + LICENSE | 21 ++ README.md | 107 ++++++++++ install.sh | 428 +++++++++++++++++++++++++++++++++++++ templates/cover/index.html | 16 ++ verify.sh | 37 ++++ 6 files changed, 617 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 install.sh create mode 100644 templates/cover/index.html create mode 100644 verify.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c164662 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +conf/ +work/ +caddy-data/ +caddy-config/ +backups/ +*.bak +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb89234 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AlexChard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f26c2d3 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# AdGuard DoH Installer + +Безопасный повторяемый установщик AdGuard Home + Caddy для VPS. + +Он разворачивает: + +- публичный DNS-over-HTTPS на `https://dns.example.com/dns-query`; +- отдельную HTTPS-панель на нейтральном домене вроде `https://node.example.com`; +- нейтральную статическую заглушку на корне DoH-домена; +- автоматические сертификаты Let's Encrypt; +- Cloudflare и Google DoH как основные upstream, несколько обычных DNS только как bootstrap/fallback; +- Docker Compose-стек с лимитами памяти и автоматическим перезапуском. + +Прямой порт AdGuard `3000` публикуется только на `127.0.0.1`. Установщик не меняет VPN, маршруты, UFW/iptables и SSH. + +## Быстрая установка + +Сначала создайте две A-записи, указывающие на IP нового VPS: + +```text +dns.example.com -> VPS_IP +node.example.com -> VPS_IP +``` + +Затем выполните одну команду: + +```bash +curl -fsSL https://git.chardb.ru/VPS/adguard-doh-installer/raw/branch/main/install.sh | sudo bash +``` + +Скрипт спросит оба домена и пароль администратора. Пароль не попадает в историю shell. Если оставить его пустым, будет создан случайный пароль и показан один раз. + +Неинтерактивный запуск: + +```bash +curl -fsSL https://git.chardb.ru/VPS/adguard-doh-installer/raw/branch/main/install.sh | \ + sudo bash -s -- --doh-domain dns.example.com --admin-domain node.example.com --email admin@example.com +``` + +## Безопасное поведение + +Перед изменениями скрипт: + +1. проверяет синтаксис доменов; +2. проверяет их A-записи и публичный IP VPS; +3. обнаруживает уже работающий AdGuard Home; +4. проверяет занятость TCP-портов `80` и `443`; +5. отказывается захватывать чужой Nginx/Caddy/Apache; +6. не принимает непустую чужую директорию без явного `--adopt-existing`; +7. сохраняет резервные копии управляемых файлов при повторном запуске; +8. сохраняет существующий `AdGuardHome.yaml`, пользователей, клиентов и правила при обновлении; +9. проверяет Compose до запуска и HTTPS после запуска. + +Проверить новый VPS без внесения изменений: + +```bash +curl -fsSL https://git.chardb.ru/VPS/adguard-doh-installer/raw/branch/main/install.sh | \ + sudo bash -s -- --check --doh-domain dns.example.com --admin-domain node.example.com +``` + +Если DNS-записи ещё распространяются, можно установить с `--allow-dns-mismatch`. Caddy будет повторять выпуск сертификата автоматически. + +## Повторный запуск и обновление + +Повторите ту же команду. Для стека, созданного этим установщиком, данные AdGuard и пароль сохраняются. Перед обновлением создаётся каталог: + +```text +/opt/stacks/adguard-doh/backups// +``` + +Чужой стек автоматически не усыновляется. Для совместимой установки можно явно указать: + +```bash +sudo bash install.sh --adopt-existing --stack-dir /opt/stacks/adguard-doh \ + --doh-domain dns.example.com --admin-domain node.example.com +``` + +## Проверка + +```bash +sudo bash verify.sh +``` + +Ожидается: + +- админка: HTTP `302` или `200`; +- `/dns-query` без DNS-пакета: HTTP `400`; +- заглушка: HTTP `200`; +- порт `3000`: только `127.0.0.1:3000`. + +## Требования и ограничения + +- Debian/Ubuntu для автоматической установки Docker; +- root/sudo; +- свободные TCP `80` и `443`; +- домены с A-записями на VPS; +- существующий reverse proxy намеренно не перенастраивается автоматически. + +Если на VPS уже занят `80/443`, установщик завершится до изменений. Это безопаснее автоматического удаления или перенастройки чужого сервиса. + +## Заглушка + +Исходник находится в `templates/cover/index.html`. Установщик кладёт автономную копию в `site/index.html`. На `/dns-query` заглушка не влияет. + +## Лицензия + +MIT. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..14026aa --- /dev/null +++ b/install.sh @@ -0,0 +1,428 @@ +#!/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" < + + + + + + Network Services + + +

Network services

This endpoint provides infrastructure services for authorized clients.

Service operational
+ diff --git a/verify.sh b/verify.sh new file mode 100644 index 0000000..034d08a --- /dev/null +++ b/verify.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +STACK_DIR="${1:-/opt/stacks/adguard-doh}" +[[ -f "$STACK_DIR/.env" && -f "$STACK_DIR/compose.yaml" ]] || { + echo "Stack not found: $STACK_DIR" >&2 + exit 1 +} + +set -a +# shellcheck disable=SC1090 +source "$STACK_DIR/.env" +set +a + +cd "$STACK_DIR" +docker compose ps +docker compose config -q + +admin_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$ADMIN_DOMAIN/")" +doh_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$DOH_DOMAIN/dns-query")" +cover_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$DOH_DOMAIN/")" + +printf 'Admin HTTPS: HTTP %s\n' "$admin_code" +printf 'DoH endpoint: HTTP %s (400 is expected without a DNS payload)\n' "$doh_code" +printf 'Cover site: HTTP %s\n' "$cover_code" + +[[ "$admin_code" == "302" || "$admin_code" == "200" ]] +[[ "$doh_code" == "400" ]] +[[ "$cover_code" == "200" ]] + +listeners="$(ss -H -ltn 'sport = :3000')" +if [[ -z "$listeners" ]] || printf '%s\n' "$listeners" | awk '{print $4}' | grep -Evq '^127\.0\.0\.1:3000$'; then + echo 'Port 3000 is exposed on a non-loopback address.' >&2 + exit 1 +fi + +echo 'Verification passed.'