""" Reverse Proxy Manager — Caddy 2 front for traffic masking. Serves a polished decoy website on ports 80/443 and optionally reverse-proxies a secret path to VPN backends (Xray, Telemt) on the internal Docker network. Reduces DPI footprint: probes see a normal HTTPS site instead of a VPN endpoint. """ import json import logging import os import re logger = logging.getLogger(__name__) BACKEND_UPSTREAMS = { 'none': None, 'xray': 'amnezia-xray', 'telemt': 'telemt', } DEFAULT_VPN_PATH = '/cdn-cgi/challenge' DEFAULT_BACKEND_PORT = 8443 class RevproxyManager: PROTOCOL = 'revproxy' CONTAINER_NAME = 'amnezia-revproxy' REMOTE_DIR = '/opt/amnezia/revproxy' SETTINGS_FILE = 'settings.json' def __init__(self, ssh): self.ssh = ssh # ===================== STATUS ===================== def check_docker_installed(self): out, _, code = self.ssh.run_command('docker --version 2>/dev/null') return code == 0 and bool(out.strip()) def check_protocol_installed(self, protocol_type='revproxy'): 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 get_server_status(self, protocol_type='revproxy'): exists = self.check_protocol_installed() running = self.check_container_running() settings = self._read_settings() if exists else {} site_url = self._build_site_url(settings) return { 'container_exists': exists, 'container_running': running, 'port': 443, 'protocol': protocol_type, 'domain': settings.get('domain', ''), 'site_title': settings.get('site_title', 'CloudEdge'), 'backend': settings.get('backend', 'none'), 'backend_port': settings.get('backend_port', DEFAULT_BACKEND_PORT), 'vpn_path': settings.get('vpn_path', DEFAULT_VPN_PATH), 'tls_mode': settings.get('tls_mode', 'internal'), 'site_url': site_url, 'mask_enabled': settings.get('backend') in ('xray', 'telemt'), } # ===================== INSTALL / UPDATE / REMOVE ===================== def install_protocol( self, protocol_type='revproxy', domain=None, site_title=None, backend='none', backend_port=None, vpn_path=None, tls_email=None, enable_telemt_mask=False, ): if not self.check_docker_installed(): return {'status': 'error', 'message': 'Docker not installed'} backend = (backend or 'none').lower() if backend not in BACKEND_UPSTREAMS: backend = 'none' backend_port = int(backend_port or DEFAULT_BACKEND_PORT) vpn_path = self._normalize_path(vpn_path or DEFAULT_VPN_PATH) site_title = (site_title or 'CloudEdge').strip() or 'CloudEdge' domain = (domain or '').strip().lower() tls_email = (tls_email or '').strip() tls_mode = 'acme' if domain else 'internal' settings = { 'domain': domain, 'site_title': site_title, 'backend': backend, 'backend_port': backend_port, 'vpn_path': vpn_path, 'tls_email': tls_email, 'tls_mode': tls_mode, } log = [] if self.check_protocol_installed(): log.append('Removing previous reverse proxy container...') self.remove_container() log.append('Ensuring Docker network amnezia-dns-net...') self._ensure_network() log.append('Ensuring docker compose plugin...') self._ensure_docker_compose() log.append('Uploading reverse proxy files...') self._upload_bundle(settings) log.append('Starting Caddy reverse proxy...') out, err, code = self.ssh.run_sudo_command( f"sh -c 'cd {self.REMOTE_DIR} && docker compose up -d'", timeout=180 ) if code != 0: out2, err2, code2 = self.ssh.run_sudo_command( f"sh -c 'cd {self.REMOTE_DIR} && docker-compose up -d'", timeout=180 ) if code2 != 0: return { 'status': 'error', 'message': f'Failed to start reverse proxy: {err or err2 or out or out2}', } if backend == 'telemt' and enable_telemt_mask: log.append('Enabling Telemt site masking (mask → Caddy)...') self._configure_telemt_mask() elif backend == 'xray': self.ssh.run_sudo_command( 'docker network connect amnezia-dns-net amnezia-xray 2>/dev/null || true' ) site_url = self._build_site_url(settings) log.append(f'Decoy site: {site_url}') if backend != 'none': log.append(f'VPN tunnel path: {vpn_path} → {BACKEND_UPSTREAMS[backend]}:{backend_port}') log.append('Ensure the backend listens on the internal port, not host 443.') return { 'status': 'success', 'protocol': 'revproxy', 'port': 443, 'domain': domain, 'site_url': site_url, 'backend': backend, 'vpn_path': vpn_path, 'tls_mode': tls_mode, 'message': 'Reverse proxy installed', 'log': log, } def update_settings( self, domain=None, site_title=None, backend=None, backend_port=None, vpn_path=None, tls_email=None, enable_telemt_mask=False, ): if not self.check_protocol_installed(): return {'status': 'error', 'message': 'Reverse proxy not installed'} current = self._read_settings() settings = { 'domain': (domain if domain is not None else current.get('domain', '')).strip().lower(), 'site_title': (site_title if site_title is not None else current.get('site_title', 'CloudEdge')).strip(), 'backend': (backend if backend is not None else current.get('backend', 'none')).lower(), 'backend_port': int(backend_port if backend_port is not None else current.get('backend_port', DEFAULT_BACKEND_PORT)), 'vpn_path': self._normalize_path(vpn_path if vpn_path is not None else current.get('vpn_path', DEFAULT_VPN_PATH)), 'tls_email': (tls_email if tls_email is not None else current.get('tls_email', '')).strip(), 'tls_mode': 'acme' if (domain if domain is not None else current.get('domain')) else 'internal', } if settings['backend'] not in BACKEND_UPSTREAMS: settings['backend'] = 'none' self._write_settings(settings) self._write_caddyfile(settings) self._write_site_html(settings) self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") if settings['backend'] == 'telemt' and enable_telemt_mask: self._configure_telemt_mask() return { 'status': 'success', **settings, 'site_url': self._build_site_url(settings), } def save_server_config(self, protocol_type, config_content): """Save raw Caddyfile and hot-reload.""" path = f"{self.REMOTE_DIR}/Caddyfile" self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), path) self.ssh.run_sudo_command( f"docker exec {self.CONTAINER_NAME} caddy reload --config /etc/caddy/Caddyfile 2>/dev/null " f"|| docker restart {self.CONTAINER_NAME}" ) def remove_container(self, protocol_type='revproxy'): 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.REMOTE_DIR}") return True def get_settings(self): return self._read_settings() def get_caddyfile(self): out, _, code = self.ssh.run_sudo_command(f"cat {self.REMOTE_DIR}/Caddyfile 2>/dev/null") if code != 0: return '' return out # ===================== INTERNALS ===================== def _ensure_network(self): script = """ docker network inspect amnezia-dns-net >/dev/null 2>&1 || \ docker network create --subnet=172.29.172.0/24 amnezia-dns-net """ self.ssh.run_sudo_script(script) def _ensure_docker_compose(self): out, _, code = self.ssh.run_command('docker compose version 2>/dev/null') if code == 0 and out.strip(): return self.ssh.run_sudo_command( 'apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null || true', timeout=120, ) def _upload_bundle(self, settings): local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy') remote = self.REMOTE_DIR self.ssh.run_sudo_command(f'mkdir -p {remote}/site') self.ssh.run_sudo_command(f'chmod -R 755 {remote}') with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f: self.ssh.upload_file_sudo(f.read(), f'{remote}/docker-compose.yml') self._write_settings(settings) self._write_caddyfile(settings) self._write_site_html(settings) for fname in ('index.html', 'style.css'): local_path = os.path.join(local_dir, 'site', fname) if os.path.exists(local_path) and fname != 'index.html': with open(local_path, 'r', encoding='utf-8') as f: self.ssh.upload_file_sudo(f.read(), f'{remote}/site/{fname}') def _write_settings(self, settings): content = json.dumps(settings, indent=2, ensure_ascii=False) self.ssh.upload_file_sudo(content, f'{self.REMOTE_DIR}/{self.SETTINGS_FILE}') def _read_settings(self): out, _, code = self.ssh.run_sudo_command( f"cat {self.REMOTE_DIR}/{self.SETTINGS_FILE} 2>/dev/null" ) if code != 0 or not out.strip(): return {} try: return json.loads(out) except json.JSONDecodeError: return {} def _write_site_html(self, settings): local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy', 'site') with open(os.path.join(local_dir, 'index.html'), 'r', encoding='utf-8') as f: html = f.read() title = settings.get('site_title', 'CloudEdge') domain = settings.get('domain') or self.ssh.host html = html.replace('{{SITE_TITLE}}', self._escape_html(title)) html = html.replace('{{SITE_DOMAIN}}', self._escape_html(domain)) self.ssh.upload_file_sudo(html, f'{self.REMOTE_DIR}/site/index.html') style_path = os.path.join(local_dir, 'style.css') if os.path.exists(style_path): with open(style_path, 'r', encoding='utf-8') as f: self.ssh.upload_file_sudo(f.read(), f'{self.REMOTE_DIR}/site/style.css') def _write_caddyfile(self, settings): caddyfile = self._build_caddyfile(settings) self.ssh.upload_file_sudo(caddyfile, f'{self.REMOTE_DIR}/Caddyfile') def _build_caddyfile(self, settings): domain = settings.get('domain', '') site_title = settings.get('site_title', 'CloudEdge') backend = settings.get('backend', 'none') backend_port = int(settings.get('backend_port', DEFAULT_BACKEND_PORT)) vpn_path = self._normalize_path(settings.get('vpn_path', DEFAULT_VPN_PATH)) tls_email = settings.get('tls_email', '') site_address = domain if domain else ':443' global_block = '{\n\tadmin off\n\tservers {\n\t\tprotocols h1 h2 h3\n\t}\n' if tls_email and domain: global_block += f'\temail {tls_email}\n' global_block += '}\n\n' tls_line = '' if not domain: tls_line = '\n\ttls internal' backend_block = '' if backend != 'none' and BACKEND_UPSTREAMS.get(backend): upstream = f'{BACKEND_UPSTREAMS[backend]}:{backend_port}' backend_block = f""" \t@vpn path {vpn_path}* \thandle @vpn {{ \t\treverse_proxy {upstream} {{ \t\t\theader_up Host {{host}} \t\t\theader_up X-Real-IP {{remote_host}} \t\t\theader_up X-Forwarded-For {{remote_host}} \t\t\theader_up X-Forwarded-Proto {{scheme}} \t\t\ttransport http {{ \t\t\t\tkeepalive 30s \t\t\t\tkeepalive_idle_conns 100 \t\t\t}} \t\t}} \t}} """ return f"""{global_block}{site_address} {{{tls_line} \troot * /srv/site \tencode gzip zstd \theader {{ \t\tStrict-Transport-Security "max-age=31536000; includeSubDomains; preload" \t\tX-Content-Type-Options nosniff \t\tX-Frame-Options SAMEORIGIN \t\tReferrer-Policy strict-origin-when-cross-origin \t\t-Server \t}} {backend_block} \thandle {{ \t\ttry_files {{path}} {{path}}/ /index.html \t\tfile_server \t}} }} :80 {{ \tredir https://{{host}}{{uri}} permanent }} """ def _configure_telemt_mask(self): """Point Telemt mask at the Caddy container on the Docker network.""" config_path = '/opt/amnezia/telemt/config.toml' out, _, code = self.ssh.run_sudo_command(f"test -f {config_path} && cat {config_path}") if code != 0: return config = out config = re.sub(r'mask\s*=\s*(true|false)', 'mask = true', config) if 'mask_host' in config: config = re.sub(r'#?\s*mask_host\s*=\s*".*?"', 'mask_host = "amnezia-revproxy"', config) else: config = config.replace('[censorship]', '[censorship]\nmask_host = "amnezia-revproxy"') if 'mask_port' in config: config = re.sub(r'#?\s*mask_port\s*=\s*\d+', 'mask_port = 443', config) else: config = config.replace('mask_host = "amnezia-revproxy"', 'mask_host = "amnezia-revproxy"\nmask_port = 443') self.ssh.upload_file_sudo(config.replace('\r\n', '\n'), config_path) self.ssh.run_sudo_command( 'docker network connect amnezia-dns-net telemt 2>/dev/null || true' ) self.ssh.run_sudo_command( 'docker kill -s HUP telemt 2>/dev/null || docker restart telemt 2>/dev/null || true' ) def _build_site_url(self, settings): domain = settings.get('domain', '') if domain: return f'https://{domain}' return f'https://{self.ssh.host}' @staticmethod def _normalize_path(path): path = (path or DEFAULT_VPN_PATH).strip() if not path.startswith('/'): path = '/' + path return path.rstrip('/') or '/' @staticmethod def _escape_html(text): return ( str(text) .replace('&', '&') .replace('<', '<') .replace('>', '>') .replace('"', '"') )