""" 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), host networking, BBR, and salamander obfs. Clients use password auth (Happ/INCY-compatible); share links are hy2:// 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 = 443 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 [] # Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs) if exists: try: name = self._container_name(protocol_type) mode, _, _ = self.ssh.run_sudo_command( f"docker inspect -f '{{{{.HostConfig.NetworkMode}}}}' {name} 2>/dev/null" ) mode = (mode or '').strip() cfg = self._read_file(self.config_path) needs_cfg = ( 'type: userpass' in cfg or 'type: salamander' not in cfg or 'type: password' not in cfg ) if (mode and mode != 'host') or needs_cfg: port = int(meta.get('port') or self.DEFAULT_PORT) self._write_config_from_clients(port) self._start_container(port) running = self.check_container_running(protocol_type) meta = self._read_metadata() except Exception as e: logger.warning('Hysteria migrate failed: %s', e) 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 _ensure_secrets(self, meta): """Password auth (Happ/INCY-friendly) + salamander obfs secrets.""" changed = False if not meta.get('password'): meta['password'] = _rand_token(24) changed = True if not meta.get('obfs_password'): meta['obfs_password'] = _rand_token(16) changed = True if changed: self._write_metadata(meta) return meta def _build_server_yaml(self, port, clients, meta=None): meta = self._ensure_secrets(meta if meta is not None else self._read_metadata()) password = meta['password'] obfs_password = meta['obfs_password'] # password auth — Happ / sing-box / INCY expect a single auth string, not userpass lines = [ f'listen: :{int(port)}', '', 'tls:', ' cert: /etc/hysteria/cert.crt', ' key: /etc/hysteria/private.key', '', 'auth:', ' type: password', f' password: "{password}"', '', 'obfs:', ' type: salamander', ' salamander:', f' password: "{obfs_password}"', '', 'quic:', ' initStreamReceiveWindow: 8388608', ' maxStreamReceiveWindow: 8388608', ' initConnReceiveWindow: 20971520', ' maxConnReceiveWindow: 20971520', ' maxIdleTimeout: 60s', ' maxIncomingStreams: 1024', ' disablePathMTUDiscovery: true', '', 'ignoreClientBandwidth: true', '', 'bandwidth:', ' up: 1 gbps', ' down: 1 gbps', '', 'masquerade:', ' type: proxy', ' proxy:', ' url: https://www.bing.com', ' rewriteHost: true', '', 'outbounds:', ' - name: direct', ' type: direct', '', ] return '\n'.join(lines) def _write_config_from_clients(self, port=None): meta = self._ensure_secrets(self._read_metadata()) port = int(port or meta.get('port') or self.DEFAULT_PORT) # clients list is panel-side only (shared password); still rewrite yaml for port/obfs/secrets self._write_file(self.config_path, self._build_server_yaml(port, self._read_clients(), meta)) return port def _tune_host_network(self): """UDP buffers + BBR — reduces quic-go EOF and improves throughput.""" script = r""" set +e mkdir -p /etc/sysctl.d printf '%s\n' \ 'net.core.rmem_max=16777216' \ 'net.core.wmem_max=16777216' \ 'net.core.rmem_default=16777216' \ 'net.core.wmem_default=16777216' \ 'net.core.default_qdisc=fq' \ 'net.ipv4.tcp_congestion_control=bbr' \ 'net.ipv4.ip_forward=1' \ > /etc/sysctl.d/99-amnezia-hysteria.conf modprobe tcp_bbr >/dev/null 2>&1 || true sysctl -p /etc/sysctl.d/99-amnezia-hysteria.conf >/dev/null 2>&1 || true sysctl -w net.core.rmem_max=16777216 >/dev/null 2>&1 || true sysctl -w net.core.wmem_max=16777216 >/dev/null 2>&1 || true sysctl -w net.core.rmem_default=16777216 >/dev/null 2>&1 || true sysctl -w net.core.wmem_default=16777216 >/dev/null 2>&1 || true sysctl -w net.core.default_qdisc=fq >/dev/null 2>&1 || true sysctl -w net.ipv4.tcp_congestion_control=bbr >/dev/null 2>&1 || true sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true """ self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) def _open_udp_port(self, port): port = int(port) script = f""" set +e if command -v ufw >/dev/null 2>&1; then ufw allow {port}/udp >/dev/null 2>&1 || true fi if command -v firewall-cmd >/dev/null 2>&1; then firewall-cmd --permanent --add-port={port}/udp >/dev/null 2>&1 || true firewall-cmd --reload >/dev/null 2>&1 || true fi iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || true """ self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) def _start_container(self, port): """Run with --network host so QUIC/UDP is not broken by Docker NAT (EOF).""" name = self.container_name port = int(port) self._tune_host_network() self._open_udp_port(port) self.ssh.run_sudo_command( f"chmod 644 {_q(self.cert_path)} {_q(self.key_path)} 2>/dev/null || true" ) self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true") run_cmd = ( f"docker run -d --restart always " f"--name {name} " f"--network host " f"--cap-add=NET_ADMIN " 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: raise RuntimeError(f'Failed to start Hysteria: {err}') return True def _reload_container(self): meta = self._read_metadata() port = int(meta.get('port') or self.DEFAULT_PORT) try: self._start_container(port) except Exception as e: logger.warning('Hysteria container reload failed: %s', e) 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)} {_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, name, meta=None): """Happ/INCY-compatible hy2 link: password auth + salamander + IP host + SNI.""" meta = self._ensure_secrets(meta if meta is not None else self._read_metadata()) password = meta['password'] obfs_password = meta['obfs_password'] sni = domain or host # Prefer raw IP in URI — domain may be CDN/DNS filtered for UDP while ICMP still works conn_host = host or domain fragment = quote(name or 'hysteria', safe='') return ( f"hy2://{quote(password, safe='')}@{conn_host}:{int(port)}/" f"?sni={quote(sni, safe='')}" f"&obfs=salamander" f"&obfs-password={quote(obfs_password, safe='')}" f"&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): name = self._container_name(protocol_type) self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true") log.append('Removed previous container') self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") self._write_clients([]) meta = { 'domain': domain, 'email': email, 'port': port, 'password': _rand_token(24), 'obfs_password': _rand_token(16), } self._write_metadata(meta) self._write_config_from_clients(port) log.append('Prepared config directory (password auth + salamander + BBR)') 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} try: self._start_container(port) except Exception as e: return {'status': 'error', 'message': str(e), 'log': log} log.append(f'Started {self.container_name} on UDP {port} (host network, BBR)') 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: cname = c.get('name') or c.get('id') result.append({ 'clientId': c.get('id'), 'client_id': c.get('id'), 'id': c.get('id'), 'name': cname, 'email': cname, 'enabled': c.get('enabled', True), 'userData': {'clientName': cname, 'enabled': c.get('enabled', True)}, }) return result def add_client(self, protocol_type, name, host, port=None): meta = self._ensure_secrets(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) clients = self._read_clients() clients.append({ 'id': client_id, 'name': name or f'client-{client_id[:6]}', 'enabled': True, }) self._write_clients(clients) # Shared password auth — refresh yaml only if secrets/port changed self._write_config_from_clients(port) self._reload_container() config = self._build_share_uri(host, port, domain, name or client_id, meta) return {'client_id': client_id, 'config': config} def get_client_config(self, protocol_type, client_id, host, port=None): meta = self._ensure_secrets(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') if client.get('enabled', True) is False: raise RuntimeError('Client is disabled') return self._build_share_uri( host, port, domain, client.get('name') or client_id, meta, ) 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) return True def toggle_client(self, protocol_type, client_id, enable=True): 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) return True