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 @@
{_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 @@
+ {{ _('donate_settings_hint') }}
+{{ _('donate_preview_hint') }} /support
++ {% if donate.get('intro') %} + {{ donate.intro }} + {% else %} + {{ _('support_intro') }} + {% endif %} +
++ {{ _('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": "实时",