Wire Hysteria 2 UI with per-server SSL defaults and renew.
Expose domain/email on servers, warn that TCP 80/443 must be free on install, and allow Let's Encrypt re-issue from Hysteria settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -843,8 +843,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', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
|
||||
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'}
|
||||
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'}
|
||||
|
||||
|
||||
def protocol_base(protocol: str) -> str:
|
||||
@@ -884,6 +884,7 @@ def protocol_display_name(protocol: str) -> str:
|
||||
'awg_legacy': 'AmneziaWG Legacy',
|
||||
'xray': 'Xray',
|
||||
'telemt': 'Telemt',
|
||||
'hysteria': 'Hysteria 2',
|
||||
'dns': 'AmneziaDNS',
|
||||
'wireguard': 'WireGuard',
|
||||
'socks5': 'SOCKS5',
|
||||
@@ -903,6 +904,7 @@ 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',
|
||||
@@ -942,6 +944,9 @@ 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)
|
||||
|
||||
@@ -1003,7 +1008,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', 'xui'}
|
||||
CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'xui'}
|
||||
|
||||
|
||||
def generate_vpn_link(config_text):
|
||||
@@ -1665,6 +1670,8 @@ class AddServerRequest(BaseModel):
|
||||
password: str = ''
|
||||
private_key: str = ''
|
||||
name: str = ''
|
||||
ssl_domain: str = ''
|
||||
ssl_email: str = ''
|
||||
|
||||
|
||||
class EditServerRequest(BaseModel):
|
||||
@@ -1677,6 +1684,8 @@ class EditServerRequest(BaseModel):
|
||||
# fields can be omitted to keep current auth unchanged.
|
||||
password: Optional[str] = None
|
||||
private_key: Optional[str] = None
|
||||
ssl_domain: Optional[str] = None
|
||||
ssl_email: Optional[str] = None
|
||||
|
||||
|
||||
class ReorderServersRequest(BaseModel):
|
||||
@@ -1706,6 +1715,9 @@ 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):
|
||||
@@ -1715,6 +1727,14 @@ class Socks5SettingsRequest(BaseModel):
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class HysteriaSettingsRequest(BaseModel):
|
||||
protocol: str = 'hysteria'
|
||||
port: Optional[int] = None
|
||||
domain: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
renew_ssl: bool = False
|
||||
|
||||
|
||||
class ProtocolRequest(BaseModel):
|
||||
protocol: str = 'awg'
|
||||
|
||||
@@ -2433,6 +2453,13 @@ async def api_add_server(request: Request, req: AddServerRequest):
|
||||
'private_key': req.private_key, 'server_info': server_info,
|
||||
'protocols': {},
|
||||
}
|
||||
ssl_domain = (req.ssl_domain or '').strip().lower()
|
||||
ssl_email = (req.ssl_email or '').strip()
|
||||
if ssl_domain:
|
||||
server_info['ssl_domain'] = ssl_domain
|
||||
if ssl_email:
|
||||
server_info['ssl_email'] = ssl_email
|
||||
server['server_info'] = server_info
|
||||
data = load_data()
|
||||
data['servers'].append(server)
|
||||
save_data(data)
|
||||
@@ -2493,9 +2520,27 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
|
||||
server['username'] = new_user
|
||||
server['password'] = new_pass
|
||||
server['private_key'] = new_key
|
||||
server['server_info'] = server_info
|
||||
# Merge SSH probe info with preserved SSL domain defaults
|
||||
info = dict(server.get('server_info') or {})
|
||||
if isinstance(server_info, dict):
|
||||
for k, v in server_info.items():
|
||||
if k not in ('ssl_domain', 'ssl_email'):
|
||||
info[k] = v
|
||||
if req.ssl_domain is not None:
|
||||
domain = (req.ssl_domain or '').strip().lower()
|
||||
if domain:
|
||||
info['ssl_domain'] = domain
|
||||
else:
|
||||
info.pop('ssl_domain', None)
|
||||
if req.ssl_email is not None:
|
||||
email = (req.ssl_email or '').strip()
|
||||
if email:
|
||||
info['ssl_email'] = email
|
||||
else:
|
||||
info.pop('ssl_email', None)
|
||||
server['server_info'] = info
|
||||
save_data(data)
|
||||
return {'status': 'success', 'server_info': server_info}
|
||||
return {'status': 'success', 'server_info': info}
|
||||
except Exception as e:
|
||||
logger.exception("Error editing server")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
@@ -2801,6 +2846,12 @@ 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]
|
||||
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):
|
||||
@@ -2952,6 +3003,13 @@ 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)
|
||||
|
||||
@@ -2978,6 +3036,18 @@ 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':
|
||||
# Remember domain/email as server defaults for next installs / renewals
|
||||
info = server.setdefault('server_info', {})
|
||||
if req.hysteria_domain:
|
||||
info['ssl_domain'] = (req.hysteria_domain or '').strip().lower()
|
||||
if req.hysteria_email:
|
||||
info['ssl_email'] = (req.hysteria_email or '').strip()
|
||||
save_data(data)
|
||||
proto_record['domain'] = result.get('domain')
|
||||
proto_record['email'] = result.get('email')
|
||||
if result.get('port'):
|
||||
proto_record['port'] = str(result['port'])
|
||||
proto_record['base_protocol'] = install_base
|
||||
proto_record['instance'] = protocol_instance(install_protocol)
|
||||
proto_record['display_name'] = protocol_display_name(install_protocol)
|
||||
@@ -3053,6 +3123,82 @@ 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,
|
||||
domain=req.domain,
|
||||
email=req.email,
|
||||
renew_ssl=bool(req.renew_ssl),
|
||||
)
|
||||
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') or result.get('domain'):
|
||||
srv_proto = server.setdefault('protocols', {}).setdefault(protocol, {})
|
||||
if result.get('port'):
|
||||
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']
|
||||
# Keep server-level SSL defaults in sync
|
||||
info = server.setdefault('server_info', {})
|
||||
if result.get('domain'):
|
||||
info['ssl_domain'] = result['domain']
|
||||
if result.get('email'):
|
||||
info['ssl_email'] = result['email']
|
||||
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):
|
||||
@@ -3086,6 +3232,7 @@ CONTAINER_NAMES = {
|
||||
'awg_legacy': 'amnezia-awg-legacy',
|
||||
'xray': 'amnezia-xray',
|
||||
'telemt': 'telemt',
|
||||
'hysteria': 'amnezia-hysteria',
|
||||
'dns': 'amnezia-dns',
|
||||
'wireguard': 'amnezia-wireguard',
|
||||
'socks5': 'amnezia-socks5proxy',
|
||||
@@ -3392,13 +3539,39 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
|
||||
ssh.run_sudo_command(f"docker stop {container}")
|
||||
action = 'stopped'
|
||||
else:
|
||||
ssh.run_sudo_command(f"docker start {container}")
|
||||
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}")
|
||||
action = 'started'
|
||||
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")
|
||||
@@ -3420,7 +3593,22 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog
|
||||
container = protocol_container_name(req.protocol)
|
||||
ssh = get_ssh(server)
|
||||
ssh.connect()
|
||||
# Generic docker logs for all protocols
|
||||
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 '',
|
||||
}
|
||||
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",
|
||||
@@ -3541,6 +3729,10 @@ 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)
|
||||
@@ -3585,6 +3777,10 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user