diff --git a/README.md b/README.md index f0f501f..1b11d95 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,15 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.6.4 +* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag). + +### v2.6.3 +* **Client connection domain (AWG / WireGuard)** — set a DNS name per server (or per AWG/WG protocol) instead of IP in client configs (`Endpoint = domain:port`). Survives server IP changes — update the A-record only. UI on server list, server page, and when adding a server. +* **Legacy `data.json` → PostgreSQL** — import normalizes old backups (UUIDs, duplicate usernames/tokens, string `server_info`); failed auto-import no longer bricks panel startup; JSON restore returns clear errors. +* **Dokploy / Traefik 404** — `traefik.http.services.amnezia_panel.loadbalancer.server.port=5000` in compose (Traefik must route to container port **5000**). +* **Support the project** — `/support` page for all users (SBP / card / crypto placeholders); admin configures requisites in **Settings** (no popups). + ### v2.6.2 * **Panel backup → PostgreSQL dump** — Settings backup downloads `.sql` via `pg_dump`; restore accepts `.sql` or legacy `data.json`. JSON export kept as secondary link. diff --git a/app.py b/app.py index 4a532a0..bae13df 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.6.2" +CURRENT_VERSION = "v2.6.4" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -116,8 +116,10 @@ from db import ( # noqa: E402 ensure_db_ready, export_data_dict, export_database_sql, + invalidate_data_cache, load_data, load_tunnel_state, + normalize_import_data, restore_database_sql, save_data, save_tunnel_state, @@ -197,6 +199,34 @@ def get_ssh(server): ) +WG_AWG_PROTOCOLS = frozenset({'awg', 'awg2', 'awg_legacy', 'wireguard'}) + + +def get_server_connect_host(server: dict, protocol: Optional[str] = None) -> str: + """Hostname embedded in client VPN configs (Endpoint, vless://, etc.). + + SSH still uses server['host']; this value can be a DNS name so clients + keep working after the server IP changes — update the A-record only. + Per-protocol connect_domain (AWG / WireGuard) overrides the server default. + """ + protocols = server.get('protocols') or {} + if protocol: + base = protocol_base(protocol) + if base in WG_AWG_PROTOCOLS: + for key in (protocol, base): + pinfo = protocols.get(key) if key else None + if isinstance(pinfo, dict): + domain = (pinfo.get('connect_domain') or '').strip() + if domain: + return domain.lower().rstrip('.') + info = server.get('server_info') or {} + for key in ('connect_domain', 'ssl_domain'): + val = (info.get(key) or '').strip() + if val: + return val.lower().rstrip('.') + return (server.get('host') or '').strip() + + def get_current_user(request: Request, data: Optional[dict] = None): user_id = request.session.get('user_id') if not user_id: @@ -211,12 +241,14 @@ def get_current_user(request: Request, data: Optional[dict] = None): def tpl(request, template, **kwargs): data = load_data() lang = request.cookies.get('lang', 'en') + donate_cfg = (data.get('settings') or {}).get('donate') or {} ctx = { 'request': request, 'current_user': get_current_user(request, data), 'site_settings': data.get('settings', {}).get('appearance', {}), 'captcha_settings': data.get('settings', {}).get('captcha', {}), 'telegram_settings': data.get('settings', {}).get('telegram', {}), + 'donate_enabled': bool(donate_cfg.get('enabled', True)), 'bot_running': tg_bot.is_running(), 'lang': lang, '_': lambda text_id: _t(text_id, lang), @@ -1079,8 +1111,9 @@ 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', 'naiveproxy', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] +BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'} +NON_DOCKER_PROTOCOLS = frozenset({'mieru'}) def protocol_base(protocol: str) -> str: @@ -1122,6 +1155,7 @@ def protocol_display_name(protocol: str) -> str: 'telemt': 'Telemt', 'hysteria': 'Hysteria 2', 'naiveproxy': 'NaiveProxy', + 'mieru': 'Mieru', 'dns': 'AmneziaDNS', 'wireguard': 'WireGuard', 'socks5': 'SOCKS5', @@ -1143,6 +1177,7 @@ def protocol_container_name(protocol: str) -> Optional[str]: 'telemt': 'telemt', 'hysteria': 'amnezia-hysteria', 'naiveproxy': 'amnezia-naiveproxy', + 'mieru': 'mita', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -1188,6 +1223,9 @@ def get_protocol_manager(ssh, protocol: str): elif base == 'naiveproxy': from managers.naiveproxy_manager import NaiveProxyManager return NaiveProxyManager(ssh, protocol) + elif base == 'mieru': + from managers.mieru_manager import MieruManager + return MieruManager(ssh, protocol) from managers.awg_manager import AWGManager return AWGManager(ssh) @@ -1249,7 +1287,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', 'naiveproxy', 'xui'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'naiveproxy', 'mieru', 'xui'} def generate_vpn_link(config_text): @@ -1430,17 +1468,19 @@ def _create_remote_client(manager, protocol: str, server: dict, name: str, sourc base = protocol_base(protocol) if base == 'telemt': user_data = (source_client or {}).get('userData') or {} + connect_host = get_server_connect_host(server, protocol) return manager.add_client( - protocol, name, server['host'], port, + protocol, name, connect_host, port, telemt_quota=user_data.get('quota'), telemt_expiry=user_data.get('expiry'), secret=user_data.get('token'), user_ad_tag=user_data.get('user_ad_tag'), max_tcp_conns=user_data.get('max_tcp_conns'), ) + connect_host = get_server_connect_host(server, protocol) if base == 'wireguard': - return manager.add_client(name, server['host']) - return manager.add_client(protocol, name, server['host'], port) + return manager.add_client(name, connect_host) + return manager.add_client(protocol, name, connect_host, port) def _move_connections_sync( @@ -1645,9 +1685,9 @@ async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: Li manager = get_protocol_manager(ssh, c_req['protocol']) if c_req['protocol'] == 'wireguard': - res = await asyncio.to_thread(manager.add_client, c_req['name'], srv['host']) + res = await asyncio.to_thread(manager.add_client, c_req['name'], get_server_connect_host(srv, 'wireguard')) else: - res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], srv['host'], port) + res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], get_server_connect_host(srv, c_req['protocol']), port) if res.get('client_id'): new_conn = { @@ -2093,6 +2133,7 @@ class AddServerRequest(BaseModel): name: str = '' ssl_domain: str = '' ssl_email: str = '' + connect_domain: str = '' class EditServerRequest(BaseModel): @@ -2107,6 +2148,12 @@ class EditServerRequest(BaseModel): private_key: Optional[str] = None ssl_domain: Optional[str] = None ssl_email: Optional[str] = None + connect_domain: Optional[str] = None + + +class ConnectDomainRequest(BaseModel): + connect_domain: str = '' + protocol: Optional[str] = None class ReorderServersRequest(BaseModel): @@ -2320,6 +2367,20 @@ class GuestSettings(BaseModel): create_xui_panel_id: str = '' +class DonateMethodSettings(BaseModel): + enabled: bool = True + title: str = '' + details: str = '' + + +class DonateSettings(BaseModel): + enabled: bool = True + intro: str = '' + sbp: DonateMethodSettings = DonateMethodSettings() + card: DonateMethodSettings = DonateMethodSettings() + crypto: DonateMethodSettings = DonateMethodSettings() + + class UpdateUserRequest(BaseModel): telegramId: Optional[str] = None email: Optional[str] = None @@ -2340,6 +2401,7 @@ class SaveSettingsRequest(BaseModel): telegram: TelegramSettings ssl: SSLSettings guest: GuestSettings = GuestSettings() + donate: DonateSettings = DonateSettings() class ToggleUserRequest(BaseModel): @@ -2929,10 +2991,13 @@ async def api_add_server(request: Request, req: AddServerRequest): } ssl_domain = (req.ssl_domain or '').strip().lower() ssl_email = (req.ssl_email or '').strip() + connect_domain = (req.connect_domain or '').strip().lower() if ssl_domain: server_info['ssl_domain'] = ssl_domain if ssl_email: server_info['ssl_email'] = ssl_email + if connect_domain: + server_info['connect_domain'] = connect_domain server['server_info'] = server_info data = load_data() data['servers'].append(server) @@ -2998,7 +3063,7 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque 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'): + if k not in ('ssl_domain', 'ssl_email', 'connect_domain'): info[k] = v if req.ssl_domain is not None: domain = (req.ssl_domain or '').strip().lower() @@ -3012,6 +3077,12 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque info['ssl_email'] = email else: info.pop('ssl_email', None) + if req.connect_domain is not None: + domain = (req.connect_domain or '').strip().lower() + if domain: + info['connect_domain'] = domain + else: + info.pop('connect_domain', None) server['server_info'] = info save_data(data) return {'status': 'success', 'server_info': info} @@ -3020,6 +3091,75 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque return JSONResponse({'error': str(e)}, status_code=500) +@app.get('/api/servers/{server_id}/connect-domain', tags=["Servers"]) +async def api_get_connect_domain(request: Request, server_id: int, protocol: Optional[str] = None): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + proto = (protocol or '').strip() + info = server.get('server_info') or {} + proto_domain = '' + if proto: + pinfo = (server.get('protocols') or {}).get(proto) or {} + proto_domain = (pinfo.get('connect_domain') or '').strip() + return { + 'connect_domain': (info.get('connect_domain') or '').strip(), + 'protocol_connect_domain': proto_domain, + 'protocol': proto, + 'effective_host': get_server_connect_host(server, proto or None), + 'ssh_host': server.get('host') or '', + } + + +@app.post('/api/servers/{server_id}/connect-domain', tags=["Servers"]) +async def api_set_connect_domain(request: Request, server_id: int, req: ConnectDomainRequest): + """Set client Endpoint hostname for AWG / WireGuard without re-verifying SSH.""" + 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) + server = data['servers'][server_id] + domain = (req.connect_domain or '').strip().lower().rstrip('.') + proto = (req.protocol or '').strip() + + if proto: + base = protocol_base(proto) + if base not in WG_AWG_PROTOCOLS: + return JSONResponse( + {'error': 'Per-protocol domain is only supported for AmneziaWG / WireGuard'}, + status_code=400, + ) + protocols = server.setdefault('protocols', {}) + proto_info = protocols.setdefault(proto, {}) + if domain: + proto_info['connect_domain'] = domain + else: + proto_info.pop('connect_domain', None) + else: + info = dict(server.get('server_info') or {}) + if domain: + info['connect_domain'] = domain + else: + info.pop('connect_domain', None) + server['server_info'] = info + + save_data(data) + return { + 'status': 'success', + 'connect_domain': domain, + 'protocol': proto, + 'effective_host': get_server_connect_host(server, proto or None), + } + except Exception as e: + logger.exception('Error saving connect domain') + return JSONResponse({'error': str(e)}, status_code=500) + + @app.get('/api/servers/{server_id}/ping', tags=["Servers"]) async def api_server_ping(request: Request, server_id: int): """Cheap reachability check: opens a TCP connection to the SSH port, @@ -3332,6 +3472,12 @@ async def api_check_server(request: Request, server_id: int): merged[key] = db_proto[key] if db_proto.get('port') and not merged.get('port'): merged['port'] = db_proto['port'] + if protocol_base(proto) == 'mieru': + for key in ('release',): + 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): @@ -3346,8 +3492,9 @@ async def api_check_server(request: Request, server_id: int): def check_proto(proto): cname = protocol_container_name(proto) + base = protocol_base(proto) # Fast path: skip deep manager probes when container is absent - if cname and cname not in inventory: + if cname and cname not in inventory and base not in NON_DOCKER_PROTOCOLS: return proto, merge_saved_protocol_status(proto, { 'container_exists': False, 'container_running': False, @@ -3440,7 +3587,9 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro ssh = get_ssh(server) ssh.connect() - docker_install_log = ensure_docker_installed(ssh) + docker_install_log = '' + if install_base not in NON_DOCKER_PROTOCOLS: + docker_install_log = ensure_docker_installed(ssh) manager = get_protocol_manager(ssh, install_protocol) # Pass parameters to installer @@ -3497,6 +3646,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro domain=req.naiveproxy_domain, email=req.naiveproxy_email, ) + elif install_base == 'mieru': + result = manager.install_protocol( + protocol_type=install_protocol, + port=req.port, + ) else: result = manager.install_protocol(install_protocol, port=req.port) @@ -3546,6 +3700,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro proto_record['email'] = result.get('email') if result.get('port'): proto_record['port'] = str(result['port']) + if install_base == 'mieru': + if result.get('port'): + proto_record['port'] = str(result['port']) + if result.get('release'): + proto_record['release'] = result.get('release') proto_record['base_protocol'] = install_base proto_record['instance'] = protocol_instance(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol) @@ -3733,6 +3892,7 @@ CONTAINER_NAMES = { 'telemt': 'telemt', 'hysteria': 'amnezia-hysteria', 'naiveproxy': 'amnezia-naiveproxy', + 'mieru': 'mita', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -3954,7 +4114,7 @@ async def api_protocol_export_clients(request: Request, server_id: int, req: Pro continue try: config = _manager_call( - manager, 'get_client_config', req.protocol, client_id, server['host'], port + manager, 'get_client_config', req.protocol, client_id, get_server_connect_host(server, req.protocol), port ) except Exception as exc: skipped.append(f'{client_name}: {exc}') @@ -4030,6 +4190,35 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe server = data['servers'][server_id] ssh = get_ssh(server) ssh.connect() + base = protocol_base(req.protocol) + if base in NON_DOCKER_PROTOCOLS: + manager = get_protocol_manager(ssh, req.protocol) + is_running = manager.check_container_running(req.protocol) + if is_running: + manager.stop_service() + action = 'stopped' + else: + manager.start_service() + action = 'started' + diag = manager.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 'Mieru 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, + } # Check current state out, _, _ = ssh.run_sudo_command( f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null" @@ -4093,6 +4282,20 @@ 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) in NON_DOCKER_PROTOCOLS: + mgr = get_protocol_manager(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 '', + 'container_status': diag.get('status') or '', + } if protocol_base(req.protocol) == 'hysteria': from managers.hysteria_manager import HysteriaManager mgr = HysteriaManager(ssh, req.protocol) @@ -4158,6 +4361,17 @@ async def api_container_logs_stream(request: Request, server_id: int, protocol: ssh = get_ssh(server) ssh.connect() try: + if protocol_base(protocol) in NON_DOCKER_PROTOCOLS: + mgr = get_protocol_manager(ssh, protocol) + logs = mgr.get_logs(protocol, tail=tail) + diag = mgr.get_container_diagnostics(protocol) + state = '|'.join([ + diag.get('status') or '', + 'true' if diag.get('running') else 'false', + '', + diag.get('error_summary') or '', + ]) + return logs, state out, err, _ = ssh.run_sudo_command( f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", timeout=25, @@ -4438,7 +4652,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio if protocol_base(req.protocol) == 'telemt': result = manager.add_client( - req.protocol, req.name, server['host'], port, + req.protocol, req.name, get_server_connect_host(server, req.protocol), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4447,9 +4661,9 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio max_tcp_conns=req.telemt_max_conns ) elif protocol_base(req.protocol) == 'wireguard': - result = manager.add_client(req.name, server['host']) + result = manager.add_client(req.name, get_server_connect_host(server, req.protocol)) else: - result = manager.add_client(req.protocol, req.name, server['host'], port) + result = manager.add_client(req.protocol, req.name, get_server_connect_host(server, req.protocol), port) ssh.disconnect() if result.get('config'): @@ -4572,7 +4786,7 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne ssh = get_ssh(server) ssh.connect() manager = get_protocol_manager(ssh, req.protocol) - config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, server['host'], port) + config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link} @@ -4755,7 +4969,7 @@ async def api_add_user(request: Request, req: AddUserRequest): manager = get_protocol_manager(ssh, req.protocol) if protocol_base(req.protocol) == 'telemt': conn_result = manager.add_client( - req.protocol, conn_name, server['host'], port, + req.protocol, conn_name, get_server_connect_host(server, req.protocol), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4764,7 +4978,7 @@ async def api_add_user(request: Request, req: AddUserRequest): max_tcp_conns=req.telemt_max_conns ) else: - conn_result = manager.add_client(req.protocol, conn_name, server['host'], port) + conn_result = manager.add_client(req.protocol, conn_name, get_server_connect_host(server, req.protocol), port) ssh.disconnect() if conn_result.get('client_id'): @@ -4972,12 +5186,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo # Link existing client config = await asyncio.to_thread( _manager_call, manager, 'get_client_config', - req.protocol, req.client_id, server['host'], port, + req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port, ) result = {'client_id': req.client_id, 'config': config} elif protocol_base(req.protocol) == 'telemt': result = await asyncio.to_thread( - manager.add_client, req.protocol, req.name, server['host'], port, + manager.add_client, req.protocol, req.name, get_server_connect_host(server, req.protocol), port, telemt_quota=req.telemt_quota, telemt_max_ips=req.telemt_max_ips, telemt_expiry=req.telemt_expiry, @@ -4986,11 +5200,11 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo max_tcp_conns=req.telemt_max_conns, ) elif protocol_base(req.protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, req.name, server['host']) + result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - req.protocol, req.name, server['host'], port, + req.protocol, req.name, get_server_connect_host(server, req.protocol), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5181,7 +5395,7 @@ async def api_share_config(token: str, connection_id: str, request: Request): ssh.connect() # Use appropriate manager for the protocol manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')} @@ -5341,7 +5555,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request): ssh = get_ssh(server) ssh.connect() manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')} @@ -5405,11 +5619,11 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request try: manager = get_protocol_manager(ssh, protocol) if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, server['host']) + result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server, protocol)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - protocol, name, server['host'], port, + protocol, name, get_server_connect_host(server, protocol), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5567,11 +5781,11 @@ async def _create_config_for_protocol( try: manager = get_protocol_manager(ssh, protocol) if protocol_base(protocol) == 'wireguard': - result = await asyncio.to_thread(manager.add_client, name, server['host']) + result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server, protocol)) else: result = await asyncio.to_thread( _manager_call, manager, 'add_client', - protocol, name, server['host'], port, + protocol, name, get_server_connect_host(server, protocol), port, ) finally: await asyncio.to_thread(ssh.disconnect) @@ -5942,7 +6156,7 @@ async def api_my_connection_config(request: Request, connection_id: str): ssh.connect() # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) manager = get_protocol_manager(ssh, conn['protocol']) - config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port) ssh.disconnect() vpn_link = generate_vpn_link(config) if config else '' expires_at = None @@ -5955,6 +6169,18 @@ async def api_my_connection_config(request: Request, connection_id: str): return JSONResponse({'error': str(e)}, status_code=500) +@app.get('/support', response_class=HTMLResponse, tags=["System Templates"]) +async def support_page(request: Request): + user = get_current_user(request) + if not user: + return RedirectResponse('/login', status_code=302) + data = load_data() + donate = dict((data.get('settings') or {}).get('donate') or {}) + if not donate.get('enabled', True): + return RedirectResponse('/my', status_code=302) + return tpl(request, 'support.html', donate=donate) + + @app.get('/settings', response_class=HTMLResponse, tags=["System Templates"]) async def settings_page(request: Request): user = _check_admin(request) @@ -6264,6 +6490,7 @@ async def save_settings(request: Request, payload: SaveSettingsRequest): else: guest['password_hash'] = existing_guest.get('password_hash') data['settings']['guest'] = guest + data['settings']['donate'] = payload.donate.dict() save_data(data) logger.info("Settings saved (including captcha and telegram)") @@ -6654,8 +6881,14 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)): backup_data.setdefault('invite_links', []) backup_data.setdefault('settings', {}) - async with DATA_LOCK: - save_data(backup_data) + try: + backup_data = normalize_import_data(backup_data) + async with DATA_LOCK: + save_data(backup_data) + except Exception as e: + invalidate_data_cache() + logger.exception('JSON backup restore failed') + return JSONResponse({'error': f'Import failed: {e}'}, status_code=400) return {'status': 'success', 'format': 'json'} try: diff --git a/db/__init__.py b/db/__init__.py index 6e457ca..70c9d73 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -7,8 +7,10 @@ from .store import ( ensure_db_ready, export_data_dict, import_from_json_file, + invalidate_data_cache, load_data, load_tunnel_state, + normalize_import_data, save_data, save_tunnel_state, update_tunnel_state, @@ -24,8 +26,10 @@ __all__ = [ 'get_database_url', 'import_from_json_file', 'init_schema', + 'invalidate_data_cache', 'load_data', 'load_tunnel_state', + 'normalize_import_data', 'restore_database_sql', 'save_data', 'save_tunnel_state', diff --git a/db/store.py b/db/store.py index 9025dbc..d3f7f45 100644 --- a/db/store.py +++ b/db/store.py @@ -10,8 +10,10 @@ import copy import json import logging import os +import secrets import shutil import threading +import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional @@ -52,6 +54,13 @@ DEFAULT_SETTINGS = { 'create_protocol': 'awg', 'create_server_id': 0, }, + 'donate': { + 'enabled': True, + 'intro': '', + 'sbp': {'enabled': True, 'title': '', 'details': ''}, + 'card': {'enabled': True, 'title': '', 'details': ''}, + 'crypto': {'enabled': True, 'title': '', 'details': ''}, + }, } @@ -83,6 +92,147 @@ def _as_uuid(value: Any) -> UUID: return UUID(str(value)) +def _is_valid_uuid(value: Any) -> bool: + if value is None: + return False + try: + UUID(str(value)) + return True + except (ValueError, AttributeError, TypeError): + return False + + +def _coerce_uuid(value: Any, *, default: Optional[str] = None) -> str: + if _is_valid_uuid(value): + return str(UUID(str(value))) + if default is not None: + return default + return str(uuid.uuid4()) + + +def _as_list(value: Any) -> list: + return list(value) if isinstance(value, list) else [] + + +def normalize_import_data(data: dict) -> dict: + """Prepare legacy data.json (or partial exports) for PostgreSQL constraints.""" + data = copy.deepcopy(data or {}) + data['servers'] = [s for s in _as_list(data.get('servers')) if isinstance(s, dict)] + data['users'] = [u for u in _as_list(data.get('users')) if isinstance(u, dict)] + data['user_connections'] = [c for c in _as_list(data.get('user_connections')) if isinstance(c, dict)] + data['api_tokens'] = [t for t in _as_list(data.get('api_tokens')) if isinstance(t, dict)] + data['invite_links'] = [l for l in _as_list(data.get('invite_links')) if isinstance(l, dict)] + if not isinstance(data.get('settings'), dict): + data['settings'] = {} + + id_map: dict[str, str] = {} + + def map_user_id(value: Any) -> Optional[str]: + if value is None or value == '': + return None + key = str(value) + if _is_valid_uuid(key): + resolved = str(UUID(key)) + id_map.setdefault(key, resolved) + return resolved + if key not in id_map: + id_map[key] = str(uuid.uuid4()) + return id_map[key] + + used_usernames: set[str] = set() + for index, user in enumerate(data['users']): + if not isinstance(user, dict): + continue + old_id = user.get('id') + if old_id is None or old_id == '': + user['id'] = str(uuid.uuid4()) + else: + key = str(old_id) + if _is_valid_uuid(key): + user['id'] = str(UUID(key)) + id_map.setdefault(key, user['id']) + else: + user['id'] = map_user_id(key) or str(uuid.uuid4()) + + username = (user.get('username') or '').strip() or f'user{index + 1}' + base = username + suffix = 2 + while username.lower() in used_usernames: + username = f'{base}_{suffix}' + suffix += 1 + user['username'] = username + used_usernames.add(username.lower()) + + user.setdefault('password_hash', '') + user.setdefault('role', 'user') + user.setdefault('enabled', True) + + valid_user_ids = {str(u['id']) for u in data['users'] if isinstance(u, dict) and u.get('id')} + + for server in data['servers']: + if not isinstance(server, dict): + continue + server['server_info'] = _as_dict(server.get('server_info')) + server['protocols'] = _as_dict(server.get('protocols')) + + for conn in data['user_connections']: + if not isinstance(conn, dict): + continue + conn['id'] = _coerce_uuid(conn.get('id')) + mapped_uid = map_user_id(conn.get('user_id')) + if mapped_uid: + conn['user_id'] = mapped_uid + + data['user_connections'] = [ + c for c in data['user_connections'] + if isinstance(c, dict) and str(c.get('user_id')) in valid_user_ids + ] + + seen_hashes: set[str] = set() + normalized_tokens = [] + for token in data['api_tokens']: + if not isinstance(token, dict): + continue + mapped_uid = map_user_id(token.get('user_id')) + if not mapped_uid or mapped_uid not in valid_user_ids: + continue + token['id'] = _coerce_uuid(token.get('id')) + token['user_id'] = mapped_uid + token_hash = (token.get('token_hash') or '').strip() + if not token_hash or token_hash in seen_hashes: + token_hash = secrets.token_hex(32) + token['token_hash'] = token_hash + seen_hashes.add(token_hash) + token.setdefault('token_prefix', (token_hash[:8] if token_hash else '')) + normalized_tokens.append(token) + data['api_tokens'] = normalized_tokens + + seen_invite_tokens: set[str] = set() + for link in data['invite_links']: + if not isinstance(link, dict): + continue + link['id'] = _coerce_uuid(link.get('id')) + mapped_uid = map_user_id(link.get('user_id')) + link['user_id'] = mapped_uid if mapped_uid in valid_user_ids else '' + token = (link.get('token') or '').strip() + if not token or token in seen_invite_tokens: + token = secrets.token_urlsafe(16) + while token in seen_invite_tokens: + token = secrets.token_urlsafe(16) + link['token'] = token + seen_invite_tokens.add(token) + + guest = data['settings'].get('guest') + if isinstance(guest, dict): + mapped_guest = map_user_id(guest.get('user_id')) + if mapped_guest in valid_user_ids: + guest['user_id'] = mapped_guest + elif guest.get('user_id'): + guest['user_id'] = '' + + return data + + def _merge_settings(raw: Optional[dict]) -> dict: settings = json.loads(json.dumps(DEFAULT_SETTINGS)) if not isinstance(raw, dict): @@ -293,6 +443,7 @@ def load_data() -> dict: def save_data(data: dict) -> None: """Replace panel state in a single transaction (same semantics as rewriting data.json).""" global _DATA_CACHE + data = normalize_import_data(data) init_schema() servers = data.get('servers') or [] users = data.get('users') or [] @@ -484,12 +635,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo if not isinstance(data, dict): raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}') - data.setdefault('servers', []) - data.setdefault('users', []) - data.setdefault('user_connections', []) - data.setdefault('api_tokens', []) - data.setdefault('invite_links', []) - data.setdefault('settings', {}) + data = normalize_import_data(data) save_data(data) @@ -555,4 +701,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None: init_schema() if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file): logger.info('Empty database — importing legacy %s', legacy_data_file) - import_from_json_file(legacy_data_file) + try: + import_from_json_file(legacy_data_file) + except Exception: + logger.exception('Legacy data.json import failed — panel will start with empty DB') + invalidate_data_cache() diff --git a/docker-compose.yml b/docker-compose.yml index 15fb7c8..fb1a596 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: labels: - traefik.enable=true - traefik.docker.network=dokploy-network + - traefik.http.services.amnezia_panel.loadbalancer.server.port=5000 expose: - "5000" healthcheck: diff --git a/managers/backup_manager.py b/managers/backup_manager.py index f2c0959..c837e43 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -70,6 +70,9 @@ class BackupManager: remote_dir = inst_path('/opt/amnezia/naiveproxy') paths['host'] = [remote_dir] paths['container'] = ['/etc/caddy'] + elif base == 'mieru': + remote_dir = inst_path('/opt/amnezia/mieru') + paths['host'] = [remote_dir] elif base == 'dns': paths['host'] = ['/opt/amnezia/dns'] paths['container'] = ['/opt/amnezia/dns'] diff --git a/managers/mieru_manager.py b/managers/mieru_manager.py new file mode 100644 index 0000000..0adf1c9 --- /dev/null +++ b/managers/mieru_manager.py @@ -0,0 +1,438 @@ +""" +Mieru protocol manager — native mita server package (systemd). + +Installs pinned release from https://github.com/enfein/mieru +Server binary: mita (systemd service), client: mieru. +Share links: mierus://user:pass@host?port=...&protocol=TCP&profile=default +""" + +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__) + +MIERU_RELEASE = '3.28.0' +GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}' + + +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)) + + +def _sanitize_username(name): + base = re.sub(r'[^a-zA-Z0-9_-]', '', (name or 'user').strip())[:24] or 'user' + return f'{base}_{_rand_token(4)}' + + +class MieruManager: + PROTOCOL = 'mieru' + SERVICE_NAME = 'mita' + BASE_DIR = '/opt/amnezia/mieru' + DEFAULT_PORT = 2999 + + def __init__(self, ssh, protocol='mieru'): + self.ssh = ssh + self.protocol = protocol or self.PROTOCOL + self.base_dir = self.BASE_DIR + self.clients_path = f'{self.base_dir}/clients.json' + self.meta_path = f'{self.base_dir}/metadata.json' + self.config_path = f'{self.base_dir}/server_config.json' + + # ===================== STATUS ===================== + + def check_docker_installed(self): + return True + + def _mita_installed(self): + out, _, code = self.ssh.run_command('command -v mita 2>/dev/null') + return code == 0 and bool(out.strip()) + + def check_protocol_installed(self, protocol_type=None): + return self._mita_installed() and self._panel_installed() + + def _panel_installed(self): + out, _, code = self.ssh.run_sudo_command(f"test -f {_q(self.meta_path)} && echo yes") + return code == 0 and 'yes' in out + + def _proxy_running(self): + out, _, code = self.ssh.run_sudo_command('mita status 2>/dev/null') + if code != 0: + return False + return 'RUNNING' in (out or '').upper() + + def check_container_running(self, protocol_type=None): + return self._proxy_running() + + def get_logs(self, protocol_type=None, tail=200): + tail = max(20, min(int(tail or 200), 2000)) + out, err, code = self.ssh.run_sudo_command( + f"journalctl -u {self.SERVICE_NAME} -n {tail} --no-pager 2>&1", + timeout=30, + ) + text = (out or err or '').strip() + if not text: + status, _, _ = self.ssh.run_sudo_command('mita status 2>&1') + text = (status or '').strip() + if not text: + text = f'(no logs: exit {code})' + return text + + def get_container_diagnostics(self, protocol_type=None): + running = self._proxy_running() + out, _, _ = self.ssh.run_sudo_command( + f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null" + ) + daemon_active = (out or '').strip() == 'active' + diag = { + 'status': 'running' if running else ('idle' if daemon_active else 'stopped'), + 'running': running, + 'error_summary': '', + 'recent_logs': self.get_logs(protocol_type, tail=40), + } + if not self._mita_installed(): + diag['status'] = 'missing' + diag['error_summary'] = 'mita package not installed' + elif not running and daemon_active: + status_out, _, _ = self.ssh.run_sudo_command('mita status 2>&1') + if 'IDLE' in (status_out or '').upper(): + diag['error_summary'] = 'Proxy stopped (mita status IDLE)' + elif status_out.strip(): + diag['error_summary'] = status_out.strip().splitlines()[-1][:160] + elif not daemon_active: + diag['error_summary'] = f'{self.SERVICE_NAME} systemd service is not active' + 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) if exists else False + meta = self._read_metadata() if exists else {} + clients = self._read_clients() if exists else [] + port = int(meta.get('port') or self.DEFAULT_PORT) + return { + 'container_exists': exists, + 'container_running': running, + 'port': port, + 'release': meta.get('release') or MIERU_RELEASE, + 'clients_count': len(clients), + 'protocol': protocol_type, + 'base_protocol': self.PROTOCOL, + 'instance': 1, + 'container_name': self.SERVICE_NAME, + } + + # ===================== IO HELPERS ===================== + + 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): + import base64 + b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii') + script = ( + f"mkdir -p $(dirname {_q(path)}) && " + f"echo {_q(b64)} | base64 -d > {_q(path)}" + ) + out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) + if code != 0: + raise RuntimeError(f'Failed to write {path}: {err or out}') + + def _read_metadata(self): + raw = self._read_file(self.meta_path).strip() + if not raw: + return {} + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _write_metadata(self, meta): + self._write_file(self.meta_path, json.dumps(meta, indent=2)) + + def _read_clients(self): + raw = self._read_file(self.clients_path).strip() + if not raw: + 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)) + + def _build_server_config(self, port, clients): + users = [] + for c in clients: + if c.get('enabled', True): + users.append({ + 'name': c.get('username') or c.get('name') or c.get('id'), + 'password': c.get('password') or '', + }) + return { + 'portBindings': [{'port': int(port), 'protocol': 'TCP'}], + 'users': users, + 'loggingLevel': 'INFO', + 'mtu': 1400, + } + + def _apply_config(self, config, reload_only=False): + self._write_file(self.config_path, json.dumps(config, indent=2)) + out, err, code = self.ssh.run_sudo_command( + f"mita apply config {_q(self.config_path)} 2>&1", + timeout=60, + ) + if code != 0: + raise RuntimeError((err or out or 'mita apply config failed').strip()) + if reload_only: + self.ssh.run_sudo_command('mita reload 2>/dev/null || true', timeout=30) + else: + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) + if code2 != 0: + raise RuntimeError((err2 or out2 or 'mita start failed').strip()) + + def _sync_server(self, reload_only=True): + meta = self._read_metadata() + port = int(meta.get('port') or self.DEFAULT_PORT) + clients = self._read_clients() + config = self._build_server_config(port, clients) + self._apply_config(config, reload_only=reload_only) + + def _open_firewall_port(self, port): + script = f""" +PORT={int(port)} +if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi active; then + ufw allow "$PORT"/tcp || true +fi +if command -v firewall-cmd >/dev/null 2>&1; then + firewall-cmd --permanent --add-port="$PORT"/tcp 2>/dev/null || true + firewall-cmd --reload 2>/dev/null || true +fi +""" + self.ssh.run_sudo_script(script, timeout=60) + + def _package_url(self): + arch_out, _, _ = self.ssh.run_command('uname -m') + arch = (arch_out or '').strip().lower() + _, _, deb_code = self.ssh.run_command('command -v dpkg 2>/dev/null') + _, _, rpm_code = self.ssh.run_command('command -v rpm 2>/dev/null') + use_deb = deb_code == 0 or rpm_code != 0 + if use_deb: + if arch in ('aarch64', 'arm64'): + return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_arm64.deb', 'deb' + return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_amd64.deb', 'deb' + if arch in ('aarch64', 'arm64'): + return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.aarch64.rpm', 'rpm' + return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.x86_64.rpm', 'rpm' + + def _install_package(self, log): + url, pkg_type = self._package_url() + tmp = f'/tmp/mita_{MIERU_RELEASE}' + if pkg_type == 'deb': + tmp += '.deb' + install_cmd = f"dpkg -i {_q(tmp)} || apt-get install -f -y" + else: + tmp += '.rpm' + install_cmd = f"rpm -Uvh --force {_q(tmp)}" + out, err, code = self.ssh.run_sudo_command( + f"curl -fL {_q(url)} -o {_q(tmp)} 2>&1", + timeout=300, + ) + if code != 0: + raise RuntimeError(f'Failed to download mita package: {err or out}') + log.append(f'Downloaded mita v{MIERU_RELEASE}') + out, err, code = self.ssh.run_sudo_command(install_cmd, timeout=180) + if code != 0: + raise RuntimeError(f'Failed to install mita package: {err or out}') + log.append('Installed mita package') + self.ssh.run_sudo_command( + f"systemctl enable --now {self.SERVICE_NAME} 2>/dev/null || true", + timeout=30, + ) + + def _build_share_uri(self, host, port, username, password, name=''): + user = quote(username or '', safe='') + pw = quote(password or '', safe='') + params = f"port={int(port)}&protocol=TCP&profile=default" + link = f"mierus://{user}:{pw}@{host}?{params}" + if name: + link += f"#{quote(name, safe='')}" + return link + + def _build_client_json(self, host, port, username, password): + return json.dumps({ + 'profiles': [{ + 'profileName': 'default', + 'user': {'name': username, 'password': password}, + 'servers': [{ + 'ipAddress': host, + 'domainName': '', + 'portBindings': [{'port': int(port), 'protocol': 'TCP'}], + }], + 'mtu': 1400, + 'multiplexing': {'level': 'MULTIPLEXING_LOW'}, + 'handshakeMode': 'HANDSHAKE_STANDARD', + }], + 'activeProfile': 'default', + 'rpcPort': 8964, + 'socks5Port': 1080, + 'loggingLevel': 'INFO', + 'socks5ListenLAN': False, + }, indent=2) + + # ===================== INSTALL / REMOVE ===================== + + def install_protocol(self, protocol_type=None, port=None): + protocol_type = protocol_type or self.protocol + port = int(port or self.DEFAULT_PORT) + if port < 1025 or port > 65535: + return {'status': 'error', 'message': 'Port must be between 1025 and 65535'} + + log = [] + if not self._mita_installed(): + self._install_package(log) + else: + log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})') + + self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") + meta = {'port': port, 'release': MIERU_RELEASE} + self._write_metadata(meta) + self._write_clients([]) + log.append(f'Prepared {self.base_dir}') + + try: + config = self._build_server_config(port, []) + self._apply_config(config, reload_only=False) + except Exception as e: + return {'status': 'error', 'message': str(e), 'log': log} + + self._open_firewall_port(port) + log.append(f'Started mita proxy on TCP {port}') + return { + 'status': 'success', + 'message': f'Mieru v{MIERU_RELEASE} installed', + 'log': log, + 'port': str(port), + 'release': MIERU_RELEASE, + } + + def remove_container(self, protocol_type=None): + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}") + return True + + def start_service(self): + out, err, code = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) + if code != 0: + raise RuntimeError((err or out or 'mita start failed').strip()) + + def stop_service(self): + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + + def get_server_config(self, protocol_type=None): + out, _, code = self.ssh.run_sudo_command('mita describe config 2>/dev/null') + if code == 0 and (out or '').strip(): + return out + 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 '') + out, err, code = self.ssh.run_sudo_command( + f"mita apply config {_q(self.config_path)} 2>&1", + timeout=60, + ) + if code != 0: + raise RuntimeError((err or out or 'mita apply config failed').strip()) + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + self.ssh.run_sudo_command('mita start 2>&1', timeout=60) + 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._read_metadata() + port = int(port or meta.get('port') or self.DEFAULT_PORT) + client_id = secrets.token_hex(8) + username = _sanitize_username(name) + 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._sync_server(reload_only=True) + config = self._build_share_uri(host, port, username, password, name or username) + json_config = self._build_client_json(host, port, username, password) + return { + 'clientId': client_id, + 'client_id': client_id, + 'id': client_id, + 'name': name or username, + 'config': config, + 'json_config': json_config, + } + + def get_client_config(self, protocol_type, client_id, host, port=None): + meta = self._read_metadata() + 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: + return '' + if not client.get('enabled', True): + return '' + username = client.get('username') or client.get('name') or client_id + password = client.get('password') or '' + name = client.get('name') or username + return self._build_share_uri(host, port, username, password, name) + + 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._sync_server(reload_only=True) + return True + + def toggle_client(self, protocol_type, client_id, enabled): + clients = self._read_clients() + for c in clients: + if c.get('id') == client_id: + c['enabled'] = bool(enabled) + self._write_clients(clients) + self._sync_server(reload_only=True) + return True diff --git a/static/css/style.css b/static/css/style.css index 65aa2d2..52fa2dd 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -949,6 +949,11 @@ a:hover { color: #38bdf8; } +.protocol-mieru .protocol-icon { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.1)); + color: #818cf8; +} + .protocol-dns .protocol-icon { background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); color: var(--success); @@ -2207,6 +2212,15 @@ a:hover { font-weight: 600; } +.nav-link-support { + opacity: 0.85; +} + +.nav-link-support:hover { + opacity: 1; + color: #f472b6; +} + .nav-user { display: flex; align-items: center; diff --git a/static/icons.svg b/static/icons.svg index 3cc7afd..98c620e 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -108,4 +108,7 @@ + + + diff --git a/telegram_bot.py b/telegram_bot.py index af66add..281278a 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", "naiveproxy", "wireguard"} +CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "mieru", "wireguard"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"} @@ -139,6 +139,7 @@ def _protocol_display_name(protocol: str) -> str: "telemt": "Telemt", "hysteria": "Hysteria 2", "naiveproxy": "NaiveProxy", + "mieru": "Mieru", "dns": "AmneziaDNS", "wireguard": "WireGuard", "socks5": "SOCKS5", @@ -342,6 +343,7 @@ def _get_ssh_and_manager(server: dict, proto: str): from managers.telemt_manager import TelemtManager from managers.hysteria_manager import HysteriaManager from managers.naiveproxy_manager import NaiveProxyManager + from managers.mieru_manager import MieruManager from managers.wireguard_manager import WireGuardManager from managers.dns_manager import DNSManager from managers.socks5_manager import Socks5Manager @@ -364,6 +366,8 @@ def _get_ssh_and_manager(server: dict, proto: str): manager = HysteriaManager(ssh, proto) elif base == "naiveproxy": manager = NaiveProxyManager(ssh, proto) + elif base == "mieru": + manager = MieruManager(ssh, proto) elif base == "wireguard": manager = WireGuardManager(ssh) elif base == "dns": @@ -569,7 +573,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", "naiveproxy") + is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru") if is_link_proto: await api.send_message(chat_id, f"🔗 Connection link (tap to copy):\n{_e(config)}") else: @@ -926,7 +930,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", "naiveproxy"): + if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru"): 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/base.html b/templates/base.html index 4e44204..13e95c2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -52,6 +52,9 @@ {{ icon('settings') }} {{ _('nav_settings') }} {% endif %} {{ icon('layers') }} {{ _('nav_connections') }} + {% if donate_enabled %} + {{ icon('heart') }} {{ _('nav_support') }} + {% endif %} @@ -139,6 +144,12 @@ +
+ + +
{{ _('server_connect_domain_hint') }}
+
+
@@ -214,6 +225,12 @@
+
+ + +
{{ _('server_connect_domain_hint') }}
+
+
@@ -268,6 +285,7 @@ private_key: document.getElementById('serverKey').value, ssl_domain: document.getElementById('serverSslDomain').value.trim(), ssl_email: document.getElementById('serverSslEmail').value.trim(), + connect_domain: document.getElementById('serverConnectDomain').value.trim(), }; const result = await apiCall('/api/servers/add', 'POST', data); @@ -318,6 +336,7 @@ document.getElementById('editServerKey').value = ''; document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || ''; document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || ''; + document.getElementById('editServerConnectDomain').value = card.dataset.connectDomain || ''; // Pre-select the tab matching the server's current auth method. const authIsKey = card.dataset.auth === 'key'; @@ -349,6 +368,7 @@ private_key: document.getElementById('editServerKey').value || null, ssl_domain: document.getElementById('editServerSslDomain').value.trim(), ssl_email: document.getElementById('editServerSslEmail').value.trim(), + connect_domain: document.getElementById('editServerConnectDomain').value.trim(), }; await apiCall(`/api/servers/${idx}/edit`, 'POST', body); showToast(_('server_saved'), 'success'); diff --git a/templates/invites.html b/templates/invites.html index 367d5f1..154acee 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -196,7 +196,7 @@ const xuiConfigured = {{ 'true' if xui_configured else 'false' }}; const xuiDefaultInbound = {{ xui_default_inbound | int }}; const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }}; - const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy']; + const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru']; const PROTO_TITLES = { awg2: 'AmneziaWG 2.0', awg: 'AmneziaWG', @@ -206,6 +206,7 @@ telemt: 'Telemt', hysteria: 'Hysteria 2', naiveproxy: 'NaiveProxy', + mieru: 'Mieru', xui: '3x-ui VLESS', }; diff --git a/templates/my_connections.html b/templates/my_connections.html index 0d65968..50cc208 100644 --- a/templates/my_connections.html +++ b/templates/my_connections.html @@ -122,7 +122,7 @@ const vpnTab = document.querySelectorAll('.config-tab')[1]; const vpnPanel = document.getElementById('panel-vpn'); const base = String(proto || '').split('__')[0]; - if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy') { + if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy' || base === 'mieru') { vpnTab.style.display = 'none'; } else { vpnTab.style.display = ''; diff --git a/templates/server.html b/templates/server.html index feb8833..e46a33e 100644 --- a/templates/server.html +++ b/templates/server.html @@ -155,6 +155,25 @@
+ @@ -344,6 +363,31 @@ + +
+
+
{{ icon('zap') }}
+
+
+
Mieru v3.28.0
+
+ {{ _('mieru_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+
@@ -758,6 +802,20 @@
{{ _('naiveproxy_client_hint') }}
+ +
+ + {% set donate_cfg = settings.get('donate') or {} %} +
+

{{ icon('heart') }} {{ _('donate_settings_title') }}

+

{{ _('donate_settings_hint') }}

+
+ +
+
+ + +
+ {% for key, label_key in [('sbp', 'donate_sbp'), ('card', 'donate_card'), ('crypto', 'donate_crypto')] %} + {% set method = donate_cfg.get(key) or {} %} +
+ +
+ + +
+
+ + +
+
+ {% endfor %} +

{{ _('donate_preview_hint') }} /support

+
+

ℹ️ {{ _('about_title') }}

@@ -1478,11 +1513,24 @@ create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '', }; + const donateMethod = (key) => ({ + enabled: document.getElementById(`donate_${key}_enabled`).checked, + title: document.getElementById(`donate_${key}_title`).value.trim(), + details: document.getElementById(`donate_${key}_details`).value.trim(), + }); + const donate = { + enabled: document.getElementById('donate_enabled').checked, + intro: document.getElementById('donate_intro').value.trim(), + sbp: donateMethod('sbp'), + card: donateMethod('card'), + crypto: donateMethod('crypto'), + }; + try { const res = await fetch('/api/settings/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest }) + body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest, donate }) }); if (!res.ok) throw new Error(_('error')); showToast(_('settings_saved'), 'success'); @@ -1496,7 +1544,7 @@ } const guestServersData = {{ servers | default([]) | tojson }}; - const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy']; + const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru']; const PROTO_TITLES = { awg2: 'AmneziaWG 2.0', awg: 'AmneziaWG', @@ -1506,6 +1554,7 @@ telemt: 'Telemt', hysteria: 'Hysteria 2', naiveproxy: 'NaiveProxy', + mieru: 'Mieru', xui: '3x-ui VLESS', }; function protoTitle(key) { diff --git a/templates/support.html b/templates/support.html new file mode 100644 index 0000000..b07ab7e --- /dev/null +++ b/templates/support.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% from "macros/icons.html" import icon %} + +{% block title_extra %} — {{ _('nav_support') }}{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
{{ icon('heart') }}
+

+ {{ _('support_title') }} +

+

+ {% if donate.get('intro') %} + {{ donate.intro }} + {% else %} + {{ _('support_intro') }} + {% endif %} +

+
+ +
+ {% set methods = [ + ('sbp', _('donate_sbp'), '💳'), + ('card', _('donate_card'), '🏦'), + ('crypto', _('donate_crypto'), '₿'), + ] %} + {% for key, default_title, emoji in methods %} + {% set method = donate.get(key) or {} %} + {% if method.get('enabled', True) %} +
+
+ + {{ method.get('title') or default_title }} +
+ {% set details = (method.get('details') or '').strip() %} + + {% if details %} + + {% endif %} +
+ {% endif %} + {% endfor %} +
+ +

+ {{ _('support_footer') }} +

+{% endblock %} diff --git a/templates/users.html b/templates/users.html index 7540017..e51a2df 100644 --- a/templates/users.html +++ b/templates/users.html @@ -631,7 +631,7 @@ const select = document.getElementById(selectId); const group = groupId ? document.getElementById(groupId) : null; select.innerHTML = ''; - const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy']; + const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru']; const titles = { awg2: 'AmneziaWG 2.0', awg: 'AmneziaWG', @@ -641,6 +641,7 @@ telemt: 'Telemt', hysteria: 'Hysteria 2', naiveproxy: 'NaiveProxy', + mieru: 'Mieru', xui: '3x-ui VLESS', }; const protoTitle = (key) => { diff --git a/translations/en.json b/translations/en.json index d09838a..bff2b5f 100644 --- a/translations/en.json +++ b/translations/en.json @@ -4,6 +4,7 @@ "nav_invites": "Invite links", "nav_settings": "Settings", "nav_connections": "Connections", + "nav_support": "Support", "nav_logout": "Logout", "theme_dark": "Dark", "theme_light": "Light", @@ -394,9 +395,38 @@ "naiveproxy_dns_hint": "Create this DNS record before installation:", "naiveproxy_ports_warning": "Warning: free TCP ports 80 and 443 are required for stable operation (Let\u0027s Encrypt on 80, HTTPS proxy on 443).", "naiveproxy_client_hint": "Stable build. Do NOT use v2rayN. Confirmed working in Karing. Other clients are untested.", + "mieru_desc": "Mieru (mita v3.28.0) — native TCP proxy from enfein/mieru. No Docker required. Client: mieru app or Clash.Meta/mihomo.", + "mieru_version": "Version", + "mieru_port_hint": "TCP port for mita (1025–65535). Open it in the server firewall.", + "mieru_install_hint": "Installs the official mita package from GitHub release v3.28.0 (Debian/RPM). Requires Linux with systemd.", + "mieru_ports_warning": "Warning: ensure the chosen TCP port is free and allowed through the firewall.", + "mieru_client_hint": "Share links use mierus:// format. Import into the mieru client or Clash.Meta (type: mieru).", "server_ssl_domain": "SSL domain (default)", "server_ssl_email": "SSL email (default)", - "server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let\u0027s Encrypt).", + "server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let's Encrypt).", + "server_connect_domain": "Client connection domain", + "server_connect_domain_hint": "Used in VPN configs instead of IP (Endpoint, vless://, etc.). When moving the server, update the DNS A-record — clients keep working.", + "server_connect_domain_awg_hint": "Used in AmneziaWG / WireGuard Endpoint instead of server IP. Leave empty to use SSH host IP.", + "server_connect_domain_all": "All AWG / WireGuard (default)", + "server_connect_domain_current": "Endpoint in configs", + "server_connect_domain_ssh": "SSH", + "server_connect_domain_saved": "Connection domain saved", + "server_endpoint_label": "Endpoint", + "support_title": "Support the project", + "support_intro": "If this panel helps you, you can support development. Choose a convenient method below.", + "support_footer": "Thank you for using Amnezia Web Panel.", + "donate_sbp": "SBP (Fast Payments)", + "donate_card": "Bank card", + "donate_crypto": "Cryptocurrency", + "donate_details_soon": "Payment details will be added soon.", + "donate_settings_title": "Support / donations", + "donate_settings_hint": "Shown on the /support page for all users. No popups — only when they open the page.", + "donate_enabled": "Show support page in navigation", + "donate_intro": "Page intro text (optional)", + "donate_method_title": "Button title (optional)", + "donate_method_details": "Payment details", + "donate_details_placeholder": "Phone, card number, wallet address…", + "donate_preview_hint": "Preview:", "container_logs": "Container logs", "logs_btn": "📋 Logs", "logs_live": "Live", diff --git a/translations/fa.json b/translations/fa.json index bb20ede..4c26d6c 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -387,6 +387,8 @@ "server_ssl_domain": "دامنه SSL (پیش‌فرض)", "server_ssl_email": "ایمیل SSL (پیش‌فرض)", "server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).", + "server_connect_domain": "دامنه اتصال کلاینت", + "server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.", "container_logs": "لاگ‌های کانتینر", "logs_btn": "📋 Logs", "logs_live": "زنده", diff --git a/translations/fr.json b/translations/fr.json index 604a612..e35a01c 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -387,6 +387,8 @@ "server_ssl_domain": "Domaine SSL (par défaut)", "server_ssl_email": "Email SSL (par défaut)", "server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).", + "server_connect_domain": "Domaine de connexion client", + "server_connect_domain_hint": "Utilisé dans les configs VPN à la place de l\u0027IP. Lors d\u0027un déplacement du serveur, mettez à jour l\u0027enregistrement DNS A.", "container_logs": "Logs du conteneur", "logs_btn": "📋 Logs", "logs_live": "Temps réel", diff --git a/translations/ru.json b/translations/ru.json index 452ebd4..6be4f63 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -4,6 +4,7 @@ "nav_invites": "Ссылки", "nav_settings": "Настройки", "nav_connections": "Подключения", + "nav_support": "Поддержать", "nav_logout": "Выход", "theme_dark": "Темная", "theme_light": "Светлая", @@ -394,9 +395,38 @@ "naiveproxy_dns_hint": "Перед установкой создайте DNS-запись:", "naiveproxy_ports_warning": "Внимание: для стабильной работы нужны свободные TCP-порты 80 и 443 (Let\u0027s Encrypt на 80, HTTPS-прокси на 443).", "naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.", + "mieru_desc": "Mieru (mita v3.28.0) — нативный TCP-прокси из enfein/mieru. Docker не нужен. Клиент: приложение mieru или Clash.Meta/mihomo.", + "mieru_version": "Версия", + "mieru_port_hint": "TCP-порт для mita (1025–65535). Откройте его в фаерволе сервера.", + "mieru_install_hint": "Устанавливает официальный пакет mita с GitHub релиза v3.28.0 (Debian/RPM). Нужен Linux со systemd.", + "mieru_ports_warning": "Внимание: выбранный TCP-порт должен быть свободен и разрешён в фаерволе.", + "mieru_client_hint": "Ссылки в формате mierus://. Импорт в клиент mieru или Clash.Meta (type: mieru).", "server_ssl_domain": "SSL-домен (по умолчанию)", "server_ssl_email": "SSL-email (по умолчанию)", - "server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let\u0027s Encrypt).", + "server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let's Encrypt).", + "server_connect_domain": "Домен для подключения клиентов", + "server_connect_domain_hint": "Подставляется в конфиги VPN вместо IP (Endpoint, vless:// и т.д.). При переносе сервера достаточно обновить A-запись DNS — клиенты продолжат работать.", + "server_connect_domain_awg_hint": "Подставляется в Endpoint конфигов AmneziaWG / WireGuard вместо IP сервера. Пусто — использовать IP из SSH.", + "server_connect_domain_all": "Все AWG / WireGuard (по умолчанию)", + "server_connect_domain_current": "Endpoint в конфигах", + "server_connect_domain_ssh": "SSH", + "server_connect_domain_saved": "Домен подключения сохранён", + "server_endpoint_label": "Endpoint", + "support_title": "Поддержать проект", + "support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.", + "support_footer": "Спасибо, что пользуетесь Amnezia Web Panel.", + "donate_sbp": "СБП", + "donate_card": "Банковская карта", + "donate_crypto": "Криптовалюта", + "donate_details_soon": "Реквизиты будут добавлены позже.", + "donate_settings_title": "Поддержка / донат", + "donate_settings_hint": "Страница /support для всех пользователей. Без всплывающих окон — только по ссылке в меню.", + "donate_enabled": "Показывать страницу поддержки в меню", + "donate_intro": "Текст в начале страницы (необязательно)", + "donate_method_title": "Название кнопки (необязательно)", + "donate_method_details": "Реквизиты", + "donate_details_placeholder": "Телефон, номер карты, адрес кошелька…", + "donate_preview_hint": "Предпросмотр:", "container_logs": "Логи контейнера", "logs_btn": "📋 Логи", "logs_live": "В реальном времени", diff --git a/translations/zh.json b/translations/zh.json index 453d333..9c0b4b7 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -387,6 +387,8 @@ "server_ssl_domain": "SSL 域名(默认)", "server_ssl_email": "SSL 邮箱(默认)", "server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。", + "server_connect_domain": "客户端连接域名", + "server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。", "container_logs": "容器日志", "logs_btn": "📋 日志", "logs_live": "实时",