diff --git a/app.py b/app.py index 85f4674..a5b6616 100644 --- a/app.py +++ b/app.py @@ -804,8 +804,8 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20): return get_tunnel_status(provider) -BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] -MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'} +BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] +MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'} def protocol_base(protocol: str) -> str: @@ -845,7 +845,6 @@ def protocol_display_name(protocol: str) -> str: 'awg_legacy': 'AmneziaWG Legacy', 'xray': 'Xray', 'telemt': 'Telemt', - 'hysteria': 'Hysteria 2', 'dns': 'AmneziaDNS', 'wireguard': 'WireGuard', 'socks5': 'SOCKS5', @@ -865,7 +864,6 @@ def protocol_container_name(protocol: str) -> Optional[str]: 'awg_legacy': 'amnezia-awg-legacy', 'xray': 'amnezia-xray', 'telemt': 'telemt', - 'hysteria': 'amnezia-hysteria', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -905,9 +903,6 @@ def get_protocol_manager(ssh, protocol: str): elif base == 'nginx': from managers.nginx_manager import NginxManager return NginxManager(ssh, protocol) - elif base == 'hysteria': - from managers.hysteria_manager import HysteriaManager - return HysteriaManager(ssh, protocol) from managers.awg_manager import AWGManager return AWGManager(ssh) @@ -969,7 +964,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs): # Protocols that own VPN clients (can list/link connections) -CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'xui'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'xui'} def generate_vpn_link(config_text): @@ -1689,9 +1684,6 @@ class InstallProtocolRequest(BaseModel): # NGINX nginx_domain: Optional[str] = None nginx_email: Optional[str] = None - # Hysteria - hysteria_domain: Optional[str] = None - hysteria_email: Optional[str] = None class Socks5SettingsRequest(BaseModel): @@ -1701,17 +1693,12 @@ class Socks5SettingsRequest(BaseModel): password: Optional[str] = None -class HysteriaSettingsRequest(BaseModel): - protocol: str = 'hysteria' - port: Optional[int] = None - - class ProtocolRequest(BaseModel): protocol: str = 'awg' class ContainerLogsRequest(BaseModel): - protocol: str = 'hysteria' + protocol: str = 'awg' tail: Optional[int] = 200 @@ -2708,13 +2695,6 @@ async def api_check_server(request: Request, server_id: int): for key in ('domain', 'email', 'site_url'): if db_proto.get(key) not in (None, ''): merged[key] = db_proto[key] - if protocol_base(proto) == 'hysteria': - for key in ('domain', 'email'): - if db_proto.get(key) not in (None, '') and not merged.get(key): - merged[key] = db_proto[key] - # Keep a useful error even when merging preserved records - if db_proto.get('port') and not merged.get('port'): - merged['port'] = db_proto['port'] return merged def should_preserve_saved_protocol(proto, result=None, err=None): @@ -2864,13 +2844,6 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro domain=req.nginx_domain, email=req.nginx_email, ) - elif install_base == 'hysteria': - result = manager.install_protocol( - protocol_type=install_protocol, - port=req.port, - domain=req.hysteria_domain, - email=req.hysteria_email, - ) else: result = manager.install_protocol(install_protocol, port=req.port) @@ -2897,9 +2870,6 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro proto_record['domain'] = result.get('domain') proto_record['email'] = result.get('email') proto_record['site_url'] = result.get('site_url') - if install_base == 'hysteria': - proto_record['domain'] = result.get('domain') - proto_record['email'] = result.get('email') proto_record['base_protocol'] = install_base proto_record['instance'] = protocol_instance(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol) @@ -2975,70 +2945,6 @@ async def api_socks5_update_credentials(request: Request, server_id: int, req: S return JSONResponse({'error': str(e)}, status_code=500) -@app.get('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"]) -async def api_hysteria_get_settings(request: Request, server_id: int, protocol: str = 'hysteria'): - """Return current Hysteria domain/port for the settings modal.""" - if not _check_admin(request): - return JSONResponse({'error': 'Forbidden'}, status_code=403) - try: - data = load_data() - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - if not is_valid_protocol(protocol) or protocol_base(protocol) != 'hysteria': - return JSONResponse({'error': 'Invalid protocol'}, status_code=400) - server = data['servers'][server_id] - ssh = get_ssh(server) - ssh.connect() - manager = get_protocol_manager(ssh, protocol) - settings = manager.get_settings() - ssh.disconnect() - saved = (server.get('protocols') or {}).get(protocol) or {} - if not settings.get('port') and saved.get('port'): - settings['port'] = int(saved['port']) - return {'status': 'success', **settings} - except Exception as e: - logger.exception("Error reading Hysteria settings") - return JSONResponse({'error': str(e)}, status_code=500) - - -@app.post('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"]) -async def api_hysteria_update_settings(request: Request, server_id: int, req: HysteriaSettingsRequest): - """Change Hysteria UDP listen port and recreate the container.""" - if not _check_admin(request): - return JSONResponse({'error': 'Forbidden'}, status_code=403) - try: - data = load_data() - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - protocol = req.protocol if is_valid_protocol(req.protocol) and protocol_base(req.protocol) == 'hysteria' else 'hysteria' - server = data['servers'][server_id] - ssh = get_ssh(server) - ssh.connect() - manager = get_protocol_manager(ssh, protocol) - result = manager.update_settings(port=req.port) - ssh.disconnect() - if result.get('status') == 'error': - return JSONResponse( - {'error': result.get('message') or 'Failed to update settings', **{k: v for k, v in result.items() if k != 'status'}}, - status_code=400, - ) - if result.get('port'): - srv_proto = server.setdefault('protocols', {}).setdefault(protocol, {}) - srv_proto['port'] = str(result['port']) - srv_proto['installed'] = True - srv_proto['base_protocol'] = protocol_base(protocol) - srv_proto['instance'] = protocol_instance(protocol) - srv_proto['display_name'] = protocol_display_name(protocol) - srv_proto['container_name'] = protocol_container_name(protocol) - if result.get('domain'): - srv_proto['domain'] = result['domain'] - save_data(data) - return result - except Exception as e: - logger.exception("Error updating Hysteria settings") - return JSONResponse({'error': str(e)}, status_code=500) - - @app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"]) async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest): if not _check_admin(request): @@ -3072,7 +2978,6 @@ CONTAINER_NAMES = { 'awg_legacy': 'amnezia-awg-legacy', 'xray': 'amnezia-xray', 'telemt': 'telemt', - 'hysteria': 'amnezia-hysteria', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -3222,41 +3127,13 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe ssh.run_sudo_command(f"docker stop {container}") action = 'stopped' else: - # Hysteria: recreate with host network / current config instead of plain start - if protocol_base(req.protocol) == 'hysteria': - from managers.hysteria_manager import HysteriaManager - mgr = HysteriaManager(ssh, req.protocol) - meta = mgr._read_metadata() - port = int(meta.get('port') or mgr.DEFAULT_PORT) - mgr._write_config_from_clients(port) - mgr._start_container(port) - else: - ssh.run_sudo_command(f"docker start {container}") + ssh.run_sudo_command(f"docker start {container}") action = 'started' - # After stop/start, return diagnostics for UI - error = '' - recent_logs = '' - if protocol_base(req.protocol) == 'hysteria': - from managers.hysteria_manager import HysteriaManager - mgr = HysteriaManager(ssh, req.protocol) - diag = mgr.get_container_diagnostics(req.protocol) - error = diag.get('error_summary') or '' - recent_logs = diag.get('recent_logs') or '' - if action == 'started' and not diag.get('running'): - ssh.disconnect() - return JSONResponse({ - 'error': error or 'Hysteria failed to start', - 'action': action, - 'container': container, - 'recent_logs': recent_logs, - }, status_code=400) ssh.disconnect() return { 'status': 'success', 'action': action, 'container': container, - 'error': error, - 'recent_logs': recent_logs, } except Exception as e: logger.exception("Error toggling container") @@ -3278,23 +3155,7 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog container = protocol_container_name(req.protocol) ssh = get_ssh(server) ssh.connect() - if protocol_base(req.protocol) == 'hysteria': - from managers.hysteria_manager import HysteriaManager - mgr = HysteriaManager(ssh, req.protocol) - logs = mgr.get_logs(req.protocol, tail=req.tail or 200) - diag = mgr.get_container_diagnostics(req.protocol) - ssh.disconnect() - return { - 'status': 'success', - 'protocol': req.protocol, - 'container': container, - 'logs': logs, - 'running': bool(diag.get('running')), - 'error': diag.get('error_summary') or '', - 'exit_code': diag.get('exit_code'), - 'container_status': diag.get('status') or '', - } - # Generic fallback for other protocols + # Generic docker logs for all protocols tail = max(20, min(int(req.tail or 200), 2000)) out, err, _ = ssh.run_sudo_command( f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", @@ -3318,7 +3179,7 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog @app.get('/api/servers/{server_id}/container/logs/stream', tags=["Protocols"]) -async def api_container_logs_stream(request: Request, server_id: int, protocol: str = 'hysteria', tail: int = 100): +async def api_container_logs_stream(request: Request, server_id: int, protocol: str = 'awg', tail: int = 100): """SSE stream of docker logs (polls every ~1.5s for new lines).""" if not _check_admin(request): return JSONResponse({'error': 'Forbidden'}, status_code=403) @@ -3415,10 +3276,6 @@ async def api_server_config(request: Request, server_id: int, req: ProtocolReque from managers.nginx_manager import NginxManager mgr = NginxManager(ssh, req.protocol) config = mgr._get_server_config(req.protocol) - elif protocol_base(req.protocol) == 'hysteria': - from managers.hysteria_manager import HysteriaManager - mgr = HysteriaManager(ssh, req.protocol) - config = mgr.get_server_config(req.protocol) else: mgr = AWGManager(ssh) config = mgr._get_server_config(req.protocol) @@ -3463,10 +3320,6 @@ async def api_server_config_save(request: Request, server_id: int, req: ServerCo from managers.nginx_manager import NginxManager mgr = NginxManager(ssh, req.protocol) mgr.save_server_config(req.protocol, req.config) - elif protocol_base(req.protocol) == 'hysteria': - from managers.hysteria_manager import HysteriaManager - mgr = HysteriaManager(ssh, req.protocol) - mgr.save_server_config(req.protocol, req.config) else: mgr = AWGManager(ssh) mgr.save_server_config(req.protocol, req.config) diff --git a/managers/backup_manager.py b/managers/backup_manager.py index 1cde323..35305ed 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -62,10 +62,6 @@ class BackupManager: remote_dir = inst_path('/opt/amnezia/telemt') paths['host'] = [remote_dir] paths['container'] = [remote_dir] - elif base == 'hysteria': - remote_dir = inst_path('/opt/amnezia/hysteria') - paths['host'] = [remote_dir] - paths['container'] = ['/etc/hysteria'] elif base == 'dns': paths['host'] = ['/opt/amnezia/dns'] paths['container'] = ['/opt/amnezia/dns'] diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py deleted file mode 100644 index 57ed12d..0000000 --- a/managers/hysteria_manager.py +++ /dev/null @@ -1,713 +0,0 @@ -""" -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 -import time -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_logs(self, protocol_type=None, tail=200): - """Return recent docker logs for the Hysteria container.""" - name = self._container_name(protocol_type or self.protocol) - tail = max(20, min(int(tail or 200), 2000)) - out, err, code = self.ssh.run_sudo_command( - f"docker logs --tail {tail} --timestamps {name} 2>&1", - timeout=30, - ) - text = (out or err or '').strip() - if code != 0 and not text: - text = f'(no logs: exit {code})' - return text - - def get_container_diagnostics(self, protocol_type=None): - """Inspect why the container is stopped / unhealthy.""" - name = self._container_name(protocol_type or self.protocol) - fmt = ( - '{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|' - '{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|' - '{{.State.StartedAt}}' - ) - out, _, code = self.ssh.run_sudo_command( - f"docker inspect -f '{fmt}' {name} 2>/dev/null" - ) - diag = { - 'status': 'unknown', - 'running': False, - 'exit_code': None, - 'oom_killed': False, - 'error': '', - 'finished_at': '', - 'started_at': '', - 'recent_logs': '', - 'error_summary': '', - } - if code != 0 or not (out or '').strip(): - diag['status'] = 'missing' - diag['error_summary'] = 'Container not found' - return diag - - parts = (out or '').strip().split('|') - while len(parts) < 7: - parts.append('') - status, running, exit_code, oom, err, finished, started = parts[:7] - diag['status'] = status or 'unknown' - diag['running'] = str(running).lower() == 'true' - try: - diag['exit_code'] = int(exit_code) - except Exception: - diag['exit_code'] = None - diag['oom_killed'] = str(oom).lower() == 'true' - diag['error'] = (err or '').strip() - diag['finished_at'] = (finished or '').strip() - diag['started_at'] = (started or '').strip() - diag['recent_logs'] = self.get_logs(protocol_type, tail=40) - - # Build human-readable summary for the UI badge - if diag['running']: - diag['error_summary'] = '' - elif diag['oom_killed']: - diag['error_summary'] = 'OOM killed (out of memory)' - elif diag['error']: - diag['error_summary'] = diag['error'] - elif diag['exit_code'] not in (None, 0): - # Prefer bind/port conflicts over generic FATAL lines - bind_line = '' - last = '' - for raw in reversed((diag['recent_logs'] or '').splitlines()): - line = self._strip_ansi(raw).strip() - if not line: - continue - # strip docker --timestamps prefix - m_ts = re.match(r'^\d{4}-\d{2}-\d{2}T\S+\s+(.*)$', line) - if m_ts: - line = m_ts.group(1).strip() - low = line.lower() - if 'address already in use' in low and not bind_line: - bind_line = line - if ('FATAL' in line or 'failed to load' in low) and not last: - last = line - if not last: - last = line - pick = bind_line or last - if pick and 'address already in use' in pick.lower(): - m = re.search(r'listen udp :(\d+)', pick, re.I) or re.search(r':(\d+):\s*bind', pick) - p = m.group(1) if m else '?' - if p == '?': - m2 = re.search(r'udp\s*:(\d+)', pick, re.I) - p = m2.group(1) if m2 else '?' - diag['error_summary'] = ( - f'UDP port {p} already in use — change port below (try 8998 or 8443)' - ) - diag['port_busy'] = True - elif pick: - short = re.sub(r'\s+', ' ', pick)[:160] - diag['error_summary'] = f"exit {diag['exit_code']}: {short}" - else: - diag['error_summary'] = f"exited with code {diag['exit_code']}" - elif diag['status'] and diag['status'] != 'running': - diag['error_summary'] = f"status: {diag['status']}" - return diag - - 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 [] - diag = {} - - # 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) - diag = {'error_summary': f'migrate failed: {e}'} - - if exists: - try: - diag = self.get_container_diagnostics(protocol_type) - running = bool(diag.get('running')) - except Exception as e: - logger.warning('Hysteria diagnostics failed: %s', e) - diag = {'error_summary': str(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), - 'error': diag.get('error_summary') or '', - 'exit_code': diag.get('exit_code'), - 'container_status': diag.get('status') or '', - 'recent_logs': diag.get('recent_logs') or '', - 'port_busy': bool(diag.get('port_busy')), - } - - # ===================== 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 _strip_ansi(self, text): - return re.sub(r'\x1b\[[0-9;]*m', '', text or '') - - def _udp_port_busy(self, port): - """Return (busy: bool, detail: str) for host UDP port (after our container is removed).""" - port = int(port) - out, _, _ = self.ssh.run_sudo_command( - f"ss -ulnp 'sport = :{port}' 2>/dev/null || " - f"ss -uln | grep -E ':{port}([[:space:]]|$)' || true" - ) - detail = (out or '').strip() - return bool(detail), detail[:240] - - 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) - if port == 80: - raise RuntimeError('Port 80 is reserved for Let\'s Encrypt validation') - 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") - busy, detail = self._udp_port_busy(port) - if busy: - raise RuntimeError( - f'UDP port {port} is already in use — choose another port in Hysteria settings. ' - f'({self._strip_ansi(detail)})' - ) - 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}') - # Confirm it stayed up (catch immediate bind failures) - time.sleep(1.2) - if not self.check_container_running(self.protocol): - diag = self.get_container_diagnostics(self.protocol) - raise RuntimeError( - diag.get('error_summary') - or 'Hysteria container exited immediately — check logs / port' - ) - return True - - def get_settings(self): - meta = self._ensure_secrets(self._read_metadata()) - port = int(meta.get('port') or self.DEFAULT_PORT) - return { - 'port': port, - 'suggested_port': 8998 if port in (443, 80) else port, - 'domain': meta.get('domain') or '', - 'email': meta.get('email') or '', - 'protocol': self.protocol, - } - - def update_settings(self, port=None): - """Change listen UDP port and recreate the container.""" - meta = self._ensure_secrets(self._read_metadata()) - if port is None: - port = meta.get('port') or self.DEFAULT_PORT - port = int(port) - if port < 1 or port > 65535: - return {'status': 'error', 'message': 'Invalid port'} - if port == 80: - return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'} - - meta['port'] = port - self._write_metadata(meta) - self._write_config_from_clients(port) - try: - self._start_container(port) - except Exception as e: - return { - 'status': 'error', - 'message': str(e), - 'port': str(port), - 'domain': meta.get('domain'), - } - return { - 'status': 'success', - 'port': str(port), - 'domain': meta.get('domain'), - 'email': meta.get('email'), - 'message': f'Hysteria listening on UDP {port}', - } - - 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 diff --git a/static/css/style.css b/static/css/style.css index 70fede5..850cde9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -823,11 +823,6 @@ a:hover { color: #38bdf8; } -.protocol-hysteria .protocol-icon { - background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(99, 102, 241, 0.1)); - color: #38bdf8; -} - .protocol-dns .protocol-icon { background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); color: var(--success); diff --git a/telegram_bot.py b/telegram_bot.py index cb4d9c0..48fe59b 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None _callback_refs = {} _pending_inputs = {} -CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "wireguard"} +CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "wireguard"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"} @@ -137,7 +137,6 @@ def _protocol_display_name(protocol: str) -> str: "awg_legacy": "AmneziaWG Legacy", "xray": "Xray", "telemt": "Telemt", - "hysteria": "Hysteria 2", "dns": "AmneziaDNS", "wireguard": "WireGuard", "socks5": "SOCKS5", @@ -550,7 +549,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p server_name = server.get("name") or server.get("host", "Unknown") await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server_name)}\n🔌 Protocol: {_e(proto.upper())}") - is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria") + is_link_proto = _proto_base(proto) in ("xray", "telemt") if is_link_proto: await api.send_message(chat_id, f"🔗 Connection link (tap to copy):\n{_e(config)}") else: @@ -907,7 +906,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int, async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable): await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server.get('name') or server.get('host'))}\n🔌 Protocol: {_e(proto.upper())}") - if _proto_base(proto) in ("xray", "telemt", "hysteria"): + if _proto_base(proto) in ("xray", "telemt"): await api.send_message(chat_id, f"🔗 Connection link:\n{_e(config)}") else: await api.send_message(chat_id, f"📄 Configuration:\n
{_e(config)}
") diff --git a/templates/invites.html b/templates/invites.html index 405c0c4..56367b7 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -128,7 +128,7 @@ {% for s in servers %} {% set server_idx = loop.index0 %} {% for key, info in (s.protocols or {}).items() %} - {% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt','hysteria'] %} + {% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %} {% endif %} {% endfor %} diff --git a/templates/my_connections.html b/templates/my_connections.html index 706e53e..0bd34d8 100644 --- a/templates/my_connections.html +++ b/templates/my_connections.html @@ -117,10 +117,10 @@ document.getElementById('configText').textContent = result.config; document.getElementById('vpnLinkText').textContent = currentVpnLink; - // Telemt / Hysteria use share links, not vpn:// Amnezia format + // Telemt uses share links, not vpn:// Amnezia format const vpnTab = document.querySelectorAll('.config-tab')[1]; const vpnPanel = document.getElementById('panel-vpn'); - if (proto === 'telemt' || String(proto || '').split('__')[0] === 'hysteria') { + if (proto === 'telemt') { vpnTab.style.display = 'none'; } else { vpnTab.style.display = ''; diff --git a/templates/server.html b/templates/server.html index e0c2b45..c82dabb 100644 --- a/templates/server.html +++ b/templates/server.html @@ -294,30 +294,6 @@ - -
-
-
🌀
-
-
-
Hysteria 2
-
- {{ _('hysteria_desc') }} -
-
- {{ _('not_checked') }} -
- -
- -
-
-
@@ -679,25 +655,6 @@
{{ _('nginx_install_hint') }}
- -
- - -
- + ${proto === 'telemt' ? `` : ''} @@ -2507,8 +2288,8 @@ document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; document.getElementById('configText').textContent = result.config; document.getElementById('vpnLinkText').textContent = currentVpnLink; - document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); - document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); + document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); + document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); switchConfigTab('conf'); openModal('configModal'); generateQR(result.config); @@ -2586,9 +2367,9 @@ const proto = document.getElementById('connProtoSelect').value; // Restore tabs visibility first - document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); + document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); document.getElementById('panel-qr').style.display = ''; - document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); + document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); document.querySelectorAll('.config-tab')[2].style.display = ''; // Client was created via native Amnezia app — private key is not stored server-side diff --git a/templates/settings.html b/templates/settings.html index 23d74f9..7cd16fd 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -115,7 +115,7 @@ {% for s in servers %} {% set server_idx = loop.index0 %} {% for key, info in (s.protocols or {}).items() %} - {% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt','hysteria'] %} + {% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}