Add Hysteria 2 protocol with admin domain SSL via Let's Encrypt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 01:46:06 +03:00
co-authored by Cursor
parent 7d7094bc38
commit 96bad74eb5
15 changed files with 560 additions and 20 deletions
+4
View File
@@ -62,6 +62,10 @@ 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']
+410
View File
@@ -0,0 +1,410 @@
"""
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.-]+(?<!-)$', domain) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError("Valid Let's Encrypt email is required")
return email
def _read_file(self, path):
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/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