Files

265 lines
11 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 _container_web_port(self):
"""Read the AdGuard HTTP address configured inside the container.
During the first-run wizard AdGuard writes web.listen_addresses to
AdGuardHome.yaml. If the wizard has not been completed yet, the
container is started with --web-addr 0.0.0.0:<port> and this method can
read that port from docker inspect."""
grep_cmd = "grep -E '^[[:space:]]*-?[[:space:]]*[0-9.]+:[0-9]+$' /opt/adguardhome/conf/AdGuardHome.yaml 2>/dev/null | head -n1"
out, _, _ = self.ssh.run_sudo_command(
f'docker exec {self.CONTAINER_NAME} sh -c "{grep_cmd}"'
)
line = out.strip().split('\n')[0].strip().lstrip('-').strip() if out.strip() else ''
if ':' in line:
try:
return int(line.rsplit(':', 1)[1])
except ValueError:
pass
out, _, _ = self.ssh.run_sudo_command(
f"docker inspect -f '{{{{json .Config.Cmd}}}}' {self.CONTAINER_NAME} 2>/dev/null"
)
marker = '--web-addr'
if marker in out:
parts = out.replace('[', ' ').replace(']', ' ').replace(',', ' ').replace('\"', ' ').split()
for i, part in enumerate(parts):
if part == marker and i + 1 < len(parts) and ':' in parts[i + 1]:
try:
return int(parts[i + 1].rsplit(':', 1)[1])
except ValueError:
pass
return None
def _exposed_web_port(self, container_port=None):
"""Reads back the host->container port mapping for the web UI port,
so the panel can show the correct admin URL after install."""
container_port = int(container_port or self.DEFAULT_WEB_PORT)
for port in (container_port, self.DEFAULT_WEB_PORT):
out, _, _ = self.ssh.run_sudo_command(
f"docker port {self.CONTAINER_NAME} {port}/tcp 2>/dev/null"
)
if not out.strip():
continue
# 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:
continue
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 ''
container_web_port = self._container_web_port() if running else None
exposed_port = self._exposed_web_port(container_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) — use the actual container web port
# when known, otherwise fall back to AdGuard's default :3000.
return {
'container_exists': exists,
'container_running': running,
'mode': mode,
'internal_ip': ip,
'web_port': exposed_port or container_web_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}:{web_port}/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} --web-addr 0.0.0.0:{web_port}"
)
_, 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