Compare commits

..
10 Commits
Author SHA1 Message Date
orohiandCursor 53bffbd4fc Fix Mieru mita start RPC EOF by waiting for daemon socket and seeding users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:39:56 +03:00
orohiandCursor 53638cf122 Show Mieru higher in server marketplace templates and add missing i18n keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:00:43 +03:00
test2 1f1234d217 Merge pull request 'Cursor/client domain and legacy json import' (#1) from cursor/client-domain-and-legacy-json-import into main
Reviewed-on: #1
2026-07-28 10:58:27 +00:00
orohiandCursor 9890e8cc3f Add optional Mieru (mita v3.28.0) install per server with mierus:// share links.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:47:59 +03:00
orohiandCursor 32a0e90e19 Release v2.6.3: support page with SBP/card/crypto donate placeholders and admin settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:32:09 +03:00
orohiandCursor 73d0ee277d Add AWG/WireGuard connect-domain UI on server page and fix Traefik port for Dokploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:21:54 +03:00
orohiandCursor 9b4ef0f9f0 Use client connect domain in VPN configs and fix legacy JSON import to PostgreSQL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:00:37 +03:00
orohiandCursor 78e69d899a Backup panel PostgreSQL database as .sql dump (v2.6.2).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:40:59 +03:00
orohiandCursor 2d62758716 Restore move connections between servers (v2.6.1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:33:27 +03:00
orohiandCursor d26843a0d1 Release v2.6.0: stable Dokploy/Docker deployment bundle.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:15:00 +03:00
28 changed files with 2182 additions and 154 deletions
+10 -3
View File
@@ -2,11 +2,18 @@
APP_PORT=5000 APP_PORT=5000
SECRET_KEY=change-me-to-a-long-random-string SECRET_KEY=change-me-to-a-long-random-string
# PostgreSQL (used by docker-compose) # PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
POSTGRES_USER=amnezia POSTGRES_USER=amnezia
POSTGRES_PASSWORD=amnezia POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=amnezia_panel POSTGRES_DB=amnezia_panel
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
# Full DSN (override if panel runs outside compose) # Full DSN (override if panel runs outside compose)
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel # DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
# Dokploy / reverse proxy (optional — set in compose for Docker)
# PANEL_IN_DOCKER=1
# PANEL_BEHIND_PROXY=1
+1 -1
View File
@@ -13,7 +13,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \ && apt-get install -y --no-install-recommends curl postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
+57 -18
View File
@@ -194,38 +194,53 @@ docker compose up -d --build
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`). Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
### Dokploy ### Dokploy (recommended)
The compose file connects the panel to Dokploy's Traefik network (`dokploy-network`) and exposes the app on **container port 5000** (not 80). The compose file connects the panel to Dokploy's Traefik network (`dokploy-network`) and exposes the app on **container port 5000** (not 80).
1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically). 1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically).
2. In Dokploy → your app → **Domains**: 2. In Dokploy → your app → **Environment** (set **before first deploy**):
- `SECRET_KEY` — long random string
- `POSTGRES_PASSWORD` — strong password (same value is used for `db` and `amnezia_panel`)
3. In Dokploy → **Domains**:
- **Service**: `amnezia_panel` (not `db`) - **Service**: `amnezia_panel` (not `db`)
- **Container port**: `5000` - **Container port**: `5000`
- **Path**: `/` - **Path**: `/`
3. Set env vars in Dokploy: `SECRET_KEY` (long random string), strong `POSTGRES_PASSWORD`. 4. **Deploy / Rebuild** (not Restart) after code or env changes.
4. Redeploy after changing domains or env.
**Postgres `password authentication failed`:** the database volume keeps the password from the **first** deploy. If you later change `POSTGRES_PASSWORD` in Dokploy, Postgres ignores the new value but the panel uses it in `DATABASE_URL`. Fix: either restore the original password in Dokploy env, or reset the DB volume (wipes panel DB data): **Verify on the server** (replace `PROJECT` and path with yours):
```bash ```bash
docker compose -p home-vpn-g6rfpd down COMPOSE_DIR=/etc/dokploy/compose/home-vpn-g6rfpd/code
docker volume rm home-vpn-g6rfpd_amnezia_pgdata PROJECT=home-vpn-g6rfpd
docker compose -p home-vpn-g6rfpd up -d --build
```
If the domain shows **404 page not found** (plain text, Go-style), Traefik has no route to a healthy backend. After redeploying **v2.5.8+**, on the server run: docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml exec amnezia_panel \
curl -fsS http://127.0.0.1:5000/health
# → {"status":"ok","version":"v2.6.0"}
```bash docker inspect ${PROJECT}-amnezia_panel-1 \
# 1) Panel must answer HTTP inside the container
docker compose -p home-vpn-g6rfpd exec amnezia_panel curl -fsS http://127.0.0.1:5000/health
# 2) Panel container must be on dokploy-network (required for Traefik)
docker inspect "$(docker compose -p home-vpn-g6rfpd ps -q amnezia_panel)" \
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
# must include: dokploy-network
``` ```
You should see `dokploy-network` in the network list. If it is missing, Dokploy cannot route the domain — redeploy with the latest `docker-compose.yml` or run `docker network connect dokploy-network <container>` and redeploy Traefik. **Full redeploy after git update:**
```bash
cd $COMPOSE_DIR && git pull origin main
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml build --no-cache amnezia_panel
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --force-recreate
```
**Postgres `password authentication failed`:** the volume keeps the password from the **first** deploy. If you change `POSTGRES_PASSWORD` in Dokploy later, reset the DB volume (wipes panel DB data):
```bash
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
docker volume rm ${PROJECT}_amnezia_pgdata
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --build
```
**Domain shows `404 page not found` (plain text):** Traefik has no route to the panel — check Domains (service `amnezia_panel`, port `5000`) and that the container is on `dokploy-network` (see verify commands above).
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
@@ -262,12 +277,36 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### v2.6.5
* **Mieru install fix** — wait for `/var/run/mita.sock`, seed a bootstrap user (empty `users` caused `mita start` RPC EOF), retry apply/start with systemd restart.
### 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.
### v2.6.1
* **Move connections between servers** — restored: select configs on a server page and move to another server (recreates peers, keeps user links). `ToggleConnectionRequest` kept intact.
### v2.6.0 — stable Dokploy / Docker release
* **Dokploy-ready compose** — `dokploy-network`, Traefik labels, port **5000**, no fixed `container_name`.
* **Docker startup** — `uvicorn` with proxy headers; SSL inside container disabled when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY`.
* **Postgres healthcheck** — verifies password (catches `POSTGRES_PASSWORD` vs volume mismatch).
* **Rollback move-connections** — removed server-to-server config move (v2.5.4); restored `ToggleConnectionRequest` (fixes crash loop on deploy).
* **Docs** — full Dokploy deploy/redeploy/password-reset guide in README.
### v2.5.11 ### v2.5.11
* **Postgres healthcheck** — verifies DB password (surfaces `POSTGRES_PASSWORD` / volume mismatch before panel starts). * **Postgres healthcheck** — verifies DB password (surfaces `POSTGRES_PASSWORD` / volume mismatch before panel starts).
### v2.5.10 ### v2.5.10
* **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy. * **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy.
* **Postgres healthcheck** — verifies password, not only `pg_isready` (catches `POSTGRES_PASSWORD` mismatch with existing volume).
### v2.5.9 ### v2.5.9
* **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4). * **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4).
+538 -52
View File
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.5.11" CURRENT_VERSION = "v2.6.5"
RELEASES_REPO_URL = repo_url() RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -111,11 +111,16 @@ TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application
# Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API. # Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API.
from db import ( # noqa: E402 from db import ( # noqa: E402
backup_filename,
clear_tunnel_state, clear_tunnel_state,
ensure_db_ready, ensure_db_ready,
export_data_dict, export_data_dict,
export_database_sql,
invalidate_data_cache,
load_data, load_data,
load_tunnel_state, load_tunnel_state,
normalize_import_data,
restore_database_sql,
save_data, save_data,
save_tunnel_state, save_tunnel_state,
update_tunnel_state, update_tunnel_state,
@@ -194,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): def get_current_user(request: Request, data: Optional[dict] = None):
user_id = request.session.get('user_id') user_id = request.session.get('user_id')
if not user_id: if not user_id:
@@ -208,12 +241,14 @@ def get_current_user(request: Request, data: Optional[dict] = None):
def tpl(request, template, **kwargs): def tpl(request, template, **kwargs):
data = load_data() data = load_data()
lang = request.cookies.get('lang', 'en') lang = request.cookies.get('lang', 'en')
donate_cfg = (data.get('settings') or {}).get('donate') or {}
ctx = { ctx = {
'request': request, 'request': request,
'current_user': get_current_user(request, data), 'current_user': get_current_user(request, data),
'site_settings': data.get('settings', {}).get('appearance', {}), 'site_settings': data.get('settings', {}).get('appearance', {}),
'captcha_settings': data.get('settings', {}).get('captcha', {}), 'captcha_settings': data.get('settings', {}).get('captcha', {}),
'telegram_settings': data.get('settings', {}).get('telegram', {}), 'telegram_settings': data.get('settings', {}).get('telegram', {}),
'donate_enabled': bool(donate_cfg.get('enabled', True)),
'bot_running': tg_bot.is_running(), 'bot_running': tg_bot.is_running(),
'lang': lang, 'lang': lang,
'_': lambda text_id: _t(text_id, lang), '_': lambda text_id: _t(text_id, lang),
@@ -1076,8 +1111,9 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20):
return get_tunnel_status(provider) 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'} MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'}
NON_DOCKER_PROTOCOLS = frozenset({'mieru'})
def protocol_base(protocol: str) -> str: def protocol_base(protocol: str) -> str:
@@ -1119,6 +1155,7 @@ def protocol_display_name(protocol: str) -> str:
'telemt': 'Telemt', 'telemt': 'Telemt',
'hysteria': 'Hysteria 2', 'hysteria': 'Hysteria 2',
'naiveproxy': 'NaiveProxy', 'naiveproxy': 'NaiveProxy',
'mieru': 'Mieru',
'dns': 'AmneziaDNS', 'dns': 'AmneziaDNS',
'wireguard': 'WireGuard', 'wireguard': 'WireGuard',
'socks5': 'SOCKS5', 'socks5': 'SOCKS5',
@@ -1140,6 +1177,7 @@ def protocol_container_name(protocol: str) -> Optional[str]:
'telemt': 'telemt', 'telemt': 'telemt',
'hysteria': 'amnezia-hysteria', 'hysteria': 'amnezia-hysteria',
'naiveproxy': 'amnezia-naiveproxy', 'naiveproxy': 'amnezia-naiveproxy',
'mieru': 'mita',
'dns': 'amnezia-dns', 'dns': 'amnezia-dns',
'wireguard': 'amnezia-wireguard', 'wireguard': 'amnezia-wireguard',
'socks5': 'amnezia-socks5proxy', 'socks5': 'amnezia-socks5proxy',
@@ -1185,6 +1223,9 @@ def get_protocol_manager(ssh, protocol: str):
elif base == 'naiveproxy': elif base == 'naiveproxy':
from managers.naiveproxy_manager import NaiveProxyManager from managers.naiveproxy_manager import NaiveProxyManager
return NaiveProxyManager(ssh, protocol) return NaiveProxyManager(ssh, protocol)
elif base == 'mieru':
from managers.mieru_manager import MieruManager
return MieruManager(ssh, protocol)
from managers.awg_manager import AWGManager from managers.awg_manager import AWGManager
return AWGManager(ssh) return AWGManager(ssh)
@@ -1246,7 +1287,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
# Protocols that own VPN clients (can list/link connections) # 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): def generate_vpn_link(config_text):
@@ -1396,6 +1437,188 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool:
return True return True
def resolve_protocol_on_server(server: dict, protocol: str) -> Optional[str]:
"""Pick an installed protocol key on server matching the requested protocol/base."""
protocols = server.get('protocols') or {}
if protocol in protocols and protocols[protocol].get('installed'):
return protocol
base = protocol_base(protocol)
candidates = [
key for key, info in protocols.items()
if protocol_base(key) == base and info.get('installed')
]
if not candidates:
return None
return sorted(candidates, key=protocol_instance)[0]
def _client_display_name(client: dict) -> str:
user_data = client.get('userData') or {}
return (
user_data.get('clientName')
or client.get('clientName')
or client.get('clientId')
or 'Connection'
)
def _create_remote_client(manager, protocol: str, server: dict, name: str, source_client: Optional[dict] = None):
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
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, 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, connect_host)
return manager.add_client(protocol, name, connect_host, port)
def _move_connections_sync(
source_server_id: int,
target_server_id: int,
protocol: str,
client_ids: List[str],
target_protocol: Optional[str] = None,
delete_source: bool = True,
) -> dict:
data = load_data()
if source_server_id >= len(data['servers']) or target_server_id >= len(data['servers']):
raise ValueError('Server not found')
if source_server_id == target_server_id:
raise ValueError('Source and target server must be different')
if protocol_base(protocol) == 'xui':
raise ValueError('Moving 3x-ui connections between servers is not supported')
source_server = data['servers'][source_server_id]
target_server = data['servers'][target_server_id]
resolved_target_protocol = target_protocol or resolve_protocol_on_server(target_server, protocol)
if not resolved_target_protocol:
raise ValueError(
f'Target server does not have {protocol_display_name(protocol)} installed'
)
source_ssh = get_ssh(source_server)
target_ssh = get_ssh(target_server)
source_ssh.connect()
target_ssh.connect()
source_manager = get_protocol_manager(source_ssh, protocol)
source_clients = _manager_call(source_manager, 'get_clients', protocol) or []
clients_map = {c.get('clientId'): c for c in source_clients if c.get('clientId')}
target_manager = get_protocol_manager(target_ssh, resolved_target_protocol)
moved = []
failed = []
try:
for client_id in client_ids:
client = clients_map.get(client_id)
if not client:
failed.append({'client_id': client_id, 'error': 'Client not found on source server'})
continue
user_data = client.get('userData') or {}
base = protocol_base(protocol)
if user_data.get('externalClient') and not user_data.get('clientPrivateKey') and base in (
'awg', 'awg2', 'awg_legacy', 'wireguard',
):
failed.append({
'client_id': client_id,
'error': 'External/native client cannot be moved (no private key)',
})
continue
name = _client_display_name(client)
enabled = client.get('enabled', True)
if user_data.get('enabled') is False:
enabled = False
try:
created = _create_remote_client(
target_manager, resolved_target_protocol, target_server, name, client,
)
new_client_id = created.get('client_id')
if not new_client_id:
failed.append({'client_id': client_id, 'error': 'Failed to create client on target server'})
continue
if not enabled:
_manager_call(
target_manager, 'toggle_client',
resolved_target_protocol, new_client_id, False,
)
user_conn = next(
(
uc for uc in data.get('user_connections', [])
if uc.get('client_id') == client_id
and uc.get('server_id') == source_server_id
and uc.get('protocol') == protocol
),
None,
)
if user_conn:
user_conn['server_id'] = target_server_id
user_conn['client_id'] = new_client_id
user_conn['protocol'] = resolved_target_protocol
user_conn['name'] = name
elif client.get('assigned_user_id'):
data.setdefault('user_connections', []).append({
'id': str(uuid.uuid4()),
'user_id': client['assigned_user_id'],
'server_id': target_server_id,
'protocol': resolved_target_protocol,
'client_id': new_client_id,
'name': name,
'created_at': datetime.now().isoformat(),
})
if delete_source:
_manager_call(source_manager, 'remove_client', protocol, client_id)
data['user_connections'] = [
uc for uc in data.get('user_connections', [])
if not (
uc.get('client_id') == client_id
and uc.get('server_id') == source_server_id
and uc.get('protocol') == protocol
)
]
moved.append({
'client_id': client_id,
'new_client_id': new_client_id,
'name': name,
})
except Exception as exc:
logger.exception('Failed to move client %s', client_id)
failed.append({'client_id': client_id, 'error': str(exc)})
finally:
source_ssh.disconnect()
target_ssh.disconnect()
if moved:
save_data(data)
return {
'status': 'success' if moved else 'error',
'moved': moved,
'failed': failed,
'target_server_id': target_server_id,
'target_protocol': resolved_target_protocol,
'message': f'Moved {len(moved)} connection(s)' + (f', {len(failed)} failed' if failed else ''),
}
async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None): async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None):
""" """
Executes multiple SSH operations efficiently. Executes multiple SSH operations efficiently.
@@ -1462,9 +1685,9 @@ async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: Li
manager = get_protocol_manager(ssh, c_req['protocol']) manager = get_protocol_manager(ssh, c_req['protocol'])
if c_req['protocol'] == 'wireguard': 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: 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'): if res.get('client_id'):
new_conn = { new_conn = {
@@ -1910,6 +2133,7 @@ class AddServerRequest(BaseModel):
name: str = '' name: str = ''
ssl_domain: str = '' ssl_domain: str = ''
ssl_email: str = '' ssl_email: str = ''
connect_domain: str = ''
class EditServerRequest(BaseModel): class EditServerRequest(BaseModel):
@@ -1924,6 +2148,12 @@ class EditServerRequest(BaseModel):
private_key: Optional[str] = None private_key: Optional[str] = None
ssl_domain: Optional[str] = None ssl_domain: Optional[str] = None
ssl_email: 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): class ReorderServersRequest(BaseModel):
@@ -2020,6 +2250,14 @@ class ToggleConnectionRequest(BaseModel):
enable: bool = True enable: bool = True
class MoveConnectionsRequest(BaseModel):
protocol: str = 'awg'
target_server_id: int
client_ids: List[str]
target_protocol: Optional[str] = None
delete_source: bool = True
class AddUserRequest(BaseModel): class AddUserRequest(BaseModel):
username: str username: str
password: str password: str
@@ -2129,6 +2367,20 @@ class GuestSettings(BaseModel):
create_xui_panel_id: str = '' 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): class UpdateUserRequest(BaseModel):
telegramId: Optional[str] = None telegramId: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
@@ -2149,6 +2401,7 @@ class SaveSettingsRequest(BaseModel):
telegram: TelegramSettings telegram: TelegramSettings
ssl: SSLSettings ssl: SSLSettings
guest: GuestSettings = GuestSettings() guest: GuestSettings = GuestSettings()
donate: DonateSettings = DonateSettings()
class ToggleUserRequest(BaseModel): class ToggleUserRequest(BaseModel):
@@ -2560,12 +2813,21 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/') return RedirectResponse(url='/')
server = data['servers'][server_id] server = data['servers'][server_id]
users_list = data.get('users', []) users_list = data.get('users', [])
servers_for_move = [
{
'id': idx,
'name': srv.get('name') or srv.get('host') or f'Server {idx + 1}',
'host': srv.get('host') or '',
}
for idx, srv in enumerate(data['servers'])
]
return tpl( return tpl(
request, request,
'server.html', 'server.html',
server=server, server=server,
server_id=server_id, server_id=server_id,
users=users_list, users=users_list,
servers_for_move=servers_for_move,
) )
@@ -2729,10 +2991,13 @@ async def api_add_server(request: Request, req: AddServerRequest):
} }
ssl_domain = (req.ssl_domain or '').strip().lower() ssl_domain = (req.ssl_domain or '').strip().lower()
ssl_email = (req.ssl_email or '').strip() ssl_email = (req.ssl_email or '').strip()
connect_domain = (req.connect_domain or '').strip().lower()
if ssl_domain: if ssl_domain:
server_info['ssl_domain'] = ssl_domain server_info['ssl_domain'] = ssl_domain
if ssl_email: if ssl_email:
server_info['ssl_email'] = ssl_email server_info['ssl_email'] = ssl_email
if connect_domain:
server_info['connect_domain'] = connect_domain
server['server_info'] = server_info server['server_info'] = server_info
data = load_data() data = load_data()
data['servers'].append(server) data['servers'].append(server)
@@ -2798,7 +3063,7 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
info = dict(server.get('server_info') or {}) info = dict(server.get('server_info') or {})
if isinstance(server_info, dict): if isinstance(server_info, dict):
for k, v in server_info.items(): 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 info[k] = v
if req.ssl_domain is not None: if req.ssl_domain is not None:
domain = (req.ssl_domain or '').strip().lower() domain = (req.ssl_domain or '').strip().lower()
@@ -2812,6 +3077,12 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
info['ssl_email'] = email info['ssl_email'] = email
else: else:
info.pop('ssl_email', None) 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 server['server_info'] = info
save_data(data) save_data(data)
return {'status': 'success', 'server_info': info} return {'status': 'success', 'server_info': info}
@@ -2820,6 +3091,75 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
return JSONResponse({'error': str(e)}, status_code=500) 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"]) @app.get('/api/servers/{server_id}/ping', tags=["Servers"])
async def api_server_ping(request: Request, server_id: int): async def api_server_ping(request: Request, server_id: int):
"""Cheap reachability check: opens a TCP connection to the SSH port, """Cheap reachability check: opens a TCP connection to the SSH port,
@@ -3132,6 +3472,12 @@ async def api_check_server(request: Request, server_id: int):
merged[key] = db_proto[key] merged[key] = db_proto[key]
if db_proto.get('port') and not merged.get('port'): if db_proto.get('port') and not merged.get('port'):
merged['port'] = db_proto['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 return merged
def should_preserve_saved_protocol(proto, result=None, err=None): def should_preserve_saved_protocol(proto, result=None, err=None):
@@ -3146,8 +3492,9 @@ async def api_check_server(request: Request, server_id: int):
def check_proto(proto): def check_proto(proto):
cname = protocol_container_name(proto) cname = protocol_container_name(proto)
base = protocol_base(proto)
# Fast path: skip deep manager probes when container is absent # 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, { return proto, merge_saved_protocol_status(proto, {
'container_exists': False, 'container_exists': False,
'container_running': False, 'container_running': False,
@@ -3240,7 +3587,9 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() 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) manager = get_protocol_manager(ssh, install_protocol)
# Pass parameters to installer # Pass parameters to installer
@@ -3297,6 +3646,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
domain=req.naiveproxy_domain, domain=req.naiveproxy_domain,
email=req.naiveproxy_email, email=req.naiveproxy_email,
) )
elif install_base == 'mieru':
result = manager.install_protocol(
protocol_type=install_protocol,
port=req.port,
)
else: else:
result = manager.install_protocol(install_protocol, port=req.port) result = manager.install_protocol(install_protocol, port=req.port)
@@ -3346,6 +3700,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
proto_record['email'] = result.get('email') proto_record['email'] = result.get('email')
if result.get('port'): if result.get('port'):
proto_record['port'] = str(result['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['base_protocol'] = install_base
proto_record['instance'] = protocol_instance(install_protocol) proto_record['instance'] = protocol_instance(install_protocol)
proto_record['display_name'] = protocol_display_name(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol)
@@ -3533,6 +3892,7 @@ CONTAINER_NAMES = {
'telemt': 'telemt', 'telemt': 'telemt',
'hysteria': 'amnezia-hysteria', 'hysteria': 'amnezia-hysteria',
'naiveproxy': 'amnezia-naiveproxy', 'naiveproxy': 'amnezia-naiveproxy',
'mieru': 'mita',
'dns': 'amnezia-dns', 'dns': 'amnezia-dns',
'wireguard': 'amnezia-wireguard', 'wireguard': 'amnezia-wireguard',
'socks5': 'amnezia-socks5proxy', 'socks5': 'amnezia-socks5proxy',
@@ -3754,7 +4114,7 @@ async def api_protocol_export_clients(request: Request, server_id: int, req: Pro
continue continue
try: try:
config = _manager_call( 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: except Exception as exc:
skipped.append(f'{client_name}: {exc}') skipped.append(f'{client_name}: {exc}')
@@ -3830,6 +4190,35 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
server = data['servers'][server_id] server = data['servers'][server_id]
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() 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 # Check current state
out, _, _ = ssh.run_sudo_command( out, _, _ = ssh.run_sudo_command(
f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null" f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null"
@@ -3893,6 +4282,20 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog
container = protocol_container_name(req.protocol) container = protocol_container_name(req.protocol)
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() 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': if protocol_base(req.protocol) == 'hysteria':
from managers.hysteria_manager import HysteriaManager from managers.hysteria_manager import HysteriaManager
mgr = HysteriaManager(ssh, req.protocol) mgr = HysteriaManager(ssh, req.protocol)
@@ -3958,6 +4361,17 @@ async def api_container_logs_stream(request: Request, server_id: int, protocol:
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
try: 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( out, err, _ = ssh.run_sudo_command(
f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
timeout=25, timeout=25,
@@ -4238,7 +4652,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
if protocol_base(req.protocol) == 'telemt': if protocol_base(req.protocol) == 'telemt':
result = manager.add_client( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, telemt_expiry=req.telemt_expiry,
@@ -4247,9 +4661,9 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
max_tcp_conns=req.telemt_max_conns max_tcp_conns=req.telemt_max_conns
) )
elif protocol_base(req.protocol) == 'wireguard': 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: 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() ssh.disconnect()
if result.get('config'): if result.get('config'):
@@ -4372,7 +4786,7 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, req.protocol) 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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link} return {'config': config, 'vpn_link': vpn_link}
@@ -4381,6 +4795,35 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
return JSONResponse({'error': str(e)}, status_code=500) return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/move', tags=["Connections"])
async def api_move_connections(request: Request, server_id: int, req: MoveConnectionsRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if not req.client_ids:
return JSONResponse({'error': 'No connections selected'}, status_code=400)
try:
result = await asyncio.to_thread(
_move_connections_sync,
server_id,
req.target_server_id,
req.protocol,
req.client_ids,
req.target_protocol,
bool(req.delete_source),
)
if not result.get('moved'):
return JSONResponse(
{'error': result.get('message') or 'Move failed', **result},
status_code=400,
)
return result
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=400)
except Exception as e:
logger.exception('Error moving connections')
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"]) @app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"])
async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest): async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest):
if not _check_admin(request): if not _check_admin(request):
@@ -4526,7 +4969,7 @@ async def api_add_user(request: Request, req: AddUserRequest):
manager = get_protocol_manager(ssh, req.protocol) manager = get_protocol_manager(ssh, req.protocol)
if protocol_base(req.protocol) == 'telemt': if protocol_base(req.protocol) == 'telemt':
conn_result = manager.add_client( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, telemt_expiry=req.telemt_expiry,
@@ -4535,7 +4978,7 @@ async def api_add_user(request: Request, req: AddUserRequest):
max_tcp_conns=req.telemt_max_conns max_tcp_conns=req.telemt_max_conns
) )
else: 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() ssh.disconnect()
if conn_result.get('client_id'): if conn_result.get('client_id'):
@@ -4743,12 +5186,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
# Link existing client # Link existing client
config = await asyncio.to_thread( config = await asyncio.to_thread(
_manager_call, manager, 'get_client_config', _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} result = {'client_id': req.client_id, 'config': config}
elif protocol_base(req.protocol) == 'telemt': elif protocol_base(req.protocol) == 'telemt':
result = await asyncio.to_thread( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, telemt_expiry=req.telemt_expiry,
@@ -4757,11 +5200,11 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
max_tcp_conns=req.telemt_max_conns, max_tcp_conns=req.telemt_max_conns,
) )
elif protocol_base(req.protocol) == 'wireguard': 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: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _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: finally:
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -4952,7 +5395,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
ssh.connect() ssh.connect()
# Use appropriate manager for the protocol # Use appropriate manager for the protocol
manager = get_protocol_manager(ssh, conn['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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')} return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')}
@@ -5112,7 +5555,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() ssh.connect()
manager = get_protocol_manager(ssh, conn['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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')} return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
@@ -5176,11 +5619,11 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
try: try:
manager = get_protocol_manager(ssh, protocol) manager = get_protocol_manager(ssh, protocol)
if protocol_base(protocol) == 'wireguard': 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: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _manager_call, manager, 'add_client',
protocol, name, server['host'], port, protocol, name, get_server_connect_host(server, protocol), port,
) )
finally: finally:
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -5338,11 +5781,11 @@ async def _create_config_for_protocol(
try: try:
manager = get_protocol_manager(ssh, protocol) manager = get_protocol_manager(ssh, protocol)
if protocol_base(protocol) == 'wireguard': 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: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _manager_call, manager, 'add_client',
protocol, name, server['host'], port, protocol, name, get_server_connect_host(server, protocol), port,
) )
finally: finally:
await asyncio.to_thread(ssh.disconnect) await asyncio.to_thread(ssh.disconnect)
@@ -5713,7 +6156,7 @@ async def api_my_connection_config(request: Request, connection_id: str):
ssh.connect() ssh.connect()
# Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users)
manager = get_protocol_manager(ssh, conn['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() ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else '' vpn_link = generate_vpn_link(config) if config else ''
expires_at = None expires_at = None
@@ -5726,6 +6169,18 @@ async def api_my_connection_config(request: Request, connection_id: str):
return JSONResponse({'error': str(e)}, status_code=500) 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"]) @app.get('/settings', response_class=HTMLResponse, tags=["System Templates"])
async def settings_page(request: Request): async def settings_page(request: Request):
user = _check_admin(request) user = _check_admin(request)
@@ -6035,6 +6490,7 @@ async def save_settings(request: Request, payload: SaveSettingsRequest):
else: else:
guest['password_hash'] = existing_guest.get('password_hash') guest['password_hash'] = existing_guest.get('password_hash')
data['settings']['guest'] = guest data['settings']['guest'] = guest
data['settings']['donate'] = payload.donate.dict()
save_data(data) save_data(data)
logger.info("Settings saved (including captcha and telegram)") logger.info("Settings saved (including captcha and telegram)")
@@ -6365,6 +6821,24 @@ async def api_revoke_token(request: Request, token_id: str):
@app.get('/api/settings/backup/download', tags=["Settings"]) @app.get('/api/settings/backup/download', tags=["Settings"])
async def api_backup_download(request: Request): async def api_backup_download(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
content = await asyncio.to_thread(export_database_sql)
except Exception as e:
logger.exception('Database backup failed')
return JSONResponse({'error': str(e)}, status_code=500)
filename = backup_filename()
return StreamingResponse(
io.BytesIO(content),
media_type='application/sql',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
@app.get('/api/settings/backup/download/json', tags=["Settings"])
async def api_backup_download_json(request: Request):
"""Legacy JSON export (same shape as old data.json)."""
if not _check_admin(request): if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403) return JSONResponse({'error': 'Forbidden'}, status_code=403)
payload = await asyncio.to_thread(export_data_dict) payload = await asyncio.to_thread(export_data_dict)
@@ -6384,33 +6858,45 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
content = await file.read() content = await file.read()
if not content: if not content:
return JSONResponse({'error': 'Empty file'}, status_code=400) return JSONResponse({'error': 'Empty file'}, status_code=400)
filename = (file.filename or '').lower()
is_json = filename.endswith('.json') or content.lstrip().startswith(b'{')
if is_json:
try:
backup_data = json.loads(content)
except json.JSONDecodeError:
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
required_keys = ['servers', 'users']
missing = [k for k in required_keys if k not in backup_data]
if missing:
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
backup_data.setdefault('user_connections', [])
backup_data.setdefault('api_tokens', [])
backup_data.setdefault('invite_links', [])
backup_data.setdefault('settings', {})
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: try:
backup_data = json.loads(content) await asyncio.to_thread(restore_database_sql, content)
except json.JSONDecodeError: except Exception as e:
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400) logger.exception('Database restore failed')
return JSONResponse({'error': str(e)}, status_code=400)
# Basic structure validation return {'status': 'success', 'format': 'sql'}
required_keys = ['servers', 'users']
missing = [k for k in required_keys if k not in backup_data]
if missing:
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
# Ensure types are correct
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
backup_data.setdefault('user_connections', [])
backup_data.setdefault('api_tokens', [])
backup_data.setdefault('invite_links', [])
backup_data.setdefault('settings', {})
# Save the new data
async with DATA_LOCK:
save_data(backup_data)
# In a real app we might want to restart or re-init background tasks
return {'status': 'success'}
except Exception as e: except Exception as e:
logger.exception("Error during restore") logger.exception("Error during restore")
return JSONResponse({'error': str(e)}, status_code=500) return JSONResponse({'error': str(e)}, status_code=500)
+8
View File
@@ -1,28 +1,36 @@
"""Database package for Amnezia Web Panel (PostgreSQL 17).""" """Database package for Amnezia Web Panel (PostgreSQL 17)."""
from .backup import backup_filename, export_database_sql, restore_database_sql
from .connection import close_pool, get_database_url, init_schema from .connection import close_pool, get_database_url, init_schema
from .store import ( from .store import (
clear_tunnel_state, clear_tunnel_state,
ensure_db_ready, ensure_db_ready,
export_data_dict, export_data_dict,
import_from_json_file, import_from_json_file,
invalidate_data_cache,
load_data, load_data,
load_tunnel_state, load_tunnel_state,
normalize_import_data,
save_data, save_data,
save_tunnel_state, save_tunnel_state,
update_tunnel_state, update_tunnel_state,
) )
__all__ = [ __all__ = [
'backup_filename',
'clear_tunnel_state', 'clear_tunnel_state',
'close_pool', 'close_pool',
'ensure_db_ready', 'ensure_db_ready',
'export_data_dict', 'export_data_dict',
'export_database_sql',
'get_database_url', 'get_database_url',
'import_from_json_file', 'import_from_json_file',
'init_schema', 'init_schema',
'invalidate_data_cache',
'load_data', 'load_data',
'load_tunnel_state', 'load_tunnel_state',
'normalize_import_data',
'restore_database_sql',
'save_data', 'save_data',
'save_tunnel_state', 'save_tunnel_state',
'update_tunnel_state', 'update_tunnel_state',
+59
View File
@@ -0,0 +1,59 @@
"""PostgreSQL backup/restore for the panel database."""
from __future__ import annotations
import logging
import subprocess
from datetime import datetime, timezone
from .connection import get_database_url
from .store import invalidate_data_cache
logger = logging.getLogger(__name__)
def backup_filename() -> str:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
return f'amnezia_panel_backup_{stamp}.sql'
def export_database_sql() -> bytes:
"""Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url()
proc = subprocess.run(
[
'pg_dump',
'--dbname', url,
'--no-owner',
'--no-acl',
'--clean',
'--if-exists',
],
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or 'pg_dump failed')
if not proc.stdout:
raise RuntimeError('pg_dump returned empty dump')
return proc.stdout
def restore_database_sql(data: bytes) -> None:
"""Restore panel data from a plain SQL dump produced by pg_dump."""
if not data or not data.strip():
raise ValueError('Empty backup file')
url = get_database_url()
proc = subprocess.run(
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
input=data,
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
out = proc.stdout.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or out or 'psql restore failed')
invalidate_data_cache()
logger.info('PostgreSQL backup restored successfully')
+157 -7
View File
@@ -10,8 +10,10 @@ import copy
import json import json
import logging import logging
import os import os
import secrets
import shutil import shutil
import threading import threading
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -52,6 +54,13 @@ DEFAULT_SETTINGS = {
'create_protocol': 'awg', 'create_protocol': 'awg',
'create_server_id': 0, '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)) 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: def _merge_settings(raw: Optional[dict]) -> dict:
settings = json.loads(json.dumps(DEFAULT_SETTINGS)) settings = json.loads(json.dumps(DEFAULT_SETTINGS))
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -293,6 +443,7 @@ def load_data() -> dict:
def save_data(data: dict) -> None: def save_data(data: dict) -> None:
"""Replace panel state in a single transaction (same semantics as rewriting data.json).""" """Replace panel state in a single transaction (same semantics as rewriting data.json)."""
global _DATA_CACHE global _DATA_CACHE
data = normalize_import_data(data)
init_schema() init_schema()
servers = data.get('servers') or [] servers = data.get('servers') or []
users = data.get('users') 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): if not isinstance(data, dict):
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}') raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
data.setdefault('servers', []) data = normalize_import_data(data)
data.setdefault('users', [])
data.setdefault('user_connections', [])
data.setdefault('api_tokens', [])
data.setdefault('invite_links', [])
data.setdefault('settings', {})
save_data(data) save_data(data)
@@ -555,4 +701,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
init_schema() init_schema()
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file): 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) 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()
+1
View File
@@ -47,6 +47,7 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=dokploy-network - traefik.docker.network=dokploy-network
- traefik.http.services.amnezia_panel.loadbalancer.server.port=5000
expose: expose:
- "5000" - "5000"
healthcheck: healthcheck:
+3
View File
@@ -70,6 +70,9 @@ class BackupManager:
remote_dir = inst_path('/opt/amnezia/naiveproxy') remote_dir = inst_path('/opt/amnezia/naiveproxy')
paths['host'] = [remote_dir] paths['host'] = [remote_dir]
paths['container'] = ['/etc/caddy'] paths['container'] = ['/etc/caddy']
elif base == 'mieru':
remote_dir = inst_path('/opt/amnezia/mieru')
paths['host'] = [remote_dir]
elif base == 'dns': elif base == 'dns':
paths['host'] = ['/opt/amnezia/dns'] paths['host'] = ['/opt/amnezia/dns']
paths['container'] = ['/opt/amnezia/dns'] paths['container'] = ['/opt/amnezia/dns']
+585
View File
@@ -0,0 +1,585 @@
"""
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
import time
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}'
MITA_SOCK = '/var/run/mita.sock'
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)} && "
f"chmod 644 {_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 _ensure_daemon(self, log=None):
"""Ensure mita systemd unit is up and RPC socket answers."""
self.ssh.run_sudo_command(
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true",
timeout=30,
)
self.ssh.run_sudo_command(
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
# Official package expects the operating user in group `mita`.
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
op_user = (user_out or 'root').strip() or 'root'
if op_user != 'root':
self.ssh.run_sudo_command(
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
timeout=15,
)
if not self._wait_for_rpc(timeout=45):
# Stale socket / crashed daemon — hard restart once.
self.ssh.run_sudo_command(
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
f"rm -f {_q(MITA_SOCK)} /var/run/mita/*.sock 2>/dev/null || true; "
f"systemctl start {self.SERVICE_NAME}",
timeout=60,
)
if not self._wait_for_rpc(timeout=45):
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 40 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
'mita systemd daemon is not ready (RPC socket missing). '
f'journal: {(journal or "").strip()[-500:]}'
)
if log is not None:
log.append('mita daemon is active')
def _wait_for_rpc(self, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
sock_out, _, sock_code = self.ssh.run_sudo_command(
f"test -S {_q(MITA_SOCK)} && echo ok"
)
if sock_code == 0 and 'ok' in (sock_out or ''):
status_out, _, status_code = self.ssh.run_sudo_command(
'mita status 2>&1',
timeout=20,
)
text = (status_out or '').upper()
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
return True
# Socket exists but CLI still races — brief pause.
time.sleep(1.5)
return False
def _mita_cli(self, args, timeout=60):
"""Run mita CLI as root so group/socket ACL is not an issue."""
cmd = f"mita {' '.join(args)} 2>&1"
return self.ssh.run_sudo_command(cmd, timeout=timeout)
def _build_server_config(self, port, clients):
users = []
for c in clients:
if not c.get('enabled', True):
continue
username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
password = (c.get('password') or '').strip()
if not username or not password:
continue
users.append({'name': username, 'password': password})
# mita rejects / crashes on empty users during `mita start` (RPC EOF).
if not users:
users = [{
'name': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
}]
return {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users,
'loggingLevel': 'INFO',
'mtu': 1400,
}
def _apply_config(self, config, reload_only=False):
self._ensure_daemon()
self._write_file(self.config_path, json.dumps(config, indent=2))
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
# Recover from transient EOF / unavailable RPC.
if self._is_rpc_error(out, err):
self._ensure_daemon()
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip())
if reload_only:
# users/loggingLevel can hot-reload; fall back to full restart.
reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
if reload_code == 0:
return
logger.info('mita reload failed, falling back to stop/start: %s',
(reload_err or reload_out or '').strip())
self._restart_proxy()
def _is_rpc_error(self, *parts):
text = ' '.join(str(p or '') for p in parts).lower()
return any(token in text for token in (
'rpc error',
'unavailable',
'error reading from server',
'eof',
'no such file or directory',
'mita.sock',
'connection refused',
))
def _restart_proxy(self):
self._mita_cli(['stop'], timeout=30)
time.sleep(1)
last_err = ''
for attempt in range(1, 4):
out, err, code = self._mita_cli(['start'], timeout=60)
if code == 0:
# Confirm RUNNING (daemon may report success then die).
time.sleep(1)
if self._proxy_running():
return
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
else:
last_err = (err or out or 'mita start failed').strip()
if self._is_rpc_error(last_err) or attempt < 3:
self.ssh.run_sudo_command(
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
self._wait_for_rpc(timeout=30)
time.sleep(1)
continue
break
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
f'{last_err}. journal: {(journal or "").strip()[-400:]}'
)
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._ensure_daemon(log)
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 = []
try:
if not self._mita_installed():
self._install_package(log)
else:
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
self._ensure_daemon(log)
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
meta = {'port': port, 'release': MIERU_RELEASE}
self._write_metadata(meta)
# Keep clients empty in panel DB, but seed a real mita user so start works.
self._write_clients([])
bootstrap = {
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
}
self._write_clients([bootstrap])
log.append(f'Prepared {self.base_dir}')
config = self._build_server_config(port, [bootstrap])
self._apply_config(config, reload_only=False)
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,
}
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
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):
self._ensure_daemon()
self._restart_proxy()
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=''):
raw = (config_text or '').strip()
if not raw:
raise RuntimeError('Config is empty')
try:
parsed = json.loads(raw)
except Exception as e:
raise RuntimeError(f'Invalid JSON config: {e}') from e
if not isinstance(parsed, dict):
raise RuntimeError('Config must be a JSON object')
self._apply_config(parsed, reload_only=False)
return True
# ===================== CLIENTS =====================
def get_clients(self, protocol_type=None):
clients = self._read_clients()
result = []
for c in clients:
if c.get('bootstrap'):
continue
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 or client.get('bootstrap'):
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]
# Keep at least bootstrap so mita never has empty users.
if not any(not c.get('bootstrap') for c in clients) and not any(c.get('bootstrap') for c in clients):
clients.append({
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
})
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
-38
View File
@@ -1,38 +0,0 @@
#!/bin/sh
set -e
PORT="${PORT:-${APP_PORT:-5000}}"
echo "Waiting for PostgreSQL at ${DATABASE_URL%%@*}@…"
python3 - <<'PY'
import os
import sys
import time
url = os.environ.get("DATABASE_URL", "").strip()
if not url:
print("DATABASE_URL is not set", file=sys.stderr)
sys.exit(1)
import psycopg
deadline = time.time() + 120
last_err = None
while time.time() < deadline:
try:
with psycopg.connect(url, connect_timeout=5):
break
except Exception as exc:
last_err = exc
time.sleep(2)
else:
print(f"PostgreSQL not ready: {last_err}", file=sys.stderr)
sys.exit(1)
PY
echo "Starting Amnezia Web Panel on 0.0.0.0:${PORT}"
exec uvicorn app:app \
--host 0.0.0.0 \
--port "${PORT}" \
--proxy-headers \
--forwarded-allow-ips='*'
+53
View File
@@ -949,6 +949,11 @@ a:hover {
color: #38bdf8; 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 { .protocol-dns .protocol-icon {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
color: var(--success); color: var(--success);
@@ -1295,6 +1300,45 @@ a:hover {
background: var(--bg-card-hover); background: var(--bg-card-hover);
} }
.conn-select-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.conn-select {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.connections-bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
}
.connections-select-all {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.85rem;
color: var(--text-secondary);
cursor: pointer;
}
.connections-selected-count {
font-size: 0.8rem;
color: var(--text-muted);
}
.client-info { .client-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2168,6 +2212,15 @@ a:hover {
font-weight: 600; font-weight: 600;
} }
.nav-link-support {
opacity: 0.85;
}
.nav-link-support:hover {
opacity: 1;
color: #f472b6;
}
.nav-user { .nav-user {
display: flex; display: flex;
align-items: center; align-items: center;
+3
View File
@@ -108,4 +108,7 @@
<symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> <symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/> <path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>
</symbol> </symbol>
<symbol id="heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
</symbol>
</svg> </svg>
+7 -3
View File
@@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None
_callback_refs = {} _callback_refs = {}
_pending_inputs = {} _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"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
@@ -139,6 +139,7 @@ def _protocol_display_name(protocol: str) -> str:
"telemt": "Telemt", "telemt": "Telemt",
"hysteria": "Hysteria 2", "hysteria": "Hysteria 2",
"naiveproxy": "NaiveProxy", "naiveproxy": "NaiveProxy",
"mieru": "Mieru",
"dns": "AmneziaDNS", "dns": "AmneziaDNS",
"wireguard": "WireGuard", "wireguard": "WireGuard",
"socks5": "SOCKS5", "socks5": "SOCKS5",
@@ -342,6 +343,7 @@ def _get_ssh_and_manager(server: dict, proto: str):
from managers.telemt_manager import TelemtManager from managers.telemt_manager import TelemtManager
from managers.hysteria_manager import HysteriaManager from managers.hysteria_manager import HysteriaManager
from managers.naiveproxy_manager import NaiveProxyManager from managers.naiveproxy_manager import NaiveProxyManager
from managers.mieru_manager import MieruManager
from managers.wireguard_manager import WireGuardManager from managers.wireguard_manager import WireGuardManager
from managers.dns_manager import DNSManager from managers.dns_manager import DNSManager
from managers.socks5_manager import Socks5Manager from managers.socks5_manager import Socks5Manager
@@ -364,6 +366,8 @@ def _get_ssh_and_manager(server: dict, proto: str):
manager = HysteriaManager(ssh, proto) manager = HysteriaManager(ssh, proto)
elif base == "naiveproxy": elif base == "naiveproxy":
manager = NaiveProxyManager(ssh, proto) manager = NaiveProxyManager(ssh, proto)
elif base == "mieru":
manager = MieruManager(ssh, proto)
elif base == "wireguard": elif base == "wireguard":
manager = WireGuardManager(ssh) manager = WireGuardManager(ssh)
elif base == "dns": 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") server_name = server.get("name") or server.get("host", "Unknown")
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>") await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
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: if is_link_proto:
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>") await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
else: 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): 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"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>") await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
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"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>") await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>")
else: else:
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>") await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>")
+3
View File
@@ -52,6 +52,9 @@
<a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a> <a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a>
{% endif %} {% endif %}
<a href="/my" class="nav-link">{{ icon('layers') }} {{ _('nav_connections') }}</a> <a href="/my" class="nav-link">{{ icon('layers') }} {{ _('nav_connections') }}</a>
{% if donate_enabled %}
<a href="/support" class="nav-link nav-link-support">{{ icon('heart') }} {{ _('nav_support') }}</a>
{% endif %}
</nav> </nav>
<div class="nav-user"> <div class="nav-user">
+1
View File
@@ -107,6 +107,7 @@
if (p === 'xui') return '3x-ui VLESS'; if (p === 'xui') return '3x-ui VLESS';
if (p === 'hysteria') return 'Hysteria 2'; if (p === 'hysteria') return 'Hysteria 2';
if (p === 'naiveproxy') return 'NaiveProxy'; if (p === 'naiveproxy') return 'NaiveProxy';
if (p === 'mieru') return 'Mieru';
if (p === 'telemt') return 'Telemt'; if (p === 'telemt') return 'Telemt';
return (p || '').toUpperCase(); return (p || '').toUpperCase();
} }
+20
View File
@@ -21,6 +21,7 @@
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}" data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}" data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}" data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}"
draggable="true"> draggable="true">
<div class="server-meta"> <div class="server-meta">
<div class="server-icon">{{ icon('server') }}</div> <div class="server-icon">{{ icon('server') }}</div>
@@ -32,6 +33,10 @@
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span> <span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
</div> </div>
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div> <div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
{% set connect_domain = (server.server_info or {}).get('connect_domain') %}
{% if connect_domain and connect_domain != server.host %}
<div class="server-host text-muted" title="{{ _('server_connect_domain_hint') }}">→ {{ connect_domain }}</div>
{% endif %}
</div> </div>
</div> </div>
@@ -139,6 +144,12 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<input class="form-input" type="text" id="editServerConnectDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)"> <div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label> <label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com"> <input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
@@ -214,6 +225,12 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<input class="form-input" type="text" id="serverConnectDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)"> <div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label> <label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com"> <input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
@@ -268,6 +285,7 @@
private_key: document.getElementById('serverKey').value, private_key: document.getElementById('serverKey').value,
ssl_domain: document.getElementById('serverSslDomain').value.trim(), ssl_domain: document.getElementById('serverSslDomain').value.trim(),
ssl_email: document.getElementById('serverSslEmail').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); const result = await apiCall('/api/servers/add', 'POST', data);
@@ -318,6 +336,7 @@
document.getElementById('editServerKey').value = ''; document.getElementById('editServerKey').value = '';
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || ''; document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || ''; 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. // Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key'; const authIsKey = card.dataset.auth === 'key';
@@ -349,6 +368,7 @@
private_key: document.getElementById('editServerKey').value || null, private_key: document.getElementById('editServerKey').value || null,
ssl_domain: document.getElementById('editServerSslDomain').value.trim(), ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
ssl_email: document.getElementById('editServerSslEmail').value.trim(), ssl_email: document.getElementById('editServerSslEmail').value.trim(),
connect_domain: document.getElementById('editServerConnectDomain').value.trim(),
}; };
await apiCall(`/api/servers/${idx}/edit`, 'POST', body); await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success'); showToast(_('server_saved'), 'success');
+2 -1
View File
@@ -196,7 +196,7 @@
const xuiConfigured = {{ 'true' if xui_configured else 'false' }}; const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInbound = {{ xui_default_inbound | int }}; const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }}; 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 = { const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0', awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG', awg: 'AmneziaWG',
@@ -206,6 +206,7 @@
telemt: 'Telemt', telemt: 'Telemt',
hysteria: 'Hysteria 2', hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy', naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
xui: '3x-ui VLESS', xui: '3x-ui VLESS',
}; };
+1 -1
View File
@@ -122,7 +122,7 @@
const vpnTab = document.querySelectorAll('.config-tab')[1]; const vpnTab = document.querySelectorAll('.config-tab')[1];
const vpnPanel = document.getElementById('panel-vpn'); const vpnPanel = document.getElementById('panel-vpn');
const base = String(proto || '').split('__')[0]; 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'; vpnTab.style.display = 'none';
} else { } else {
vpnTab.style.display = ''; vpnTab.style.display = '';
+325 -2
View File
@@ -155,6 +155,25 @@
</div> </div>
</div> </div>
</div> </div>
<div id="connectDomainSection" class="hidden" style="margin-top: var(--space-md);">
<div class="divider"></div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<div class="flex gap-sm" style="flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 0 0 200px; min-width: 160px;">
<select class="form-input" id="connectDomainProto" onchange="refreshConnectDomainFields()"></select>
</div>
<div style="flex: 1; min-width: 200px;">
<input class="form-input" type="text" id="connectDomainInput" placeholder="vpn.example.com">
</div>
<button type="button" class="btn btn-primary btn-sm" id="connectDomainSaveBtn" onclick="saveConnectDomain()">
{{ _('save') }}
</button>
</div>
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('server_connect_domain_awg_hint') }}</div>
<div class="text-muted text-sm" id="connectDomainEffective" style="margin-top: var(--space-xs);"></div>
</div>
</div>
</div> </div>
<!-- Installed Applications Section --> <!-- Installed Applications Section -->
@@ -295,6 +314,31 @@
</div> </div>
</div> </div>
<!-- Mieru Card -->
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('zap') }}</div>
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Mieru <span
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
<div class="protocol-desc">
{{ _('mieru_desc') }}
</div>
<div class="protocol-status" id="mieru-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="mieru-info" class="hidden">
<div class="protocol-info" id="mieru-info-grid"></div>
</div>
<div class="flex gap-sm" id="mieru-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- Hysteria Card --> <!-- Hysteria Card -->
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria"> <div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -544,12 +588,23 @@
<option value="telemt">Telemt</option> <option value="telemt">Telemt</option>
<option value="wireguard">WireGuard</option> <option value="wireguard">WireGuard</option>
</select> </select>
<button type="button" class="btn btn-secondary btn-sm hidden" id="moveConnectionsBtn" onclick="openMoveConnectionsModal()">
<span>{{ icon('share') }}</span> {{ _('move_connections') }}
</button>
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()"> <button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
<span>{{ icon('plus') }}</span> {{ _('add') }} <span>{{ icon('plus') }}</span> {{ _('add') }}
</button> </button>
</div> </div>
</div> </div>
<div id="connectionsBulkBar" class="connections-bulk-bar hidden">
<label class="connections-select-all">
<input type="checkbox" id="connSelectAll" onchange="toggleSelectAllConnections(this.checked)">
<span>{{ _('select_all') }}</span>
</label>
<span class="connections-selected-count" id="connSelectedCount">0</span>
</div>
<div id="connectionsList"> <div id="connectionsList">
<div class="loading-overlay" id="connectionsLoading" style="display:none;"> <div class="loading-overlay" id="connectionsLoading" style="display:none;">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@@ -747,6 +802,20 @@
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('naiveproxy_client_hint') }}</div> <div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('naiveproxy_client_hint') }}</div>
</div> </div>
<div id="mieruOptions"
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
{{ _('mieru_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (TCP) *</label>
<input class="form-input" type="number" id="installMieruPort" value="2999" min="1025" max="65535">
<div class="form-hint">{{ _('mieru_port_hint') }}</div>
</div>
<div class="form-hint">{{ _('mieru_install_hint') }}</div>
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('mieru_client_hint') }}</div>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button> <button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn"> <button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
@@ -969,6 +1038,35 @@
</div> </div>
</div> </div>
<!-- ===== Move Connections Modal ===== -->
<div class="modal-backdrop" id="moveConnectionsModal">
<div class="modal" style="max-width: 520px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('move_connections_title') }}</h2>
<button class="modal-close" onclick="closeModal('moveConnectionsModal')">×</button>
</div>
<p class="form-hint">{{ _('move_connections_hint') }}</p>
<div class="form-group">
<label class="form-label" for="moveTargetServer">{{ _('move_connections_target') }}</label>
<select class="form-select" id="moveTargetServer"></select>
</div>
<label class="form-check" style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom: var(--space-md);">
<input type="checkbox" id="moveDeleteSource" checked>
<span>{{ _('move_connections_delete_source') }}</span>
</label>
<div class="form-hint" style="border-left: 3px solid var(--warning); padding-left: var(--space-sm);">
{{ _('move_connections_warning') }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('moveConnectionsModal')">{{ _('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="moveSelectedConnections()" id="moveConnectionsSubmitBtn">
<span id="moveConnectionsSubmitText">{{ _('move_connections') }}</span>
<div class="spinner hidden" id="moveConnectionsSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<!-- ===== Connection Config Modal (with Tabs) ===== --> <!-- ===== Connection Config Modal (with Tabs) ===== -->
<div class="modal-backdrop" id="configModal"> <div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;"> <div class="modal" style="max-width: 600px;">
@@ -1090,7 +1188,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script> <script>
const SERVER_ID = {{ server_id }}; const SERVER_ID = {{ server_id }};
const ALL_SERVERS = {{ servers_for_move | tojson }};
const SERVER_HOST = "{{ server.host }}"; const SERVER_HOST = "{{ server.host }}";
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }}; const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }}; const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
const MARKETPLACE_APPS = [ const MARKETPLACE_APPS = [
@@ -1099,6 +1199,7 @@
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' }, { proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' }, { proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' }, { proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' }, { proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' }, { proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' }, { proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
@@ -1112,6 +1213,8 @@
{ id: 'services', icon: 'wrench', titleKey: 'services' }, { id: 'services', icon: 'wrench', titleKey: 'services' },
{ id: 'web_servers', icon: 'globe', titleKey: 'web_servers' }, { id: 'web_servers', icon: 'globe', titleKey: 'web_servers' },
]; ];
const WG_AWG_BASES = new Set(['awg', 'awg2', 'awg_legacy', 'wireguard']);
let connectDomainEffective = {};
let currentInstallProto = 'awg'; let currentInstallProto = 'awg';
let currentInstallAnother = false; let currentInstallAnother = false;
let currentConfig = ''; let currentConfig = '';
@@ -1171,6 +1274,94 @@
return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]); return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]);
} }
function hasWgAwgInstalled() {
return Object.entries(installedProtocols || {}).some(([key, installed]) => installed && WG_AWG_BASES.has(protoBase(key)));
}
function listInstalledWgAwgProtocols() {
const protos = new Set();
Object.entries(installedProtocols || {}).forEach(([key, installed]) => {
if (installed && WG_AWG_BASES.has(protoBase(key))) protos.add(key);
});
return Array.from(protos).sort((a, b) => {
const ao = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(a));
const bo = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(b));
if (ao !== bo) return ao - bo;
return protoInstance(a) - protoInstance(b);
});
}
function updateConnectDomainSection() {
const section = document.getElementById('connectDomainSection');
if (!section) return;
const show = hasWgAwgInstalled();
section.classList.toggle('hidden', !show);
if (!show) return;
const select = document.getElementById('connectDomainProto');
if (!select) return;
const prev = select.value;
const installed = listInstalledWgAwgProtocols();
select.innerHTML = `<option value="">${_('server_connect_domain_all')}</option>` +
installed.map(p => `<option value="${p}">${getProtoTitle(p)}</option>`).join('');
if (prev && [...select.options].some(opt => opt.value === prev)) {
select.value = prev;
}
refreshConnectDomainFields();
}
async function refreshConnectDomainFields() {
const proto = document.getElementById('connectDomainProto')?.value || '';
const input = document.getElementById('connectDomainInput');
const eff = document.getElementById('connectDomainEffective');
try {
const query = proto ? `?protocol=${encodeURIComponent(proto)}` : '';
const data = await apiCall(`/api/servers/${SERVER_ID}/connect-domain${query}`, 'GET');
connectDomainEffective = data;
if (input) {
input.value = proto ? (data.protocol_connect_domain || '') : (data.connect_domain || '');
}
if (eff) {
if (data.effective_host && data.effective_host !== data.ssh_host) {
eff.textContent = `${_('server_connect_domain_current')}: ${data.effective_host} (${_('server_connect_domain_ssh')}: ${data.ssh_host})`;
} else {
eff.textContent = `${_('server_connect_domain_current')}: ${data.ssh_host || SERVER_HOST}`;
}
}
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
updateProtocolCard(p, info);
}
});
} catch (err) {
if (input && !proto) input.value = SERVER_CONNECT_DOMAIN || '';
if (eff) eff.textContent = '';
}
}
async function saveConnectDomain() {
const btn = document.getElementById('connectDomainSaveBtn');
const proto = document.getElementById('connectDomainProto')?.value || '';
const domain = document.getElementById('connectDomainInput')?.value.trim() || '';
if (btn) btn.disabled = true;
try {
await apiCall(`/api/servers/${SERVER_ID}/connect-domain`, 'POST', {
connect_domain: domain,
protocol: proto || null,
});
showToast(_('server_connect_domain_saved'), 'success');
await refreshConnectDomainFields();
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
updateProtocolCard(p, info);
}
});
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function switchCategory(cat) { function switchCategory(cat) {
if (cat === 'management') { if (cat === 'management') {
openManagementModal(); openManagementModal();
@@ -1329,6 +1520,7 @@
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0); if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
const loading = document.getElementById('installedAppsLoading'); const loading = document.getElementById('installedAppsLoading');
if (loading) loading.classList.toggle('hidden', !installedAppsLoading); if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
updateConnectDomainSection();
} }
function getProtoTitle(proto) { function getProtoTitle(proto) {
@@ -1343,6 +1535,7 @@
case 'telemt': title = 'Telemt'; break; case 'telemt': title = 'Telemt'; break;
case 'hysteria': title = 'Hysteria 2'; break; case 'hysteria': title = 'Hysteria 2'; break;
case 'naiveproxy': title = 'NaiveProxy'; break; case 'naiveproxy': title = 'NaiveProxy'; break;
case 'mieru': title = 'Mieru'; break;
case 'wireguard': title = 'WireGuard'; break; case 'wireguard': title = 'WireGuard'; break;
case 'dns': title = 'AmneziaDNS'; break; case 'dns': title = 'AmneziaDNS'; break;
case 'socks5': title = 'SOCKS5 Proxy'; break; case 'socks5': title = 'SOCKS5 Proxy'; break;
@@ -1402,7 +1595,7 @@
for (const [proto, info] of orderedProtocolEntries(data.protocols)) { for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
updateProtocolCard(proto, info); updateProtocolCard(proto, info);
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'wireguard'].includes(protoBase(proto)); const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru', 'wireguard'].includes(protoBase(proto));
if (info.container_running && isVPN) { if (info.container_running && isVPN) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = proto; opt.value = proto;
@@ -1630,13 +1823,22 @@
if (isService) { if (isService) {
grid = buildServiceInfoGrid(proto, info); grid = buildServiceInfoGrid(proto, info);
} else if (info.port) { } else if (info.port) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt', 'naiveproxy'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt', 'naiveproxy', 'mieru'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
if (WG_AWG_BASES.has(protoBase(proto))) {
const endpoint = (connectDomainEffective.effective_host && connectDomainEffective.effective_host !== SERVER_HOST)
? connectDomainEffective.effective_host
: SERVER_HOST;
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('server_endpoint_label')}</span><span class="protocol-info-value">${endpoint}</span></div>`;
}
if (protoBase(proto) === 'hysteria' && info.domain) { if (protoBase(proto) === 'hysteria' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
} }
if (protoBase(proto) === 'naiveproxy' && info.domain) { if (protoBase(proto) === 'naiveproxy' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
} }
if (protoBase(proto) === 'mieru' && info.release) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('mieru_version')}</span><span class="protocol-info-value">v${info.release}</span></div>`;
}
} }
if (!isService && info.clients_count !== undefined) { if (!isService && info.clients_count !== undefined) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
@@ -1686,6 +1888,13 @@
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button> <button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`; `;
showConnectionsSection(); showConnectionsSection();
} else if (protoBase(proto) === 'mieru') {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
showConnectionsSection();
} else { } else {
actionsEl.innerHTML = ` actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button> <button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
@@ -2089,6 +2298,7 @@
const nginxOpts = document.getElementById('nginxOptions'); const nginxOpts = document.getElementById('nginxOptions');
const hysteriaOpts = document.getElementById('hysteriaOptions'); const hysteriaOpts = document.getElementById('hysteriaOptions');
const naiveproxyOpts = document.getElementById('naiveproxyOptions'); const naiveproxyOpts = document.getElementById('naiveproxyOptions');
const mieruOpts = document.getElementById('mieruOptions');
telemtOpts.style.display = 'none'; telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none'; socks5Opts.style.display = 'none';
@@ -2096,6 +2306,7 @@
nginxOpts.style.display = 'none'; nginxOpts.style.display = 'none';
hysteriaOpts.style.display = 'none'; hysteriaOpts.style.display = 'none';
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none'; if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
if (mieruOpts) mieruOpts.style.display = 'none';
if (portGroup) portGroup.style.display = ''; if (portGroup) portGroup.style.display = '';
if (base === 'dns') { if (base === 'dns') {
@@ -2163,6 +2374,17 @@
if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN; if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN;
if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL; if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL;
updateNaiveproxyDnsHint(); updateNaiveproxyDnsHint();
} else if (base === 'mieru') {
if (portGroup) portGroup.style.display = 'none';
const suggested = '2999';
portInput.value = suggested;
portInput.disabled = false;
if (mieruOpts) mieruOpts.style.display = 'block';
const miPort = document.getElementById('installMieruPort');
if (miPort) {
miPort.value = suggested;
miPort.oninput = () => { portInput.value = miPort.value; };
}
} else { } else {
portLabel.textContent = _('port') + ' (UDP)'; portLabel.textContent = _('port') + ' (UDP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424'; portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
@@ -2240,6 +2462,11 @@
params.port = '443'; params.port = '443';
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim(); params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
params.naiveproxy_email = document.getElementById('installNaiveproxyEmail').value.trim(); params.naiveproxy_email = document.getElementById('installNaiveproxyEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'mieru') {
const miPortEl = document.getElementById('installMieruPort');
if (miPortEl && miPortEl.value) {
params.port = miPortEl.value;
}
} }
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params); const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
clearInterval(progressInterval); clearInterval(progressInterval);
@@ -2608,6 +2835,95 @@
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' }); document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
} }
const selectedConnectionIds = new Set();
function updateMoveSelection() {
selectedConnectionIds.clear();
document.querySelectorAll('.conn-select:checked').forEach((el) => {
if (el.dataset.clientId) selectedConnectionIds.add(el.dataset.clientId);
});
const count = selectedConnectionIds.size;
const bulkBar = document.getElementById('connectionsBulkBar');
const moveBtn = document.getElementById('moveConnectionsBtn');
const countEl = document.getElementById('connSelectedCount');
const selectAll = document.getElementById('connSelectAll');
const boxes = document.querySelectorAll('.conn-select');
if (bulkBar) bulkBar.classList.toggle('hidden', boxes.length === 0);
if (moveBtn) moveBtn.classList.toggle('hidden', boxes.length === 0);
if (countEl) countEl.textContent = _('connections_selected_count').replace('{}', String(count));
if (selectAll) {
selectAll.indeterminate = count > 0 && count < boxes.length;
selectAll.checked = boxes.length > 0 && count === boxes.length;
}
}
function toggleSelectAllConnections(checked) {
document.querySelectorAll('.conn-select').forEach((el) => { el.checked = checked; });
updateMoveSelection();
}
function openMoveConnectionsModal() {
if (!selectedConnectionIds.size) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
const select = document.getElementById('moveTargetServer');
select.innerHTML = '';
(ALL_SERVERS || []).forEach((srv) => {
if (srv.id === SERVER_ID) return;
const opt = document.createElement('option');
opt.value = String(srv.id);
opt.textContent = `${srv.name} (${srv.host})`;
select.appendChild(opt);
});
if (!select.options.length) {
showToast(_('move_connections_no_targets'), 'error');
return;
}
openModal('moveConnectionsModal');
}
async function moveSelectedConnections() {
const targetServerId = parseInt(document.getElementById('moveTargetServer').value, 10);
const deleteSource = document.getElementById('moveDeleteSource').checked;
const proto = document.getElementById('connProtoSelect').value;
const clientIds = Array.from(selectedConnectionIds);
if (!clientIds.length || Number.isNaN(targetServerId)) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
if (!confirm(_('move_connections_confirm').replace('{}', String(clientIds.length)))) return;
const btn = document.getElementById('moveConnectionsSubmitBtn');
const text = document.getElementById('moveConnectionsSubmitText');
const spinner = document.getElementById('moveConnectionsSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections/move`, 'POST', {
protocol: proto,
target_server_id: targetServerId,
client_ids: clientIds,
delete_source: deleteSource,
});
const moved = (data.moved || []).length;
const failed = (data.failed || []).length;
let msg = _('move_connections_success').replace('{}', String(moved));
if (failed) msg += '. ' + _('move_connections_partial_fail').replace('{}', String(failed));
showToast(msg, failed && !moved ? 'error' : 'success');
closeModal('moveConnectionsModal');
selectedConnectionIds.clear();
loadConnections();
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = _('move_connections');
}
}
async function loadConnections() { async function loadConnections() {
const proto = document.getElementById('connProtoSelect').value; const proto = document.getElementById('connProtoSelect').value;
const loading = document.getElementById('connectionsLoading'); const loading = document.getElementById('connectionsLoading');
@@ -2622,11 +2938,14 @@
loading.style.display = ''; loading.style.display = '';
emptyEl.classList.add('hidden'); emptyEl.classList.add('hidden');
listEl.innerHTML = ''; listEl.innerHTML = '';
selectedConnectionIds.clear();
updateMoveSelection();
try { try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`); const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none'; loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) { if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden'); emptyEl.classList.remove('hidden');
updateMoveSelection();
return; return;
} }
window.connectionsStore = {}; window.connectionsStore = {};
@@ -2670,6 +2989,9 @@
listEl.innerHTML += ` listEl.innerHTML += `
<div class="client-item" style="${disabledStyle}"> <div class="client-item" style="${disabledStyle}">
<label class="conn-select-wrap">
<input type="checkbox" class="conn-select" data-client-id="${escapeJs(client.clientId)}" onchange="updateMoveSelection()">
</label>
<div class="client-info"> <div class="client-info">
<div class="client-avatar">${initial}</div> <div class="client-avatar">${initial}</div>
<div> <div>
@@ -2686,6 +3008,7 @@
</div> </div>
`; `;
}); });
updateMoveSelection();
} catch (err) { } catch (err) {
loading.style.display = 'none'; loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error'); showToast(_('connections_load_error') + ': ' + err.message, 'error');
+61 -5
View File
@@ -604,15 +604,19 @@
<div class="card" style="margin-top: var(--space-lg);"> <div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3> <h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm);"> <div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary" <a href="/api/settings/backup/download" class="btn btn-secondary"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);"> style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>⬇️</span> {{ _('download_backup') }} <span>⬇️</span> {{ _('download_backup') }}
</a> </a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>📄</span> {{ _('download_backup_json') }}
</a>
</div> </div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);"> <div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div style="display: flex; flex-direction: column; gap: var(--space-sm);"> <div style="display: flex; flex-direction: column; gap: var(--space-sm);">
<input type="file" id="backupFile" accept=".json" style="display: none;" <input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
onchange="handleRestore(event)"> onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn" onclick="document.getElementById('backupFile').click()" id="restoreBtn"
@@ -623,6 +627,9 @@
</div> </div>
</div> </div>
<p class="form-hint" style="margin-top: var(--space-sm);"> <p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('backup_hint') }}
</p>
<p class="form-hint" style="margin-top: var(--space-xs);">
{{ _('restore_confirm') }} {{ _('restore_confirm') }}
</p> </p>
</div> </div>
@@ -643,6 +650,41 @@
</div> </div>
</div> </div>
<!-- BLOCK: Support / Donate (admin) -->
{% set donate_cfg = settings.get('donate') or {} %}
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ icon('heart') }} {{ _('donate_settings_title') }}</h3>
<p class="form-hint" style="margin-top: 0; margin-bottom: var(--space-md);">{{ _('donate_settings_hint') }}</p>
<div class="form-group">
<label class="form-label" style="display:flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox" id="donate_enabled" {% if donate_cfg.get('enabled', True) %}checked{% endif %}>
{{ _('donate_enabled') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('donate_intro') }}</label>
<textarea class="form-textarea" id="donate_intro" rows="2" placeholder="{{ _('support_intro') }}">{{ donate_cfg.get('intro') or '' }}</textarea>
</div>
{% for key, label_key in [('sbp', 'donate_sbp'), ('card', 'donate_card'), ('crypto', 'donate_crypto')] %}
{% set method = donate_cfg.get(key) or {} %}
<div style="border:1px solid var(--border-color); border-radius:var(--radius-md); padding:var(--space-md); margin-bottom:var(--space-md);">
<label class="form-label" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:var(--space-sm);">
<input type="checkbox" id="donate_{{ key }}_enabled" {% if method.get('enabled', True) %}checked{% endif %}>
{{ _(label_key) }}
</label>
<div class="form-group" style="margin-bottom:var(--space-sm);">
<label class="form-label">{{ _('donate_method_title') }}</label>
<input type="text" class="form-input" id="donate_{{ key }}_title" value="{{ method.get('title') or '' }}" placeholder="{{ _(label_key) }}">
</div>
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">{{ _('donate_method_details') }}</label>
<textarea class="form-textarea" id="donate_{{ key }}_details" rows="3" placeholder="{{ _('donate_details_placeholder') }}">{{ method.get('details') or '' }}</textarea>
</div>
</div>
{% endfor %}
<p class="form-hint">{{ _('donate_preview_hint') }} <a href="/support" target="_blank" rel="noopener">/support</a></p>
</div>
<!-- BLOCK: About & Updates --> <!-- BLOCK: About & Updates -->
<div class="card" style="margin-top: var(--space-lg);"> <div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">️ {{ _('about_title') }}</h3> <h3 class="card-title" style="margin-bottom: var(--space-lg);">️ {{ _('about_title') }}</h3>
@@ -1471,11 +1513,24 @@
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '', 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 { try {
const res = await fetch('/api/settings/save', { const res = await fetch('/api/settings/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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')); if (!res.ok) throw new Error(_('error'));
showToast(_('settings_saved'), 'success'); showToast(_('settings_saved'), 'success');
@@ -1489,7 +1544,7 @@
} }
const guestServersData = {{ servers | default([]) | tojson }}; 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 = { const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0', awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG', awg: 'AmneziaWG',
@@ -1499,6 +1554,7 @@
telemt: 'Telemt', telemt: 'Telemt',
hysteria: 'Hysteria 2', hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy', naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
xui: '3x-ui VLESS', xui: '3x-ui VLESS',
}; };
function protoTitle(key) { function protoTitle(key) {
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ _('nav_support') }}{% endblock %}
{% block head_extra %}
<style>
.support-hero {
text-align: center;
padding: var(--space-xl) var(--space-md);
margin-bottom: var(--space-xl);
}
.support-hero-icon {
font-size: 2.5rem;
margin-bottom: var(--space-md);
color: var(--accent);
}
.support-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-lg);
max-width: 960px;
margin: 0 auto;
}
.support-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.support-card-title {
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.support-details {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
padding: var(--space-sm);
border-radius: var(--radius-md);
background: var(--bg-primary);
border: 1px dashed var(--border-color);
min-height: 4rem;
color: var(--text-muted);
}
.support-details.has-content {
color: var(--text-primary);
border-style: solid;
}
</style>
{% endblock %}
{% block content %}
<section class="support-hero">
<div class="support-hero-icon">{{ icon('heart') }}</div>
<h1 class="section-title" style="justify-content: center; margin-bottom: var(--space-sm);">
{{ _('support_title') }}
</h1>
<p class="text-muted" style="max-width: 560px; margin: 0 auto; line-height: 1.5;">
{% if donate.get('intro') %}
{{ donate.intro }}
{% else %}
{{ _('support_intro') }}
{% endif %}
</p>
</section>
<div class="support-grid">
{% 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) %}
<div class="card support-card">
<div class="support-card-title">
<span aria-hidden="true">{{ emoji }}</span>
<span>{{ method.get('title') or default_title }}</span>
</div>
{% set details = (method.get('details') or '').strip() %}
<div class="support-details{% if details %} has-content{% endif %}" id="donate-{{ key }}-details">{{ details or _('donate_details_soon') }}</div>
{% if details %}
<button type="button" class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('donate-{{ key }}-details').textContent)">
{{ icon('copy') }} {{ _('copy') }}
</button>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<p class="text-muted text-sm" style="text-align: center; margin-top: var(--space-xl); max-width: 520px; margin-left: auto; margin-right: auto;">
{{ _('support_footer') }}
</p>
{% endblock %}
+2 -1
View File
@@ -631,7 +631,7 @@
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
const group = groupId ? document.getElementById(groupId) : null; const group = groupId ? document.getElementById(groupId) : null;
select.innerHTML = ''; 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 = { const titles = {
awg2: 'AmneziaWG 2.0', awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG', awg: 'AmneziaWG',
@@ -641,6 +641,7 @@
telemt: 'Telemt', telemt: 'Telemt',
hysteria: 'Hysteria 2', hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy', naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
xui: '3x-ui VLESS', xui: '3x-ui VLESS',
}; };
const protoTitle = (key) => { const protoTitle = (key) => {
+50 -5
View File
@@ -4,6 +4,7 @@
"nav_invites": "Invite links", "nav_invites": "Invite links",
"nav_settings": "Settings", "nav_settings": "Settings",
"nav_connections": "Connections", "nav_connections": "Connections",
"nav_support": "Support",
"nav_logout": "Logout", "nav_logout": "Logout",
"theme_dark": "Dark", "theme_dark": "Dark",
"theme_light": "Light", "theme_light": "Light",
@@ -101,6 +102,19 @@
"connection_created": "Connection \"{}\" created", "connection_created": "Connection \"{}\" created",
"delete_connection_confirm": "Delete this connection? The configuration will stop working.", "delete_connection_confirm": "Delete this connection? The configuration will stop working.",
"connection_deleted": "Connection deleted", "connection_deleted": "Connection deleted",
"select_all": "Select all",
"connections_selected_count": "{} selected",
"move_connections": "Move",
"move_connections_title": "Move connections to another server",
"move_connections_target": "Target server",
"move_connections_confirm": "Move {} connection(s) to the selected server? Users will need new configs.",
"move_connections_hint": "Recreates selected connections on the target server and updates user assignments.",
"move_connections_warning": "Clients get new keys and IPs. Old configs stop working after move.",
"move_connections_delete_source": "Remove from current server after move",
"move_connections_success": "Moved {} connection(s)",
"move_connections_partial_fail": "{} could not be moved",
"move_connections_none_selected": "Select at least one connection",
"move_connections_no_targets": "No other servers available",
"config_tab": "📄 Config", "config_tab": "📄 Config",
"vpn_key_tab": "🔑 VPN-key", "vpn_key_tab": "🔑 VPN-key",
"qr_code_tab": "📱 QR-code", "qr_code_tab": "📱 QR-code",
@@ -348,11 +362,13 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "Simple Backup", "backup_title": "Simple Backup",
"download_backup": "Download data.json", "download_backup": "Download PostgreSQL dump (.sql)",
"restore_backup": "Restore from file", "download_backup_json": "Export JSON (legacy)",
"restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.", "backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.",
"restore_backup": "Restore from .sql or .json",
"restore_confirm": "Restore will overwrite all current panel data in the database.",
"restore_success": "Restore successful! Restarting...", "restore_success": "Restore successful! Restarting...",
"invalid_backup_file": "Invalid backup file or structure", "invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)",
"config_unavailable": "Configuration unavailable", "config_unavailable": "Configuration unavailable",
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.", "config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
"client_public_key": "Client public key:", "client_public_key": "Client public key:",
@@ -379,9 +395,38 @@
"naiveproxy_dns_hint": "Create this DNS record before installation:", "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_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.", "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 (102565535). 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_domain": "SSL domain (default)",
"server_ssl_email": "SSL email (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", "container_logs": "Container logs",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "Live", "logs_live": "Live",
+27 -4
View File
@@ -100,6 +100,19 @@
"connection_created": "اتصال \"{}\" ایجاد شد", "connection_created": "اتصال \"{}\" ایجاد شد",
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.", "delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
"connection_deleted": "اتصال حذف شد", "connection_deleted": "اتصال حذف شد",
"select_all": "انتخاب همه",
"connections_selected_count": "{} انتخاب شده",
"move_connections": "انتقال",
"move_connections_title": "انتقال اتصال‌ها به سرور دیگر",
"move_connections_target": "سرور مقصد",
"move_connections_confirm": "{} اتصال به سرور انتخاب‌شده منتقل شود؟ کاربران به پیکربندی جدید نیاز دارند.",
"move_connections_hint": "اتصال‌های انتخاب‌شده روی سرور مقصد دوباره ساخته می‌شوند و پیوند کاربران حفظ می‌شود.",
"move_connections_warning": "کلیدها و IP جدید صادر می‌شود. پیکربندی‌های قدیمی کار نمی‌کنند.",
"move_connections_delete_source": "پس از انتقال از سرور فعلی حذف شود",
"move_connections_success": "{} اتصال منتقل شد",
"move_connections_partial_fail": "{} منتقل نشد",
"move_connections_none_selected": "حداقل یک اتصال انتخاب کنید",
"move_connections_no_targets": "سرور دیگری برای انتقال وجود ندارد",
"config_tab": "📄 فایل", "config_tab": "📄 فایل",
"vpn_key_tab": "🔑 کلید-VPN", "vpn_key_tab": "🔑 کلید-VPN",
"qr_code_tab": "📱 کد-QR", "qr_code_tab": "📱 کد-QR",
@@ -331,11 +344,13 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "پشتیبان‌گیری ساده", "backup_title": "پشتیبان‌گیری ساده",
"download_backup": "دانلود data.json", "download_backup": "دانلود dump PostgreSQL (.sql)",
"restore_backup": "بازیابی از فایل", "download_backup_json": "خروجی JSON (قدیمی)",
"restore_confirm": "آیا مطمئن هستید؟ داده‌های فعلی بازنویسی می‌شوند و برنامه دوباره راه‌اندازی خواهد شد.", "backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخه‌های قدیمی.",
"restore_backup": "بازیابی از .sql یا .json",
"restore_confirm": "بازیابی تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند.",
"restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...", "restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...",
"invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است", "invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)",
"config_unavailable": "پیکربندی در دسترس نیست", "config_unavailable": "پیکربندی در دسترس نیست",
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.", "config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.",
"client_public_key": "کلید عمومی کلاینت:", "client_public_key": "کلید عمومی کلاینت:",
@@ -369,9 +384,17 @@
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:", "naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورت‌های TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).", "naiveproxy_ports_warning": "هشدار: برای کار پایدار پورت‌های TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینت‌ها آزمایش نشده‌اند.", "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 (۱۰۲۵–۶۵۵۳۵). در فایروال سرور باز کنید.",
"mieru_install_hint": "بسته رسمی mita را از GitHub v3.28.0 نصب می‌کند (Debian/RPM). لینوکس با systemd لازم است.",
"mieru_ports_warning": "هشدار: پورت TCP انتخابی باید آزاد و در فایروال مجاز باشد.",
"mieru_client_hint": "لینک‌ها با فرمت mierus://. در کلاینت mieru یا Clash.Meta (type: mieru) وارد کنید.",
"server_ssl_domain": "دامنه SSL (پیش‌فرض)", "server_ssl_domain": "دامنه SSL (پیش‌فرض)",
"server_ssl_email": "ایمیل SSL (پیش‌فرض)", "server_ssl_email": "ایمیل SSL (پیش‌فرض)",
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).", "server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده می‌شود (Let\u0027s Encrypt).",
"server_connect_domain": "دامنه اتصال کلاینت",
"server_connect_domain_hint": "در پیکربندی VPN به‌جای IP استفاده می‌شود. هنگام انتقال سرور، رکورد DNS A را به‌روز کنید.",
"container_logs": "لاگ‌های کانتینر", "container_logs": "لاگ‌های کانتینر",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "زنده", "logs_live": "زنده",
+27 -4
View File
@@ -100,6 +100,19 @@
"connection_created": "Connexion \"{}\" créée", "connection_created": "Connexion \"{}\" créée",
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.", "delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
"connection_deleted": "Connexion supprimée", "connection_deleted": "Connexion supprimée",
"select_all": "Tout sélectionner",
"connections_selected_count": "{} sélectionné(s)",
"move_connections": "Déplacer",
"move_connections_title": "Déplacer les connexions vers un autre serveur",
"move_connections_target": "Serveur cible",
"move_connections_confirm": "Déplacer {} connexion(s) vers le serveur sélectionné ? Les utilisateurs auront besoin de nouveaux configs.",
"move_connections_hint": "Recrée les connexions sur le serveur cible et conserve les affectations utilisateur.",
"move_connections_warning": "Les clients reçoivent de nouvelles clés et IP. Les anciens configs cessent de fonctionner.",
"move_connections_delete_source": "Supprimer du serveur actuel après le déplacement",
"move_connections_success": "{} connexion(s) déplacée(s)",
"move_connections_partial_fail": "{} n'ont pas pu être déplacées",
"move_connections_none_selected": "Sélectionnez au moins une connexion",
"move_connections_no_targets": "Aucun autre serveur disponible",
"config_tab": "📄 Config", "config_tab": "📄 Config",
"vpn_key_tab": "🔑 Clé-VPN", "vpn_key_tab": "🔑 Clé-VPN",
"qr_code_tab": "📱 Code-QR", "qr_code_tab": "📱 Code-QR",
@@ -331,11 +344,13 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "Sauvegarde Simple", "backup_title": "Sauvegarde Simple",
"download_backup": "Télécharger data.json", "download_backup": "Télécharger le dump PostgreSQL (.sql)",
"restore_backup": "Restaurer depuis un fichier", "download_backup_json": "Exporter JSON (ancien)",
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l\u0027application redémarrera.", "backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.",
"restore_backup": "Restaurer depuis .sql ou .json",
"restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.",
"restore_success": "Restauration réussie ! Redémarrage...", "restore_success": "Restauration réussie ! Redémarrage...",
"invalid_backup_file": "Fichier de sauvegarde ou structure invalide", "invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)",
"config_unavailable": "Configuration indisponible", "config_unavailable": "Configuration indisponible",
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.", "config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
"client_public_key": "Clé publique du client :", "client_public_key": "Clé publique du client :",
@@ -369,9 +384,17 @@
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :", "naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).", "naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.", "naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
"mieru_desc": "Mieru (mita v3.28.0) — proxy TCP natif enfein/mieru. Pas besoin de Docker. Client : mieru ou Clash.Meta/mihomo.",
"mieru_version": "Version",
"mieru_port_hint": "Port TCP pour mita (102565535). Ouvrez-le dans le pare-feu du serveur.",
"mieru_install_hint": "Installe le paquet officiel mita depuis GitHub v3.28.0 (Debian/RPM). Linux avec systemd requis.",
"mieru_ports_warning": "Attention : le port TCP choisi doit être libre et autorisé dans le pare-feu.",
"mieru_client_hint": "Liens au format mierus://. Importez dans le client mieru ou Clash.Meta (type: mieru).",
"server_ssl_domain": "Domaine SSL (par défaut)", "server_ssl_domain": "Domaine SSL (par défaut)",
"server_ssl_email": "Email 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_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", "container_logs": "Logs du conteneur",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "Temps réel", "logs_live": "Temps réel",
+50 -5
View File
@@ -4,6 +4,7 @@
"nav_invites": "Ссылки", "nav_invites": "Ссылки",
"nav_settings": "Настройки", "nav_settings": "Настройки",
"nav_connections": "Подключения", "nav_connections": "Подключения",
"nav_support": "Поддержать",
"nav_logout": "Выход", "nav_logout": "Выход",
"theme_dark": "Темная", "theme_dark": "Темная",
"theme_light": "Светлая", "theme_light": "Светлая",
@@ -101,6 +102,19 @@
"connection_created": "Подключение \"{}\" создано", "connection_created": "Подключение \"{}\" создано",
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.", "delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
"connection_deleted": "Подключение удалено", "connection_deleted": "Подключение удалено",
"select_all": "Выбрать все",
"connections_selected_count": "Выбрано: {}",
"move_connections": "Перенести",
"move_connections_title": "Перенос подключений на другой сервер",
"move_connections_target": "Целевой сервер",
"move_connections_confirm": "Перенести {} подключение(й) на выбранный сервер? Пользователям понадобятся новые конфиги.",
"move_connections_hint": "Подключения будут созданы заново на целевом сервере, привязки к пользователям сохранятся.",
"move_connections_warning": "У клиентов будут новые ключи и IP. Старые конфиги перестанут работать.",
"move_connections_delete_source": "Удалить с текущего сервера после переноса",
"move_connections_success": "Перенесено подключений: {}",
"move_connections_partial_fail": "Не удалось перенести: {}",
"move_connections_none_selected": "Выберите хотя бы одно подключение",
"move_connections_no_targets": "Нет других серверов для переноса",
"config_tab": "📄 Конфиг", "config_tab": "📄 Конфиг",
"vpn_key_tab": "🔑 VPN-ключ", "vpn_key_tab": "🔑 VPN-ключ",
"qr_code_tab": "📱 QR-код", "qr_code_tab": "📱 QR-код",
@@ -348,11 +362,13 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "Резервное копирование", "backup_title": "Резервное копирование",
"download_backup": "Скачать data.json", "download_backup": "Скачать дамп PostgreSQL (.sql)",
"restore_backup": "Восстановить из файла", "download_backup_json": "Экспорт JSON (устар.)",
"restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.", "backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.",
"restore_backup": "Восстановить из .sql или .json",
"restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.",
"restore_success": "Восстановление успешно! Перезагрузка...", "restore_success": "Восстановление успешно! Перезагрузка...",
"invalid_backup_file": "Неверный файл резервной копии или структура", "invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)",
"config_unavailable": "Конфигурация недоступна", "config_unavailable": "Конфигурация недоступна",
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.", "config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
"client_public_key": "Публичный ключ клиента:", "client_public_key": "Публичный ключ клиента:",
@@ -379,9 +395,38 @@
"naiveproxy_dns_hint": "Перед установкой создайте DNS-запись:", "naiveproxy_dns_hint": "Перед установкой создайте DNS-запись:",
"naiveproxy_ports_warning": "Внимание: для стабильной работы нужны свободные TCP-порты 80 и 443 (Let\u0027s Encrypt на 80, HTTPS-прокси на 443).", "naiveproxy_ports_warning": "Внимание: для стабильной работы нужны свободные TCP-порты 80 и 443 (Let\u0027s Encrypt на 80, HTTPS-прокси на 443).",
"naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.", "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_domain": "SSL-домен (по умолчанию)",
"server_ssl_email": "SSL-email (по умолчанию)", "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": "Логи контейнера", "container_logs": "Логи контейнера",
"logs_btn": "📋 Логи", "logs_btn": "📋 Логи",
"logs_live": "В реальном времени", "logs_live": "В реальном времени",
+27 -4
View File
@@ -100,6 +100,19 @@
"connection_created": "连接 \"{}\" 已创建", "connection_created": "连接 \"{}\" 已创建",
"delete_connection_confirm": "确定删除此连接?配置将失效。", "delete_connection_confirm": "确定删除此连接?配置将失效。",
"connection_deleted": "连接已删除", "connection_deleted": "连接已删除",
"select_all": "全选",
"connections_selected_count": "已选 {}",
"move_connections": "迁移",
"move_connections_title": "将连接迁移到另一台服务器",
"move_connections_target": "目标服务器",
"move_connections_confirm": "将 {} 个连接迁移到所选服务器?用户需要新的配置。",
"move_connections_hint": "在目标服务器上重新创建所选连接,并保留用户绑定。",
"move_connections_warning": "客户端将获得新密钥和 IP,旧配置将失效。",
"move_connections_delete_source": "迁移后从当前服务器删除",
"move_connections_success": "已迁移 {} 个连接",
"move_connections_partial_fail": "{} 个无法迁移",
"move_connections_none_selected": "请至少选择一个连接",
"move_connections_no_targets": "没有其他可用服务器",
"config_tab": "📄 配置文件", "config_tab": "📄 配置文件",
"vpn_key_tab": "🔑 VPN 密钥", "vpn_key_tab": "🔑 VPN 密钥",
"qr_code_tab": "📱 二维码", "qr_code_tab": "📱 二维码",
@@ -331,11 +344,13 @@
"lang_zh": "中文 (Chinese)", "lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)", "lang_fa": "فارسی (Persian)",
"backup_title": "简易备份", "backup_title": "简易备份",
"download_backup": "下载 data.json", "download_backup": "下载 PostgreSQL 转储 (.sql)",
"restore_backup": "从文件恢复", "download_backup_json": "导出 JSON(旧版)",
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。", "backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。",
"restore_backup": "从 .sql 或 .json 恢复",
"restore_confirm": "恢复将覆盖数据库中所有当前面板数据。",
"restore_success": "恢复成功!正在重启...", "restore_success": "恢复成功!正在重启...",
"invalid_backup_file": "备份文件无效或结构错误", "invalid_backup_file": "无效的备份文件.sql 转储或旧版 data.json",
"config_unavailable": "配置文件不可用", "config_unavailable": "配置文件不可用",
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。", "config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
"client_public_key": "客户端公钥:", "client_public_key": "客户端公钥:",
@@ -369,9 +384,17 @@
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:", "naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443Let\u0027s Encrypt 使用 80HTTPS 代理使用 443)。", "naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443Let\u0027s Encrypt 使用 80HTTPS 代理使用 443)。",
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。", "naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
"mieru_desc": "Mierumita v3.28.0)— enfein/mieru 原生 TCP 代理,无需 Docker。客户端:mieru 或 Clash.Meta/mihomo。",
"mieru_version": "版本",
"mieru_port_hint": "mita 的 TCP 端口(1025–65535)。请在服务器防火墙中放行。",
"mieru_install_hint": "从 GitHub v3.28.0 安装官方 mita 包(Debian/RPM)。需要带 systemd 的 Linux。",
"mieru_ports_warning": "注意:所选 TCP 端口必须空闲,并在防火墙中允许。",
"mieru_client_hint": "分享链接为 mierus:// 格式。可导入 mieru 客户端或 Clash.Metatype: mieru)。",
"server_ssl_domain": "SSL 域名(默认)", "server_ssl_domain": "SSL 域名(默认)",
"server_ssl_email": "SSL 邮箱(默认)", "server_ssl_email": "SSL 邮箱(默认)",
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。", "server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
"server_connect_domain": "客户端连接域名",
"server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。",
"container_logs": "容器日志", "container_logs": "容器日志",
"logs_btn": "📋 日志", "logs_btn": "📋 日志",
"logs_live": "实时", "logs_live": "实时",