Template
Compare commits
28
Commits
v2.0.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53bffbd4fc | ||
|
|
53638cf122 | ||
|
|
1f1234d217 | ||
|
|
9890e8cc3f | ||
|
|
32a0e90e19 | ||
|
|
73d0ee277d | ||
|
|
9b4ef0f9f0 | ||
|
|
78e69d899a | ||
|
|
2d62758716 | ||
|
|
d26843a0d1 | ||
|
|
706d2c9c0d | ||
|
|
2746f981af | ||
|
|
4f5a1d7554 | ||
|
|
ff7cc5c592 | ||
|
|
2808a49fa5 | ||
|
|
2973b96713 | ||
|
|
5d63e5d6ef | ||
|
|
a5b8f26db1 | ||
|
|
599e0a487c | ||
|
|
77c6f0dab7 | ||
|
|
fd257a59f7 | ||
|
|
6206697e3b | ||
|
|
4c19099b04 | ||
|
|
80160d0ef4 | ||
|
|
ac76a0e540 | ||
|
|
bf7bd16fcd | ||
|
|
24e793d943 | ||
|
|
415cfea5eb |
+10
-3
@@ -2,11 +2,18 @@
|
||||
APP_PORT=5000
|
||||
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_PASSWORD=amnezia
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=amnezia_panel
|
||||
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)
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
+7
-4
@@ -5,12 +5,15 @@ FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_PORT=5000 \
|
||||
PORT=5000 \
|
||||
PANEL_IN_DOCKER=1 \
|
||||
PANEL_BEHIND_PROXY=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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/*
|
||||
|
||||
COPY requirements.txt .
|
||||
@@ -20,7 +23,7 @@ COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:5000/health" >/dev/null || exit 1
|
||||
|
||||
CMD ["python3", "app.py"]
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Amnezia Web Panel
|
||||
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **NordVPN**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
|
||||
> ### 🔄 Compatibility with Official Amnezia Client
|
||||
>
|
||||
@@ -63,9 +63,10 @@ Configuration panel for system parameters and preferences:
|
||||
* **Classic WireGuard**: Standard, high-performance WireGuard protocol for unmatched speed and broad device compatibility with traffic monitoring support.
|
||||
* **Xray (XTLS-Reality)**: Stealthy protocol that masks VPN traffic as standard HTTPS browsing. Pinned to **Xray-core v26.x**; transparently reads both the **panel layout** (`meta.json` + `clientsTable.json`) and the **native Amnezia client layout** (`xray_*.key` files + `clientsTable`), so a node first installed via the official mobile/desktop app can be attached to the panel without re-installation.
|
||||
* **Hysteria 2**: QUIC/HTTP3 proxy from [apernet/hysteria](https://github.com/apernet/hysteria) via `tobyxdd/hysteria:v2` — Let's Encrypt TLS, salamander obfuscation, password auth, `hy2://` share links. Per-server SSL domain/email defaults, renew from settings; install requires free TCP **80** and **443**.
|
||||
* **NaiveProxy**: HTTPS/HTTP2 camouflage proxy via [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) (Caddy + [klzgrad/forwardproxy](https://github.com/klzgrad/forwardproxy) naïve fork). ACME TLS on the domain, per-client basic auth, `naive+https://` share links. Requires free TCP **80** and **443**.
|
||||
* **NaiveProxy** (stable): HTTPS/HTTP2 camouflage proxy via [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) (Caddy + [klzgrad/forwardproxy](https://github.com/klzgrad/forwardproxy) naïve fork). ACME TLS on the domain, per-client basic auth, `naive+https://` share links. Requires free TCP **80** and **443**. **Use Karing** as the client — do **not** use v2rayN; other clients are untested.
|
||||
* **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed.
|
||||
* **Cloudflare WARP**: Add and manage WARP-powered connectivity from the panel for routing and network flexibility.
|
||||
* **NordVPN**: Connect/disconnect outbound NordVPN from Settings via the official CLI (optional country/city).
|
||||
* **🛠 Services**:
|
||||
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings.
|
||||
* **AdGuard Home**: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host.
|
||||
@@ -85,6 +86,11 @@ Configuration panel for system parameters and preferences:
|
||||
* **🌐 Internationalization (i18n)**:
|
||||
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
|
||||
* Native **RTL (Right-to-Left)** support for Persian language.
|
||||
* **🔄 Auto-update**:
|
||||
* Settings → About checks releases on [git.evilfox.cc](https://git.evilfox.cc/test2/Amnezia-Web-Panel-main) and can install the latest tag via git + restart (when the panel runs from a git checkout).
|
||||
* **🌍 Tunnels & outbound VPN**:
|
||||
* **Cloudflare Quick Tunnel** and **ngrok** expose the local panel to the internet.
|
||||
* **Cloudflare WARP** and **NordVPN** route the host outbound (no public panel URL).
|
||||
* **👥 Advanced User Management**:
|
||||
* Role-based access (Admin, Support, Regular User).
|
||||
* Traffic limits, status monitoring, and account expiration.
|
||||
@@ -182,22 +188,71 @@ Mac
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set SECRET_KEY and strong POSTGRES_PASSWORD in .env
|
||||
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create dokploy-network
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
|
||||
|
||||
### 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).
|
||||
|
||||
1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically).
|
||||
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`)
|
||||
- **Container port**: `5000`
|
||||
- **Path**: `/`
|
||||
4. **Deploy / Rebuild** (not Restart) after code or env changes.
|
||||
|
||||
**Verify on the server** (replace `PROJECT` and path with yours):
|
||||
|
||||
```bash
|
||||
COMPOSE_DIR=/etc/dokploy/compose/home-vpn-g6rfpd/code
|
||||
PROJECT=home-vpn-g6rfpd
|
||||
|
||||
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"}
|
||||
|
||||
docker inspect ${PROJECT}-amnezia_panel-1 \
|
||||
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||
# must include: dokploy-network
|
||||
```
|
||||
|
||||
**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 |
|
||||
| --- | --- |
|
||||
| `Dockerfile` | Production image (Python 3.12) |
|
||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks |
|
||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks, Dokploy/Traefik labels |
|
||||
| `.env.example` | Environment template |
|
||||
|
||||
**Environment**
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `APP_PORT` | `5000` | Host port / in-container listen port |
|
||||
| `APP_PORT` / `PORT` | `5000` | Host port / in-container listen port |
|
||||
| `SECRET_KEY` | (random) | Session signing key — set in production |
|
||||
| `DATABASE_URL` | compose DSN | PostgreSQL connection string |
|
||||
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
|
||||
@@ -222,6 +277,86 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
|
||||
## 📋 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
|
||||
* **Postgres healthcheck** — verifies DB password (surfaces `POSTGRES_PASSWORD` / volume mismatch before panel starts).
|
||||
|
||||
### v2.5.10
|
||||
* **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy.
|
||||
|
||||
### v2.5.9
|
||||
* **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4).
|
||||
|
||||
### v2.5.8
|
||||
* **Docker/Dokploy: panel actually serves HTTP** — entrypoint waits for Postgres, runs `uvicorn` with proxy headers; SSL inside the container is forced off when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY` is set (fixes empty replies on port 5000 behind Traefik).
|
||||
|
||||
### v2.5.7
|
||||
* **Dokploy / Traefik 404 fix** — `docker-compose.yml` joins `dokploy-network`, Traefik labels point to port **5000**; removed fixed `container_name`; README Dokploy domain setup.
|
||||
|
||||
### v2.5.6
|
||||
* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link.
|
||||
|
||||
### v2.5.5
|
||||
* **Fix 404 after panel update** — correct tunnel/local port when `APP_PORT` is set (Docker), health check before reload, auto-restart quick tunnels after reboot, reliable archive download URLs.
|
||||
|
||||
### v2.5.3
|
||||
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
|
||||
|
||||
### v2.5.2
|
||||
* **Tunnels UI redesign** — Settings → Tunnels: grouped layout (public access / outbound VPN), provider cards, status pills, responsive grid.
|
||||
|
||||
### v2.5.1
|
||||
* **One-click panel update** — Settings → About → **Update panel**: downloads release ZIP from Gitea (or `git checkout` when `.git` exists), runs `pip install`, restarts.
|
||||
|
||||
### v2.5.0
|
||||
* **NordVPN in Tunnels** — connect/disconnect outbound NordVPN from Settings (official `nordvpn` CLI; optional country/city).
|
||||
|
||||
### v2.4.0
|
||||
* **Auto-update from Gitea**: Settings → About checks https://git.evilfox.cc/test2/Amnezia-Web-Panel-main releases and can install the latest tag via `git fetch` + checkout, then restart (git install only).
|
||||
|
||||
### v2.3.0
|
||||
* **Cascade removed** — double-VPN feature dropped from the panel (manager, API, UI, i18n).
|
||||
|
||||
### v2.2.1
|
||||
* **Cascade egress verify fix**: bind curl to ENTRY `awg0`/`wg0` IP (client-subnet policy route), not cascade peer Address.
|
||||
* Soft-warn (no rollback) when policy route via `cascade` is OK but public-IP echo services fail.
|
||||
|
||||
### v2.2.0
|
||||
* **Cascade (double VPN) restored** — later removed in **v2.3.0**.
|
||||
* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic ran over panel SSH (**no remote curl|bash**).
|
||||
|
||||
### v2.1.0
|
||||
* **NaiveProxy — stable**: production-ready install (Caddy + klzgrad/forwardproxy), share links always include `:443`.
|
||||
* **Client notes (important)**:
|
||||
* **Do not use v2rayN** with NaiveProxy (broken DNS/IPv6 behaviour → latency −1 ms even when the server is fine).
|
||||
* **Karing** — confirmed working.
|
||||
* Other clients — untested / use at your own risk.
|
||||
* Share-link and config UI hints updated accordingly.
|
||||
|
||||
### v2.0.0-beta
|
||||
* **NaiveProxy** (Beta): install [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) server via Caddy + naïve [forwardproxy](https://github.com/klzgrad/forwardproxy) — domain TLS (ACME), per-client basic auth, `naive+https://` share links.
|
||||
* Marketplace / server page / users / invites / Telegram / backups include NaiveProxy.
|
||||
@@ -245,7 +380,7 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
### v1.6.0
|
||||
* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui).
|
||||
* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow.
|
||||
* **Cascade removed** for now (pending redesign — showed active while internet often did not work).
|
||||
* **Cascade** was temporarily removed in this release (briefly restored in v2.2.x, removed again in **v2.3.0**).
|
||||
* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
|
||||
* **API performance**: in-memory data cache, faster server check/stats.
|
||||
* **Share / guest**: one-tap “Copy key”.
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
"""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 .store import (
|
||||
clear_tunnel_state,
|
||||
ensure_db_ready,
|
||||
export_data_dict,
|
||||
import_from_json_file,
|
||||
invalidate_data_cache,
|
||||
load_data,
|
||||
load_tunnel_state,
|
||||
normalize_import_data,
|
||||
save_data,
|
||||
save_tunnel_state,
|
||||
update_tunnel_state,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'backup_filename',
|
||||
'clear_tunnel_state',
|
||||
'close_pool',
|
||||
'ensure_db_ready',
|
||||
'export_data_dict',
|
||||
'export_database_sql',
|
||||
'get_database_url',
|
||||
'import_from_json_file',
|
||||
'init_schema',
|
||||
'invalidate_data_cache',
|
||||
'load_data',
|
||||
'load_tunnel_state',
|
||||
'normalize_import_data',
|
||||
'restore_database_sql',
|
||||
'save_data',
|
||||
'save_tunnel_state',
|
||||
'update_tunnel_state',
|
||||
|
||||
@@ -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
@@ -10,8 +10,10 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -52,6 +54,13 @@ DEFAULT_SETTINGS = {
|
||||
'create_protocol': 'awg',
|
||||
'create_server_id': 0,
|
||||
},
|
||||
'donate': {
|
||||
'enabled': True,
|
||||
'intro': '',
|
||||
'sbp': {'enabled': True, 'title': '', 'details': ''},
|
||||
'card': {'enabled': True, 'title': '', 'details': ''},
|
||||
'crypto': {'enabled': True, 'title': '', 'details': ''},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +92,147 @@ def _as_uuid(value: Any) -> UUID:
|
||||
return UUID(str(value))
|
||||
|
||||
|
||||
def _is_valid_uuid(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
UUID(str(value))
|
||||
return True
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_uuid(value: Any, *, default: Optional[str] = None) -> str:
|
||||
if _is_valid_uuid(value):
|
||||
return str(UUID(str(value)))
|
||||
if default is not None:
|
||||
return default
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list:
|
||||
return list(value) if isinstance(value, list) else []
|
||||
|
||||
|
||||
def normalize_import_data(data: dict) -> dict:
|
||||
"""Prepare legacy data.json (or partial exports) for PostgreSQL constraints."""
|
||||
data = copy.deepcopy(data or {})
|
||||
data['servers'] = [s for s in _as_list(data.get('servers')) if isinstance(s, dict)]
|
||||
data['users'] = [u for u in _as_list(data.get('users')) if isinstance(u, dict)]
|
||||
data['user_connections'] = [c for c in _as_list(data.get('user_connections')) if isinstance(c, dict)]
|
||||
data['api_tokens'] = [t for t in _as_list(data.get('api_tokens')) if isinstance(t, dict)]
|
||||
data['invite_links'] = [l for l in _as_list(data.get('invite_links')) if isinstance(l, dict)]
|
||||
if not isinstance(data.get('settings'), dict):
|
||||
data['settings'] = {}
|
||||
|
||||
id_map: dict[str, str] = {}
|
||||
|
||||
def map_user_id(value: Any) -> Optional[str]:
|
||||
if value is None or value == '':
|
||||
return None
|
||||
key = str(value)
|
||||
if _is_valid_uuid(key):
|
||||
resolved = str(UUID(key))
|
||||
id_map.setdefault(key, resolved)
|
||||
return resolved
|
||||
if key not in id_map:
|
||||
id_map[key] = str(uuid.uuid4())
|
||||
return id_map[key]
|
||||
|
||||
used_usernames: set[str] = set()
|
||||
for index, user in enumerate(data['users']):
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
old_id = user.get('id')
|
||||
if old_id is None or old_id == '':
|
||||
user['id'] = str(uuid.uuid4())
|
||||
else:
|
||||
key = str(old_id)
|
||||
if _is_valid_uuid(key):
|
||||
user['id'] = str(UUID(key))
|
||||
id_map.setdefault(key, user['id'])
|
||||
else:
|
||||
user['id'] = map_user_id(key) or str(uuid.uuid4())
|
||||
|
||||
username = (user.get('username') or '').strip() or f'user{index + 1}'
|
||||
base = username
|
||||
suffix = 2
|
||||
while username.lower() in used_usernames:
|
||||
username = f'{base}_{suffix}'
|
||||
suffix += 1
|
||||
user['username'] = username
|
||||
used_usernames.add(username.lower())
|
||||
|
||||
user.setdefault('password_hash', '')
|
||||
user.setdefault('role', 'user')
|
||||
user.setdefault('enabled', True)
|
||||
|
||||
valid_user_ids = {str(u['id']) for u in data['users'] if isinstance(u, dict) and u.get('id')}
|
||||
|
||||
for server in data['servers']:
|
||||
if not isinstance(server, dict):
|
||||
continue
|
||||
server['server_info'] = _as_dict(server.get('server_info'))
|
||||
server['protocols'] = _as_dict(server.get('protocols'))
|
||||
|
||||
for conn in data['user_connections']:
|
||||
if not isinstance(conn, dict):
|
||||
continue
|
||||
conn['id'] = _coerce_uuid(conn.get('id'))
|
||||
mapped_uid = map_user_id(conn.get('user_id'))
|
||||
if mapped_uid:
|
||||
conn['user_id'] = mapped_uid
|
||||
|
||||
data['user_connections'] = [
|
||||
c for c in data['user_connections']
|
||||
if isinstance(c, dict) and str(c.get('user_id')) in valid_user_ids
|
||||
]
|
||||
|
||||
seen_hashes: set[str] = set()
|
||||
normalized_tokens = []
|
||||
for token in data['api_tokens']:
|
||||
if not isinstance(token, dict):
|
||||
continue
|
||||
mapped_uid = map_user_id(token.get('user_id'))
|
||||
if not mapped_uid or mapped_uid not in valid_user_ids:
|
||||
continue
|
||||
token['id'] = _coerce_uuid(token.get('id'))
|
||||
token['user_id'] = mapped_uid
|
||||
token_hash = (token.get('token_hash') or '').strip()
|
||||
if not token_hash or token_hash in seen_hashes:
|
||||
token_hash = secrets.token_hex(32)
|
||||
token['token_hash'] = token_hash
|
||||
seen_hashes.add(token_hash)
|
||||
token.setdefault('token_prefix', (token_hash[:8] if token_hash else ''))
|
||||
normalized_tokens.append(token)
|
||||
data['api_tokens'] = normalized_tokens
|
||||
|
||||
seen_invite_tokens: set[str] = set()
|
||||
for link in data['invite_links']:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
link['id'] = _coerce_uuid(link.get('id'))
|
||||
mapped_uid = map_user_id(link.get('user_id'))
|
||||
link['user_id'] = mapped_uid if mapped_uid in valid_user_ids else ''
|
||||
token = (link.get('token') or '').strip()
|
||||
if not token or token in seen_invite_tokens:
|
||||
token = secrets.token_urlsafe(16)
|
||||
while token in seen_invite_tokens:
|
||||
token = secrets.token_urlsafe(16)
|
||||
link['token'] = token
|
||||
seen_invite_tokens.add(token)
|
||||
|
||||
guest = data['settings'].get('guest')
|
||||
if isinstance(guest, dict):
|
||||
mapped_guest = map_user_id(guest.get('user_id'))
|
||||
if mapped_guest in valid_user_ids:
|
||||
guest['user_id'] = mapped_guest
|
||||
elif guest.get('user_id'):
|
||||
guest['user_id'] = ''
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _merge_settings(raw: Optional[dict]) -> dict:
|
||||
settings = json.loads(json.dumps(DEFAULT_SETTINGS))
|
||||
if not isinstance(raw, dict):
|
||||
@@ -293,6 +443,7 @@ def load_data() -> dict:
|
||||
def save_data(data: dict) -> None:
|
||||
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
||||
global _DATA_CACHE
|
||||
data = normalize_import_data(data)
|
||||
init_schema()
|
||||
servers = data.get('servers') or []
|
||||
users = data.get('users') or []
|
||||
@@ -484,12 +635,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
|
||||
|
||||
data.setdefault('servers', [])
|
||||
data.setdefault('users', [])
|
||||
data.setdefault('user_connections', [])
|
||||
data.setdefault('api_tokens', [])
|
||||
data.setdefault('invite_links', [])
|
||||
data.setdefault('settings', {})
|
||||
data = normalize_import_data(data)
|
||||
|
||||
save_data(data)
|
||||
|
||||
@@ -555,4 +701,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
|
||||
init_schema()
|
||||
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
|
||||
logger.info('Empty database — importing legacy %s', legacy_data_file)
|
||||
import_from_json_file(legacy_data_file)
|
||||
try:
|
||||
import_from_json_file(legacy_data_file)
|
||||
except Exception:
|
||||
logger.exception('Legacy data.json import failed — panel will start with empty DB')
|
||||
invalidate_data_cache()
|
||||
|
||||
+27
-7
@@ -1,18 +1,21 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
container_name: amnezia_panel_db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
||||
volumes:
|
||||
- amnezia_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && PGPASSWORD=$${POSTGRES_PASSWORD} psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' >/dev/null",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -23,7 +26,6 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: amnezia-panel:local
|
||||
container_name: amnezia_panel
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -33,15 +35,33 @@ services:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
||||
SECRET_KEY: ${SECRET_KEY:-}
|
||||
APP_PORT: "5000"
|
||||
PORT: "5000"
|
||||
PANEL_IN_DOCKER: "1"
|
||||
PANEL_BEHIND_PROXY: "1"
|
||||
volumes:
|
||||
- amnezia_data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
- dokploy-network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=dokploy-network
|
||||
- traefik.http.services.amnezia_panel.loadbalancer.server.port=5000
|
||||
expose:
|
||||
- "5000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
internal:
|
||||
dokploy-network:
|
||||
external: true
|
||||
name: dokploy-network
|
||||
|
||||
volumes:
|
||||
amnezia_data:
|
||||
|
||||
@@ -70,6 +70,9 @@ class BackupManager:
|
||||
remote_dir = inst_path('/opt/amnezia/naiveproxy')
|
||||
paths['host'] = [remote_dir]
|
||||
paths['container'] = ['/etc/caddy']
|
||||
elif base == 'mieru':
|
||||
remote_dir = inst_path('/opt/amnezia/mieru')
|
||||
paths['host'] = [remote_dir]
|
||||
elif base == 'dns':
|
||||
paths['host'] = ['/opt/amnezia/dns']
|
||||
paths['container'] = ['/opt/amnezia/dns']
|
||||
|
||||
@@ -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
|
||||
@@ -232,9 +232,13 @@ class NaiveProxyManager:
|
||||
# Keep a non-shared bootstrap user so the proxy is never open
|
||||
auth_lines = [f' basic_auth bootstrap {_rand_token(24)}']
|
||||
auth_block = '\n'.join(auth_lines)
|
||||
# probe_resistance looks like a fake domain to browsers
|
||||
probe = (probe_path or _rand_token(12)).strip('/')
|
||||
if '.' not in probe:
|
||||
probe = f'{probe}.com'
|
||||
return f"""{{
|
||||
order forward_proxy before file_server
|
||||
admin off
|
||||
log {{
|
||||
exclude http.log.error
|
||||
}}
|
||||
@@ -242,16 +246,14 @@ class NaiveProxyManager:
|
||||
:443, {domain} {{
|
||||
tls {email}
|
||||
encode
|
||||
route {{
|
||||
forward_proxy {{
|
||||
forward_proxy {{
|
||||
{auth_block}
|
||||
hide_ip
|
||||
hide_via
|
||||
probe_resistance {probe}
|
||||
}}
|
||||
file_server {{
|
||||
root /var/www/html
|
||||
}}
|
||||
hide_ip
|
||||
hide_via
|
||||
probe_resistance {probe}
|
||||
}}
|
||||
file_server {{
|
||||
root /var/www/html
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
@@ -398,13 +400,21 @@ CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile
|
||||
self._start_container()
|
||||
|
||||
def _build_share_uri(self, domain, username, password, name, port=443):
|
||||
"""v2rayN / NekoRay / sing-box compatible share link.
|
||||
|
||||
Always include an explicit port — without `:443` v2rayN often treats
|
||||
the port as empty/80 and shows «Свойство Порт недопустимо» / SSL errors.
|
||||
"""
|
||||
# Keep credentials unescaped when alphanumeric (our generator); quote otherwise.
|
||||
user = quote(username or '', safe='')
|
||||
pw = quote(password or '', safe='')
|
||||
host = domain or ''
|
||||
host = (domain or '').strip().lower()
|
||||
port = int(port or 443)
|
||||
authority = f'{user}:{pw}@{host}' if port == 443 else f'{user}:{pw}@{host}:{port}'
|
||||
fragment = quote(name or 'naiveproxy', safe='')
|
||||
return f'naive+https://{authority}#{fragment}'
|
||||
if port < 1 or port > 65535:
|
||||
port = 443
|
||||
# Remark: ASCII-safe, no spaces that break some importers
|
||||
remark = re.sub(r'[^\w.\-]+', '-', (name or 'naiveproxy').strip())[:48] or 'naiveproxy'
|
||||
return f'naive+https://{user}:{pw}@{host}:{port}#{remark}'
|
||||
|
||||
# ===================== INSTALL / REMOVE =====================
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Panel self-update from the EvilFox Gitea repository (git or release archive)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_REPO_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main'
|
||||
DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git'
|
||||
DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest'
|
||||
UPDATE_REMOTE = 'panel-update'
|
||||
|
||||
ARCHIVE_SKIP_DIRS = frozenset({
|
||||
'__pycache__', '.git', 'bin', 'data', 'node_modules', '.venv', 'venv', '.cursor',
|
||||
})
|
||||
ARCHIVE_SKIP_FILES = frozenset({
|
||||
'.env', 'data.json', 'tunnels_state.json',
|
||||
})
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
return (os.environ.get(name) or default).strip()
|
||||
|
||||
|
||||
def repo_url() -> str:
|
||||
return _env('PANEL_UPDATE_REPO_URL', DEFAULT_REPO_URL).rstrip('/')
|
||||
|
||||
|
||||
def git_url() -> str:
|
||||
return _env('PANEL_UPDATE_GIT_URL', DEFAULT_GIT_URL)
|
||||
|
||||
|
||||
def api_latest_url() -> str:
|
||||
return _env('PANEL_UPDATE_API_LATEST', DEFAULT_API_LATEST)
|
||||
|
||||
|
||||
def parse_version(value: str) -> tuple:
|
||||
"""Parse semver-ish tags like v2.3.0 / 2.3.0-beta into a comparable tuple."""
|
||||
raw = (value or '').strip()
|
||||
if not raw:
|
||||
return (0, 0, 0, 1, '')
|
||||
raw = raw.lstrip('vV')
|
||||
main, _, pre = raw.partition('-')
|
||||
parts = []
|
||||
for piece in main.split('.'):
|
||||
if piece.isdigit():
|
||||
parts.append(int(piece))
|
||||
else:
|
||||
m = re.match(r'(\d+)', piece or '')
|
||||
parts.append(int(m.group(1)) if m else 0)
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
pre_rank = 0 if not pre else 1
|
||||
return (parts[0], parts[1], parts[2], pre_rank, pre)
|
||||
|
||||
|
||||
def version_gt(a: str, b: str) -> bool:
|
||||
return parse_version(a) > parse_version(b)
|
||||
|
||||
|
||||
def normalize_tag(value: str) -> str:
|
||||
tag = (value or '').strip()
|
||||
if not tag:
|
||||
return ''
|
||||
return tag if tag.startswith('v') else f'v{tag}'
|
||||
|
||||
|
||||
def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
)
|
||||
return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip()
|
||||
except FileNotFoundError:
|
||||
return 127, '', f'Command not found: {cmd[0]}'
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, '', f'Timeout running: {" ".join(cmd)}'
|
||||
|
||||
|
||||
class UpdateManager:
|
||||
def __init__(self, app_root: str, current_version: str):
|
||||
self.app_root = os.path.abspath(app_root)
|
||||
self.current_version = current_version
|
||||
|
||||
def _in_docker(self) -> bool:
|
||||
return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
|
||||
|
||||
def _docker_update_allowed(self) -> bool:
|
||||
if os.environ.get('PANEL_UPDATE_DISABLE_DOCKER', '').strip().lower() in ('1', 'true', 'yes'):
|
||||
return False
|
||||
# In-container archive updates are allowed by default when /app is writable.
|
||||
return True
|
||||
|
||||
def _app_root_writable(self) -> bool:
|
||||
probe = os.path.join(self.app_root, '.panel_update_write_probe')
|
||||
try:
|
||||
with open(probe, 'w', encoding='utf-8') as fh:
|
||||
fh.write('ok')
|
||||
os.remove(probe)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def detect_mode(self) -> dict:
|
||||
frozen = bool(getattr(sys, 'frozen', False))
|
||||
in_docker = self._in_docker()
|
||||
git_dir = os.path.join(self.app_root, '.git')
|
||||
is_git = os.path.isdir(git_dir)
|
||||
git_ok = False
|
||||
if is_git and not frozen:
|
||||
code, _, _ = _run(['git', '--version'], self.app_root, timeout=10)
|
||||
git_ok = code == 0
|
||||
|
||||
writable = self._app_root_writable()
|
||||
can_git = bool(git_ok and is_git and not frozen)
|
||||
can_archive = bool(
|
||||
writable
|
||||
and not frozen
|
||||
and (not in_docker or self._docker_update_allowed())
|
||||
)
|
||||
can_auto = can_git or can_archive
|
||||
|
||||
if can_git:
|
||||
method = 'git'
|
||||
elif can_archive:
|
||||
method = 'archive'
|
||||
elif in_docker:
|
||||
method = 'docker'
|
||||
elif frozen:
|
||||
method = 'binary'
|
||||
else:
|
||||
method = 'manual'
|
||||
|
||||
return {
|
||||
'mode': method,
|
||||
'update_method': method,
|
||||
'can_auto_update': can_auto,
|
||||
'can_git_update': can_git,
|
||||
'can_archive_update': can_archive,
|
||||
'is_git': is_git,
|
||||
'frozen': frozen,
|
||||
'in_docker': in_docker,
|
||||
'writable': writable,
|
||||
'app_root': self.app_root,
|
||||
'repo_url': repo_url(),
|
||||
'git_url': git_url(),
|
||||
}
|
||||
|
||||
def _ensure_update_remote(self) -> Optional[str]:
|
||||
code, out, err = _run(['git', 'remote'], self.app_root, timeout=15)
|
||||
if code != 0:
|
||||
return err or out or 'git remote failed'
|
||||
remotes = set((out or '').split())
|
||||
target = git_url()
|
||||
if UPDATE_REMOTE in remotes:
|
||||
code, _, err = _run(
|
||||
['git', 'remote', 'set-url', UPDATE_REMOTE, target],
|
||||
self.app_root,
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
code, _, err = _run(
|
||||
['git', 'remote', 'add', UPDATE_REMOTE, target],
|
||||
self.app_root,
|
||||
timeout=15,
|
||||
)
|
||||
if code != 0:
|
||||
return err or 'Failed to configure update remote'
|
||||
return None
|
||||
|
||||
def working_tree_dirty(self) -> bool:
|
||||
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
|
||||
return code == 0 and bool(out.strip())
|
||||
|
||||
def _pip_install(self) -> tuple[bool, str]:
|
||||
req = os.path.join(self.app_root, 'requirements.txt')
|
||||
if not os.path.isfile(req):
|
||||
return True, ''
|
||||
code_p, out_p, err_p = _run(
|
||||
[sys.executable, '-m', 'pip', 'install', '-r', req],
|
||||
self.app_root,
|
||||
timeout=300,
|
||||
)
|
||||
log = (out_p or err_p or '')[:1500]
|
||||
return code_p == 0, log
|
||||
|
||||
def _apply_git_update(self, target: str, allow_dirty: bool) -> dict:
|
||||
if not allow_dirty and self.working_tree_dirty():
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': (
|
||||
'Working tree has local changes. Confirm update anyway to force checkout, '
|
||||
'or commit/stash changes first.'
|
||||
),
|
||||
'needs_confirm_dirty': True,
|
||||
}
|
||||
|
||||
err = self._ensure_update_remote()
|
||||
if err:
|
||||
return {'status': 'error', 'message': err}
|
||||
|
||||
code, out, stderr = _run(
|
||||
['git', 'fetch', '--tags', '--force', UPDATE_REMOTE],
|
||||
self.app_root,
|
||||
timeout=180,
|
||||
)
|
||||
if code != 0:
|
||||
return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'}
|
||||
|
||||
# Ensure tag is present locally
|
||||
code, _, _ = _run(
|
||||
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
|
||||
self.app_root,
|
||||
timeout=15,
|
||||
)
|
||||
if code != 0:
|
||||
_run(
|
||||
['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'],
|
||||
self.app_root,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
checkout_ref = f'tags/{target}'
|
||||
code, out, stderr = _run(
|
||||
['git', 'checkout', '--force', checkout_ref],
|
||||
self.app_root,
|
||||
timeout=60,
|
||||
)
|
||||
if code != 0:
|
||||
code_m, out_m, err_m = _run(
|
||||
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
|
||||
self.app_root,
|
||||
timeout=60,
|
||||
)
|
||||
if code_m != 0:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'git checkout {target} failed: {stderr or out or err_m or out_m}',
|
||||
}
|
||||
checkout_ref = f'{UPDATE_REMOTE}/main'
|
||||
|
||||
ok, pip_log = self._pip_install()
|
||||
if not ok:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'pip install failed after checkout: {pip_log[:500]}',
|
||||
'checkout': checkout_ref,
|
||||
'method': 'git',
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': f'Updated to {target} via git. Panel will restart.',
|
||||
'target_version': target,
|
||||
'checkout': checkout_ref,
|
||||
'pip': pip_log[:500],
|
||||
'method': 'git',
|
||||
'restart': True,
|
||||
}
|
||||
|
||||
def _archive_urls(self, tag: str) -> list[str]:
|
||||
urls: list[str] = []
|
||||
api_base = api_latest_url().rsplit('/releases/latest', 1)[0]
|
||||
for endpoint in (f'{api_base}/releases/tags/{tag}', api_latest_url()):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'Amnezia-Web-Panel-Updater'},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode('utf-8', errors='replace') or '{}')
|
||||
zipball = (payload.get('zipball_url') or '').strip()
|
||||
if zipball:
|
||||
urls.append(zipball)
|
||||
except Exception:
|
||||
pass
|
||||
base = repo_url()
|
||||
urls.extend([
|
||||
f'{base}/archive/{tag}.zip',
|
||||
f'{api_base}/archive/{tag}.zip',
|
||||
])
|
||||
seen = set()
|
||||
ordered: list[str] = []
|
||||
for url in urls:
|
||||
if url and url not in seen:
|
||||
seen.add(url)
|
||||
ordered.append(url)
|
||||
return ordered
|
||||
|
||||
def _download_archive(self, tag: str, dest_path: str) -> None:
|
||||
last_err = 'unknown error'
|
||||
for url in self._archive_urls(tag):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Amnezia-Web-Panel-Updater'})
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
with open(dest_path, 'wb') as out:
|
||||
shutil.copyfileobj(resp, out)
|
||||
if os.path.getsize(dest_path) > 1024:
|
||||
return
|
||||
last_err = f'empty archive from {url}'
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = f'HTTP {e.code} for {url}'
|
||||
except Exception as e:
|
||||
last_err = str(e)
|
||||
raise RuntimeError(f'Failed to download release archive: {last_err}')
|
||||
|
||||
@staticmethod
|
||||
def _extract_zip_root(extract_dir: str) -> str:
|
||||
entries = [e for e in os.listdir(extract_dir) if e not in ('.', '..')]
|
||||
if len(entries) == 1:
|
||||
only = os.path.join(extract_dir, entries[0])
|
||||
if os.path.isdir(only):
|
||||
return only
|
||||
return extract_dir
|
||||
|
||||
def _verify_tree(self, root: str) -> Optional[str]:
|
||||
app_py = os.path.join(root, 'app.py')
|
||||
if not os.path.isfile(app_py):
|
||||
return 'Release archive is missing app.py'
|
||||
code, _, err = _run([sys.executable, '-m', 'py_compile', app_py], root, timeout=60)
|
||||
if code != 0:
|
||||
return f'Updated app.py failed validation: {err or code}'
|
||||
return None
|
||||
|
||||
def _sync_tree(self, src_root: str) -> None:
|
||||
for root, dirs, files in os.walk(src_root):
|
||||
dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS]
|
||||
rel = os.path.relpath(root, src_root)
|
||||
dest_root = self.app_root if rel in ('.', '') else os.path.join(self.app_root, rel)
|
||||
os.makedirs(dest_root, exist_ok=True)
|
||||
for fname in files:
|
||||
if fname in ARCHIVE_SKIP_FILES:
|
||||
continue
|
||||
src_file = os.path.join(root, fname)
|
||||
dest_file = os.path.join(dest_root, fname)
|
||||
shutil.copy2(src_file, dest_file)
|
||||
|
||||
def _apply_archive_update(self, target: str) -> dict:
|
||||
tmpdir = tempfile.mkdtemp(prefix='panel-update-')
|
||||
zip_path = os.path.join(tmpdir, f'{target}.zip')
|
||||
extract_dir = os.path.join(tmpdir, 'extract')
|
||||
try:
|
||||
self._download_archive(target, zip_path)
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(extract_dir)
|
||||
src_root = self._extract_zip_root(extract_dir)
|
||||
if not os.path.isdir(src_root):
|
||||
return {'status': 'error', 'message': 'Invalid release archive layout'}
|
||||
verify_err = self._verify_tree(src_root)
|
||||
if verify_err:
|
||||
return {'status': 'error', 'message': verify_err, 'method': 'archive'}
|
||||
self._sync_tree(src_root)
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e), 'method': 'archive'}
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
ok, pip_log = self._pip_install()
|
||||
if not ok:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'Files updated but pip install failed: {pip_log[:500]}',
|
||||
'method': 'archive',
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': f'Updated to {target} from release archive. Panel will restart.',
|
||||
'target_version': target,
|
||||
'pip': pip_log[:500],
|
||||
'method': 'archive',
|
||||
'restart': True,
|
||||
}
|
||||
|
||||
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
|
||||
mode = self.detect_mode()
|
||||
target = normalize_tag(target_version)
|
||||
if not target or not re.match(r'^v\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
|
||||
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
|
||||
|
||||
if mode.get('can_git_update'):
|
||||
return self._apply_git_update(target, allow_dirty)
|
||||
if mode.get('can_archive_update'):
|
||||
return self._apply_archive_update(target)
|
||||
|
||||
hint = (
|
||||
'Cannot update from inside this install. '
|
||||
'Use a git clone or writable source directory, or rebuild the Docker image on the host.'
|
||||
)
|
||||
if mode.get('in_docker'):
|
||||
hint = (
|
||||
'Docker image install: on the host run '
|
||||
'`git pull && docker compose up -d --build` '
|
||||
'(or set PANEL_UPDATE_ALLOW_DOCKER=1 if /app is bind-mounted).'
|
||||
)
|
||||
return {'status': 'error', 'message': hint, 'mode': mode}
|
||||
|
||||
@staticmethod
|
||||
def schedule_restart(app_root: str, delay_sec: float = 1.5) -> None:
|
||||
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
|
||||
|
||||
def _restart():
|
||||
time.sleep(max(0.5, float(delay_sec)))
|
||||
cwd = os.path.abspath(app_root or os.getcwd())
|
||||
argv = [sys.executable] + sys.argv
|
||||
logger.info('Restarting panel after update: %s (cwd=%s, docker=%s)', argv, cwd, in_docker)
|
||||
if in_docker:
|
||||
# Let Docker restart policy bring the container back with updated /app files.
|
||||
os._exit(0)
|
||||
try:
|
||||
os.chdir(cwd)
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
start_new_session=True,
|
||||
close_fds=(os.name != 'nt'),
|
||||
)
|
||||
time.sleep(2.0)
|
||||
if proc.poll() is None:
|
||||
logger.info('New panel process started (pid=%s), stopping old process', proc.pid)
|
||||
os._exit(0)
|
||||
except Exception:
|
||||
logger.exception('subprocess restart failed; trying execv')
|
||||
try:
|
||||
os.chdir(cwd)
|
||||
os.execv(sys.executable, argv)
|
||||
except Exception:
|
||||
logger.exception('Failed to restart after update')
|
||||
os._exit(1)
|
||||
|
||||
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
|
||||
@@ -949,6 +949,11 @@ a:hover {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.protocol-mieru .protocol-icon {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.1));
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.protocol-dns .protocol-icon {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
|
||||
color: var(--success);
|
||||
@@ -1282,6 +1287,7 @@ a:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1294,6 +1300,45 @@ a: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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1623,6 +1668,319 @@ a:hover {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ===== Tunnels (Settings) ===== */
|
||||
.tunnels-card .tunnels-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tunnels-card .tunnels-header .card-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-card .tunnels-subtitle {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tunnels-local {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-focus);
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.06));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tunnels-local-icon {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-gradient);
|
||||
font-size: 1.25rem;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.tunnels-local-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tunnels-local-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnels-local-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-local-url code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tunnels-section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tunnels-section:last-of-type {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnels-section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-sm) var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.tunnel-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.tunnel-card--running {
|
||||
border-color: var(--success-border);
|
||||
box-shadow: 0 0 0 1px var(--success-border), inset 0 1px 0 rgba(34, 197, 94, 0.06);
|
||||
}
|
||||
|
||||
.tunnel-card--connected {
|
||||
border-color: var(--success-border);
|
||||
box-shadow: 0 0 0 1px var(--success-border);
|
||||
}
|
||||
|
||||
.tunnel-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnel-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tunnel-card-icon--cloudflare { background: linear-gradient(135deg, #f38020, #faae40); }
|
||||
.tunnel-card-icon--ngrok { background: linear-gradient(135deg, #1f2d3d, #3d5a80); }
|
||||
.tunnel-card-icon--warp { background: linear-gradient(135deg, #f38020, #e85d04); }
|
||||
.tunnel-card-icon--nordvpn { background: linear-gradient(135deg, #4687ff, #2b4eff); }
|
||||
.tunnel-card-icon--local { background: var(--accent-gradient); }
|
||||
|
||||
.tunnel-card-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tunnel-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tunnel-card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: var(--space-xs) 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tunnel-status-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tunnel-status-pill--ok {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.tunnel-status-pill--info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.tunnel-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnel-url-box {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tunnel-url-box--empty {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tunnel-url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnel-url-row:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tunnel-url-row code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-accent);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tunnel-status-line {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tunnel-status-line--server {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-accent);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tunnel-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tunnel-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.tunnel-card-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 7rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tunnel-card-actions--triple .btn {
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.tunnel-nordvpn-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-footnote {
|
||||
margin: var(--space-md) 0 0;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--accent);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tunnels-local {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tunnels-local-url {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tunnel-nordvpn-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
@@ -1854,6 +2212,15 @@ a:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-link-support {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.nav-link-support:hover {
|
||||
opacity: 1;
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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">
|
||||
<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 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>
|
||||
|
||||
+7
-3
@@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None
|
||||
_callback_refs = {}
|
||||
_pending_inputs = {}
|
||||
|
||||
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "wireguard"}
|
||||
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "mieru", "wireguard"}
|
||||
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ def _protocol_display_name(protocol: str) -> str:
|
||||
"telemt": "Telemt",
|
||||
"hysteria": "Hysteria 2",
|
||||
"naiveproxy": "NaiveProxy",
|
||||
"mieru": "Mieru",
|
||||
"dns": "AmneziaDNS",
|
||||
"wireguard": "WireGuard",
|
||||
"socks5": "SOCKS5",
|
||||
@@ -342,6 +343,7 @@ def _get_ssh_and_manager(server: dict, proto: str):
|
||||
from managers.telemt_manager import TelemtManager
|
||||
from managers.hysteria_manager import HysteriaManager
|
||||
from managers.naiveproxy_manager import NaiveProxyManager
|
||||
from managers.mieru_manager import MieruManager
|
||||
from managers.wireguard_manager import WireGuardManager
|
||||
from managers.dns_manager import DNSManager
|
||||
from managers.socks5_manager import Socks5Manager
|
||||
@@ -364,6 +366,8 @@ def _get_ssh_and_manager(server: dict, proto: str):
|
||||
manager = HysteriaManager(ssh, proto)
|
||||
elif base == "naiveproxy":
|
||||
manager = NaiveProxyManager(ssh, proto)
|
||||
elif base == "mieru":
|
||||
manager = MieruManager(ssh, proto)
|
||||
elif base == "wireguard":
|
||||
manager = WireGuardManager(ssh)
|
||||
elif base == "dns":
|
||||
@@ -569,7 +573,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p
|
||||
server_name = server.get("name") or server.get("host", "Unknown")
|
||||
await api.send_message(chat_id, f"✅ <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:
|
||||
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
|
||||
else:
|
||||
@@ -926,7 +930,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int,
|
||||
|
||||
async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable):
|
||||
await api.send_message(chat_id, f"✅ <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>")
|
||||
else:
|
||||
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>")
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
<a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
<div class="nav-user">
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
if (p === 'xui') return '3x-ui VLESS';
|
||||
if (p === 'hysteria') return 'Hysteria 2';
|
||||
if (p === 'naiveproxy') return 'NaiveProxy';
|
||||
if (p === 'mieru') return 'Mieru';
|
||||
if (p === 'telemt') return 'Telemt';
|
||||
return (p || '').toUpperCase();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
|
||||
data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}"
|
||||
draggable="true">
|
||||
<div class="server-meta">
|
||||
<div class="server-icon">{{ icon('server') }}</div>
|
||||
@@ -32,6 +33,10 @@
|
||||
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
|
||||
</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>
|
||||
|
||||
@@ -139,6 +144,12 @@
|
||||
</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)">
|
||||
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
||||
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
|
||||
@@ -214,6 +225,12 @@
|
||||
</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)">
|
||||
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
||||
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
|
||||
@@ -268,6 +285,7 @@
|
||||
private_key: document.getElementById('serverKey').value,
|
||||
ssl_domain: document.getElementById('serverSslDomain').value.trim(),
|
||||
ssl_email: document.getElementById('serverSslEmail').value.trim(),
|
||||
connect_domain: document.getElementById('serverConnectDomain').value.trim(),
|
||||
};
|
||||
|
||||
const result = await apiCall('/api/servers/add', 'POST', data);
|
||||
@@ -318,6 +336,7 @@
|
||||
document.getElementById('editServerKey').value = '';
|
||||
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
|
||||
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || '';
|
||||
document.getElementById('editServerConnectDomain').value = card.dataset.connectDomain || '';
|
||||
|
||||
// Pre-select the tab matching the server's current auth method.
|
||||
const authIsKey = card.dataset.auth === 'key';
|
||||
@@ -349,6 +368,7 @@
|
||||
private_key: document.getElementById('editServerKey').value || null,
|
||||
ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
|
||||
ssl_email: document.getElementById('editServerSslEmail').value.trim(),
|
||||
connect_domain: document.getElementById('editServerConnectDomain').value.trim(),
|
||||
};
|
||||
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
|
||||
showToast(_('server_saved'), 'success');
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
|
||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||
const PROTO_TITLES = {
|
||||
awg2: 'AmneziaWG 2.0',
|
||||
awg: 'AmneziaWG',
|
||||
@@ -206,6 +206,7 @@
|
||||
telemt: 'Telemt',
|
||||
hysteria: 'Hysteria 2',
|
||||
naiveproxy: 'NaiveProxy',
|
||||
mieru: 'Mieru',
|
||||
xui: '3x-ui VLESS',
|
||||
};
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
const vpnTab = document.querySelectorAll('.config-tab')[1];
|
||||
const vpnPanel = document.getElementById('panel-vpn');
|
||||
const base = String(proto || '').split('__')[0];
|
||||
if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy') {
|
||||
if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy' || base === 'mieru') {
|
||||
vpnTab.style.display = 'none';
|
||||
} else {
|
||||
vpnTab.style.display = '';
|
||||
|
||||
+345
-8
@@ -1,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon %}
|
||||
|
||||
{% block title_extra %} — {{ server.name }}{% endblock %}
|
||||
@@ -155,6 +155,25 @@
|
||||
</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>
|
||||
|
||||
<!-- Installed Applications Section -->
|
||||
@@ -295,6 +314,31 @@
|
||||
</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 -->
|
||||
<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);">
|
||||
@@ -325,10 +369,11 @@
|
||||
<div class="protocol-icon">{{ icon('link') }}</div>
|
||||
<div class="flex gap-sm" id="naiveproxy-ctrl" style="display:none!important;"></div>
|
||||
</div>
|
||||
<div class="protocol-name">NaiveProxy</div>
|
||||
<div class="protocol-desc">
|
||||
{{ _('naiveproxy_desc') }}
|
||||
</div>
|
||||
<div class="protocol-name">NaiveProxy <span
|
||||
style="font-size:0.65rem; background:var(--success, #16a34a); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">STABLE</span></div>
|
||||
<div class="protocol-desc">
|
||||
{{ _('naiveproxy_desc') }}
|
||||
</div>
|
||||
<div class="protocol-status" id="naiveproxy-status">
|
||||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||||
</div>
|
||||
@@ -543,12 +588,23 @@
|
||||
<option value="telemt">Telemt</option>
|
||||
<option value="wireguard">WireGuard</option>
|
||||
</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()">
|
||||
<span>{{ icon('plus') }}</span> {{ _('add') }}
|
||||
</button>
|
||||
</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 class="loading-overlay" id="connectionsLoading" style="display:none;">
|
||||
<div class="loading-spinner"></div>
|
||||
@@ -743,6 +799,21 @@
|
||||
</div>
|
||||
<div class="form-hint">{{ _('naiveproxy_install_hint') }}</div>
|
||||
<div class="form-hint" style="margin-top: var(--space-sm);">{{ _('naiveproxy_port_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 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">
|
||||
@@ -967,6 +1038,35 @@
|
||||
</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) ===== -->
|
||||
<div class="modal-backdrop" id="configModal">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
@@ -986,6 +1086,7 @@
|
||||
<div class="config-panel active" id="panel-conf">
|
||||
<div class="config-display">
|
||||
<div class="config-text" id="configText"></div>
|
||||
<div class="form-hint hidden" id="configClientHint" style="margin-top: var(--space-sm);"></div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
|
||||
{{ _('copy') }}
|
||||
@@ -1087,7 +1188,9 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script>
|
||||
const SERVER_ID = {{ server_id }};
|
||||
const ALL_SERVERS = {{ servers_for_move | tojson }};
|
||||
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_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
|
||||
const MARKETPLACE_APPS = [
|
||||
@@ -1096,8 +1199,9 @@
|
||||
{ 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: '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: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc' },
|
||||
{ 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: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
|
||||
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
|
||||
@@ -1109,6 +1213,8 @@
|
||||
{ id: 'services', icon: 'wrench', titleKey: 'services' },
|
||||
{ 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 currentInstallAnother = false;
|
||||
let currentConfig = '';
|
||||
@@ -1168,6 +1274,94 @@
|
||||
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) {
|
||||
if (cat === 'management') {
|
||||
openManagementModal();
|
||||
@@ -1326,6 +1520,7 @@
|
||||
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
|
||||
const loading = document.getElementById('installedAppsLoading');
|
||||
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
|
||||
updateConnectDomainSection();
|
||||
}
|
||||
|
||||
function getProtoTitle(proto) {
|
||||
@@ -1340,6 +1535,7 @@
|
||||
case 'telemt': title = 'Telemt'; break;
|
||||
case 'hysteria': title = 'Hysteria 2'; break;
|
||||
case 'naiveproxy': title = 'NaiveProxy'; break;
|
||||
case 'mieru': title = 'Mieru'; break;
|
||||
case 'wireguard': title = 'WireGuard'; break;
|
||||
case 'dns': title = 'AmneziaDNS'; break;
|
||||
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
||||
@@ -1399,7 +1595,7 @@
|
||||
|
||||
for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
|
||||
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) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = proto;
|
||||
@@ -1627,13 +1823,22 @@
|
||||
if (isService) {
|
||||
grid = buildServiceInfoGrid(proto, info);
|
||||
} 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) {
|
||||
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) {
|
||||
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) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
|
||||
@@ -1683,6 +1888,13 @@
|
||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||
`;
|
||||
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 {
|
||||
actionsEl.innerHTML = `
|
||||
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
||||
@@ -2086,6 +2298,7 @@
|
||||
const nginxOpts = document.getElementById('nginxOptions');
|
||||
const hysteriaOpts = document.getElementById('hysteriaOptions');
|
||||
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
|
||||
const mieruOpts = document.getElementById('mieruOptions');
|
||||
|
||||
telemtOpts.style.display = 'none';
|
||||
socks5Opts.style.display = 'none';
|
||||
@@ -2093,6 +2306,7 @@
|
||||
nginxOpts.style.display = 'none';
|
||||
hysteriaOpts.style.display = 'none';
|
||||
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
|
||||
if (mieruOpts) mieruOpts.style.display = 'none';
|
||||
if (portGroup) portGroup.style.display = '';
|
||||
|
||||
if (base === 'dns') {
|
||||
@@ -2160,6 +2374,17 @@
|
||||
if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN;
|
||||
if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL;
|
||||
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 {
|
||||
portLabel.textContent = _('port') + ' (UDP)';
|
||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
|
||||
@@ -2237,6 +2462,11 @@
|
||||
params.port = '443';
|
||||
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').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);
|
||||
clearInterval(progressInterval);
|
||||
@@ -2605,6 +2835,95 @@
|
||||
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() {
|
||||
const proto = document.getElementById('connProtoSelect').value;
|
||||
const loading = document.getElementById('connectionsLoading');
|
||||
@@ -2619,11 +2938,14 @@
|
||||
loading.style.display = '';
|
||||
emptyEl.classList.add('hidden');
|
||||
listEl.innerHTML = '';
|
||||
selectedConnectionIds.clear();
|
||||
updateMoveSelection();
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
|
||||
loading.style.display = 'none';
|
||||
if (!data.clients || data.clients.length === 0) {
|
||||
emptyEl.classList.remove('hidden');
|
||||
updateMoveSelection();
|
||||
return;
|
||||
}
|
||||
window.connectionsStore = {};
|
||||
@@ -2667,6 +2989,9 @@
|
||||
|
||||
listEl.innerHTML += `
|
||||
<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-avatar">${initial}</div>
|
||||
<div>
|
||||
@@ -2683,6 +3008,7 @@
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
updateMoveSelection();
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
showToast(_('connections_load_error') + ': ' + err.message, 'error');
|
||||
@@ -2840,6 +3166,16 @@
|
||||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||||
document.getElementById('configText').textContent = result.config;
|
||||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||||
const hintEl = document.getElementById('configClientHint');
|
||||
if (hintEl) {
|
||||
if (protoBase(proto) === 'naiveproxy') {
|
||||
hintEl.textContent = _('naiveproxy_client_hint');
|
||||
hintEl.classList.remove('hidden');
|
||||
} else {
|
||||
hintEl.textContent = '';
|
||||
hintEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
switchConfigTab('conf');
|
||||
openModal('configModal');
|
||||
generateQR(result.config);
|
||||
@@ -2952,6 +3288,7 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ========== Init ==========
|
||||
applyInstalledAppsVisibility();
|
||||
checkServer();
|
||||
|
||||
+452
-102
@@ -204,88 +204,147 @@
|
||||
</div>
|
||||
|
||||
<!-- BLOCK Tunnels -->
|
||||
<div class="card">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>{{ _('local_server') }}</strong>
|
||||
<span class="badge badge-info">{{ _('active') }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:var(--space-sm); align-items:center;">
|
||||
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
|
||||
<div class="card tunnels-card">
|
||||
<div class="tunnels-header">
|
||||
<h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
|
||||
<p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tunnels-local">
|
||||
<div class="tunnels-local-icon" aria-hidden="true">🏠</div>
|
||||
<div class="tunnels-local-body">
|
||||
<div class="tunnels-local-label">{{ _('local_server') }}</div>
|
||||
<div class="tunnels-local-url">
|
||||
<code id="localServerUrl">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tunnel-status-pill tunnel-status-pill--ok">{{ _('active') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>Cloudflare Quick Tunnel</strong>
|
||||
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
|
||||
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
|
||||
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
|
||||
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnels-section">
|
||||
<h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
|
||||
<div class="tunnels-grid">
|
||||
<article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true">☁</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions tunnel-card-actions--triple">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
|
||||
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
|
||||
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
|
||||
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>ngrok Tunnel + Authtoken</strong>
|
||||
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: var(--space-sm);">
|
||||
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
|
||||
</div>
|
||||
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
|
||||
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
|
||||
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
|
||||
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>Cloudflare WARP</strong>
|
||||
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
|
||||
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
|
||||
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
|
||||
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
|
||||
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
<article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">ngrok</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
|
||||
<div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions tunnel-card-actions--triple">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
|
||||
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
|
||||
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
|
||||
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
|
||||
|
||||
<div class="tunnels-section">
|
||||
<h4 class="tunnels-section-title">{{ _('tunnels_section_outbound') }}</h4>
|
||||
<div class="tunnels-grid">
|
||||
<article class="tunnel-card tunnel-row" data-provider="warp" id="tunnelCard-warp">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--warp" aria-hidden="true">W</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">Cloudflare WARP</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_warp_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="warpInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<p id="warpStatusText" class="tunnel-status-line">{{ _('warp_status_unknown') }}</p>
|
||||
<p id="warpHintText" class="tunnel-hint">{{ _('warp_hint') }}</p>
|
||||
</div>
|
||||
<div class="tunnel-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="warpConnectBtn" onclick="connectWarp()">
|
||||
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
|
||||
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
|
||||
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tunnel-card tunnel-row" data-provider="nordvpn" id="tunnelCard-nordvpn">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--nordvpn" aria-hidden="true">N</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">NordVPN</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_nordvpn_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<p id="nordvpnStatusText" class="tunnel-status-line">{{ _('nordvpn_status_unknown') }}</p>
|
||||
<p id="nordvpnServerText" class="tunnel-status-line tunnel-status-line--server hidden"></p>
|
||||
<p id="nordvpnHintText" class="tunnel-hint">{{ _('nordvpn_hint') }}</p>
|
||||
<div class="tunnel-nordvpn-fields">
|
||||
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
|
||||
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="nordvpnConnectBtn" onclick="connectNordvpn()">
|
||||
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
|
||||
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
|
||||
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tunnels-footnote">{{ _('tunnels_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK SSL/HTTPS -->
|
||||
@@ -545,15 +604,19 @@
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<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; 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"
|
||||
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') }}
|
||||
</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 style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
||||
<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)">
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
|
||||
@@ -564,6 +627,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -584,6 +650,41 @@
|
||||
</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 -->
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">ℹ️ {{ _('about_title') }}</h3>
|
||||
@@ -597,15 +698,24 @@
|
||||
<!-- Updated via JS -->
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
|
||||
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;">
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-primary" onclick="upgradePanel()" id="upgradePanelBtn" style="flex:1; min-width:180px;">
|
||||
<span id="upgradePanelBtnText">⬆ {{ _('upgrade_panel') }}</span>
|
||||
<div class="spinner hidden" id="upgradePanelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;">
|
||||
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
|
||||
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-primary hidden" id="downloadUpdateBtn" style="flex:1; text-decoration: none; justify-content: center;">
|
||||
<button type="button" class="btn btn-secondary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
|
||||
<span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span>
|
||||
<div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-secondary hidden" id="downloadUpdateBtn" style="flex:1; min-width:140px; text-decoration: none; justify-content: center;">
|
||||
⬇️ {{ _('download_update') }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -916,31 +1026,45 @@
|
||||
const el = document.getElementById(`${provider}PublicUrls`);
|
||||
if (!el) return;
|
||||
if (!urls || !urls.length) {
|
||||
el.className = 'tunnel-url-box tunnel-url-box--empty';
|
||||
el.textContent = _('tunnel_no_public_url');
|
||||
return;
|
||||
}
|
||||
el.className = 'tunnel-url-box';
|
||||
el.innerHTML = urls.map((url, idx) => `
|
||||
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
|
||||
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
|
||||
<div class="tunnel-url-row">
|
||||
<code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setTunnelStatusPill(badge, { ok, info, text }) {
|
||||
if (!badge) return;
|
||||
badge.className = 'tunnel-status-pill' + (ok ? ' tunnel-status-pill--ok' : (info ? ' tunnel-status-pill--info' : ''));
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
function updateTunnelProvider(provider, status) {
|
||||
const badge = document.getElementById(`${provider}InstallBadge`);
|
||||
const text = document.getElementById(`${provider}TunnelBtnText`);
|
||||
const startBtn = document.getElementById(`${provider}TunnelBtn`);
|
||||
const stopBtn = document.getElementById(`${provider}StopBtn`);
|
||||
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
|
||||
const card = document.getElementById(`tunnelCard-${provider}`);
|
||||
if (!badge || !text) return;
|
||||
|
||||
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
|
||||
badge.textContent = status.installed ? _('installed') : _('not_installed');
|
||||
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
|
||||
if (startBtn) startBtn.disabled = !!status.running;
|
||||
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
|
||||
const running = !!status.running;
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: running,
|
||||
info: !running && status.installed,
|
||||
text: running ? _('tunnel_running') : (status.installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
text.textContent = running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
|
||||
if (startBtn) startBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
|
||||
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
|
||||
if (card) card.classList.toggle('tunnel-card--running', running);
|
||||
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
|
||||
}
|
||||
|
||||
@@ -951,6 +1075,7 @@
|
||||
updateTunnelProvider('cloudflare', data.cloudflare || {});
|
||||
updateTunnelProvider('ngrok', data.ngrok || {});
|
||||
updateWarpStatus(data.warp || {});
|
||||
updateNordvpnStatus(data.nordvpn || {});
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
}
|
||||
@@ -963,12 +1088,16 @@
|
||||
const connectBtn = document.getElementById('warpConnectBtn');
|
||||
const disconnectBtn = document.getElementById('warpDisconnectBtn');
|
||||
const connectText = document.getElementById('warpConnectBtnText');
|
||||
const card = document.getElementById('tunnelCard-warp');
|
||||
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
|
||||
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected || status.status === 'connected';
|
||||
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
|
||||
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: connected,
|
||||
info: !connected && installed,
|
||||
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
|
||||
const statusKey = `warp_status_${status.status || 'unknown'}`;
|
||||
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
|
||||
@@ -976,6 +1105,7 @@
|
||||
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.classList.toggle('hidden', !connected);
|
||||
if (card) card.classList.toggle('tunnel-card--connected', connected);
|
||||
}
|
||||
|
||||
async function connectWarp() {
|
||||
@@ -1020,6 +1150,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateNordvpnStatus(status) {
|
||||
const badge = document.getElementById('nordvpnInstallBadge');
|
||||
const statusText = document.getElementById('nordvpnStatusText');
|
||||
const serverText = document.getElementById('nordvpnServerText');
|
||||
const hintText = document.getElementById('nordvpnHintText');
|
||||
const connectBtn = document.getElementById('nordvpnConnectBtn');
|
||||
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
|
||||
const connectText = document.getElementById('nordvpnConnectBtnText');
|
||||
const card = document.getElementById('tunnelCard-nordvpn');
|
||||
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
|
||||
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected || status.status === 'connected';
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: connected,
|
||||
info: !connected && installed,
|
||||
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
|
||||
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
|
||||
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
|
||||
if (serverText) {
|
||||
const serverLine = status.server || '';
|
||||
serverText.textContent = serverLine;
|
||||
serverText.classList.toggle('hidden', !serverLine);
|
||||
}
|
||||
hintText.textContent = status.last_error || (installed ? _('nordvpn_hint') : (status.install_hint || _('nordvpn_install_hint')));
|
||||
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.classList.toggle('hidden', !connected);
|
||||
if (card) card.classList.toggle('tunnel-card--connected', connected);
|
||||
}
|
||||
|
||||
async function connectNordvpn() {
|
||||
const btn = document.getElementById('nordvpnConnectBtn');
|
||||
const spinner = document.getElementById('nordvpnConnectSpinner');
|
||||
const text = document.getElementById('nordvpnConnectBtnText');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
|
||||
try {
|
||||
const country = (document.getElementById('nordvpnCountry') || {}).value || '';
|
||||
const city = (document.getElementById('nordvpnCity') || {}).value || '';
|
||||
const status = await apiCall('/api/settings/nordvpn/connect', 'POST', {
|
||||
country: country.trim(),
|
||||
city: city.trim(),
|
||||
});
|
||||
updateNordvpnStatus(status);
|
||||
showToast(_('nordvpn_connected'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
await loadTunnelStatus();
|
||||
} finally {
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectNordvpn() {
|
||||
const btn = document.getElementById('nordvpnDisconnectBtn');
|
||||
const spinner = document.getElementById('nordvpnDisconnectSpinner');
|
||||
const text = document.getElementById('nordvpnDisconnectBtnText');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
|
||||
try {
|
||||
const status = await apiCall('/api/settings/nordvpn/disconnect', 'POST');
|
||||
updateNordvpnStatus(status);
|
||||
showToast(_('nordvpn_disconnected'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
await loadTunnelStatus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = _('nordvpn_disconnect');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTunnel(provider) {
|
||||
const btn = document.getElementById(`${provider}TunnelBtn`);
|
||||
const spinner = document.getElementById(`${provider}TunnelSpinner`);
|
||||
@@ -1303,11 +1513,24 @@
|
||||
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
||||
};
|
||||
|
||||
const donateMethod = (key) => ({
|
||||
enabled: document.getElementById(`donate_${key}_enabled`).checked,
|
||||
title: document.getElementById(`donate_${key}_title`).value.trim(),
|
||||
details: document.getElementById(`donate_${key}_details`).value.trim(),
|
||||
});
|
||||
const donate = {
|
||||
enabled: document.getElementById('donate_enabled').checked,
|
||||
intro: document.getElementById('donate_intro').value.trim(),
|
||||
sbp: donateMethod('sbp'),
|
||||
card: donateMethod('card'),
|
||||
crypto: donateMethod('crypto'),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest })
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest, donate })
|
||||
});
|
||||
if (!res.ok) throw new Error(_('error'));
|
||||
showToast(_('settings_saved'), 'success');
|
||||
@@ -1321,7 +1544,7 @@
|
||||
}
|
||||
|
||||
const guestServersData = {{ servers | default([]) | tojson }};
|
||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
|
||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||
const PROTO_TITLES = {
|
||||
awg2: 'AmneziaWG 2.0',
|
||||
awg: 'AmneziaWG',
|
||||
@@ -1331,6 +1554,7 @@
|
||||
telemt: 'Telemt',
|
||||
hysteria: 'Hysteria 2',
|
||||
naiveproxy: 'NaiveProxy',
|
||||
mieru: 'Mieru',
|
||||
xui: '3x-ui VLESS',
|
||||
};
|
||||
function protoTitle(key) {
|
||||
@@ -1482,42 +1706,168 @@
|
||||
updateProtocolsForSync();
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
let latestUpdateInfo = null;
|
||||
|
||||
async function checkForUpdates(silent) {
|
||||
const btn = document.getElementById('checkUpdateBtn');
|
||||
const text = document.getElementById('checkUpdateBtnText');
|
||||
const spinner = document.getElementById('checkUpdateSpinner');
|
||||
const statusDiv = document.getElementById('updateStatus');
|
||||
const downloadBtn = document.getElementById('downloadUpdateBtn');
|
||||
|
||||
btn.disabled = true;
|
||||
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
|
||||
spinner.classList.remove('hidden');
|
||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||
const modeHint = document.getElementById('updateModeHint');
|
||||
|
||||
if (!silent) {
|
||||
btn.disabled = true;
|
||||
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
|
||||
spinner.classList.remove('hidden');
|
||||
}
|
||||
statusDiv.classList.add('hidden');
|
||||
downloadBtn.classList.add('hidden');
|
||||
applyBtn.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const data = await apiCall('/api/settings/check_updates');
|
||||
latestUpdateInfo = data;
|
||||
const latestVersion = data.latest_version || '';
|
||||
statusDiv.classList.remove('hidden');
|
||||
|
||||
|
||||
if (modeHint) {
|
||||
const method = data.update_mode || (data.mode && data.mode.update_method) || (data.mode && data.mode.mode) || '';
|
||||
if (!data.can_auto_update) {
|
||||
if (method === 'docker') {
|
||||
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker: on the host run git pull && docker compose up -d --build.') }}";
|
||||
} else {
|
||||
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a writable install directory or git clone.') }}";
|
||||
}
|
||||
} else if (method === 'archive') {
|
||||
modeHint.textContent = "{{ _('auto_update_archive')|default('One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.') }}";
|
||||
} else {
|
||||
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
|
||||
}
|
||||
}
|
||||
|
||||
const upgradeBtn = document.getElementById('upgradePanelBtn');
|
||||
if (upgradeBtn) {
|
||||
upgradeBtn.disabled = false;
|
||||
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
|
||||
}
|
||||
|
||||
if (data.update_available && latestVersion) {
|
||||
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
|
||||
statusDiv.style.border = '1px solid var(--success)';
|
||||
downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
|
||||
downloadBtn.classList.remove('hidden');
|
||||
applyBtn.classList.remove('hidden');
|
||||
} else {
|
||||
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
|
||||
statusDiv.style.border = '1px solid var(--border-color)';
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to check for updates: ' + err.message, 'error');
|
||||
if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
|
||||
spinner.classList.add('hidden');
|
||||
if (!silent) {
|
||||
btn.disabled = false;
|
||||
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPanelRestart(timeoutMs = 120000, panelUrl) {
|
||||
const base = String(panelUrl || window.location.origin || '').replace(/\/$/, '');
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/health`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
window.location.href = base + '/';
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
const hint = base ? ` ${base}` : '';
|
||||
showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}" + hint, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applyPanelUpdate() {
|
||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||
const applyText = document.getElementById('applyUpdateBtnText');
|
||||
const applySpinner = document.getElementById('applyUpdateSpinner');
|
||||
const target = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
|
||||
if (!target) {
|
||||
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
|
||||
return;
|
||||
}
|
||||
applyBtn.disabled = true;
|
||||
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
|
||||
applySpinner.classList.remove('hidden');
|
||||
let restarting = false;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/apply_update', 'POST', {
|
||||
target_version: target,
|
||||
allow_dirty: true,
|
||||
});
|
||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||
restarting = true;
|
||||
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||
await waitForPanelRestart(120000, data.panel_url);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
if (!restarting) {
|
||||
applyBtn.disabled = false;
|
||||
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
|
||||
applySpinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradePanel() {
|
||||
const btn = document.getElementById('upgradePanelBtn');
|
||||
const text = document.getElementById('upgradePanelBtnText');
|
||||
const spinner = document.getElementById('upgradePanelSpinner');
|
||||
const latest = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
|
||||
const confirmMsg = latest
|
||||
? ("{{ _('upgrade_panel_confirm')|default('Download and install') }} " + latest + "?")
|
||||
: "{{ _('upgrade_panel_confirm_latest')|default('Check for updates and install the latest release?') }}";
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
|
||||
let restarting = false;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
|
||||
if (data.up_to_date) {
|
||||
showToast("{{ _('upgrade_panel_up_to_date')|default('Already on the latest version') }}", 'info');
|
||||
await checkForUpdates(true);
|
||||
return;
|
||||
}
|
||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||
restarting = true;
|
||||
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||
await waitForPanelRestart(120000, data.panel_url);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
await checkForUpdates(true);
|
||||
} finally {
|
||||
if (!restarting) {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check once on Settings load
|
||||
setTimeout(() => checkForUpdates(true), 800);
|
||||
|
||||
/* ========== API Tokens ========== */
|
||||
|
||||
function fmtTokenDate(iso) {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -631,7 +631,7 @@
|
||||
const select = document.getElementById(selectId);
|
||||
const group = groupId ? document.getElementById(groupId) : null;
|
||||
select.innerHTML = '';
|
||||
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
|
||||
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||
const titles = {
|
||||
awg2: 'AmneziaWG 2.0',
|
||||
awg: 'AmneziaWG',
|
||||
@@ -641,6 +641,7 @@
|
||||
telemt: 'Telemt',
|
||||
hysteria: 'Hysteria 2',
|
||||
naiveproxy: 'NaiveProxy',
|
||||
mieru: 'Mieru',
|
||||
xui: '3x-ui VLESS',
|
||||
};
|
||||
const protoTitle = (key) => {
|
||||
|
||||
+663
-576
File diff suppressed because it is too large
Load Diff
+590
-519
File diff suppressed because it is too large
Load Diff
+590
-519
File diff suppressed because it is too large
Load Diff
+663
-576
File diff suppressed because it is too large
Load Diff
+590
-519
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user