434 lines
15 KiB
Bash
434 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."
|
|
|
|
tcp_port_busy() { ss -H -ltn "sport = :$1" 2>/dev/null | grep -q .; }
|
|
udp_port_busy() { ss -H -lun "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 3000; do
|
|
if tcp_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
|
|
if udp_port_busy 443 && ! our_stack_running; then
|
|
ss -lunp 'sport = :443' >&2 || true
|
|
die "UDP port 443 is already occupied by another service. Nothing was changed."
|
|
fi
|
|
|
|
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'
|