Files
amnezia/managers/adguard_manager.py
T
test2andCursor 7ef408afe7 Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:41:50 +03:00

230 lines
9.2 KiB
Python

"""
AdGuard Home Manager — runs adguard/adguardhome in a Docker container,
joined to the same internal `amnezia-dns-net` network the rest of the panel
uses. Two install modes:
* 'replace' — removes the AmneziaDNS container (if present) and takes
its static IP (172.29.172.254) so VPN clients keep using
the same upstream.
* 'sidebyside' — runs alongside AmneziaDNS on a different static IP
(172.29.172.253). VPN users can hit it on demand
(e.g. via the web admin UI from inside the tunnel).
Initial setup of AdGuard itself runs through its built-in wizard on the web
UI port — we do not try to script it (the JSON setup API is unstable across
versions and trying to drive it programmatically tends to break on upgrade).
"""
import logging
logger = logging.getLogger(__name__)
class AdguardManager:
PROTOCOL = 'adguard'
CONTAINER_NAME = 'amnezia-adguard'
IMAGE_NAME = 'adguard/adguardhome:latest'
NETWORK_NAME = 'amnezia-dns-net'
NETWORK_SUBNET = '172.29.172.0/24'
REPLACE_IP = '172.29.172.254' # AmneziaDNS's slot
SIDEBYSIDE_IP = '172.29.172.253' # parallel to AmneziaDNS
HOST_DIR = '/opt/amnezia/adguard'
DEFAULT_DNS_PORT = 53
DEFAULT_WEB_PORT = 3000
DEFAULT_DOT_PORT = 853
DEFAULT_DOH_PORT = 443
def __init__(self, ssh):
self.ssh = ssh
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = self.ssh.run_command(
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
)
return 'active' in out2 or 'running' in out2.lower()
def check_protocol_installed(self, protocol_type='adguard'):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
)
return self.CONTAINER_NAME in out.strip().split('\n')
def check_container_running(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def _container_ip(self):
out, _, _ = self.ssh.run_sudo_command(
f"docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' {self.CONTAINER_NAME} 2>/dev/null"
)
ip = out.strip().split()[0] if out.strip() else ''
return ip
def _detect_mode(self):
"""Return 'replace' or 'sidebyside' based on the running container's IP.
Returns None if not detectable (container not running)."""
ip = self._container_ip()
if ip == self.REPLACE_IP:
return 'replace'
if ip == self.SIDEBYSIDE_IP:
return 'sidebyside'
return None
def _exposed_web_port(self):
"""Reads back the host->container port mapping for the web UI port,
so the panel can show the correct admin URL after install."""
out, _, _ = self.ssh.run_sudo_command(
f"docker port {self.CONTAINER_NAME} 3000/tcp 2>/dev/null"
)
if not out.strip():
return None
# output like "0.0.0.0:3000" — take the last colon-separated chunk
last = out.strip().split('\n')[0].split(':')[-1].strip()
try:
return int(last)
except ValueError:
return None
def get_server_status(self, protocol_type='adguard'):
exists = self.check_protocol_installed()
running = self.check_container_running()
mode = self._detect_mode() if running else None
ip = self._container_ip() if running else ''
exposed_port = self._exposed_web_port() if running else None
# When the web UI is not bound to the host the user still needs an
# admin URL (reachable via VPN) — fall back to the default :3000 the
# container listens on inside the docker network.
return {
'container_exists': exists,
'container_running': running,
'mode': mode,
'internal_ip': ip,
'web_port': exposed_port or self.DEFAULT_WEB_PORT,
'web_exposed': exposed_port is not None,
'port': self.DEFAULT_DNS_PORT,
'protocol': protocol_type,
}
# ===================== INSTALL / REMOVE =====================
def _ensure_network(self):
self.ssh.run_sudo_command(
f"docker network ls | grep -q {self.NETWORK_NAME} || "
f"docker network create --subnet {self.NETWORK_SUBNET} {self.NETWORK_NAME}"
)
def install_protocol(
self,
protocol_type='adguard',
mode='sidebyside',
web_port=None,
expose_web=False,
dns_port=None,
dot_port=None,
doh_port=None,
expose_dns=False,
expose_dot=False,
expose_doh=False,
):
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
if mode not in ('replace', 'sidebyside'):
return {'status': 'error', 'message': f"Invalid mode '{mode}'"}
web_port = int(web_port or self.DEFAULT_WEB_PORT)
dns_port = int(dns_port or self.DEFAULT_DNS_PORT)
dot_port = int(dot_port or self.DEFAULT_DOT_PORT)
doh_port = int(doh_port or self.DEFAULT_DOH_PORT)
# Persistent volumes — without these the AdGuard setup wizard would have
# to re-run on every container recreate.
self.ssh.run_sudo_command(f"mkdir -p {self.HOST_DIR}/work {self.HOST_DIR}/conf")
self._ensure_network()
# Replace mode: detach + remove AmneziaDNS so we can claim its IP.
if mode == 'replace':
self.ssh.run_sudo_command(
f"docker network disconnect {self.NETWORK_NAME} amnezia-dns 2>/dev/null || true"
)
self.ssh.run_sudo_command("docker stop amnezia-dns 2>/dev/null || true")
self.ssh.run_sudo_command("docker rm -fv amnezia-dns 2>/dev/null || true")
target_ip = self.REPLACE_IP
else:
target_ip = self.SIDEBYSIDE_IP
if self.check_protocol_installed():
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
# Build port mapping. By default ports are reachable only inside
# `amnezia-dns-net` (so VPN clients hit them via the static IP).
# Optional `expose_*` flags add host port mappings for direct access.
ports = []
if expose_web:
ports.append(f"-p {web_port}:3000/tcp")
if expose_dns:
ports.append(f"-p {dns_port}:53/tcp")
ports.append(f"-p {dns_port}:53/udp")
if expose_dot:
ports.append(f"-p {dot_port}:853/tcp")
if expose_doh:
ports.append(f"-p {doh_port}:443/tcp")
ports_str = ' '.join(ports)
run_cmd = (
f"docker run -d --name {self.CONTAINER_NAME} --restart always "
f"--network {self.NETWORK_NAME} --ip {target_ip} "
f"-v {self.HOST_DIR}/work:/opt/adguardhome/work "
f"-v {self.HOST_DIR}/conf:/opt/adguardhome/conf "
f"{ports_str} "
f"{self.IMAGE_NAME}"
)
_, err, code = self.ssh.run_sudo_command(run_cmd)
if code != 0:
return {'status': 'error', 'message': f'Failed to start container: {err}'}
# Re-attach known VPN containers to the DNS network so they can reach
# AdGuard at target_ip (mirrors what dns_manager.py does on install).
for c in ('amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'amnezia-wireguard', 'telemt'):
self.ssh.run_sudo_command(
f"docker ps --format '{{{{.Names}}}}' | grep -q '^{c}$' && "
f"docker network connect {self.NETWORK_NAME} {c} 2>/dev/null || true"
)
url_host = self.ssh.host if expose_web else target_ip
admin_url = f"http://{url_host}:{web_port}"
return {
'status': 'success',
'protocol': 'adguard',
'mode': mode,
'internal_ip': target_ip,
'web_port': web_port,
'expose_web': bool(expose_web),
'admin_url': admin_url,
'message': 'AdGuard Home installed. Complete the setup wizard via the web UI.',
'log': [
f"AdGuard Home installed in '{mode}' mode",
f"Internal IP: {target_ip}",
f"Admin UI: {admin_url}" + ("" if expose_web else " (VPN-only — connect via VPN to reach it)"),
'Open the URL above to run the AdGuard setup wizard.',
],
}
def remove_container(self, protocol_type='adguard'):
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
self.ssh.run_sudo_command(f"rm -rf {self.HOST_DIR}")
return True