""" Hysteria 2 protocol manager — teddysun/hysteria Docker image. Installs Hysteria with Let's Encrypt TLS for an admin-provided domain (certbot standalone on port 80), then runs the UDP QUIC server. Clients use userpass auth; share links are hysteria2:// URIs. """ from __future__ import annotations import json import logging import re import secrets import shlex import string from urllib.parse import quote logger = logging.getLogger(__name__) def _q(value): return shlex.quote(str(value)) def _rand_token(length=16): alphabet = string.ascii_lowercase + string.digits return ''.join(secrets.choice(alphabet) for _ in range(length)) class HysteriaManager: PROTOCOL = 'hysteria' CONTAINER_NAME = 'amnezia-hysteria' IMAGE_NAME = 'teddysun/hysteria:latest' CERTBOT_IMAGE = 'certbot/certbot:latest' BASE_DIR = '/opt/amnezia/hysteria' DEFAULT_PORT = 8998 def __init__(self, ssh, protocol='hysteria'): self.ssh = ssh self.protocol = protocol or self.PROTOCOL self.instance = self._instance_index(self.protocol) self.container_name = self._container_name(self.protocol) self.base_dir = self._base_dir(self.protocol) self.config_path = f'{self.base_dir}/server.yaml' self.clients_path = f'{self.base_dir}/clients.json' self.meta_path = f'{self.base_dir}/metadata.json' self.cert_path = f'{self.base_dir}/cert.crt' self.key_path = f'{self.base_dir}/private.key' self.letsencrypt_dir = f'{self.base_dir}/letsencrypt' def _instance_index(self, protocol=None): parts = str(protocol or self.protocol or '').split('__', 1) if len(parts) == 2: try: return max(1, int(parts[1])) except ValueError: return 1 return 1 def _container_name(self, protocol=None): idx = self._instance_index(protocol or self.protocol) return self.CONTAINER_NAME if idx <= 1 else f'{self.CONTAINER_NAME}-{idx}' def _base_dir(self, protocol=None): idx = self._instance_index(protocol or self.protocol) return self.BASE_DIR if idx <= 1 else f'{self.BASE_DIR}-{idx}' # ===================== 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=None): name = self._container_name(protocol_type or self.protocol) out, _, _ = self.ssh.run_sudo_command( f"docker ps -a --filter name=^{name}$ --format '{{{{.Names}}}}'" ) return name in out.strip().split('\n') def check_container_running(self, protocol_type=None): name = self._container_name(protocol_type or self.protocol) out, _, _ = self.ssh.run_sudo_command( f"docker ps --filter name=^{name}$ --format '{{{{.Status}}}}'" ) return 'Up' in out def get_server_status(self, protocol_type=None): protocol_type = protocol_type or self.protocol exists = self.check_protocol_installed(protocol_type) running = self.check_container_running(protocol_type) meta = self._read_metadata() if exists else {} clients = self._read_clients() if exists else [] return { 'container_exists': exists, 'container_running': running, 'port': int(meta.get('port') or self.DEFAULT_PORT), 'domain': meta.get('domain'), 'email': meta.get('email'), 'clients_count': len(clients), 'protocol': protocol_type, 'base_protocol': self.PROTOCOL, 'instance': self._instance_index(protocol_type), 'container_name': self._container_name(protocol_type), } # ===================== HELPERS ===================== def _validate_domain(self, domain): domain = (domain or '').strip().lower() if not domain or len(domain) > 253: raise ValueError('Domain is required for Hysteria SSL') if not re.match(r'^(?!-)[a-z0-9.-]+(?/dev/null") return out if code == 0 else '' def _write_file(self, path, content): parent = path.rsplit('/', 1)[0] self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}") self.ssh.upload_file_sudo(content, path) def _read_metadata(self): raw = self._read_file(self.meta_path) if not raw.strip(): return {} try: return json.loads(raw) except Exception: return {} def _write_metadata(self, meta): self._write_file(self.meta_path, json.dumps(meta, indent=2, ensure_ascii=False)) def _read_clients(self): raw = self._read_file(self.clients_path) if not raw.strip(): return [] try: data = json.loads(raw) return data if isinstance(data, list) else [] except Exception: return [] def _write_clients(self, clients): self._write_file(self.clients_path, json.dumps(clients, indent=2, ensure_ascii=False)) def _build_server_yaml(self, port, clients): lines = [ f'listen: :{int(port)}', '', 'tls:', ' cert: /etc/hysteria/cert.crt', ' key: /etc/hysteria/private.key', '', 'auth:', ' type: userpass', ' userpass:', ] if clients: for c in clients: user = str(c.get('username') or '').strip() pwd = str(c.get('password') or '').strip() if user and pwd: # YAML double-quote to allow special chars lines.append(f' "{user}": "{pwd}"') else: # Placeholder so Hysteria starts; first real client replaces this lines.append(f' "__bootstrap__": "{_rand_token(24)}"') lines.extend([ '', 'masquerade:', ' type: proxy', ' proxy:', ' url: https://www.bing.com', ' rewriteHost: true', '', ]) return '\n'.join(lines) def _write_config_from_clients(self, port=None): meta = self._read_metadata() port = int(port or meta.get('port') or self.DEFAULT_PORT) clients = self._read_clients() self._write_file(self.config_path, self._build_server_yaml(port, clients)) return port def _reload_container(self): name = self.container_name if self.check_container_running(self.protocol): self.ssh.run_sudo_command(f"docker restart {name}", timeout=60) def _issue_certificate(self, domain, email): """Issue Let's Encrypt cert via certbot standalone (needs free TCP 80).""" self.ssh.run_sudo_command(f"mkdir -p {_q(self.letsencrypt_dir)}") self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180) # Stop anything briefly occupying 80 if it's our leftover certbot self.ssh.run_sudo_command( "docker rm -fv amnezia-hysteria-certbot 2>/dev/null || true" ) cmd = ( f"docker run --rm --name amnezia-hysteria-certbot " f"-p 80:80 " f"-v {_q(self.letsencrypt_dir)}:/etc/letsencrypt " f"{self.CERTBOT_IMAGE} certonly --standalone " f"--non-interactive --agree-tos --no-eff-email " f"--email {_q(email)} -d {_q(domain)}" ) out, err, code = self.ssh.run_sudo_command(cmd, timeout=300) if code != 0: raise RuntimeError( f"Let's Encrypt failed for {domain}. " f"Point A-record to this server and free TCP port 80. {err or out}" ) # Copy live certs into paths expected by teddysun image copy_script = f""" set -e LIVE={_q(self.letsencrypt_dir)}/live/{_q(domain)} test -s "$LIVE/fullchain.pem" test -s "$LIVE/privkey.pem" cp -f "$LIVE/fullchain.pem" {_q(self.cert_path)} cp -f "$LIVE/privkey.pem" {_q(self.key_path)} chmod 644 {_q(self.cert_path)} chmod 600 {_q(self.key_path)} """ out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30) if code != 0: raise RuntimeError(f'Failed to install certificate files: {err or out}') def _build_share_uri(self, host, port, domain, username, password, name): userinfo = f"{quote(username, safe='')}:{quote(password, safe='')}" fragment = quote(name or username, safe='') sni = domain or host conn_host = domain or host return ( f"hysteria2://{userinfo}@{conn_host}:{int(port)}/" f"?sni={quote(sni, safe='')}&insecure=0#{fragment}" ) # ===================== INSTALL / REMOVE ===================== def install_protocol(self, protocol_type=None, port=None, domain=None, email=None): protocol_type = protocol_type or self.protocol if not self.check_docker_installed(): return {'status': 'error', 'message': 'Docker not installed'} domain = self._validate_domain(domain) email = self._validate_email(email) port = int(port or self.DEFAULT_PORT) if port == 80: return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'} log = [] self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180) log.append(f'Pulled {self.IMAGE_NAME}') if self.check_protocol_installed(protocol_type): self.remove_container(protocol_type) log.append('Removed previous container') self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") self._write_clients([]) self._write_metadata({ 'domain': domain, 'email': email, 'port': port, }) self._write_config_from_clients(port) log.append('Prepared config directory') try: self._issue_certificate(domain, email) log.append(f"Issued Let's Encrypt certificate for {domain}") except Exception as e: return {'status': 'error', 'message': str(e), 'log': log} # Map host dir to /etc/hysteria as required by teddysun image run_cmd = ( f"docker run -d --restart always " f"--name {self.container_name} " f"--cap-add=NET_ADMIN " f"-p {port}:{port}/udp " f"-v {_q(self.base_dir)}:/etc/hysteria " f"{self.IMAGE_NAME}" ) _, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60) if code != 0: return {'status': 'error', 'message': f'Failed to start Hysteria: {err}', 'log': log} log.append(f'Started {self.container_name} on UDP {port}') return { 'status': 'success', 'message': 'Hysteria installed', 'log': log, 'port': str(port), 'domain': domain, 'email': email, } def remove_container(self, protocol_type=None): protocol_type = protocol_type or self.protocol name = self._container_name(protocol_type) base = self._base_dir(protocol_type) self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true") self.ssh.run_sudo_command(f"rm -rf {_q(base)}") return True def get_server_config(self, protocol_type=None): return self._read_file(self.config_path) def save_server_config(self, protocol_type=None, config_text=''): self._write_file(self.config_path, config_text or '') self._reload_container() return True # ===================== CLIENTS ===================== def get_clients(self, protocol_type=None): clients = self._read_clients() result = [] for c in clients: result.append({ 'clientId': c.get('id'), 'client_id': c.get('id'), 'id': c.get('id'), 'name': c.get('name') or c.get('username'), 'email': c.get('username'), 'userData': {'clientName': c.get('name') or c.get('username')}, }) return result def add_client(self, protocol_type, name, host, port=None): meta = self._read_metadata() domain = meta.get('domain') or host port = int(port or meta.get('port') or self.DEFAULT_PORT) client_id = secrets.token_hex(8) username = f"u_{client_id}" password = _rand_token(20) clients = self._read_clients() clients.append({ 'id': client_id, 'name': name or username, 'username': username, 'password': password, 'enabled': True, }) self._write_clients(clients) self._write_config_from_clients(port) self._reload_container() config = self._build_share_uri(host, port, domain, username, password, name or username) return {'client_id': client_id, 'config': config} def get_client_config(self, protocol_type, client_id, host, port=None): meta = self._read_metadata() domain = meta.get('domain') or host port = int(port or meta.get('port') or self.DEFAULT_PORT) clients = self._read_clients() client = next((c for c in clients if c.get('id') == client_id), None) if not client: raise RuntimeError('Client not found') return self._build_share_uri( host, port, domain, client.get('username'), client.get('password'), client.get('name') or client.get('username'), ) def remove_client(self, protocol_type, client_id): clients = [c for c in self._read_clients() if c.get('id') != client_id] self._write_clients(clients) self._write_config_from_clients() self._reload_container() return True def toggle_client(self, protocol_type, client_id, enable=True): # Soft-disable: remove from yaml but keep in table with enabled=false clients = self._read_clients() found = False for c in clients: if c.get('id') == client_id: c['enabled'] = bool(enable) found = True break if not found: raise RuntimeError('Client not found') self._write_clients(clients) # Rebuild yaml with only enabled clients active = [c for c in clients if c.get('enabled', True)] meta = self._read_metadata() port = int(meta.get('port') or self.DEFAULT_PORT) self._write_file(self.config_path, self._build_server_yaml(port, active)) self._reload_container() return True