Files
adguard-doh-installer/install.sh
T

429 lines
15 KiB
Bash

#!/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" <<EOF
COMPOSE_PROJECT_NAME=$PROJECT_NAME
DOH_DOMAIN=$DOH_DOMAIN
ADMIN_DOMAIN=$ADMIN_DOMAIN
ACME_EMAIL=$ACME_EMAIL
EOF
chmod 600 "$STACK_DIR/.env"
cat > "$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" <<EOF
${global_options}$DOH_DOMAIN {
@doh path /dns-query /dns-query/*
handle @doh {
reverse_proxy adguardhome:3000
}
handle {
root * /srv/cover
file_server
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
X-Frame-Options DENY
}
}
}
$ADMIN_DOMAIN {
reverse_proxy adguardhome:3000
}
EOF
cat > "$STACK_DIR/site/index.html" <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Network Services</title>
<style>
:root{color-scheme:dark;--bg:#0b1220;--card:#111c30;--text:#dce7f7;--muted:#8ea3bf;--accent:#55d6be}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 25% 10%,#16304a 0,var(--bg) 50%);font:16px/1.55 system-ui,sans-serif;color:var(--text)}
main{width:min(680px,calc(100% - 32px));padding:42px;border:1px solid #243653;border-radius:24px;background:color-mix(in srgb,var(--card) 90%,transparent);box-shadow:0 24px 80px #0008}
.mark{width:54px;height:54px;border-radius:16px;display:grid;place-items:center;background:#173649;color:var(--accent);font-size:28px}h1{margin:24px 0 8px;font-size:clamp(28px,5vw,44px)}p{margin:0;color:var(--muted)}.status{display:flex;gap:10px;align-items:center;margin-top:30px;padding-top:22px;border-top:1px solid #243653}.dot{width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 18px var(--accent)}
</style>
</head>
<body><main><div class="mark">◆</div><h1>Network services</h1><p>This endpoint provides infrastructure services for authorized clients.</p><div class="status"><span class="dot"></span><span>Service operational</span></div></main></body>
</html>
EOF
if ((new_install)); then
cat > "$STACK_DIR/conf/AdGuardHome.yaml" <<EOF
http:
pprof:
port: 6060
enabled: false
doh:
routes:
- GET /dns-query
- POST /dns-query
- GET /dns-query/{ClientID}
- POST /dns-query/{ClientID}
insecure_enabled: true
address: 0.0.0.0:3000
session_ttl: 30d
users:
- name: $ADMIN_USER
password: $ADMIN_HASH
auth_attempts: 5
block_auth_min: 15
http_proxy: ""
language: ""
theme: auto
dns:
bind_hosts:
- 0.0.0.0
port: 53
anonymize_client_ip: false
ratelimit: 100
ratelimit_subnet_len_ipv4: 24
ratelimit_subnet_len_ipv6: 56
ratelimit_whitelist: []
refuse_any: true
upstream_dns:
- https://cloudflare-dns.com/dns-query
- https://dns.google/dns-query
upstream_dns_file: ""
bootstrap_dns:
- 1.1.1.1
- 8.8.8.8
- 8.8.4.4
- 9.9.9.10
fallback_dns:
- 1.1.1.1
- 8.8.8.8
- 8.8.4.4
- 9.9.9.10
upstream_mode: parallel
fastest_timeout: 1s
allowed_clients: []
disallowed_clients: []
blocked_hosts: [version.bind, id.server, hostname.bind]
trusted_proxies:
- 127.0.0.0/8
- ::1/128
- 172.16.0.0/12
cache_enabled: true
cache_size: 4194304
cache_ttl_min: 0
cache_ttl_max: 0
cache_optimistic: true
cache_optimistic_answer_ttl: 30s
cache_optimistic_max_age: 12h
bogus_nxdomain: []
aaaa_disabled: false
enable_dnssec: true
edns_client_subnet: {custom_ip: "", enabled: false, use_custom: false}
max_goroutines: 150
handle_ddr: true
ipset: []
ipset_file: ""
bootstrap_prefer_ipv6: false
upstream_timeout: 10s
private_networks: []
use_private_ptr_resolvers: false
local_ptr_upstreams: []
use_dns64: false
dns64_prefixes: []
serve_http3: false
use_http3_upstreams: false
serve_plain_dns: true
hostsfile_enabled: true
pending_requests: {enabled: true}
tls: {enabled: false, server_name: "", force_https: false, port_https: 443, port_dns_over_tls: 853, port_dns_over_quic: 853, port_dnscrypt: 0, dnscrypt_config_file: "", certificate_chain: "", private_key: "", certificate_path: "", private_key_path: "", strict_sni_check: false}
querylog: {dir_path: "", ignored: [], interval: 90d, size_memory: 1000, enabled: true, ignored_enabled: false, file_enabled: true}
statistics: {dir_path: "", ignored: [], interval: 1d, enabled: true, ignored_enabled: false}
filters:
- enabled: true
url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
name: AdGuard DNS filter
id: 1
whitelist_filters: []
user_rules: []
dhcp:
enabled: false
interface_name: ""
local_domain_name: lan
dhcpv4: {gateway_ip: "", subnet_mask: "", range_start: "", range_end: "", lease_duration: 86400, icmp_timeout_msec: 1000, options: []}
dhcpv6: {range_start: "", lease_duration: 86400, ra_slaac_only: false, ra_allow_slaac: false}
filtering:
blocking_ipv4: ""
blocking_ipv6: ""
blocked_services: {schedule: {time_zone: UTC}, ids: []}
protection_disabled_until: null
safe_search: {enabled: false, bing: true, duckduckgo: true, ecosia: true, google: true, pixabay: true, yandex: true, youtube: true}
blocking_mode: default
parental_block_host: family-block.dns.adguard.com
safebrowsing_block_host: standard-block.dns.adguard.com
rewrites: []
safe_fs_patterns: [/opt/adguardhome/work/userfilters/*]
max_http_size: 256MB
safebrowsing_cache_size: 1048576
safesearch_cache_size: 1048576
parental_cache_size: 1048576
cache_time: 30
filters_update_interval: 24
blocked_response_ttl: 10
filtering_enabled: true
rewrites_enabled: true
parental_enabled: false
safebrowsing_enabled: false
protection_enabled: true
clients:
runtime_sources: {whois: true, arp: true, rdns: true, dhcp: true, hosts: true}
persistent: []
log: {enabled: true, file: "", max_backups: 0, max_size: 100, max_age: 3, compress: false, local_time: false, verbose: false}
os: {group: "", user: "", rlimit_nofile: 0}
schema_version: 34
EOF
fi
chmod 600 "$STACK_DIR/conf/AdGuardHome.yaml"
cd "$STACK_DIR"
docker compose config -q
docker compose pull
rollback() {
warn "Deployment failed. Existing stack files remain backed up at ${backup_dir:-not-created}."
}
trap rollback ERR
docker compose up -d
log "Waiting for AdGuard Home and HTTPS certificates."
for _ in $(seq 1 24); do
admin_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$ADMIN_DOMAIN/" || true)"
doh_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$DOH_DOMAIN/dns-query" || true)"
[[ "$admin_code" == "302" || "$admin_code" == "200" ]] && [[ "$doh_code" == "400" ]] && break
sleep 5
done
trap - ERR
admin_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$ADMIN_DOMAIN/" || true)"
doh_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$DOH_DOMAIN/dns-query" || true)"
[[ "$admin_code" == "302" || "$admin_code" == "200" ]] || die "Admin HTTPS verification failed (HTTP $admin_code). Check: docker compose logs caddy"
[[ "$doh_code" == "400" ]] || die "DoH endpoint verification failed (HTTP $doh_code)."
log "Installation complete."
printf 'DoH URL: https://%s/dns-query\n' "$DOH_DOMAIN"
printf 'Admin URL: https://%s\n' "$ADMIN_DOMAIN"
printf 'Cover URL: https://%s/\n' "$DOH_DOMAIN"
if ((new_install)); then
printf 'Admin user: %s\n' "$ADMIN_USER"
printf 'Admin password (shown once): %s\n' "$ADMIN_PASSWORD"
fi
printf 'Direct port 3000 is bound to localhost only. Firewall and VPN settings were not changed.\n'