Compare commits

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:47:59 +03:00
orohiandCursor 32a0e90e19 Release v2.6.3: support page with SBP/card/crypto donate placeholders and admin settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:32:09 +03:00
orohiandCursor 73d0ee277d Add AWG/WireGuard connect-domain UI on server page and fix Traefik port for Dokploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:21:54 +03:00
orohiandCursor 9b4ef0f9f0 Use client connect domain in VPN configs and fix legacy JSON import to PostgreSQL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:00:37 +03:00
orohiandCursor 78e69d899a Backup panel PostgreSQL database as .sql dump (v2.6.2).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:40:59 +03:00
orohiandCursor 2d62758716 Restore move connections between servers (v2.6.1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:33:27 +03:00
orohiandCursor d26843a0d1 Release v2.6.0: stable Dokploy/Docker deployment bundle.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:15:00 +03:00
orohiandCursor 706d2c9c0d Postgres healthcheck verifies password; document volume password mismatch (v2.5.11).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:06:07 +03:00
orohiandCursor 2746f981af Simplify Docker CMD to uvicorn directly (v2.5.10).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:59:43 +03:00
orohiandCursor 4f5a1d7554 Fix panel startup crash and remove move-connections feature (v2.5.9).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:44:35 +03:00
orohi ff7cc5c592 v2.5.6: fix 404 after update without tunnel 2026-07-28 11:00:34 +03:00
orohi 2808a49fa5 v2.5.5: fix 404 after panel update 2026-07-28 10:26:08 +03:00
orohi 2973b96713 v2.5.4: move selected connections between servers 2026-07-28 10:14:08 +03:00
orohi 5d63e5d6ef v2.5.3: fix disabled Update panel button 2026-07-28 10:02:57 +03:00
orohi a5b8f26db1 v2.5.2: redesign Tunnels section in Settings 2026-07-28 09:48:19 +03:00
orohi 599e0a487c Enable true one-click panel update from Settings (v2.5.1).
Download release ZIP from git.evilfox.cc when git is unavailable, add upgrade_panel API, and show a primary Update panel button.
2026-07-28 09:41:51 +03:00
orohi 77c6f0dab7 Add NordVPN outbound control in Tunnels (v2.5.0).
Connect and disconnect via official nordvpn CLI from Settings with optional country/city, alongside Cloudflare WARP.
2026-07-28 09:34:10 +03:00
orohi fd257a59f7 Add one-click panel auto-update from git.evilfox.cc (v2.4.0).
Check Gitea releases in Settings and install the latest tag via git fetch/checkout with pip sync and restart when running from a git checkout.
2026-07-28 09:28:15 +03:00
orohi 6206697e3b Remove Cascade double-VPN (v2.3.0).
Drop cascade manager, API, server UI, and i18n keys after unstable egress/routing behavior.
2026-07-27 05:08:18 +03:00
orohi 4c19099b04 Fix i18n: strip UTF-8 BOM so translations load again.
PowerShell rewrites had added a BOM that made Python json.load fail, so the UI showed raw keys like nav_servers.
2026-07-27 04:40:44 +03:00
orohi 80160d0ef4 Fix Cascade egress verify to bind awg0 IP (v2.2.1).
Use client-subnet policy routing for curl checks instead of cascade peer Address; soft-warn when echo services fail but the route is OK.
2026-07-27 04:27:00 +03:00
orohi ac76a0e540 Release v2.2.0: restore safe Cascade double-VPN for servers.
Reintroduce entry-to-exit AmneziaWG/WireGuard cascade inspired by ryderams/amneziawg-cascade, with handshake and egress checks and no remote curl|bash.
2026-07-27 04:09:16 +03:00
orohi bf7bd16fcd Release v2.1.0: mark NaiveProxy stable with client notes.
Stable NaiveProxy; note that v2rayN must not be used, Karing is confirmed, other clients are untested.
2026-07-26 11:23:10 +03:00
orohiandCursor 24e793d943 Add NaiveProxy client hint for v2rayN IPv6 DNS failures.
Show guidance when copying config if latency fails due to Google DNS over IPv6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:31:41 +03:00
orohiandCursor 415cfea5eb Fix NaiveProxy share links to always include port 443.
v2rayN treated links without an explicit port as invalid/80 and failed TLS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:26:37 +03:00
orohiandCursor 04a32fb168 Release v2.0.0-beta: add NaiveProxy (Caddy + klzgrad/forwardproxy).
Installable marketplace protocol with ACME TLS, per-client basic auth, naive+https share links; bump panel version to 2.0 Beta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:06:16 +03:00
orohiandCursor 183071f588 Wire Hysteria 2 UI with per-server SSL defaults and renew.
Expose domain/email on servers, warn that TCP 80/443 must be free on install, and allow Let's Encrypt re-issue from Hysteria settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:46:20 +03:00
orohiandCursor ed5c54cf96 Add Hysteria 2 protocol manager (apernet/hysteria).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:25:02 +03:00
orohiandCursor a9a0eea61d v1.7.0: pick server first, then protocol in create flows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:00:09 +03:00
orohiandCursor 64aa1850a0 Proxy update checks through the panel to avoid CORS failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:55:07 +03:00
orohiandCursor b71c31605d Point update checker to git.evilfox.cc instead of GitHub.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:47:44 +03:00
31 changed files with 9094 additions and 2879 deletions
+10 -3
View File
@@ -2,11 +2,18 @@
APP_PORT=5000 APP_PORT=5000
SECRET_KEY=change-me-to-a-long-random-string SECRET_KEY=change-me-to-a-long-random-string
# PostgreSQL (used by docker-compose) # PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
POSTGRES_USER=amnezia POSTGRES_USER=amnezia
POSTGRES_PASSWORD=amnezia POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=amnezia_panel POSTGRES_DB=amnezia_panel
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
# Full DSN (override if panel runs outside compose) # Full DSN (override if panel runs outside compose)
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel # DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
# Dokploy / reverse proxy (optional — set in compose for Docker)
# PANEL_IN_DOCKER=1
# PANEL_BEHIND_PROXY=1
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+7 -4
View File
@@ -5,12 +5,15 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
APP_PORT=5000 \ APP_PORT=5000 \
PORT=5000 \
PANEL_IN_DOCKER=1 \
PANEL_BEHIND_PROXY=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \ && apt-get install -y --no-install-recommends curl postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
@@ -20,7 +23,7 @@ COPY . .
EXPOSE 5000 EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1 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", "*"]
+165 -6
View File
@@ -1,6 +1,6 @@
# Amnezia Web Panel # Amnezia Web Panel
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **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 > ### 🔄 Compatibility with Official Amnezia Client
> >
@@ -62,8 +62,11 @@ Configuration panel for system parameters and preferences:
* **AmneziaWG (AWG / AWG 2.0 / AWG Legacy)**: Advanced WireGuard-based protocol with S3/S4 obfuscation to bypass deep packet inspection (DPI). Three coexisting variants — modern AWG 2.0 with full junk-packet masking, and a legacy variant for older clients. * **AmneziaWG (AWG / AWG 2.0 / AWG Legacy)**: Advanced WireGuard-based protocol with S3/S4 obfuscation to bypass deep packet inspection (DPI). Three coexisting variants — modern AWG 2.0 with full junk-packet masking, and a legacy variant for older clients.
* **Classic WireGuard**: Standard, high-performance WireGuard protocol for unmatched speed and broad device compatibility with traffic monitoring support. * **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. * **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** (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. * **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. * **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**: * **🛠 Services**:
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings. * **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. * **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.
@@ -71,17 +74,23 @@ Configuration panel for system parameters and preferences:
* **NGINX + Let's Encrypt**: Reverse-proxy and HTTPS automation with certificate management for secure public endpoints. * **NGINX + Let's Encrypt**: Reverse-proxy and HTTPS automation with certificate management for secure public endpoints.
* **⚙️ Core Server Management**: * **⚙️ Core Server Management**:
* **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically. * **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically.
* **Per-server SSL defaults**: optional domain + Let's Encrypt email on each server, pre-filled when installing Hysteria or NGINX.
* **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers. * **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers.
* **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol. * **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol.
* **Reboot** the server directly from the UI. * **Reboot** the server directly from the UI.
* Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback. * Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback.
* **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang. * **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang.
* **🧩 Marketplace & Templates**: * **🧩 Marketplace & Templates**:
* Market templates provide quick presets for installing and configuring supported protocols and services. * Market templates provide quick presets for installing and configuring supported protocols and services (including **Hysteria 2**).
* Multi-protocol management lets you run and control multiple protocol instances on the same server. * Multi-protocol management lets you run and control multiple protocol instances on the same server.
* **🌐 Internationalization (i18n)**: * **🌐 Internationalization (i18n)**:
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**. * Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
* Native **RTL (Right-to-Left)** support for Persian language. * 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**: * **👥 Advanced User Management**:
* Role-based access (Admin, Support, Regular User). * Role-based access (Admin, Support, Regular User).
* Traffic limits, status monitoring, and account expiration. * Traffic limits, status monitoring, and account expiration.
@@ -179,22 +188,71 @@ Mac
```bash ```bash
cp .env.example .env cp .env.example .env
# set SECRET_KEY and strong POSTGRES_PASSWORD in .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 docker compose up -d --build
``` ```
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`). 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 | | File | Purpose |
| --- | --- | | --- | --- |
| `Dockerfile` | Production image (Python 3.12) | | `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 | | `.env.example` | Environment template |
**Environment** **Environment**
| Variable | Default | Description | | 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 | | `SECRET_KEY` | (random) | Session signing key — set in production |
| `DATABASE_URL` | compose DSN | PostgreSQL connection string | | `DATABASE_URL` | compose DSN | PostgreSQL connection string |
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service | | `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
@@ -219,16 +277,115 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### v2.6.5
* **Mieru install fix** — wait for `/var/run/mita.sock`, seed a bootstrap user (empty `users` caused `mita start` RPC EOF), retry apply/start with systemd restart.
### v2.6.4
* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag).
### v2.6.3
* **Client connection domain (AWG / WireGuard)** — set a DNS name per server (or per AWG/WG protocol) instead of IP in client configs (`Endpoint = domain:port`). Survives server IP changes — update the A-record only. UI on server list, server page, and when adding a server.
* **Legacy `data.json` → PostgreSQL** — import normalizes old backups (UUIDs, duplicate usernames/tokens, string `server_info`); failed auto-import no longer bricks panel startup; JSON restore returns clear errors.
* **Dokploy / Traefik 404** — `traefik.http.services.amnezia_panel.loadbalancer.server.port=5000` in compose (Traefik must route to container port **5000**).
* **Support the project** — `/support` page for all users (SBP / card / crypto placeholders); admin configures requisites in **Settings** (no popups).
### v2.6.2
* **Panel backup → PostgreSQL dump** — Settings backup downloads `.sql` via `pg_dump`; restore accepts `.sql` or legacy `data.json`. JSON export kept as secondary link.
### v2.6.1
* **Move connections between servers** — restored: select configs on a server page and move to another server (recreates peers, keeps user links). `ToggleConnectionRequest` kept intact.
### v2.6.0 — stable Dokploy / Docker release
* **Dokploy-ready compose** — `dokploy-network`, Traefik labels, port **5000**, no fixed `container_name`.
* **Docker startup** — `uvicorn` with proxy headers; SSL inside container disabled when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY`.
* **Postgres healthcheck** — verifies password (catches `POSTGRES_PASSWORD` vs volume mismatch).
* **Rollback move-connections** — removed server-to-server config move (v2.5.4); restored `ToggleConnectionRequest` (fixes crash loop on deploy).
* **Docs** — full Dokploy deploy/redeploy/password-reset guide in README.
### v2.5.11
* **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.
* Install warns that free TCP **80** and **443** are required; uses per-server SSL domain/email defaults when set.
* Official prebuilt Caddy on amd64; xcaddy build path for other arches.
* Version line bumped to **2.0 Beta** (`v2.0.0-beta`).
### v1.8.0
* **Hysteria 2** fully wired in marketplace, server page, users/invites, Telegram bot, and backups.
* **Per-server SSL defaults**: set domain + Let's Encrypt email when adding/editing a server; values pre-fill Hysteria (and NGINX) install forms.
* **SSL renew**: change domain or use “Renew SSL” in Hysteria settings to re-issue the certificate (needs free TCP 80).
* **Install warning**: free TCP ports **80** and **443** required for stable Let's Encrypt / HTTPS-QUIC operation.
* Official image `tobyxdd/hysteria:v2`, salamander obfs, password auth, `hy2://` share links, selectable UDP listen port.
### v1.7.0
* **Clearer create flow**: pick **server first**, then **protocol** (WireGuard, AmneziaWG 2.0, …) — invites, guest access, and user connections no longer dump everything into one messy list.
* Protocol titles are human-readable and ordered (AWG 2.0 → AWG → Legacy → WireGuard → Xray → Telemt → Hysteria).
* 3x-ui is a separate “server” choice; VLESS inbounds still load from that panels API.
* **Hysteria 2** manager restored ([apernet/hysteria](https://github.com/apernet/hysteria)) — see **v1.8.0** for full panel integration.
### v1.6.0 ### 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). * **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. * **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. * **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
* **API performance**: in-memory data cache, faster server check/stats. * **API performance**: in-memory data cache, faster server check/stats.
* **Share / guest**: one-tap “Copy key”. * **Share / guest**: one-tap “Copy key”.
* **User expiration**: countdown can start on first config use; UTC-safe comparisons. * **User expiration**: countdown can start on first config use; UTC-safe comparisons.
* **UI**: shared SVG icon system. * **UI**: shared SVG icon system.
* **Removed earlier**: Hysteria 2.
## 🔧 Project Details ## 🔧 Project Details
@@ -290,6 +447,8 @@ web-panel/
│ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy │ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy
│ ├── wireguard_manager.py # Classic WireGuard │ ├── wireguard_manager.py # Classic WireGuard
│ ├── xray_manager.py # Xray-core (VLESS-Reality) │ ├── xray_manager.py # Xray-core (VLESS-Reality)
│ ├── hysteria_manager.py # Hysteria 2 (apernet/hysteria)
│ ├── naiveproxy_manager.py # NaiveProxy (Caddy + klzgrad/forwardproxy)
│ ├── telemt_manager.py # Telegram MTProxy │ ├── telemt_manager.py # Telegram MTProxy
│ ├── dns_manager.py # AmneziaDNS (Unbound) │ ├── dns_manager.py # AmneziaDNS (Unbound)
│ ├── adguard_manager.py # AdGuard Home │ ├── adguard_manager.py # AdGuard Home
+1205 -73
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -1,28 +1,36 @@
"""Database package for Amnezia Web Panel (PostgreSQL 17).""" """Database package for Amnezia Web Panel (PostgreSQL 17)."""
from .backup import backup_filename, export_database_sql, restore_database_sql
from .connection import close_pool, get_database_url, init_schema from .connection import close_pool, get_database_url, init_schema
from .store import ( from .store import (
clear_tunnel_state, clear_tunnel_state,
ensure_db_ready, ensure_db_ready,
export_data_dict, export_data_dict,
import_from_json_file, import_from_json_file,
invalidate_data_cache,
load_data, load_data,
load_tunnel_state, load_tunnel_state,
normalize_import_data,
save_data, save_data,
save_tunnel_state, save_tunnel_state,
update_tunnel_state, update_tunnel_state,
) )
__all__ = [ __all__ = [
'backup_filename',
'clear_tunnel_state', 'clear_tunnel_state',
'close_pool', 'close_pool',
'ensure_db_ready', 'ensure_db_ready',
'export_data_dict', 'export_data_dict',
'export_database_sql',
'get_database_url', 'get_database_url',
'import_from_json_file', 'import_from_json_file',
'init_schema', 'init_schema',
'invalidate_data_cache',
'load_data', 'load_data',
'load_tunnel_state', 'load_tunnel_state',
'normalize_import_data',
'restore_database_sql',
'save_data', 'save_data',
'save_tunnel_state', 'save_tunnel_state',
'update_tunnel_state', 'update_tunnel_state',
+59
View File
@@ -0,0 +1,59 @@
"""PostgreSQL backup/restore for the panel database."""
from __future__ import annotations
import logging
import subprocess
from datetime import datetime, timezone
from .connection import get_database_url
from .store import invalidate_data_cache
logger = logging.getLogger(__name__)
def backup_filename() -> str:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
return f'amnezia_panel_backup_{stamp}.sql'
def export_database_sql() -> bytes:
"""Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url()
proc = subprocess.run(
[
'pg_dump',
'--dbname', url,
'--no-owner',
'--no-acl',
'--clean',
'--if-exists',
],
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or 'pg_dump failed')
if not proc.stdout:
raise RuntimeError('pg_dump returned empty dump')
return proc.stdout
def restore_database_sql(data: bytes) -> None:
"""Restore panel data from a plain SQL dump produced by pg_dump."""
if not data or not data.strip():
raise ValueError('Empty backup file')
url = get_database_url()
proc = subprocess.run(
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
input=data,
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
out = proc.stdout.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or out or 'psql restore failed')
invalidate_data_cache()
logger.info('PostgreSQL backup restored successfully')
+157 -7
View File
@@ -10,8 +10,10 @@ import copy
import json import json
import logging import logging
import os import os
import secrets
import shutil import shutil
import threading import threading
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -52,6 +54,13 @@ DEFAULT_SETTINGS = {
'create_protocol': 'awg', 'create_protocol': 'awg',
'create_server_id': 0, 'create_server_id': 0,
}, },
'donate': {
'enabled': True,
'intro': '',
'sbp': {'enabled': True, 'title': '', 'details': ''},
'card': {'enabled': True, 'title': '', 'details': ''},
'crypto': {'enabled': True, 'title': '', 'details': ''},
},
} }
@@ -83,6 +92,147 @@ def _as_uuid(value: Any) -> UUID:
return UUID(str(value)) return UUID(str(value))
def _is_valid_uuid(value: Any) -> bool:
if value is None:
return False
try:
UUID(str(value))
return True
except (ValueError, AttributeError, TypeError):
return False
def _coerce_uuid(value: Any, *, default: Optional[str] = None) -> str:
if _is_valid_uuid(value):
return str(UUID(str(value)))
if default is not None:
return default
return str(uuid.uuid4())
def _as_list(value: Any) -> list:
return list(value) if isinstance(value, list) else []
def normalize_import_data(data: dict) -> dict:
"""Prepare legacy data.json (or partial exports) for PostgreSQL constraints."""
data = copy.deepcopy(data or {})
data['servers'] = [s for s in _as_list(data.get('servers')) if isinstance(s, dict)]
data['users'] = [u for u in _as_list(data.get('users')) if isinstance(u, dict)]
data['user_connections'] = [c for c in _as_list(data.get('user_connections')) if isinstance(c, dict)]
data['api_tokens'] = [t for t in _as_list(data.get('api_tokens')) if isinstance(t, dict)]
data['invite_links'] = [l for l in _as_list(data.get('invite_links')) if isinstance(l, dict)]
if not isinstance(data.get('settings'), dict):
data['settings'] = {}
id_map: dict[str, str] = {}
def map_user_id(value: Any) -> Optional[str]:
if value is None or value == '':
return None
key = str(value)
if _is_valid_uuid(key):
resolved = str(UUID(key))
id_map.setdefault(key, resolved)
return resolved
if key not in id_map:
id_map[key] = str(uuid.uuid4())
return id_map[key]
used_usernames: set[str] = set()
for index, user in enumerate(data['users']):
if not isinstance(user, dict):
continue
old_id = user.get('id')
if old_id is None or old_id == '':
user['id'] = str(uuid.uuid4())
else:
key = str(old_id)
if _is_valid_uuid(key):
user['id'] = str(UUID(key))
id_map.setdefault(key, user['id'])
else:
user['id'] = map_user_id(key) or str(uuid.uuid4())
username = (user.get('username') or '').strip() or f'user{index + 1}'
base = username
suffix = 2
while username.lower() in used_usernames:
username = f'{base}_{suffix}'
suffix += 1
user['username'] = username
used_usernames.add(username.lower())
user.setdefault('password_hash', '')
user.setdefault('role', 'user')
user.setdefault('enabled', True)
valid_user_ids = {str(u['id']) for u in data['users'] if isinstance(u, dict) and u.get('id')}
for server in data['servers']:
if not isinstance(server, dict):
continue
server['server_info'] = _as_dict(server.get('server_info'))
server['protocols'] = _as_dict(server.get('protocols'))
for conn in data['user_connections']:
if not isinstance(conn, dict):
continue
conn['id'] = _coerce_uuid(conn.get('id'))
mapped_uid = map_user_id(conn.get('user_id'))
if mapped_uid:
conn['user_id'] = mapped_uid
data['user_connections'] = [
c for c in data['user_connections']
if isinstance(c, dict) and str(c.get('user_id')) in valid_user_ids
]
seen_hashes: set[str] = set()
normalized_tokens = []
for token in data['api_tokens']:
if not isinstance(token, dict):
continue
mapped_uid = map_user_id(token.get('user_id'))
if not mapped_uid or mapped_uid not in valid_user_ids:
continue
token['id'] = _coerce_uuid(token.get('id'))
token['user_id'] = mapped_uid
token_hash = (token.get('token_hash') or '').strip()
if not token_hash or token_hash in seen_hashes:
token_hash = secrets.token_hex(32)
token['token_hash'] = token_hash
seen_hashes.add(token_hash)
token.setdefault('token_prefix', (token_hash[:8] if token_hash else ''))
normalized_tokens.append(token)
data['api_tokens'] = normalized_tokens
seen_invite_tokens: set[str] = set()
for link in data['invite_links']:
if not isinstance(link, dict):
continue
link['id'] = _coerce_uuid(link.get('id'))
mapped_uid = map_user_id(link.get('user_id'))
link['user_id'] = mapped_uid if mapped_uid in valid_user_ids else ''
token = (link.get('token') or '').strip()
if not token or token in seen_invite_tokens:
token = secrets.token_urlsafe(16)
while token in seen_invite_tokens:
token = secrets.token_urlsafe(16)
link['token'] = token
seen_invite_tokens.add(token)
guest = data['settings'].get('guest')
if isinstance(guest, dict):
mapped_guest = map_user_id(guest.get('user_id'))
if mapped_guest in valid_user_ids:
guest['user_id'] = mapped_guest
elif guest.get('user_id'):
guest['user_id'] = ''
return data
def _merge_settings(raw: Optional[dict]) -> dict: def _merge_settings(raw: Optional[dict]) -> dict:
settings = json.loads(json.dumps(DEFAULT_SETTINGS)) settings = json.loads(json.dumps(DEFAULT_SETTINGS))
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -293,6 +443,7 @@ def load_data() -> dict:
def save_data(data: dict) -> None: def save_data(data: dict) -> None:
"""Replace panel state in a single transaction (same semantics as rewriting data.json).""" """Replace panel state in a single transaction (same semantics as rewriting data.json)."""
global _DATA_CACHE global _DATA_CACHE
data = normalize_import_data(data)
init_schema() init_schema()
servers = data.get('servers') or [] servers = data.get('servers') or []
users = data.get('users') or [] users = data.get('users') or []
@@ -484,12 +635,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}') raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
data.setdefault('servers', []) data = normalize_import_data(data)
data.setdefault('users', [])
data.setdefault('user_connections', [])
data.setdefault('api_tokens', [])
data.setdefault('invite_links', [])
data.setdefault('settings', {})
save_data(data) save_data(data)
@@ -555,4 +701,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
init_schema() init_schema()
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file): if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
logger.info('Empty database — importing legacy %s', legacy_data_file) logger.info('Empty database — importing legacy %s', legacy_data_file)
import_from_json_file(legacy_data_file) try:
import_from_json_file(legacy_data_file)
except Exception:
logger.exception('Legacy data.json import failed — panel will start with empty DB')
invalidate_data_cache()
+27 -7
View File
@@ -1,18 +1,21 @@
services: services:
db: db:
image: postgres:17 image: postgres:17
container_name: amnezia_panel_db
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-amnezia} POSTGRES_USER: ${POSTGRES_USER:-amnezia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel} POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
volumes: volumes:
- amnezia_pgdata:/var/lib/postgresql/data - amnezia_pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped restart: unless-stopped
networks:
- internal
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -23,7 +26,6 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: amnezia-panel:local image: amnezia-panel:local
container_name: amnezia_panel
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -33,15 +35,33 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
SECRET_KEY: ${SECRET_KEY:-} SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000" APP_PORT: "5000"
PORT: "5000"
PANEL_IN_DOCKER: "1"
PANEL_BEHIND_PROXY: "1"
volumes: volumes:
- amnezia_data:/app/data - amnezia_data:/app/data
restart: unless-stopped 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: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 60s
networks:
internal:
dokploy-network:
external: true
name: dokploy-network
volumes: volumes:
amnezia_data: amnezia_data:
+11
View File
@@ -62,6 +62,17 @@ class BackupManager:
remote_dir = inst_path('/opt/amnezia/telemt') remote_dir = inst_path('/opt/amnezia/telemt')
paths['host'] = [remote_dir] paths['host'] = [remote_dir]
paths['container'] = [remote_dir] paths['container'] = [remote_dir]
elif base == 'hysteria':
remote_dir = inst_path('/opt/amnezia/hysteria')
paths['host'] = [remote_dir]
paths['container'] = ['/etc/hysteria']
elif base == 'naiveproxy':
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': elif base == 'dns':
paths['host'] = ['/opt/amnezia/dns'] paths['host'] = ['/opt/amnezia/dns']
paths['container'] = ['/opt/amnezia/dns'] paths['container'] = ['/opt/amnezia/dns']
+744
View File
@@ -0,0 +1,744 @@
"""
Hysteria 2 protocol manager official tobyxdd/hysteria image (apernet/hysteria).
Installs Hysteria with Let's Encrypt TLS for an admin-provided domain
(certbot standalone on port 80), host networking, BBR, and salamander obfs.
Clients use password auth (Happ/INCY-compatible); share links are hy2:// URIs.
"""
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__)
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))
class HysteriaManager:
PROTOCOL = 'hysteria'
CONTAINER_NAME = 'amnezia-hysteria'
IMAGE_NAME = 'tobyxdd/hysteria:v2'
CERTBOT_IMAGE = 'certbot/certbot:latest'
BASE_DIR = '/opt/amnezia/hysteria'
DEFAULT_PORT = 8998
def __init__(self, ssh, protocol='hysteria'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
self.instance = self._instance_index(self.protocol)
self.container_name = self._container_name(self.protocol)
self.base_dir = self._base_dir(self.protocol)
self.config_path = f'{self.base_dir}/server.yaml'
self.clients_path = f'{self.base_dir}/clients.json'
self.meta_path = f'{self.base_dir}/metadata.json'
self.cert_path = f'{self.base_dir}/cert.crt'
self.key_path = f'{self.base_dir}/private.key'
self.letsencrypt_dir = f'{self.base_dir}/letsencrypt'
def _instance_index(self, protocol=None):
parts = str(protocol or self.protocol or '').split('__', 1)
if len(parts) == 2:
try:
return max(1, int(parts[1]))
except ValueError:
return 1
return 1
def _container_name(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return self.CONTAINER_NAME if idx <= 1 else f'{self.CONTAINER_NAME}-{idx}'
def _base_dir(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return self.BASE_DIR if idx <= 1 else f'{self.BASE_DIR}-{idx}'
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = self.ssh.run_command(
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
)
return 'active' in out2 or 'running' in out2.lower()
def check_protocol_installed(self, protocol_type=None):
name = self._container_name(protocol_type or self.protocol)
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{name}$ --format '{{{{.Names}}}}'"
)
return name in out.strip().split('\n')
def check_container_running(self, protocol_type=None):
name = self._container_name(protocol_type or self.protocol)
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{name}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def get_logs(self, protocol_type=None, tail=200):
"""Return recent docker logs for the Hysteria container."""
name = self._container_name(protocol_type or self.protocol)
tail = max(20, min(int(tail or 200), 2000))
out, err, code = self.ssh.run_sudo_command(
f"docker logs --tail {tail} --timestamps {name} 2>&1",
timeout=30,
)
text = (out or err or '').strip()
if code != 0 and not text:
text = f'(no logs: exit {code})'
return text
def get_container_diagnostics(self, protocol_type=None):
"""Inspect why the container is stopped / unhealthy."""
name = self._container_name(protocol_type or self.protocol)
fmt = (
'{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|'
'{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|'
'{{.State.StartedAt}}'
)
out, _, code = self.ssh.run_sudo_command(
f"docker inspect -f '{fmt}' {name} 2>/dev/null"
)
diag = {
'status': 'unknown',
'running': False,
'exit_code': None,
'oom_killed': False,
'error': '',
'finished_at': '',
'started_at': '',
'recent_logs': '',
'error_summary': '',
}
if code != 0 or not (out or '').strip():
diag['status'] = 'missing'
diag['error_summary'] = 'Container not found'
return diag
parts = (out or '').strip().split('|')
while len(parts) < 7:
parts.append('')
status, running, exit_code, oom, err, finished, started = parts[:7]
diag['status'] = status or 'unknown'
diag['running'] = str(running).lower() == 'true'
try:
diag['exit_code'] = int(exit_code)
except Exception:
diag['exit_code'] = None
diag['oom_killed'] = str(oom).lower() == 'true'
diag['error'] = (err or '').strip()
diag['finished_at'] = (finished or '').strip()
diag['started_at'] = (started or '').strip()
diag['recent_logs'] = self.get_logs(protocol_type, tail=40)
# Build human-readable summary for the UI badge
if diag['running']:
diag['error_summary'] = ''
elif diag['oom_killed']:
diag['error_summary'] = 'OOM killed (out of memory)'
elif diag['error']:
diag['error_summary'] = diag['error']
elif diag['exit_code'] not in (None, 0):
# Prefer bind/port conflicts over generic FATAL lines
bind_line = ''
last = ''
for raw in reversed((diag['recent_logs'] or '').splitlines()):
line = self._strip_ansi(raw).strip()
if not line:
continue
# strip docker --timestamps prefix
m_ts = re.match(r'^\d{4}-\d{2}-\d{2}T\S+\s+(.*)$', line)
if m_ts:
line = m_ts.group(1).strip()
low = line.lower()
if 'address already in use' in low and not bind_line:
bind_line = line
if ('FATAL' in line or 'failed to load' in low) and not last:
last = line
if not last:
last = line
pick = bind_line or last
if pick and 'address already in use' in pick.lower():
m = re.search(r'listen udp :(\d+)', pick, re.I) or re.search(r':(\d+):\s*bind', pick)
p = m.group(1) if m else '?'
if p == '?':
m2 = re.search(r'udp\s*:(\d+)', pick, re.I)
p = m2.group(1) if m2 else '?'
diag['error_summary'] = (
f'UDP port {p} already in use — change port below (try 8998 or 8443)'
)
diag['port_busy'] = True
elif pick:
short = re.sub(r'\s+', ' ', pick)[:160]
diag['error_summary'] = f"exit {diag['exit_code']}: {short}"
else:
diag['error_summary'] = f"exited with code {diag['exit_code']}"
elif diag['status'] and diag['status'] != 'running':
diag['error_summary'] = f"status: {diag['status']}"
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)
meta = self._read_metadata() if exists else {}
clients = self._read_clients() if exists else []
diag = {}
# Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs)
if exists:
try:
name = self._container_name(protocol_type)
mode, _, _ = self.ssh.run_sudo_command(
f"docker inspect -f '{{{{.HostConfig.NetworkMode}}}}' {name} 2>/dev/null"
)
mode = (mode or '').strip()
cfg = self._read_file(self.config_path)
needs_cfg = (
'type: userpass' in cfg
or 'type: salamander' not in cfg
or 'type: password' not in cfg
)
if (mode and mode != 'host') or needs_cfg:
port = int(meta.get('port') or self.DEFAULT_PORT)
self._write_config_from_clients(port)
self._start_container(port)
running = self.check_container_running(protocol_type)
meta = self._read_metadata()
except Exception as e:
logger.warning('Hysteria migrate failed: %s', e)
diag = {'error_summary': f'migrate failed: {e}'}
if exists:
try:
diag = self.get_container_diagnostics(protocol_type)
running = bool(diag.get('running'))
except Exception as e:
logger.warning('Hysteria diagnostics failed: %s', e)
diag = {'error_summary': str(e)}
return {
'container_exists': exists,
'container_running': running,
'port': int(meta.get('port') or self.DEFAULT_PORT),
'domain': meta.get('domain'),
'email': meta.get('email'),
'clients_count': len(clients),
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': self._instance_index(protocol_type),
'container_name': self._container_name(protocol_type),
'error': diag.get('error_summary') or '',
'exit_code': diag.get('exit_code'),
'container_status': diag.get('status') or '',
'recent_logs': diag.get('recent_logs') or '',
'port_busy': bool(diag.get('port_busy')),
}
# ===================== HELPERS =====================
def _validate_domain(self, domain):
domain = (domain or '').strip().lower()
if not domain or len(domain) > 253:
raise ValueError('Domain is required for Hysteria SSL')
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError("Valid Let's Encrypt email is required")
return email
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):
parent = path.rsplit('/', 1)[0]
self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}")
self.ssh.upload_file_sudo(content, path)
def _read_metadata(self):
raw = self._read_file(self.meta_path)
if not raw.strip():
return {}
try:
return json.loads(raw)
except Exception:
return {}
def _write_metadata(self, meta):
self._write_file(self.meta_path, json.dumps(meta, indent=2, ensure_ascii=False))
def _read_clients(self):
raw = self._read_file(self.clients_path)
if not raw.strip():
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, ensure_ascii=False))
def _ensure_secrets(self, meta):
"""Password auth (Happ/INCY-friendly) + salamander obfs secrets."""
changed = False
if not meta.get('password'):
meta['password'] = _rand_token(24)
changed = True
if not meta.get('obfs_password'):
meta['obfs_password'] = _rand_token(16)
changed = True
if changed:
self._write_metadata(meta)
return meta
def _build_server_yaml(self, port, clients, meta=None):
meta = self._ensure_secrets(meta if meta is not None else self._read_metadata())
password = meta['password']
obfs_password = meta['obfs_password']
# password auth — Happ / sing-box / INCY expect a single auth string, not userpass
lines = [
f'listen: :{int(port)}',
'',
'tls:',
' cert: /etc/hysteria/cert.crt',
' key: /etc/hysteria/private.key',
'',
'auth:',
' type: password',
f' password: "{password}"',
'',
'obfs:',
' type: salamander',
' salamander:',
f' password: "{obfs_password}"',
'',
'quic:',
' initStreamReceiveWindow: 8388608',
' maxStreamReceiveWindow: 8388608',
' initConnReceiveWindow: 20971520',
' maxConnReceiveWindow: 20971520',
' maxIdleTimeout: 60s',
' maxIncomingStreams: 1024',
' disablePathMTUDiscovery: true',
'',
'ignoreClientBandwidth: true',
'',
'bandwidth:',
' up: 1 gbps',
' down: 1 gbps',
'',
'masquerade:',
' type: proxy',
' proxy:',
' url: https://www.bing.com',
' rewriteHost: true',
'',
'outbounds:',
' - name: direct',
' type: direct',
'',
]
return '\n'.join(lines)
def _write_config_from_clients(self, port=None):
meta = self._ensure_secrets(self._read_metadata())
port = int(port or meta.get('port') or self.DEFAULT_PORT)
# clients list is panel-side only (shared password); still rewrite yaml for port/obfs/secrets
self._write_file(self.config_path, self._build_server_yaml(port, self._read_clients(), meta))
return port
def _tune_host_network(self):
"""UDP buffers + BBR — reduces quic-go EOF and improves throughput."""
script = r"""
set +e
mkdir -p /etc/sysctl.d
printf '%s\n' \
'net.core.rmem_max=16777216' \
'net.core.wmem_max=16777216' \
'net.core.rmem_default=16777216' \
'net.core.wmem_default=16777216' \
'net.core.default_qdisc=fq' \
'net.ipv4.tcp_congestion_control=bbr' \
'net.ipv4.ip_forward=1' \
> /etc/sysctl.d/99-amnezia-hysteria.conf
modprobe tcp_bbr >/dev/null 2>&1 || true
sysctl -p /etc/sysctl.d/99-amnezia-hysteria.conf >/dev/null 2>&1 || true
sysctl -w net.core.rmem_max=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.wmem_max=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.rmem_default=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.wmem_default=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.default_qdisc=fq >/dev/null 2>&1 || true
sysctl -w net.ipv4.tcp_congestion_control=bbr >/dev/null 2>&1 || true
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
"""
self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
def _strip_ansi(self, text):
return re.sub(r'\x1b\[[0-9;]*m', '', text or '')
def _udp_port_busy(self, port):
"""Return (busy: bool, detail: str) for host UDP port (after our container is removed)."""
port = int(port)
out, _, _ = self.ssh.run_sudo_command(
f"ss -ulnp 'sport = :{port}' 2>/dev/null || "
f"ss -uln | grep -E ':{port}([[:space:]]|$)' || true"
)
detail = (out or '').strip()
return bool(detail), detail[:240]
def _open_udp_port(self, port):
port = int(port)
script = f"""
set +e
if command -v ufw >/dev/null 2>&1; then
ufw allow {port}/udp >/dev/null 2>&1 || true
fi
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --permanent --add-port={port}/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || true
"""
self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
def _start_container(self, port):
"""Run with --network host so QUIC/UDP is not broken by Docker NAT (EOF)."""
name = self.container_name
port = int(port)
if port == 80:
raise RuntimeError('Port 80 is reserved for Let\'s Encrypt validation')
self._tune_host_network()
self._open_udp_port(port)
self.ssh.run_sudo_command(
f"chmod 644 {_q(self.cert_path)} {_q(self.key_path)} 2>/dev/null || true"
)
self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true")
busy, detail = self._udp_port_busy(port)
if busy:
raise RuntimeError(
f'UDP port {port} is already in use — choose another port in Hysteria settings. '
f'({self._strip_ansi(detail)})'
)
run_cmd = (
f"docker run -d --restart always "
f"--name {name} "
f"--network host "
f"--cap-add=NET_ADMIN "
f"-v {_q(self.base_dir)}:/etc/hysteria "
f"{self.IMAGE_NAME} "
f"-c /etc/hysteria/server.yaml server"
)
_, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60)
if code != 0:
raise RuntimeError(f'Failed to start Hysteria: {err}')
# Confirm it stayed up (catch immediate bind failures)
time.sleep(1.2)
if not self.check_container_running(self.protocol):
diag = self.get_container_diagnostics(self.protocol)
raise RuntimeError(
diag.get('error_summary')
or 'Hysteria container exited immediately — check logs / port'
)
return True
def get_settings(self):
meta = self._ensure_secrets(self._read_metadata())
port = int(meta.get('port') or self.DEFAULT_PORT)
return {
'port': port,
'suggested_port': 8998 if port in (443, 80) else port,
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'protocol': self.protocol,
}
def update_settings(self, port=None, domain=None, email=None, renew_ssl=False):
"""Change listen UDP port and/or re-issue Let's Encrypt for domain."""
meta = self._ensure_secrets(self._read_metadata())
if port is None:
port = meta.get('port') or self.DEFAULT_PORT
port = int(port)
if port < 1 or port > 65535:
return {'status': 'error', 'message': 'Invalid port'}
if port == 80:
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
old_domain = (meta.get('domain') or '').strip().lower()
domain_changed = False
try:
if domain is not None and str(domain).strip():
new_domain = self._validate_domain(domain)
domain_changed = new_domain != old_domain
meta['domain'] = new_domain
if email is not None and str(email).strip():
meta['email'] = self._validate_email(email)
except ValueError as e:
return {'status': 'error', 'message': str(e)}
if renew_ssl or domain_changed:
# Domain change or explicit renew → re-issue certificate (needs free TCP 80)
try:
d = meta.get('domain') or ''
e = meta.get('email') or ''
if not d or not e:
return {
'status': 'error',
'message': 'Domain and email are required to issue SSL',
}
self._issue_certificate(d, e)
except Exception as exc:
return {'status': 'error', 'message': str(exc)}
meta['port'] = port
self._write_metadata(meta)
self._write_config_from_clients(port)
try:
self._start_container(port)
except Exception as e:
return {
'status': 'error',
'message': str(e),
'port': str(port),
'domain': meta.get('domain'),
'email': meta.get('email'),
}
msg = 'Hysteria settings updated'
if renew_ssl or domain_changed:
msg = 'SSL certificate issued and Hysteria settings updated'
return {
'status': 'success',
'port': str(port),
'domain': meta.get('domain'),
'email': meta.get('email'),
'message': msg,
}
def _reload_container(self):
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
try:
self._start_container(port)
except Exception as e:
logger.warning('Hysteria container reload failed: %s', e)
def _issue_certificate(self, domain, email):
"""Issue Let's Encrypt cert via certbot standalone (needs free TCP 80)."""
self.ssh.run_sudo_command(f"mkdir -p {_q(self.letsencrypt_dir)}")
self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180)
# Stop anything briefly occupying 80 if it's our leftover certbot
self.ssh.run_sudo_command(
"docker rm -fv amnezia-hysteria-certbot 2>/dev/null || true"
)
cmd = (
f"docker run --rm --name amnezia-hysteria-certbot "
f"-p 80:80 "
f"-v {_q(self.letsencrypt_dir)}:/etc/letsencrypt "
f"{self.CERTBOT_IMAGE} certonly --standalone "
f"--non-interactive --agree-tos --no-eff-email "
f"--email {_q(email)} -d {_q(domain)}"
)
out, err, code = self.ssh.run_sudo_command(cmd, timeout=300)
if code != 0:
raise RuntimeError(
f"Let's Encrypt failed for {domain}. "
f"Point A-record to this server and free TCP port 80. {err or out}"
)
# Copy live certs into paths expected by the server config
copy_script = f"""
set -e
LIVE={_q(self.letsencrypt_dir)}/live/{_q(domain)}
test -s "$LIVE/fullchain.pem"
test -s "$LIVE/privkey.pem"
cp -f "$LIVE/fullchain.pem" {_q(self.cert_path)}
cp -f "$LIVE/privkey.pem" {_q(self.key_path)}
chmod 644 {_q(self.cert_path)} {_q(self.key_path)}
"""
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to install certificate files: {err or out}')
def _build_share_uri(self, host, port, domain, name, meta=None):
"""Happ/INCY-compatible hy2 link: password auth + salamander + IP host + SNI."""
meta = self._ensure_secrets(meta if meta is not None else self._read_metadata())
password = meta['password']
obfs_password = meta['obfs_password']
sni = domain or host
# Prefer raw IP in URI — domain may be CDN/DNS filtered for UDP while ICMP still works
conn_host = host or domain
fragment = quote(name or 'hysteria', safe='')
return (
f"hy2://{quote(password, safe='')}@{conn_host}:{int(port)}/"
f"?sni={quote(sni, safe='')}"
f"&obfs=salamander"
f"&obfs-password={quote(obfs_password, safe='')}"
f"&insecure=0#{fragment}"
)
# ===================== INSTALL / REMOVE =====================
def install_protocol(self, protocol_type=None, port=None, domain=None, email=None):
protocol_type = protocol_type or self.protocol
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
domain = self._validate_domain(domain)
email = self._validate_email(email)
port = int(port or self.DEFAULT_PORT)
if port == 80:
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
log = []
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180)
log.append(f'Pulled {self.IMAGE_NAME}')
if self.check_protocol_installed(protocol_type):
name = self._container_name(protocol_type)
self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true")
log.append('Removed previous container')
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
self._write_clients([])
meta = {
'domain': domain,
'email': email,
'port': port,
'password': _rand_token(24),
'obfs_password': _rand_token(16),
}
self._write_metadata(meta)
self._write_config_from_clients(port)
log.append('Prepared config directory (password auth + salamander + BBR)')
try:
self._issue_certificate(domain, email)
log.append(f"Issued Let's Encrypt certificate for {domain}")
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
try:
self._start_container(port)
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
log.append(f'Started {self.container_name} on UDP {port} (host network, BBR)')
return {
'status': 'success',
'message': 'Hysteria installed',
'log': log,
'port': str(port),
'domain': domain,
'email': email,
}
def remove_container(self, protocol_type=None):
protocol_type = protocol_type or self.protocol
name = self._container_name(protocol_type)
base = self._base_dir(protocol_type)
self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true")
self.ssh.run_sudo_command(f"rm -rf {_q(base)}")
return True
def get_server_config(self, protocol_type=None):
return self._read_file(self.config_path)
def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.config_path, config_text or '')
self._reload_container()
return True
# ===================== CLIENTS =====================
def get_clients(self, protocol_type=None):
clients = self._read_clients()
result = []
for c in clients:
cname = c.get('name') or c.get('id')
result.append({
'clientId': c.get('id'),
'client_id': c.get('id'),
'id': c.get('id'),
'name': cname,
'email': cname,
'enabled': c.get('enabled', True),
'userData': {'clientName': cname, 'enabled': c.get('enabled', True)},
})
return result
def add_client(self, protocol_type, name, host, port=None):
meta = self._ensure_secrets(self._read_metadata())
domain = meta.get('domain') or host
port = int(port or meta.get('port') or self.DEFAULT_PORT)
client_id = secrets.token_hex(8)
clients = self._read_clients()
clients.append({
'id': client_id,
'name': name or f'client-{client_id[:6]}',
'enabled': True,
})
self._write_clients(clients)
# Shared password auth — refresh yaml only if secrets/port changed
self._write_config_from_clients(port)
self._reload_container()
config = self._build_share_uri(host, port, domain, name or client_id, meta)
return {'client_id': client_id, 'config': config}
def get_client_config(self, protocol_type, client_id, host, port=None):
meta = self._ensure_secrets(self._read_metadata())
domain = meta.get('domain') or host
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:
raise RuntimeError('Client not found')
if client.get('enabled', True) is False:
raise RuntimeError('Client is disabled')
return self._build_share_uri(
host, port, domain,
client.get('name') or client_id,
meta,
)
def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id]
self._write_clients(clients)
return True
def toggle_client(self, protocol_type, client_id, enable=True):
clients = self._read_clients()
found = False
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enable)
found = True
break
if not found:
raise RuntimeError('Client not found')
self._write_clients(clients)
return True
+585
View File
@@ -0,0 +1,585 @@
"""
Mieru protocol manager native mita server package (systemd).
Installs pinned release from https://github.com/enfein/mieru
Server binary: mita (systemd service), client: mieru.
Share links: mierus://user:pass@host?port=...&protocol=TCP&profile=default
"""
from __future__ import annotations
import json
import logging
import re
import secrets
import shlex
import string
import time
from urllib.parse import quote
logger = logging.getLogger(__name__)
MIERU_RELEASE = '3.28.0'
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
MITA_SOCK = '/var/run/mita.sock'
def _q(value):
return shlex.quote(str(value))
def _rand_token(length=16):
alphabet = string.ascii_lowercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def _sanitize_username(name):
base = re.sub(r'[^a-zA-Z0-9_-]', '', (name or 'user').strip())[:24] or 'user'
return f'{base}_{_rand_token(4)}'
class MieruManager:
PROTOCOL = 'mieru'
SERVICE_NAME = 'mita'
BASE_DIR = '/opt/amnezia/mieru'
DEFAULT_PORT = 2999
def __init__(self, ssh, protocol='mieru'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
self.base_dir = self.BASE_DIR
self.clients_path = f'{self.base_dir}/clients.json'
self.meta_path = f'{self.base_dir}/metadata.json'
self.config_path = f'{self.base_dir}/server_config.json'
# ===================== STATUS =====================
def check_docker_installed(self):
return True
def _mita_installed(self):
out, _, code = self.ssh.run_command('command -v mita 2>/dev/null')
return code == 0 and bool(out.strip())
def check_protocol_installed(self, protocol_type=None):
return self._mita_installed() and self._panel_installed()
def _panel_installed(self):
out, _, code = self.ssh.run_sudo_command(f"test -f {_q(self.meta_path)} && echo yes")
return code == 0 and 'yes' in out
def _proxy_running(self):
out, _, code = self.ssh.run_sudo_command('mita status 2>/dev/null')
if code != 0:
return False
return 'RUNNING' in (out or '').upper()
def check_container_running(self, protocol_type=None):
return self._proxy_running()
def get_logs(self, protocol_type=None, tail=200):
tail = max(20, min(int(tail or 200), 2000))
out, err, code = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n {tail} --no-pager 2>&1",
timeout=30,
)
text = (out or err or '').strip()
if not text:
status, _, _ = self.ssh.run_sudo_command('mita status 2>&1')
text = (status or '').strip()
if not text:
text = f'(no logs: exit {code})'
return text
def get_container_diagnostics(self, protocol_type=None):
running = self._proxy_running()
out, _, _ = self.ssh.run_sudo_command(
f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null"
)
daemon_active = (out or '').strip() == 'active'
diag = {
'status': 'running' if running else ('idle' if daemon_active else 'stopped'),
'running': running,
'error_summary': '',
'recent_logs': self.get_logs(protocol_type, tail=40),
}
if not self._mita_installed():
diag['status'] = 'missing'
diag['error_summary'] = 'mita package not installed'
elif not running and daemon_active:
status_out, _, _ = self.ssh.run_sudo_command('mita status 2>&1')
if 'IDLE' in (status_out or '').upper():
diag['error_summary'] = 'Proxy stopped (mita status IDLE)'
elif status_out.strip():
diag['error_summary'] = status_out.strip().splitlines()[-1][:160]
elif not daemon_active:
diag['error_summary'] = f'{self.SERVICE_NAME} systemd service is not active'
return diag
def get_server_status(self, protocol_type=None):
protocol_type = protocol_type or self.protocol
exists = self.check_protocol_installed(protocol_type)
running = self.check_container_running(protocol_type) if exists else False
meta = self._read_metadata() if exists else {}
clients = self._read_clients() if exists else []
port = int(meta.get('port') or self.DEFAULT_PORT)
return {
'container_exists': exists,
'container_running': running,
'port': port,
'release': meta.get('release') or MIERU_RELEASE,
'clients_count': len(clients),
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': 1,
'container_name': self.SERVICE_NAME,
}
# ===================== IO HELPERS =====================
def _read_file(self, path):
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
return out if code == 0 else ''
def _write_file(self, path, content):
import base64
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
script = (
f"mkdir -p $(dirname {_q(path)}) && "
f"echo {_q(b64)} | base64 -d > {_q(path)} && "
f"chmod 644 {_q(path)}"
)
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to write {path}: {err or out}')
def _read_metadata(self):
raw = self._read_file(self.meta_path).strip()
if not raw:
return {}
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _write_metadata(self, meta):
self._write_file(self.meta_path, json.dumps(meta, indent=2))
def _read_clients(self):
raw = self._read_file(self.clients_path).strip()
if not raw:
return []
try:
data = json.loads(raw)
return data if isinstance(data, list) else []
except Exception:
return []
def _write_clients(self, clients):
self._write_file(self.clients_path, json.dumps(clients, indent=2))
def _ensure_daemon(self, log=None):
"""Ensure mita systemd unit is up and RPC socket answers."""
self.ssh.run_sudo_command(
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true",
timeout=30,
)
self.ssh.run_sudo_command(
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
# Official package expects the operating user in group `mita`.
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
op_user = (user_out or 'root').strip() or 'root'
if op_user != 'root':
self.ssh.run_sudo_command(
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
timeout=15,
)
if not self._wait_for_rpc(timeout=45):
# Stale socket / crashed daemon — hard restart once.
self.ssh.run_sudo_command(
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
f"rm -f {_q(MITA_SOCK)} /var/run/mita/*.sock 2>/dev/null || true; "
f"systemctl start {self.SERVICE_NAME}",
timeout=60,
)
if not self._wait_for_rpc(timeout=45):
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 40 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
'mita systemd daemon is not ready (RPC socket missing). '
f'journal: {(journal or "").strip()[-500:]}'
)
if log is not None:
log.append('mita daemon is active')
def _wait_for_rpc(self, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
sock_out, _, sock_code = self.ssh.run_sudo_command(
f"test -S {_q(MITA_SOCK)} && echo ok"
)
if sock_code == 0 and 'ok' in (sock_out or ''):
status_out, _, status_code = self.ssh.run_sudo_command(
'mita status 2>&1',
timeout=20,
)
text = (status_out or '').upper()
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
return True
# Socket exists but CLI still races — brief pause.
time.sleep(1.5)
return False
def _mita_cli(self, args, timeout=60):
"""Run mita CLI as root so group/socket ACL is not an issue."""
cmd = f"mita {' '.join(args)} 2>&1"
return self.ssh.run_sudo_command(cmd, timeout=timeout)
def _build_server_config(self, port, clients):
users = []
for c in clients:
if not c.get('enabled', True):
continue
username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
password = (c.get('password') or '').strip()
if not username or not password:
continue
users.append({'name': username, 'password': password})
# mita rejects / crashes on empty users during `mita start` (RPC EOF).
if not users:
users = [{
'name': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
}]
return {
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
'users': users,
'loggingLevel': 'INFO',
'mtu': 1400,
}
def _apply_config(self, config, reload_only=False):
self._ensure_daemon()
self._write_file(self.config_path, json.dumps(config, indent=2))
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
# Recover from transient EOF / unavailable RPC.
if self._is_rpc_error(out, err):
self._ensure_daemon()
out, err, code = self._mita_cli(
['apply', 'config', _q(self.config_path)],
timeout=60,
)
if code != 0:
raise RuntimeError((err or out or 'mita apply config failed').strip())
if reload_only:
# users/loggingLevel can hot-reload; fall back to full restart.
reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
if reload_code == 0:
return
logger.info('mita reload failed, falling back to stop/start: %s',
(reload_err or reload_out or '').strip())
self._restart_proxy()
def _is_rpc_error(self, *parts):
text = ' '.join(str(p or '') for p in parts).lower()
return any(token in text for token in (
'rpc error',
'unavailable',
'error reading from server',
'eof',
'no such file or directory',
'mita.sock',
'connection refused',
))
def _restart_proxy(self):
self._mita_cli(['stop'], timeout=30)
time.sleep(1)
last_err = ''
for attempt in range(1, 4):
out, err, code = self._mita_cli(['start'], timeout=60)
if code == 0:
# Confirm RUNNING (daemon may report success then die).
time.sleep(1)
if self._proxy_running():
return
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
else:
last_err = (err or out or 'mita start failed').strip()
if self._is_rpc_error(last_err) or attempt < 3:
self.ssh.run_sudo_command(
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
timeout=60,
)
self._wait_for_rpc(timeout=30)
time.sleep(1)
continue
break
journal, _, _ = self.ssh.run_sudo_command(
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager 2>&1",
timeout=30,
)
raise RuntimeError(
f'{last_err}. journal: {(journal or "").strip()[-400:]}'
)
def _sync_server(self, reload_only=True):
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
config = self._build_server_config(port, clients)
self._apply_config(config, reload_only=reload_only)
def _open_firewall_port(self, port):
script = f"""
PORT={int(port)}
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi active; then
ufw allow "$PORT"/tcp || true
fi
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --permanent --add-port="$PORT"/tcp 2>/dev/null || true
firewall-cmd --reload 2>/dev/null || true
fi
"""
self.ssh.run_sudo_script(script, timeout=60)
def _package_url(self):
arch_out, _, _ = self.ssh.run_command('uname -m')
arch = (arch_out or '').strip().lower()
_, _, deb_code = self.ssh.run_command('command -v dpkg 2>/dev/null')
_, _, rpm_code = self.ssh.run_command('command -v rpm 2>/dev/null')
use_deb = deb_code == 0 or rpm_code != 0
if use_deb:
if arch in ('aarch64', 'arm64'):
return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_arm64.deb', 'deb'
return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_amd64.deb', 'deb'
if arch in ('aarch64', 'arm64'):
return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.aarch64.rpm', 'rpm'
return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.x86_64.rpm', 'rpm'
def _install_package(self, log):
url, pkg_type = self._package_url()
tmp = f'/tmp/mita_{MIERU_RELEASE}'
if pkg_type == 'deb':
tmp += '.deb'
install_cmd = f"dpkg -i {_q(tmp)} || apt-get install -f -y"
else:
tmp += '.rpm'
install_cmd = f"rpm -Uvh --force {_q(tmp)}"
out, err, code = self.ssh.run_sudo_command(
f"curl -fL {_q(url)} -o {_q(tmp)} 2>&1",
timeout=300,
)
if code != 0:
raise RuntimeError(f'Failed to download mita package: {err or out}')
log.append(f'Downloaded mita v{MIERU_RELEASE}')
out, err, code = self.ssh.run_sudo_command(install_cmd, timeout=180)
if code != 0:
raise RuntimeError(f'Failed to install mita package: {err or out}')
log.append('Installed mita package')
self._ensure_daemon(log)
def _build_share_uri(self, host, port, username, password, name=''):
user = quote(username or '', safe='')
pw = quote(password or '', safe='')
params = f"port={int(port)}&protocol=TCP&profile=default"
link = f"mierus://{user}:{pw}@{host}?{params}"
if name:
link += f"#{quote(name, safe='')}"
return link
def _build_client_json(self, host, port, username, password):
return json.dumps({
'profiles': [{
'profileName': 'default',
'user': {'name': username, 'password': password},
'servers': [{
'ipAddress': host,
'domainName': '',
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
}],
'mtu': 1400,
'multiplexing': {'level': 'MULTIPLEXING_LOW'},
'handshakeMode': 'HANDSHAKE_STANDARD',
}],
'activeProfile': 'default',
'rpcPort': 8964,
'socks5Port': 1080,
'loggingLevel': 'INFO',
'socks5ListenLAN': False,
}, indent=2)
# ===================== INSTALL / REMOVE =====================
def install_protocol(self, protocol_type=None, port=None):
protocol_type = protocol_type or self.protocol
port = int(port or self.DEFAULT_PORT)
if port < 1025 or port > 65535:
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
log = []
try:
if not self._mita_installed():
self._install_package(log)
else:
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
self._ensure_daemon(log)
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
meta = {'port': port, 'release': MIERU_RELEASE}
self._write_metadata(meta)
# Keep clients empty in panel DB, but seed a real mita user so start works.
self._write_clients([])
bootstrap = {
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
}
self._write_clients([bootstrap])
log.append(f'Prepared {self.base_dir}')
config = self._build_server_config(port, [bootstrap])
self._apply_config(config, reload_only=False)
self._open_firewall_port(port)
log.append(f'Started mita proxy on TCP {port}')
return {
'status': 'success',
'message': f'Mieru v{MIERU_RELEASE} installed',
'log': log,
'port': str(port),
'release': MIERU_RELEASE,
}
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
def remove_container(self, protocol_type=None):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
return True
def start_service(self):
self._ensure_daemon()
self._restart_proxy()
def stop_service(self):
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
def get_server_config(self, protocol_type=None):
out, _, code = self.ssh.run_sudo_command('mita describe config 2>/dev/null')
if code == 0 and (out or '').strip():
return out
return self._read_file(self.config_path)
def save_server_config(self, protocol_type=None, config_text=''):
raw = (config_text or '').strip()
if not raw:
raise RuntimeError('Config is empty')
try:
parsed = json.loads(raw)
except Exception as e:
raise RuntimeError(f'Invalid JSON config: {e}') from e
if not isinstance(parsed, dict):
raise RuntimeError('Config must be a JSON object')
self._apply_config(parsed, reload_only=False)
return True
# ===================== CLIENTS =====================
def get_clients(self, protocol_type=None):
clients = self._read_clients()
result = []
for c in clients:
if c.get('bootstrap'):
continue
cname = c.get('name') or c.get('id')
result.append({
'clientId': c.get('id'),
'client_id': c.get('id'),
'id': c.get('id'),
'name': cname,
'email': cname,
'enabled': c.get('enabled', True),
'userData': {'clientName': cname, 'enabled': c.get('enabled', True)},
})
return result
def add_client(self, protocol_type, name, host, port=None):
meta = self._read_metadata()
port = int(port or meta.get('port') or self.DEFAULT_PORT)
client_id = secrets.token_hex(8)
username = _sanitize_username(name)
password = _rand_token(20)
clients = self._read_clients()
clients.append({
'id': client_id,
'name': name or username,
'username': username,
'password': password,
'enabled': True,
})
self._write_clients(clients)
self._sync_server(reload_only=True)
config = self._build_share_uri(host, port, username, password, name or username)
json_config = self._build_client_json(host, port, username, password)
return {
'clientId': client_id,
'client_id': client_id,
'id': client_id,
'name': name or username,
'config': config,
'json_config': json_config,
}
def get_client_config(self, protocol_type, client_id, host, port=None):
meta = self._read_metadata()
port = int(port or meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
client = next((c for c in clients if c.get('id') == client_id), None)
if not client or client.get('bootstrap'):
return ''
if not client.get('enabled', True):
return ''
username = client.get('username') or client.get('name') or client_id
password = client.get('password') or ''
name = client.get('name') or username
return self._build_share_uri(host, port, username, password, name)
def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id]
# Keep at least bootstrap so mita never has empty users.
if not any(not c.get('bootstrap') for c in clients) and not any(c.get('bootstrap') for c in clients):
clients.append({
'id': secrets.token_hex(8),
'name': 'panel-bootstrap',
'username': f'panel_{_rand_token(6)}',
'password': _rand_token(20),
'enabled': True,
'bootstrap': True,
})
self._write_clients(clients)
self._sync_server(reload_only=True)
return True
def toggle_client(self, protocol_type, client_id, enabled):
clients = self._read_clients()
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enabled)
self._write_clients(clients)
self._sync_server(reload_only=True)
return True
+604
View File
@@ -0,0 +1,604 @@
"""
NaiveProxy manager Caddy + klzgrad/forwardproxy (naive fork).
Server side is the naïve Caddy forward_proxy from:
https://github.com/klzgrad/naiveproxy
https://github.com/klzgrad/forwardproxy
Uses the official prebuilt Caddy binary on amd64, or builds via xcaddy on other
arches. Clients authenticate with HTTP basic auth; share links are
naive+https://user:pass@domain URIs (NekoRay / sing-box compatible).
Requires free TCP 80 (ACME) and 443 (HTTPS / HTTP2 proxy).
"""
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__)
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))
class NaiveProxyManager:
PROTOCOL = 'naiveproxy'
CONTAINER_NAME = 'amnezia-naiveproxy'
IMAGE_NAME = 'amnezia-naiveproxy:local'
BASE_DIR = '/opt/amnezia/naiveproxy'
DEFAULT_PORT = 443
# Official Caddy+forwardproxy naive build (linux amd64)
CADDY_RELEASE = 'v2.11.2-naive'
CADDY_URL = (
'https://github.com/klzgrad/forwardproxy/releases/download/'
f'{CADDY_RELEASE}/caddy-forwardproxy-naive.tar.xz'
)
def __init__(self, ssh, protocol='naiveproxy'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
self.container_name = self.CONTAINER_NAME
self.base_dir = self.BASE_DIR
self.caddyfile_path = f'{self.base_dir}/Caddyfile'
self.clients_path = f'{self.base_dir}/clients.json'
self.meta_path = f'{self.base_dir}/metadata.json'
self.html_dir = f'{self.base_dir}/html'
self.bin_dir = f'{self.base_dir}/bin'
self.data_dir = f'{self.base_dir}/caddy-data'
self.config_dir = f'{self.base_dir}/caddy-config'
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = self.ssh.run_command(
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
)
return 'active' in out2 or 'running' in out2.lower()
def check_protocol_installed(self, protocol_type=None):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
)
return self.CONTAINER_NAME in out.strip().split('\n')
def check_container_running(self, protocol_type=None):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
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"docker logs --tail {tail} --timestamps {self.CONTAINER_NAME} 2>&1",
timeout=30,
)
text = (out or err or '').strip()
if code != 0 and not text:
text = f'(no logs: exit {code})'
return text
def get_container_diagnostics(self, protocol_type=None):
fmt = (
'{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|'
'{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|'
'{{.State.StartedAt}}'
)
out, _, code = self.ssh.run_sudo_command(
f"docker inspect -f '{fmt}' {self.CONTAINER_NAME} 2>/dev/null"
)
diag = {
'status': 'unknown',
'running': False,
'exit_code': None,
'error_summary': '',
'recent_logs': '',
}
if code == 0 and out.strip():
parts = out.strip().split('|')
while len(parts) < 7:
parts.append('')
diag['status'] = parts[0]
diag['running'] = parts[1].lower() == 'true'
try:
diag['exit_code'] = int(parts[2])
except ValueError:
diag['exit_code'] = parts[2]
if parts[3].lower() == 'true':
diag['error_summary'] = 'OOM killed'
elif parts[4]:
diag['error_summary'] = parts[4]
elif not diag['running'] and diag['exit_code'] not in (0, '0', None, ''):
diag['error_summary'] = f'Exited with code {diag["exit_code"]}'
diag['recent_logs'] = self.get_logs(protocol_type, tail=80)
if not diag['error_summary'] and not diag['running']:
logs = diag['recent_logs'].lower()
if 'address already in use' in logs or 'bind:' in logs:
diag['error_summary'] = (
'TCP 80/443 already in use — free them or stop conflicting services'
)
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,
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'clients_count': len(clients),
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': 1,
'container_name': self.CONTAINER_NAME,
}
# ===================== IO HELPERS =====================
def _validate_domain(self, domain):
domain = (domain or '').strip().lower()
if not domain or len(domain) > 253:
raise ValueError('Domain is required')
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError('Valid Let\'s Encrypt email is required')
return email
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):
# Write via base64 to avoid shell escaping issues
import base64
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
script = (
f"mkdir -p $(dirname {_q(path)}) && "
f"echo {_q(b64)} | base64 -d > {_q(path)}"
)
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to write {path}: {err or out}')
def _read_metadata(self):
raw = self._read_file(self.meta_path).strip()
if not raw:
return {}
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _write_metadata(self, meta):
self._write_file(self.meta_path, json.dumps(meta, indent=2, ensure_ascii=False))
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, ensure_ascii=False))
def _caddy_auth_lines(self, clients):
lines = []
for c in clients:
if c.get('enabled', True) is False:
continue
user = (c.get('username') or '').strip()
password = (c.get('password') or '').strip()
if not user or not password:
continue
# Credentials are alphanumeric from our generator — safe in Caddyfile
lines.append(f' basic_auth {user} {password}')
return lines
def _build_caddyfile(self, domain, email, clients, probe_path):
auth_lines = self._caddy_auth_lines(clients)
if not auth_lines:
# 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
}}
}}
:443, {domain} {{
tls {email}
encode
forward_proxy {{
{auth_block}
hide_ip
hide_via
probe_resistance {probe}
}}
file_server {{
root /var/www/html
}}
}}
"""
def _write_config_from_clients(self):
meta = self._read_metadata()
domain = meta.get('domain') or ''
email = meta.get('email') or ''
if not domain or not email:
raise RuntimeError('Domain and email are required in metadata')
clients = self._read_clients()
probe = meta.get('probe_resistance') or _rand_token(12)
if not meta.get('probe_resistance'):
meta['probe_resistance'] = probe
self._write_metadata(meta)
text = self._build_caddyfile(domain, email, clients, probe)
self._write_file(self.caddyfile_path, text)
def _ensure_placeholder_site(self):
index = f'{self.html_dir}/index.html'
existing = self._read_file(index)
if existing.strip():
return
html = (
'<!DOCTYPE html><html><head><meta charset="utf-8">'
'<title>Welcome</title></head><body><h1>It works</h1></body></html>\n'
)
self._write_file(index, html)
def _host_arch(self):
out, _, _ = self.ssh.run_command("uname -m")
return (out or '').strip().lower()
def _prepare_caddy_binary(self, log):
"""Download official amd64 build or compile with xcaddy for other arches."""
self.ssh.run_sudo_command(f"mkdir -p {_q(self.bin_dir)}")
arch = self._host_arch()
caddy_path = f'{self.bin_dir}/caddy'
if arch in ('x86_64', 'amd64'):
script = f"""
set -e
cd {_q(self.bin_dir)}
curl -fsSL -o caddy-forwardproxy-naive.tar.xz {_q(self.CADDY_URL)}
tar -xf caddy-forwardproxy-naive.tar.xz
if [ -f caddy-forwardproxy-naive/caddy ]; then
cp -f caddy-forwardproxy-naive/caddy ./caddy
elif [ -f caddy ]; then
true
else
echo "caddy binary not found in archive" >&2
exit 1
fi
chmod +x ./caddy
./caddy version
"""
out, err, code = self.ssh.run_sudo_command(f"bash -lc {_q(script)}", timeout=300)
if code != 0:
raise RuntimeError(f'Failed to download NaiveProxy Caddy: {err or out}')
log.append(f'Downloaded official Caddy+forwardproxy ({self.CADDY_RELEASE})')
return caddy_path
# arm64 / other: build with xcaddy inside golang image
build_script = f"""
set -e
docker pull golang:1.22-bookworm
docker run --rm -v {_q(self.bin_dir)}:/out -w /tmp golang:1.22-bookworm bash -lc '
set -e
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
/go/bin/xcaddy build --with github.com/caddyserver/forwardproxy=github.com/klzgrad/forwardproxy@naive
cp -f caddy /out/caddy
chmod +x /out/caddy
'
{_q(caddy_path)} version
"""
out, err, code = self.ssh.run_sudo_command(f"bash -lc {_q(build_script)}", timeout=900)
if code != 0:
raise RuntimeError(f'Failed to build NaiveProxy Caddy for {arch}: {err or out}')
log.append(f'Built Caddy+forwardproxy via xcaddy for {arch}')
return caddy_path
def _build_docker_image(self, log):
dockerfile = f"""FROM debian:bookworm-slim
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ca-certificates \\
&& rm -rf /var/lib/apt/lists/*
COPY caddy /usr/bin/caddy
RUN chmod +x /usr/bin/caddy
WORKDIR /etc/caddy
EXPOSE 80 443
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
"""
self._write_file(f'{self.bin_dir}/Dockerfile', dockerfile)
out, err, code = self.ssh.run_sudo_command(
f"docker build -t {self.IMAGE_NAME} {_q(self.bin_dir)}",
timeout=300,
)
if code != 0:
raise RuntimeError(f'Failed to build Docker image: {err or out}')
log.append(f'Built image {self.IMAGE_NAME}')
def _start_container(self):
self.ssh.run_sudo_command(
f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true"
)
self.ssh.run_sudo_command(f"mkdir -p {_q(self.data_dir)} {_q(self.config_dir)} {_q(self.html_dir)}")
run_cmd = (
f"docker run -d --name {self.CONTAINER_NAME} --restart unless-stopped "
f"--network host "
f"-v {_q(self.base_dir)}:/etc/caddy "
f"-v {_q(self.html_dir)}:/var/www/html "
f"-v {_q(self.data_dir)}:/data "
f"-v {_q(self.config_dir)}:/config "
f"-e XDG_DATA_HOME=/data "
f"-e XDG_CONFIG_HOME=/config "
f"{self.IMAGE_NAME}"
)
_, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60)
if code != 0:
raise RuntimeError(f'Failed to start NaiveProxy: {err}')
time.sleep(2.0)
if not self.check_container_running():
diag = self.get_container_diagnostics()
raise RuntimeError(
diag.get('error_summary')
or 'NaiveProxy container exited — free TCP 80/443 and check logs'
)
return True
def _reload_container(self):
try:
self._write_config_from_clients()
# Prefer graceful reload; fall back to recreate
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.CONTAINER_NAME} caddy reload "
f"--config /etc/caddy/Caddyfile --adapter caddyfile 2>&1",
timeout=30,
)
if code != 0:
logger.warning('Caddy reload failed (%s), recreating container', out)
self._start_container()
except Exception as e:
logger.warning('NaiveProxy reload failed: %s', e)
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 '').strip().lower()
port = int(port or 443)
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 =====================
def install_protocol(self, protocol_type=None, port=None, domain=None, email=None):
protocol_type = protocol_type or self.protocol
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
try:
domain = self._validate_domain(domain)
email = self._validate_email(email)
except ValueError as e:
return {'status': 'error', 'message': str(e)}
port = int(port or self.DEFAULT_PORT)
if port not in (443,):
# Caddy ACME + naive typically bind :443; keep simple for beta
port = 443
log = []
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)} {_q(self.html_dir)} {_q(self.bin_dir)}")
if self.check_protocol_installed(protocol_type):
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
log.append('Removed previous container')
try:
self._prepare_caddy_binary(log)
self._build_docker_image(log)
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
self._write_clients([])
meta = {
'domain': domain,
'email': email,
'port': port,
'probe_resistance': _rand_token(12),
'caddy_release': self.CADDY_RELEASE,
}
self._write_metadata(meta)
self._ensure_placeholder_site()
try:
self._write_config_from_clients()
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
log.append('Wrote Caddyfile (forward_proxy + ACME TLS)')
try:
self._start_container()
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
log.append(
f'Started {self.CONTAINER_NAME} on TCP 443 (host network). '
'Ports 80 and 443 must stay free for ACME and HTTPS.'
)
return {
'status': 'success',
'message': 'NaiveProxy installed',
'log': log,
'port': str(port),
'domain': domain,
'email': email,
}
def remove_container(self, protocol_type=None):
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
return True
def get_server_config(self, protocol_type=None):
return self._read_file(self.caddyfile_path)
def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.caddyfile_path, config_text or '')
self._reload_container()
return True
def get_settings(self):
meta = self._read_metadata()
return {
'port': int(meta.get('port') or self.DEFAULT_PORT),
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'protocol': self.protocol,
}
def update_settings(self, domain=None, email=None):
meta = self._read_metadata()
try:
if domain is not None and str(domain).strip():
meta['domain'] = self._validate_domain(domain)
if email is not None and str(email).strip():
meta['email'] = self._validate_email(email)
except ValueError as e:
return {'status': 'error', 'message': str(e)}
if not meta.get('domain') or not meta.get('email'):
return {'status': 'error', 'message': 'Domain and email are required'}
meta['port'] = int(meta.get('port') or self.DEFAULT_PORT)
self._write_metadata(meta)
try:
self._write_config_from_clients()
self._start_container()
except Exception as e:
return {'status': 'error', 'message': str(e), **self.get_settings()}
return {'status': 'success', 'message': 'NaiveProxy settings updated', **self.get_settings()}
# ===================== CLIENTS =====================
def get_clients(self, protocol_type=None):
clients = self._read_clients()
result = []
for c in clients:
cname = c.get('name') or c.get('username') 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),
'username': c.get('username'),
},
})
return result
def add_client(self, protocol_type, name, host, port=None):
meta = self._read_metadata()
domain = meta.get('domain') or host
port = int(port or meta.get('port') or self.DEFAULT_PORT)
client_id = secrets.token_hex(8)
username = f'u{client_id[:8]}'
password = _rand_token(20)
clients = self._read_clients()
clients.append({
'id': client_id,
'name': name or f'client-{client_id[:6]}',
'username': username,
'password': password,
'enabled': True,
})
self._write_clients(clients)
self._reload_container()
config = self._build_share_uri(domain, username, password, name or client_id, port)
return {'client_id': client_id, 'config': config}
def get_client_config(self, protocol_type, client_id, host, port=None):
meta = self._read_metadata()
domain = meta.get('domain') or host
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:
raise RuntimeError('Client not found')
if client.get('enabled', True) is False:
raise RuntimeError('Client is disabled')
return self._build_share_uri(
domain,
client.get('username'),
client.get('password'),
client.get('name') or client_id,
port,
)
def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id]
self._write_clients(clients)
self._reload_container()
return True
def toggle_client(self, protocol_type, client_id, enable=True):
clients = self._read_clients()
found = False
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enable)
found = True
break
if not found:
raise RuntimeError('Client not found')
self._write_clients(clients)
self._reload_container()
return True
+452
View File
@@ -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()
+372
View File
@@ -944,6 +944,16 @@ a:hover {
color: #38bdf8; color: #38bdf8;
} }
.protocol-hysteria .protocol-icon {
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(99, 102, 241, 0.1));
color: #38bdf8;
}
.protocol-mieru .protocol-icon {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.1));
color: #818cf8;
}
.protocol-dns .protocol-icon { .protocol-dns .protocol-icon {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
color: var(--success); color: var(--success);
@@ -1277,6 +1287,7 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1289,6 +1300,45 @@ a:hover {
background: var(--bg-card-hover); background: var(--bg-card-hover);
} }
.conn-select-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.conn-select {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.connections-bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
}
.connections-select-all {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.85rem;
color: var(--text-secondary);
cursor: pointer;
}
.connections-selected-count {
font-size: 0.8rem;
color: var(--text-muted);
}
.client-info { .client-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1618,6 +1668,319 @@ a:hover {
gap: var(--space-md); 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 { .mt-md {
margin-top: var(--space-md); margin-top: var(--space-md);
} }
@@ -1849,6 +2212,15 @@ a:hover {
font-weight: 600; font-weight: 600;
} }
.nav-link-support {
opacity: 0.85;
}
.nav-link-support:hover {
opacity: 1;
color: #f472b6;
}
.nav-user { .nav-user {
display: flex; display: flex;
align-items: center; align-items: center;
+3
View File
@@ -108,4 +108,7 @@
<symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> <symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/> <path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>
</symbol> </symbol>
<symbol id="heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
</symbol>
</svg> </svg>
+15 -3
View File
@@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None
_callback_refs = {} _callback_refs = {}
_pending_inputs = {} _pending_inputs = {}
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "wireguard"} CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "mieru", "wireguard"}
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
@@ -137,6 +137,9 @@ def _protocol_display_name(protocol: str) -> str:
"awg_legacy": "AmneziaWG Legacy", "awg_legacy": "AmneziaWG Legacy",
"xray": "Xray", "xray": "Xray",
"telemt": "Telemt", "telemt": "Telemt",
"hysteria": "Hysteria 2",
"naiveproxy": "NaiveProxy",
"mieru": "Mieru",
"dns": "AmneziaDNS", "dns": "AmneziaDNS",
"wireguard": "WireGuard", "wireguard": "WireGuard",
"socks5": "SOCKS5", "socks5": "SOCKS5",
@@ -338,6 +341,9 @@ def _get_ssh_and_manager(server: dict, proto: str):
from managers.awg_manager import AWGManager from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager from managers.xray_manager import XrayManager
from managers.telemt_manager import TelemtManager 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.wireguard_manager import WireGuardManager
from managers.dns_manager import DNSManager from managers.dns_manager import DNSManager
from managers.socks5_manager import Socks5Manager from managers.socks5_manager import Socks5Manager
@@ -356,6 +362,12 @@ def _get_ssh_and_manager(server: dict, proto: str):
manager = XrayManager(ssh, proto) manager = XrayManager(ssh, proto)
elif base == "telemt": elif base == "telemt":
manager = TelemtManager(ssh, proto) manager = TelemtManager(ssh, proto)
elif base == "hysteria":
manager = HysteriaManager(ssh, proto)
elif base == "naiveproxy":
manager = NaiveProxyManager(ssh, proto)
elif base == "mieru":
manager = MieruManager(ssh, proto)
elif base == "wireguard": elif base == "wireguard":
manager = WireGuardManager(ssh) manager = WireGuardManager(ssh)
elif base == "dns": elif base == "dns":
@@ -561,7 +573,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p
server_name = server.get("name") or server.get("host", "Unknown") server_name = server.get("name") or server.get("host", "Unknown")
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>") await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
is_link_proto = _proto_base(proto) in ("xray", "telemt") is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru")
if is_link_proto: if is_link_proto:
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>") await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
else: else:
@@ -918,7 +930,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int,
async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable): async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable):
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>") await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
if _proto_base(proto) in ("xray", "telemt"): if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru"):
await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>") await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>")
else: else:
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>") await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>")
+3
View File
@@ -52,6 +52,9 @@
<a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a> <a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a>
{% endif %} {% endif %}
<a href="/my" class="nav-link">{{ icon('layers') }} {{ _('nav_connections') }}</a> <a href="/my" class="nav-link">{{ icon('layers') }} {{ _('nav_connections') }}</a>
{% if donate_enabled %}
<a href="/support" class="nav-link nav-link-support">{{ icon('heart') }} {{ _('nav_support') }}</a>
{% endif %}
</nav> </nav>
<div class="nav-user"> <div class="nav-user">
+4
View File
@@ -105,6 +105,10 @@
if (p === 'awg_legacy') return 'AWG Legacy'; if (p === 'awg_legacy') return 'AWG Legacy';
if (p === 'xray') return 'Xray'; if (p === 'xray') return 'Xray';
if (p === 'xui') return '3x-ui VLESS'; 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(); return (p || '').toUpperCase();
} }
+48
View File
@@ -19,6 +19,9 @@
<div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}" <div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}"
data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}" data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}"
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}" data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}"
draggable="true"> draggable="true">
<div class="server-meta"> <div class="server-meta">
<div class="server-icon">{{ icon('server') }}</div> <div class="server-icon">{{ icon('server') }}</div>
@@ -30,6 +33,10 @@
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span> <span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
</div> </div>
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div> <div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
{% set connect_domain = (server.server_info or {}).get('connect_domain') %}
{% if connect_domain and connect_domain != server.host %}
<div class="server-host text-muted" title="{{ _('server_connect_domain_hint') }}">→ {{ connect_domain }}</div>
{% endif %}
</div> </div>
</div> </div>
@@ -137,6 +144,22 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<input class="form-input" type="text" id="editServerConnectDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_ssl_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('server_ssl_email') }}</label>
<input class="form-input" type="email" id="editServerSslEmail" placeholder="admin@example.com">
</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel') <button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
}}</button> }}</button>
@@ -202,6 +225,22 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<input class="form-input" type="text" id="serverConnectDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_ssl_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('server_ssl_email') }}</label>
<input class="form-input" type="email" id="serverSslEmail" placeholder="admin@example.com">
</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel') <button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
}}</button> }}</button>
@@ -244,6 +283,9 @@
username: document.getElementById('serverUser').value, username: document.getElementById('serverUser').value,
password: document.getElementById('serverPassword').value, password: document.getElementById('serverPassword').value,
private_key: document.getElementById('serverKey').value, 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); const result = await apiCall('/api/servers/add', 'POST', data);
@@ -292,6 +334,9 @@
document.getElementById('editServerUser').value = card.dataset.username || ''; document.getElementById('editServerUser').value = card.dataset.username || '';
document.getElementById('editServerPassword').value = ''; document.getElementById('editServerPassword').value = '';
document.getElementById('editServerKey').value = ''; 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. // Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key'; const authIsKey = card.dataset.auth === 'key';
@@ -321,6 +366,9 @@
// Empty -> null means "leave unchanged" on the backend. // Empty -> null means "leave unchanged" on the backend.
password: document.getElementById('editServerPassword').value || null, password: document.getElementById('editServerPassword').value || null,
private_key: document.getElementById('editServerKey').value || null, 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); await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success'); showToast(_('server_saved'), 'success');
+116 -29
View File
@@ -127,17 +127,22 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="inviteServer" onchange="onInviteServerChange()">
{% if xui_configured %}
<option value="xui">3x-ui</option>
{% endif %}
{% for s in servers %}
<option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div>
<div class="form-group" id="inviteProtoGroup">
<label class="form-label">{{ _('protocol_label') }}</label> <label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()"> <select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
<option value="xui">3x-ui VLESS</option> <!-- filled by JS from selected server -->
{% for s in servers %}
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
<option value="{{ key }}|{{ server_idx }}">{{ s.name or s.host }} — {{ key }}</option>
{% endif %}
{% endfor %}
{% endfor %}
</select> </select>
</div> </div>
@@ -187,9 +192,31 @@
<script> <script>
const invitesData = {{ invites | tojson }}; const invitesData = {{ invites | tojson }};
const serversData = {{ servers | tojson }};
const xuiConfigured = {{ 'true' if xui_configured else 'false' }}; const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInbound = {{ xui_default_inbound | int }}; const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }}; const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
const raw = String(key || '');
const base = raw.split('__')[0];
const title = PROTO_TITLES[base] || base.toUpperCase();
const m = raw.match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
}
function inviteUrl(token) { function inviteUrl(token) {
return `${window.location.origin}/invite/${token}`; return `${window.location.origin}/invite/${token}`;
@@ -200,6 +227,57 @@
showToast(_('copied'), 'success'); showToast(_('copied'), 'success');
} }
function fillInviteProtocols(preferProtocol) {
const serverVal = document.getElementById('inviteServer').value;
const sel = document.getElementById('inviteProtocol');
sel.innerHTML = '';
if (serverVal === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = PROTO_TITLES.xui;
sel.appendChild(opt);
updateInviteProtoFields();
return;
}
const server = serversData[parseInt(serverVal, 10)];
const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(k => {
const info = protocols[k] || {};
if (!info.installed) return false;
return VPN_PROTO_ORDER.includes(k.split('__')[0]);
});
installed.sort((a, b) => {
const ia = VPN_PROTO_ORDER.indexOf(a.split('__')[0]);
const ib = VPN_PROTO_ORDER.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
if (!installed.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = _('no_protocols');
opt.disabled = true;
sel.appendChild(opt);
updateInviteProtoFields();
return;
}
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = protoTitle(key);
if (preferProtocol && preferProtocol === key) opt.selected = true;
sel.appendChild(opt);
});
updateInviteProtoFields();
}
function onInviteServerChange() {
fillInviteProtocols();
}
function updateInviteProtoFields() { function updateInviteProtoFields() {
const v = document.getElementById('inviteProtocol').value; const v = document.getElementById('inviteProtocol').value;
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none'; document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
@@ -237,25 +315,26 @@
} }
} }
function parseProtocolValue() { function parseInviteSelection() {
const raw = document.getElementById('inviteProtocol').value; const serverVal = document.getElementById('inviteServer').value;
if (raw === 'xui') return { protocol: 'xui', server_id: 0 }; const protocol = document.getElementById('inviteProtocol').value;
if (raw.includes('|')) { if (serverVal === 'xui' || protocol === 'xui') {
const [protocol, sid] = raw.split('|'); return { protocol: 'xui', server_id: 0 };
return { protocol, server_id: parseInt(sid || '0') };
} }
return { protocol: raw, server_id: 0 }; return { protocol, server_id: parseInt(serverVal || '0', 10) || 0 };
} }
function setProtocolSelect(protocol, serverId) { function setInviteSelection(protocol, serverId) {
const sel = document.getElementById('inviteProtocol'); const serverSel = document.getElementById('inviteServer');
if (protocol === 'xui') sel.value = 'xui'; if (protocol === 'xui') {
else { if ([...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
const want = `${protocol}|${serverId || 0}`; fillInviteProtocols('xui');
if ([...sel.options].some(o => o.value === want)) sel.value = want; return;
else sel.value = 'xui';
} }
updateInviteProtoFields(); const sid = String(serverId || 0);
if ([...serverSel.options].some(o => o.value === sid)) serverSel.value = sid;
else if (serverSel.options.length) serverSel.selectedIndex = xuiConfigured ? 1 : 0;
fillInviteProtocols(protocol);
} }
function openCreateInvite() { function openCreateInvite() {
@@ -269,12 +348,17 @@
document.getElementById('inviteNote').value = ''; document.getElementById('inviteNote').value = '';
document.getElementById('inviteClearPwdWrap').style.display = 'none'; document.getElementById('inviteClearPwdWrap').style.display = 'none';
document.getElementById('inviteResetUsedWrap').style.display = 'none'; document.getElementById('inviteResetUsedWrap').style.display = 'none';
setProtocolSelect('xui', 0); const serverSel = document.getElementById('inviteServer');
if (xuiConfigured && [...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
else if (serverSel.options.length) serverSel.selectedIndex = 0;
fillInviteProtocols();
if (xuiDefaultPanelId) { if (xuiDefaultPanelId) {
const p = document.getElementById('inviteXuiPanel'); const p = document.getElementById('inviteXuiPanel');
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId; if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
} }
loadInviteInbounds(xuiDefaultInbound); if (document.getElementById('inviteProtocol').value === 'xui') {
loadInviteInbounds(xuiDefaultInbound);
}
openModal('inviteModal'); openModal('inviteModal');
} }
@@ -293,12 +377,14 @@
document.getElementById('inviteClearPassword').checked = false; document.getElementById('inviteClearPassword').checked = false;
document.getElementById('inviteResetUsedWrap').style.display = 'block'; document.getElementById('inviteResetUsedWrap').style.display = 'block';
document.getElementById('inviteResetUsed').checked = false; document.getElementById('inviteResetUsed').checked = false;
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0); setInviteSelection(inv.protocol || 'xui', inv.server_id || 0);
const panelSel = document.getElementById('inviteXuiPanel'); const panelSel = document.getElementById('inviteXuiPanel');
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) { if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
panelSel.value = inv.xui_panel_id; panelSel.value = inv.xui_panel_id;
} }
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound); if ((inv.protocol || '') === 'xui') {
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
}
openModal('inviteModal'); openModal('inviteModal');
} }
@@ -309,7 +395,8 @@
spinner.classList.remove('hidden'); spinner.classList.remove('hidden');
try { try {
const editId = document.getElementById('inviteEditId').value; const editId = document.getElementById('inviteEditId').value;
const proto = parseProtocolValue(); const proto = parseInviteSelection();
if (!proto.protocol) throw new Error(_('no_protocols'));
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0'); const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required')); if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
+3 -2
View File
@@ -118,10 +118,11 @@
document.getElementById('configText').textContent = result.config; document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink; document.getElementById('vpnLinkText').textContent = currentVpnLink;
// Telemt uses share links, not vpn:// Amnezia format // Telemt / Hysteria / NaiveProxy use share links, not vpn:// Amnezia format
const vpnTab = document.querySelectorAll('.config-tab')[1]; const vpnTab = document.querySelectorAll('.config-tab')[1];
const vpnPanel = document.getElementById('panel-vpn'); const vpnPanel = document.getElementById('panel-vpn');
if (proto === 'telemt') { const base = String(proto || '').split('__')[0];
if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy' || base === 'mieru') {
vpnTab.style.display = 'none'; vpnTab.style.display = 'none';
} else { } else {
vpnTab.style.display = ''; vpnTab.style.display = '';
+690 -10
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/icons.html" import icon %} {% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ server.name }}{% endblock %} {% block title_extra %} — {{ server.name }}{% endblock %}
@@ -155,6 +155,25 @@
</div> </div>
</div> </div>
</div> </div>
<div id="connectDomainSection" class="hidden" style="margin-top: var(--space-md);">
<div class="divider"></div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">{{ _('server_connect_domain') }}</label>
<div class="flex gap-sm" style="flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 0 0 200px; min-width: 160px;">
<select class="form-input" id="connectDomainProto" onchange="refreshConnectDomainFields()"></select>
</div>
<div style="flex: 1; min-width: 200px;">
<input class="form-input" type="text" id="connectDomainInput" placeholder="vpn.example.com">
</div>
<button type="button" class="btn btn-primary btn-sm" id="connectDomainSaveBtn" onclick="saveConnectDomain()">
{{ _('save') }}
</button>
</div>
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('server_connect_domain_awg_hint') }}</div>
<div class="text-muted text-sm" id="connectDomainEffective" style="margin-top: var(--space-xs);"></div>
</div>
</div>
</div> </div>
<!-- Installed Applications Section --> <!-- Installed Applications Section -->
@@ -295,6 +314,80 @@
</div> </div>
</div> </div>
<!-- Mieru Card -->
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('zap') }}</div>
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Mieru <span
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
<div class="protocol-desc">
{{ _('mieru_desc') }}
</div>
<div class="protocol-status" id="mieru-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="mieru-info" class="hidden">
<div class="protocol-info" id="mieru-info-grid"></div>
</div>
<div class="flex gap-sm" id="mieru-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- Hysteria Card -->
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('refresh') }}</div>
<div class="flex gap-sm" id="hysteria-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Hysteria 2</div>
<div class="protocol-desc">
{{ _('hysteria_desc') }}
</div>
<div class="protocol-status" id="hysteria-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="hysteria-info" class="hidden">
<div class="protocol-info" id="hysteria-info-grid"></div>
</div>
<div class="flex gap-sm" id="hysteria-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('hysteria')" id="hysteria-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- NaiveProxy Card -->
<div class="card card-hover protocol-card protocol-naiveproxy" id="proto-naiveproxy">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<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 <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>
<div id="naiveproxy-info" class="hidden">
<div class="protocol-info" id="naiveproxy-info-grid"></div>
</div>
<div class="flex gap-sm" id="naiveproxy-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('naiveproxy')" id="naiveproxy-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- WireGuard Card --> <!-- WireGuard Card -->
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard"> <div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -495,12 +588,23 @@
<option value="telemt">Telemt</option> <option value="telemt">Telemt</option>
<option value="wireguard">WireGuard</option> <option value="wireguard">WireGuard</option>
</select> </select>
<button type="button" class="btn btn-secondary btn-sm hidden" id="moveConnectionsBtn" onclick="openMoveConnectionsModal()">
<span>{{ icon('share') }}</span> {{ _('move_connections') }}
</button>
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()"> <button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
<span>{{ icon('plus') }}</span> {{ _('add') }} <span>{{ icon('plus') }}</span> {{ _('add') }}
</button> </button>
</div> </div>
</div> </div>
<div id="connectionsBulkBar" class="connections-bulk-bar hidden">
<label class="connections-select-all">
<input type="checkbox" id="connSelectAll" onchange="toggleSelectAllConnections(this.checked)">
<span>{{ _('select_all') }}</span>
</label>
<span class="connections-selected-count" id="connSelectedCount">0</span>
</div>
<div id="connectionsList"> <div id="connectionsList">
<div class="loading-overlay" id="connectionsLoading" style="display:none;"> <div class="loading-overlay" id="connectionsLoading" style="display:none;">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@@ -657,6 +761,61 @@
<div class="form-hint">{{ _('nginx_install_hint') }}</div> <div class="form-hint">{{ _('nginx_install_hint') }}</div>
</div> </div>
<div id="hysteriaOptions"
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);">
{{ _('hysteria_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP) *</label>
<input class="form-input" type="number" id="installHysteriaPort" value="8998" min="1" max="65535">
<div class="form-hint">{{ _('hysteria_port_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="installHysteriaDomain" placeholder="vpn.example.com" oninput="updateHysteriaDnsHint()">
<div class="form-hint" id="hysteriaDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_email') }}</label>
<input class="form-input" type="email" id="installHysteriaEmail" placeholder="admin@example.com">
</div>
<div class="form-hint">{{ _('hysteria_install_hint') }}</div>
</div>
<div id="naiveproxyOptions"
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);">
{{ _('naiveproxy_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('naiveproxy_domain') }}</label>
<input class="form-input" type="text" id="installNaiveproxyDomain" placeholder="vpn.example.com" oninput="updateNaiveproxyDnsHint()">
<div class="form-hint" id="naiveproxyDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('naiveproxy_email') }}</label>
<input class="form-input" type="email" id="installNaiveproxyEmail" placeholder="admin@example.com">
</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"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button> <button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn"> <button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
@@ -711,6 +870,45 @@
</div> </div>
</div> </div>
<!-- ===== Hysteria Settings Modal ===== -->
<div class="modal-backdrop" id="hysteriaSettingsModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="hysteriaSettingsTitle">{{ _('hysteria_settings_title') }}</h2>
<button class="modal-close" onclick="closeModal('hysteriaSettingsModal')">×</button>
</div>
<input type="hidden" id="hysteriaSetProto" value="hysteria" />
<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);">
{{ _('hysteria_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="hysteriaSetDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('hysteria_ssl_change_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_email') }}</label>
<input class="form-input" type="email" id="hysteriaSetEmail" placeholder="admin@example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP)</label>
<input class="form-input" type="number" id="hysteriaSetPort" min="1" max="65535" value="8998">
<div class="form-hint">{{ _('hysteria_port_change_hint') }}</div>
</div>
<div class="modal-footer" style="flex-wrap: wrap; gap: var(--space-sm);">
<button class="btn btn-secondary" onclick="closeModal('hysteriaSettingsModal')">{{ _('cancel') }}</button>
<button class="btn btn-secondary" onclick="renewHysteriaSsl()" id="hysteriaRenewBtn">
<span id="hysteriaRenewBtnText">{{ _('hysteria_renew_ssl') }}</span>
<div class="spinner hidden" id="hysteriaRenewSpinner"></div>
</button>
<button class="btn btn-primary" onclick="saveHysteriaSettings()" id="hysteriaSaveBtn">
<span id="hysteriaSaveBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="hysteriaSaveSpinner"></div>
</button>
</div>
</div>
</div>
<!-- ===== NGINX Site Modal ===== --> <!-- ===== NGINX Site Modal ===== -->
<div class="modal-backdrop" id="siteConfigModal"> <div class="modal-backdrop" id="siteConfigModal">
<div class="modal" style="max-width:680px;"> <div class="modal" style="max-width:680px;">
@@ -840,6 +1038,35 @@
</div> </div>
</div> </div>
<!-- ===== Move Connections Modal ===== -->
<div class="modal-backdrop" id="moveConnectionsModal">
<div class="modal" style="max-width: 520px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('move_connections_title') }}</h2>
<button class="modal-close" onclick="closeModal('moveConnectionsModal')">×</button>
</div>
<p class="form-hint">{{ _('move_connections_hint') }}</p>
<div class="form-group">
<label class="form-label" for="moveTargetServer">{{ _('move_connections_target') }}</label>
<select class="form-select" id="moveTargetServer"></select>
</div>
<label class="form-check" style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom: var(--space-md);">
<input type="checkbox" id="moveDeleteSource" checked>
<span>{{ _('move_connections_delete_source') }}</span>
</label>
<div class="form-hint" style="border-left: 3px solid var(--warning); padding-left: var(--space-sm);">
{{ _('move_connections_warning') }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('moveConnectionsModal')">{{ _('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="moveSelectedConnections()" id="moveConnectionsSubmitBtn">
<span id="moveConnectionsSubmitText">{{ _('move_connections') }}</span>
<div class="spinner hidden" id="moveConnectionsSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<!-- ===== Connection Config Modal (with Tabs) ===== --> <!-- ===== Connection Config Modal (with Tabs) ===== -->
<div class="modal-backdrop" id="configModal"> <div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;"> <div class="modal" style="max-width: 600px;">
@@ -859,6 +1086,7 @@
<div class="config-panel active" id="panel-conf"> <div class="config-panel active" id="panel-conf">
<div class="config-display"> <div class="config-display">
<div class="config-text" id="configText"></div> <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"> <div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1"> <button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
{{ _('copy') }} {{ _('copy') }}
@@ -960,13 +1188,20 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script> <script>
const SERVER_ID = {{ server_id }}; const SERVER_ID = {{ server_id }};
const ALL_SERVERS = {{ servers_for_move | tojson }};
const SERVER_HOST = "{{ server.host }}"; const SERVER_HOST = "{{ server.host }}";
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
const MARKETPLACE_APPS = [ const MARKETPLACE_APPS = [
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' }, { proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' },
{ proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' }, { proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' }, { proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' }, { proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' }, { proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' }, { proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_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' }, { proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
@@ -978,6 +1213,8 @@
{ id: 'services', icon: 'wrench', titleKey: 'services' }, { id: 'services', icon: 'wrench', titleKey: 'services' },
{ id: 'web_servers', icon: 'globe', titleKey: 'web_servers' }, { id: 'web_servers', icon: 'globe', titleKey: 'web_servers' },
]; ];
const WG_AWG_BASES = new Set(['awg', 'awg2', 'awg_legacy', 'wireguard']);
let connectDomainEffective = {};
let currentInstallProto = 'awg'; let currentInstallProto = 'awg';
let currentInstallAnother = false; let currentInstallAnother = false;
let currentConfig = ''; let currentConfig = '';
@@ -1002,7 +1239,7 @@
} }
function isMultiInstanceBase(proto) { function isMultiInstanceBase(proto) {
return ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'].includes(protoBase(proto)); return ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'].includes(protoBase(proto));
} }
function nextSuggestedPort(base, fallback) { function nextSuggestedPort(base, fallback) {
@@ -1037,6 +1274,94 @@
return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]); return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]);
} }
function hasWgAwgInstalled() {
return Object.entries(installedProtocols || {}).some(([key, installed]) => installed && WG_AWG_BASES.has(protoBase(key)));
}
function listInstalledWgAwgProtocols() {
const protos = new Set();
Object.entries(installedProtocols || {}).forEach(([key, installed]) => {
if (installed && WG_AWG_BASES.has(protoBase(key))) protos.add(key);
});
return Array.from(protos).sort((a, b) => {
const ao = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(a));
const bo = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(b));
if (ao !== bo) return ao - bo;
return protoInstance(a) - protoInstance(b);
});
}
function updateConnectDomainSection() {
const section = document.getElementById('connectDomainSection');
if (!section) return;
const show = hasWgAwgInstalled();
section.classList.toggle('hidden', !show);
if (!show) return;
const select = document.getElementById('connectDomainProto');
if (!select) return;
const prev = select.value;
const installed = listInstalledWgAwgProtocols();
select.innerHTML = `<option value="">${_('server_connect_domain_all')}</option>` +
installed.map(p => `<option value="${p}">${getProtoTitle(p)}</option>`).join('');
if (prev && [...select.options].some(opt => opt.value === prev)) {
select.value = prev;
}
refreshConnectDomainFields();
}
async function refreshConnectDomainFields() {
const proto = document.getElementById('connectDomainProto')?.value || '';
const input = document.getElementById('connectDomainInput');
const eff = document.getElementById('connectDomainEffective');
try {
const query = proto ? `?protocol=${encodeURIComponent(proto)}` : '';
const data = await apiCall(`/api/servers/${SERVER_ID}/connect-domain${query}`, 'GET');
connectDomainEffective = data;
if (input) {
input.value = proto ? (data.protocol_connect_domain || '') : (data.connect_domain || '');
}
if (eff) {
if (data.effective_host && data.effective_host !== data.ssh_host) {
eff.textContent = `${_('server_connect_domain_current')}: ${data.effective_host} (${_('server_connect_domain_ssh')}: ${data.ssh_host})`;
} else {
eff.textContent = `${_('server_connect_domain_current')}: ${data.ssh_host || SERVER_HOST}`;
}
}
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
updateProtocolCard(p, info);
}
});
} catch (err) {
if (input && !proto) input.value = SERVER_CONNECT_DOMAIN || '';
if (eff) eff.textContent = '';
}
}
async function saveConnectDomain() {
const btn = document.getElementById('connectDomainSaveBtn');
const proto = document.getElementById('connectDomainProto')?.value || '';
const domain = document.getElementById('connectDomainInput')?.value.trim() || '';
if (btn) btn.disabled = true;
try {
await apiCall(`/api/servers/${SERVER_ID}/connect-domain`, 'POST', {
connect_domain: domain,
protocol: proto || null,
});
showToast(_('server_connect_domain_saved'), 'success');
await refreshConnectDomainFields();
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
updateProtocolCard(p, info);
}
});
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function switchCategory(cat) { function switchCategory(cat) {
if (cat === 'management') { if (cat === 'management') {
openManagementModal(); openManagementModal();
@@ -1195,6 +1520,7 @@
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0); if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
const loading = document.getElementById('installedAppsLoading'); const loading = document.getElementById('installedAppsLoading');
if (loading) loading.classList.toggle('hidden', !installedAppsLoading); if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
updateConnectDomainSection();
} }
function getProtoTitle(proto) { function getProtoTitle(proto) {
@@ -1207,6 +1533,9 @@
case 'awg_legacy': title = 'AmneziaWG Legacy'; break; case 'awg_legacy': title = 'AmneziaWG Legacy'; break;
case 'xray': title = 'Xray'; break; case 'xray': title = 'Xray'; break;
case 'telemt': title = 'Telemt'; break; 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 'wireguard': title = 'WireGuard'; break;
case 'dns': title = 'AmneziaDNS'; break; case 'dns': title = 'AmneziaDNS'; break;
case 'socks5': title = 'SOCKS5 Proxy'; break; case 'socks5': title = 'SOCKS5 Proxy'; break;
@@ -1266,7 +1595,7 @@
for (const [proto, info] of orderedProtocolEntries(data.protocols)) { for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
updateProtocolCard(proto, info); updateProtocolCard(proto, info);
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'].includes(protoBase(proto)); const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru', 'wireguard'].includes(protoBase(proto));
if (info.container_running && isVPN) { if (info.container_running && isVPN) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = proto; opt.value = proto;
@@ -1494,7 +1823,22 @@
if (isService) { if (isService) {
grid = buildServiceInfoGrid(proto, info); grid = buildServiceInfoGrid(proto, info);
} else if (info.port) { } else if (info.port) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt'].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) { if (!isService && info.clients_count !== undefined) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
@@ -1529,6 +1873,28 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button> <button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button> <button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`; `;
} else if (protoBase(proto) === 'hysteria') {
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="openHysteriaSettings('${proto}')">${_('hysteria_change_port')}</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 if (protoBase(proto) === 'naiveproxy') {
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 if (protoBase(proto) === 'mieru') {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
showConnectionsSection();
} else { } else {
actionsEl.innerHTML = ` actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button> <button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
@@ -1565,6 +1931,7 @@
} else if (info.container_exists) { } else if (info.container_exists) {
installedProtocols[proto] = true; installedProtocols[proto] = true;
const errText = String(info.error || '').trim(); const errText = String(info.error || '').trim();
const portBusy = !!info.port_busy || /address already in use|UDP port \d+ already in use|already in use/i.test(errText);
statusEl.innerHTML = errText statusEl.innerHTML = errText
? `<span class="badge badge-danger" style="max-width:100%; white-space:normal; text-align:left; line-height:1.35;"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 120))}</span>` ? `<span class="badge badge-danger" style="max-width:100%; white-space:normal; text-align:left; line-height:1.35;"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 120))}</span>`
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`; : `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
@@ -1583,6 +1950,25 @@
if (errText) { if (errText) {
grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`; grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`;
} }
if (protoBase(proto) === 'hysteria') {
const curPort = parseInt(info.port, 10) || 443;
const suggest = (curPort === 443 || portBusy) ? 8998 : curPort;
grid += `
<div class="protocol-info-item" style="grid-column:1/-1; display:block; padding-top:8px;">
<span class="protocol-info-label" style="display:block; margin-bottom:6px;">${_('hysteria_change_port')} (UDP)</span>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input class="form-input" type="number" id="hysteriaInlinePort-${protoDomKey(proto)}"
value="${suggest}" min="1" max="65535" style="width:120px; flex:0 0 auto;">
<button class="btn btn-primary btn-sm" type="button"
onclick="applyHysteriaPort('${proto}', document.getElementById('hysteriaInlinePort-${protoDomKey(proto)}').value)">
${_('save')} &amp; ${_('start_btn')}
</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openHysteriaSettings('${proto}')">${_('hysteria_settings_title')}</button>
</div>
<div class="form-hint" style="margin-top:6px;">${_('hysteria_port_change_hint')}</div>
</div>
`;
}
infoGrid.innerHTML = grid; infoGrid.innerHTML = grid;
} }
if (isService) { if (isService) {
@@ -1592,6 +1978,13 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</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> <button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`; `;
} else if (protoBase(proto) === 'hysteria') {
actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openHysteriaSettings('${proto}')" style="flex:1">${_('hysteria_change_port')}</button>
<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</button>
<button class="btn btn-secondary btn-sm" onclick="openInstallModal('${proto}')">${_('reinstall')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else { } else {
actionsEl.innerHTML = ` actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button> <button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
@@ -1872,6 +2265,22 @@
hint.innerHTML = `${_('nginx_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`; hint.innerHTML = `${_('nginx_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
} }
function updateHysteriaDnsHint() {
const input = document.getElementById('installHysteriaDomain');
const hint = document.getElementById('hysteriaDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
hint.innerHTML = `${_('hysteria_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function updateNaiveproxyDnsHint() {
const input = document.getElementById('installNaiveproxyDomain');
const hint = document.getElementById('naiveproxyDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function openInstallModal(proto, installAnother = false) { function openInstallModal(proto, installAnother = false) {
const base = protoBase(proto); const base = protoBase(proto);
currentInstallProto = installAnother ? base : proto; currentInstallProto = installAnother ? base : proto;
@@ -1887,11 +2296,17 @@
const socks5Opts = document.getElementById('socks5Options'); const socks5Opts = document.getElementById('socks5Options');
const adguardOpts = document.getElementById('adguardOptions'); const adguardOpts = document.getElementById('adguardOptions');
const nginxOpts = document.getElementById('nginxOptions'); const nginxOpts = document.getElementById('nginxOptions');
const hysteriaOpts = document.getElementById('hysteriaOptions');
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
const mieruOpts = document.getElementById('mieruOptions');
telemtOpts.style.display = 'none'; telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none'; socks5Opts.style.display = 'none';
adguardOpts.style.display = 'none'; adguardOpts.style.display = 'none';
nginxOpts.style.display = 'none'; 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 (portGroup) portGroup.style.display = '';
if (base === 'dns') { if (base === 'dns') {
@@ -1928,7 +2343,48 @@
portInput.disabled = false; portInput.disabled = false;
portHint.textContent = _('nginx_port_hint'); portHint.textContent = _('nginx_port_hint');
nginxOpts.style.display = 'block'; nginxOpts.style.display = 'block';
const nxDomain = document.getElementById('installNginxDomain');
const nxEmail = document.getElementById('installNginxEmail');
if (nxDomain && !nxDomain.value && SERVER_SSL_DOMAIN) nxDomain.value = SERVER_SSL_DOMAIN;
if (nxEmail && !nxEmail.value && SERVER_SSL_EMAIL) nxEmail.value = SERVER_SSL_EMAIL;
updateNginxDnsHint(); updateNginxDnsHint();
} else if (base === 'hysteria') {
if (portGroup) portGroup.style.display = 'none';
const suggested = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
portInput.value = suggested;
portInput.disabled = false;
hysteriaOpts.style.display = 'block';
const hyPort = document.getElementById('installHysteriaPort');
if (hyPort) {
hyPort.value = suggested;
hyPort.oninput = () => { portInput.value = hyPort.value; };
}
const hyDomain = document.getElementById('installHysteriaDomain');
const hyEmail = document.getElementById('installHysteriaEmail');
if (hyDomain && !hyDomain.value && SERVER_SSL_DOMAIN) hyDomain.value = SERVER_SSL_DOMAIN;
if (hyEmail && !hyEmail.value && SERVER_SSL_EMAIL) hyEmail.value = SERVER_SSL_EMAIL;
updateHysteriaDnsHint();
} else if (base === 'naiveproxy') {
if (portGroup) portGroup.style.display = 'none';
portInput.value = '443';
portInput.disabled = false;
if (naiveproxyOpts) naiveproxyOpts.style.display = 'block';
const npDomain = document.getElementById('installNaiveproxyDomain');
const npEmail = document.getElementById('installNaiveproxyEmail');
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 { } else {
portLabel.textContent = _('port') + ' (UDP)'; portLabel.textContent = _('port') + ' (UDP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424'; portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
@@ -1995,6 +2451,22 @@
} else if (protoBase(currentInstallProto) === 'nginx') { } else if (protoBase(currentInstallProto) === 'nginx') {
params.nginx_domain = document.getElementById('installNginxDomain').value.trim(); params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
params.nginx_email = document.getElementById('installNginxEmail').value.trim(); params.nginx_email = document.getElementById('installNginxEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'hysteria') {
const hyPortEl = document.getElementById('installHysteriaPort');
if (hyPortEl && hyPortEl.value) {
params.port = hyPortEl.value;
}
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
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); const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
clearInterval(progressInterval); clearInterval(progressInterval);
@@ -2210,6 +2682,107 @@
} }
} }
// ========== Hysteria Settings (port) ==========
async function applyHysteriaPort(proto, portValue) {
const port = parseInt(portValue, 10);
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
try {
showToast(_('saving') || 'Saving...', 'info');
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto || 'hysteria', port,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
openHysteriaSettings(proto);
}
}
async function openHysteriaSettings(proto = 'hysteria') {
const live = currentProtocolStatus[proto] || {};
const livePort = live.port || 443;
const suggest = (parseInt(livePort, 10) === 443) ? 8998 : (livePort || 8998);
document.getElementById('hysteriaSetProto').value = proto;
document.getElementById('hysteriaSettingsTitle').textContent =
`${_('hysteria_settings_title')} — ${getProtoTitle(proto)}`;
document.getElementById('hysteriaSetDomain').value = live.domain || SERVER_SSL_DOMAIN || '';
document.getElementById('hysteriaSetEmail').value = live.email || SERVER_SSL_EMAIL || '';
document.getElementById('hysteriaSetPort').value = suggest;
openModal('hysteriaSettingsModal');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`);
if (data.domain) document.getElementById('hysteriaSetDomain').value = data.domain;
if (data.email) document.getElementById('hysteriaSetEmail').value = data.email;
const p = parseInt(data.port, 10);
if (p && p !== 443) document.getElementById('hysteriaSetPort').value = p;
else if (p === 443) document.getElementById('hysteriaSetPort').value = 8998;
} catch (_) { /* modal already open with local values */ }
}
async function saveHysteriaSettings(opts = {}) {
const btn = document.getElementById('hysteriaSaveBtn');
const text = document.getElementById('hysteriaSaveBtnText');
const spinner = document.getElementById('hysteriaSaveSpinner');
const renewBtn = document.getElementById('hysteriaRenewBtn');
const renewText = document.getElementById('hysteriaRenewBtnText');
const renewSpinner = document.getElementById('hysteriaRenewSpinner');
const proto = document.getElementById('hysteriaSetProto').value || 'hysteria';
const port = parseInt(document.getElementById('hysteriaSetPort').value, 10);
const domain = document.getElementById('hysteriaSetDomain').value.trim();
const email = document.getElementById('hysteriaSetEmail').value.trim();
const renewSsl = !!opts.renew_ssl;
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
if ((renewSsl || domain) && (!domain || !email)) {
showToast(_('error') + ': ' + _('hysteria_ssl_required'), 'error');
return;
}
btn.disabled = true;
if (renewBtn) renewBtn.disabled = true;
if (renewSsl) {
renewText.textContent = _('saving') || 'Saving...';
renewSpinner.classList.remove('hidden');
} else {
text.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
}
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto, port, domain, email, renew_ssl: renewSsl,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
closeModal('hysteriaSettingsModal');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
if (renewBtn) renewBtn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
if (renewText) renewText.textContent = _('hysteria_renew_ssl');
if (renewSpinner) renewSpinner.classList.add('hidden');
}
}
async function renewHysteriaSsl() {
return saveHysteriaSettings({ renew_ssl: true });
}
// ========== SOCKS5 Settings ========== // ========== SOCKS5 Settings ==========
async function openSocks5Settings(proto = 'socks5') { async function openSocks5Settings(proto = 'socks5') {
currentSocks5Proto = proto || 'socks5'; currentSocks5Proto = proto || 'socks5';
@@ -2262,6 +2835,95 @@
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' }); document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
} }
const selectedConnectionIds = new Set();
function updateMoveSelection() {
selectedConnectionIds.clear();
document.querySelectorAll('.conn-select:checked').forEach((el) => {
if (el.dataset.clientId) selectedConnectionIds.add(el.dataset.clientId);
});
const count = selectedConnectionIds.size;
const bulkBar = document.getElementById('connectionsBulkBar');
const moveBtn = document.getElementById('moveConnectionsBtn');
const countEl = document.getElementById('connSelectedCount');
const selectAll = document.getElementById('connSelectAll');
const boxes = document.querySelectorAll('.conn-select');
if (bulkBar) bulkBar.classList.toggle('hidden', boxes.length === 0);
if (moveBtn) moveBtn.classList.toggle('hidden', boxes.length === 0);
if (countEl) countEl.textContent = _('connections_selected_count').replace('{}', String(count));
if (selectAll) {
selectAll.indeterminate = count > 0 && count < boxes.length;
selectAll.checked = boxes.length > 0 && count === boxes.length;
}
}
function toggleSelectAllConnections(checked) {
document.querySelectorAll('.conn-select').forEach((el) => { el.checked = checked; });
updateMoveSelection();
}
function openMoveConnectionsModal() {
if (!selectedConnectionIds.size) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
const select = document.getElementById('moveTargetServer');
select.innerHTML = '';
(ALL_SERVERS || []).forEach((srv) => {
if (srv.id === SERVER_ID) return;
const opt = document.createElement('option');
opt.value = String(srv.id);
opt.textContent = `${srv.name} (${srv.host})`;
select.appendChild(opt);
});
if (!select.options.length) {
showToast(_('move_connections_no_targets'), 'error');
return;
}
openModal('moveConnectionsModal');
}
async function moveSelectedConnections() {
const targetServerId = parseInt(document.getElementById('moveTargetServer').value, 10);
const deleteSource = document.getElementById('moveDeleteSource').checked;
const proto = document.getElementById('connProtoSelect').value;
const clientIds = Array.from(selectedConnectionIds);
if (!clientIds.length || Number.isNaN(targetServerId)) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
if (!confirm(_('move_connections_confirm').replace('{}', String(clientIds.length)))) return;
const btn = document.getElementById('moveConnectionsSubmitBtn');
const text = document.getElementById('moveConnectionsSubmitText');
const spinner = document.getElementById('moveConnectionsSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections/move`, 'POST', {
protocol: proto,
target_server_id: targetServerId,
client_ids: clientIds,
delete_source: deleteSource,
});
const moved = (data.moved || []).length;
const failed = (data.failed || []).length;
let msg = _('move_connections_success').replace('{}', String(moved));
if (failed) msg += '. ' + _('move_connections_partial_fail').replace('{}', String(failed));
showToast(msg, failed && !moved ? 'error' : 'success');
closeModal('moveConnectionsModal');
selectedConnectionIds.clear();
loadConnections();
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = _('move_connections');
}
}
async function loadConnections() { async function loadConnections() {
const proto = document.getElementById('connProtoSelect').value; const proto = document.getElementById('connProtoSelect').value;
const loading = document.getElementById('connectionsLoading'); const loading = document.getElementById('connectionsLoading');
@@ -2276,11 +2938,14 @@
loading.style.display = ''; loading.style.display = '';
emptyEl.classList.add('hidden'); emptyEl.classList.add('hidden');
listEl.innerHTML = ''; listEl.innerHTML = '';
selectedConnectionIds.clear();
updateMoveSelection();
try { try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`); const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none'; loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) { if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden'); emptyEl.classList.remove('hidden');
updateMoveSelection();
return; return;
} }
window.connectionsStore = {}; window.connectionsStore = {};
@@ -2294,7 +2959,7 @@
const sent = userData.dataSent || ''; const sent = userData.dataSent || '';
const initial = name.charAt(0).toUpperCase(); const initial = name.charAt(0).toUpperCase();
const enabled = (client.enabled !== undefined) ? client.enabled : (userData.enabled !== false); const enabled = (client.enabled !== undefined) ? client.enabled : (userData.enabled !== false);
const hasPrivKey = !!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt'; const hasPrivKey = !!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy';
const assignedUser = client.assigned_user || ''; const assignedUser = client.assigned_user || '';
let metaHtml = ''; let metaHtml = '';
@@ -2324,6 +2989,9 @@
listEl.innerHTML += ` listEl.innerHTML += `
<div class="client-item" style="${disabledStyle}"> <div class="client-item" style="${disabledStyle}">
<label class="conn-select-wrap">
<input type="checkbox" class="conn-select" data-client-id="${escapeJs(client.clientId)}" onchange="updateMoveSelection()">
</label>
<div class="client-info"> <div class="client-info">
<div class="client-avatar">${initial}</div> <div class="client-avatar">${initial}</div>
<div> <div>
@@ -2332,7 +3000,7 @@
</div> </div>
</div> </div>
<div class="client-actions"> <div class="client-actions">
<button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt'})" title="${_('config')}">${uiIcon('file')}</button> <button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy'})" title="${_('config')}">${uiIcon('file')}</button>
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">${uiIcon('pencil')}</button>` : ''} ${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">${uiIcon('pencil')}</button>` : ''}
<button class="btn btn-secondary btn-sm btn-icon" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled})" title="${toggleTitle}">${toggleIcon}</button> <button class="btn btn-secondary btn-sm btn-icon" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled})" title="${toggleTitle}">${toggleIcon}</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">${uiIcon('trash')}</button> <button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">${uiIcon('trash')}</button>
@@ -2340,6 +3008,7 @@
</div> </div>
`; `;
}); });
updateMoveSelection();
} catch (err) { } catch (err) {
loading.style.display = 'none'; loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error'); showToast(_('connections_load_error') + ': ' + err.message, 'error');
@@ -2384,8 +3053,8 @@
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config; document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink; document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
switchConfigTab('conf'); switchConfigTab('conf');
openModal('configModal'); openModal('configModal');
generateQR(result.config); generateQR(result.config);
@@ -2463,9 +3132,9 @@
const proto = document.getElementById('connProtoSelect').value; const proto = document.getElementById('connProtoSelect').value;
// Restore tabs visibility first // Restore tabs visibility first
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.getElementById('panel-qr').style.display = ''; document.getElementById('panel-qr').style.display = '';
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.querySelectorAll('.config-tab')[2].style.display = ''; document.querySelectorAll('.config-tab')[2].style.display = '';
// Client was created via native Amnezia app — private key is not stored server-side // Client was created via native Amnezia app — private key is not stored server-side
@@ -2497,6 +3166,16 @@
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config; document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink; 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'); switchConfigTab('conf');
openModal('configModal'); openModal('configModal');
generateQR(result.config); generateQR(result.config);
@@ -2609,6 +3288,7 @@
} }
// ========== Init ========== // ========== Init ==========
applyInstalledAppsVisibility(); applyInstalledAppsVisibility();
checkServer(); checkServer();
+544 -128
View File
@@ -110,21 +110,26 @@
<div id="guestCreateFields" <div id="guestCreateFields"
style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);"> style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);">
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label> <label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()"> <select class="form-select" id="guest_create_server" onchange="fillGuestProtocols()">
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option> {% if xui_servers %}
{% for s in servers %} <option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui</option>
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
<option value="{{ key }}|{{ server_idx }}"
{% if settings.guest.create_protocol == key and settings.guest.create_server_id == server_idx %}selected{% endif %}>
{{ s.name or s.host }} — {{ key }}
</option>
{% endif %} {% endif %}
{% endfor %} {% for s in servers %}
<option value="{{ loop.index0 }}"
{% if settings.guest.create_protocol != 'xui' and settings.guest.create_server_id == loop.index0 %}selected{% endif %}>
{{ s.name or s.host }} ({{ s.host }})
</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
<!-- filled by JS -->
</select>
<input type="hidden" id="guest_create_protocol_pref" value="{{ settings.guest.create_protocol or '' }}">
</div> </div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}"> <div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_server_select_label') }}</label> <label class="form-label">{{ _('xui_server_select_label') }}</label>
@@ -199,88 +204,147 @@
</div> </div>
<!-- BLOCK Tunnels --> <!-- BLOCK Tunnels -->
<div class="card"> <div class="card tunnels-card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3> <div class="tunnels-header">
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </div>
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span> <div class="tunnels-local">
</div> <div class="tunnels-local-icon" aria-hidden="true">🏠</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;"> <div class="tunnels-local-body">
<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="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> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div> </div>
</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 class="tunnels-section">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
<strong>Cloudflare Quick Tunnel</strong> <div class="tunnels-grid">
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span> <article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
</div> <div class="tunnel-card-head">
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> <div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true"></div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-meta">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')"> <h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')"> </div>
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span> <div class="tunnel-card-body">
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div> <div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</button> </div>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')"> <div class="tunnel-card-actions tunnel-card-actions--triple">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span> <button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div> <span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
</button> <div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</div> </button>
</div> <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);"> <article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <div class="tunnel-card-head">
<strong>ngrok Tunnel + Authtoken</strong> <div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span> <div class="tunnel-card-meta">
</div> <h5 class="tunnel-card-title">ngrok</h5>
<div class="form-group" style="margin-bottom: var(--space-sm);"> <p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off"> </div>
</div> <span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> </div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-body">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')"> <input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')"> <button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span> <span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')"> <button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span> <span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button> </button>
</div> <button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
</div> <span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
<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>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </article>
<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>
</div> </div>
</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> </div>
<!-- BLOCK SSL/HTTPS --> <!-- BLOCK SSL/HTTPS -->
@@ -540,15 +604,19 @@
<div class="card" style="margin-top: var(--space-lg);"> <div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3> <h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm);"> <div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary" <a href="/api/settings/backup/download" class="btn btn-secondary"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);"> style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>⬇️</span> {{ _('download_backup') }} <span>⬇️</span> {{ _('download_backup') }}
</a> </a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>📄</span> {{ _('download_backup_json') }}
</a>
</div> </div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);"> <div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div style="display: flex; flex-direction: column; gap: var(--space-sm);"> <div style="display: flex; flex-direction: column; gap: var(--space-sm);">
<input type="file" id="backupFile" accept=".json" style="display: none;" <input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
onchange="handleRestore(event)"> onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn" onclick="document.getElementById('backupFile').click()" id="restoreBtn"
@@ -559,6 +627,9 @@
</div> </div>
</div> </div>
<p class="form-hint" style="margin-top: var(--space-sm);"> <p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('backup_hint') }}
</p>
<p class="form-hint" style="margin-top: var(--space-xs);">
{{ _('restore_confirm') }} {{ _('restore_confirm') }}
</p> </p>
</div> </div>
@@ -579,6 +650,41 @@
</div> </div>
</div> </div>
<!-- BLOCK: Support / Donate (admin) -->
{% set donate_cfg = settings.get('donate') or {} %}
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ icon('heart') }} {{ _('donate_settings_title') }}</h3>
<p class="form-hint" style="margin-top: 0; margin-bottom: var(--space-md);">{{ _('donate_settings_hint') }}</p>
<div class="form-group">
<label class="form-label" style="display:flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox" id="donate_enabled" {% if donate_cfg.get('enabled', True) %}checked{% endif %}>
{{ _('donate_enabled') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('donate_intro') }}</label>
<textarea class="form-textarea" id="donate_intro" rows="2" placeholder="{{ _('support_intro') }}">{{ donate_cfg.get('intro') or '' }}</textarea>
</div>
{% for key, label_key in [('sbp', 'donate_sbp'), ('card', 'donate_card'), ('crypto', 'donate_crypto')] %}
{% set method = donate_cfg.get(key) or {} %}
<div style="border:1px solid var(--border-color); border-radius:var(--radius-md); padding:var(--space-md); margin-bottom:var(--space-md);">
<label class="form-label" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:var(--space-sm);">
<input type="checkbox" id="donate_{{ key }}_enabled" {% if method.get('enabled', True) %}checked{% endif %}>
{{ _(label_key) }}
</label>
<div class="form-group" style="margin-bottom:var(--space-sm);">
<label class="form-label">{{ _('donate_method_title') }}</label>
<input type="text" class="form-input" id="donate_{{ key }}_title" value="{{ method.get('title') or '' }}" placeholder="{{ _(label_key) }}">
</div>
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">{{ _('donate_method_details') }}</label>
<textarea class="form-textarea" id="donate_{{ key }}_details" rows="3" placeholder="{{ _('donate_details_placeholder') }}">{{ method.get('details') or '' }}</textarea>
</div>
</div>
{% endfor %}
<p class="form-hint">{{ _('donate_preview_hint') }} <a href="/support" target="_blank" rel="noopener">/support</a></p>
</div>
<!-- BLOCK: About & Updates --> <!-- BLOCK: About & Updates -->
<div class="card" style="margin-top: var(--space-lg);"> <div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">️ {{ _('about_title') }}</h3> <h3 class="card-title" style="margin-bottom: var(--space-lg);">️ {{ _('about_title') }}</h3>
@@ -592,15 +698,24 @@
<!-- Updated via JS --> <!-- Updated via JS -->
</div> </div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);"> <div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;"> <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> <span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<a href="https://github.com/PRVTPRO/Amnezia-Web-Panel/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') }} ⬇️ {{ _('download_update') }}
</a> </a>
</div> </div>
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -911,31 +1026,45 @@
const el = document.getElementById(`${provider}PublicUrls`); const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return; if (!el) return;
if (!urls || !urls.length) { if (!urls || !urls.length) {
el.className = 'tunnel-url-box tunnel-url-box--empty';
el.textContent = _('tunnel_no_public_url'); el.textContent = _('tunnel_no_public_url');
return; return;
} }
el.className = 'tunnel-url-box';
el.innerHTML = urls.map((url, idx) => ` el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};"> <div class="tunnel-url-row">
<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> <code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div> </div>
`).join(''); `).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) { function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`); const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`); const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`); const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`); const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`); const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
const card = document.getElementById(`tunnelCard-${provider}`);
if (!badge || !text) return; if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary'; const running = !!status.running;
badge.textContent = status.installed ? _('installed') : _('not_installed'); setTunnelStatusPill(badge, {
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable')); ok: running,
if (startBtn) startBtn.disabled = !!status.running; info: !running && status.installed,
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running); 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 (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] : [])); renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
} }
@@ -946,6 +1075,7 @@
updateTunnelProvider('cloudflare', data.cloudflare || {}); updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {}); updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {}); updateWarpStatus(data.warp || {});
updateNordvpnStatus(data.nordvpn || {});
} catch (err) { } catch (err) {
showToast(`${_('error')}: ` + err.message, 'error'); showToast(`${_('error')}: ` + err.message, 'error');
} }
@@ -958,12 +1088,16 @@
const connectBtn = document.getElementById('warpConnectBtn'); const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn'); const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText'); const connectText = document.getElementById('warpConnectBtnText');
const card = document.getElementById('tunnelCard-warp');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return; if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed; const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected'; const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary'); setTunnelStatusPill(badge, {
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')); ok: connected,
info: !connected && installed,
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `warp_status_${status.status || 'unknown'}`; const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown'); statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
@@ -971,6 +1105,7 @@
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required'); connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected; connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected); disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
} }
async function connectWarp() { async function connectWarp() {
@@ -1015,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) { async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`); const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`); const spinner = document.getElementById(`${provider}TunnelSpinner`);
@@ -1275,13 +1490,14 @@
let createProtocol = 'xui'; let createProtocol = 'xui';
let createServerId = 0; let createServerId = 0;
const guestProtoRaw = document.getElementById('guest_create_protocol').value; const guestServerVal = document.getElementById('guest_create_server')?.value || 'xui';
if (guestProtoRaw === 'xui') { const guestProtoVal = document.getElementById('guest_create_protocol')?.value || '';
if (guestServerVal === 'xui' || guestProtoVal === 'xui') {
createProtocol = 'xui'; createProtocol = 'xui';
} else if (guestProtoRaw.includes('|')) { createServerId = 0;
const parts = guestProtoRaw.split('|'); } else {
createProtocol = parts[0]; createProtocol = guestProtoVal;
createServerId = parseInt(parts[1] || '0'); createServerId = parseInt(guestServerVal || '0', 10) || 0;
} }
const guest = { const guest = {
@@ -1297,11 +1513,24 @@
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '', create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
}; };
const donateMethod = (key) => ({
enabled: document.getElementById(`donate_${key}_enabled`).checked,
title: document.getElementById(`donate_${key}_title`).value.trim(),
details: document.getElementById(`donate_${key}_details`).value.trim(),
});
const donate = {
enabled: document.getElementById('donate_enabled').checked,
intro: document.getElementById('donate_intro').value.trim(),
sbp: donateMethod('sbp'),
card: donateMethod('card'),
crypto: donateMethod('crypto'),
};
try { try {
const res = await fetch('/api/settings/save', { const res = await fetch('/api/settings/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest }) body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest, donate })
}); });
if (!res.ok) throw new Error(_('error')); if (!res.ok) throw new Error(_('error'));
showToast(_('settings_saved'), 'success'); showToast(_('settings_saved'), 'success');
@@ -1314,6 +1543,71 @@
} }
} }
const guestServersData = {{ servers | default([]) | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
const raw = String(key || '');
const base = raw.split('__')[0];
const title = PROTO_TITLES[base] || base.toUpperCase();
const m = raw.match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
}
function fillGuestProtocols() {
const serverSel = document.getElementById('guest_create_server');
const protoSel = document.getElementById('guest_create_protocol');
if (!serverSel || !protoSel) return;
const prefer = document.getElementById('guest_create_protocol_pref')?.value || protoSel.value || '';
const serverVal = serverSel.value;
protoSel.innerHTML = '';
if (serverVal === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = PROTO_TITLES.xui;
protoSel.appendChild(opt);
updateGuestCreateFields();
return;
}
const server = guestServersData[parseInt(serverVal, 10)];
const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(k => {
const info = protocols[k] || {};
return !!info.installed && VPN_PROTO_ORDER.includes(k.split('__')[0]);
}).sort((a, b) => {
const ia = VPN_PROTO_ORDER.indexOf(a.split('__')[0]);
const ib = VPN_PROTO_ORDER.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
if (!installed.length) {
const opt = document.createElement('option');
opt.value = '';
opt.disabled = true;
opt.textContent = _('no_protocols');
protoSel.appendChild(opt);
} else {
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = protoTitle(key);
if (prefer && prefer === key) opt.selected = true;
protoSel.appendChild(opt);
});
}
updateGuestCreateFields();
}
function updateGuestCreateFields() { function updateGuestCreateFields() {
const v = document.getElementById('guest_create_protocol')?.value; const v = document.getElementById('guest_create_protocol')?.value;
const group = document.getElementById('guestInboundGroup'); const group = document.getElementById('guestInboundGroup');
@@ -1321,6 +1615,7 @@
if (v === 'xui') loadGuestInbounds(); if (v === 'xui') loadGuestInbounds();
} }
fillGuestProtocols();
if (document.getElementById('guest_create_protocol')?.value === 'xui') { if (document.getElementById('guest_create_protocol')?.value === 'xui') {
loadGuestInbounds(); loadGuestInbounds();
} }
@@ -1411,47 +1706,168 @@
updateProtocolsForSync(); updateProtocolsForSync();
} }
async function checkForUpdates() { let latestUpdateInfo = null;
async function checkForUpdates(silent) {
const btn = document.getElementById('checkUpdateBtn'); const btn = document.getElementById('checkUpdateBtn');
const text = document.getElementById('checkUpdateBtnText'); const text = document.getElementById('checkUpdateBtnText');
const spinner = document.getElementById('checkUpdateSpinner'); const spinner = document.getElementById('checkUpdateSpinner');
const statusDiv = document.getElementById('updateStatus'); const statusDiv = document.getElementById('updateStatus');
const downloadBtn = document.getElementById('downloadUpdateBtn'); const downloadBtn = document.getElementById('downloadUpdateBtn');
const applyBtn = document.getElementById('applyUpdateBtn');
btn.disabled = true; const modeHint = document.getElementById('updateModeHint');
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden'); if (!silent) {
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
}
statusDiv.classList.add('hidden'); statusDiv.classList.add('hidden');
downloadBtn.classList.add('hidden'); downloadBtn.classList.add('hidden');
applyBtn.classList.add('hidden');
try { try {
const response = await fetch('https://api.github.com/repos/PRVTPRO/Amnezia-Web-Panel/releases/latest'); const data = await apiCall('/api/settings/check_updates');
if (!response.ok) throw new Error('API Error'); latestUpdateInfo = data;
const data = await response.json(); const latestVersion = data.latest_version || '';
const latestVersion = data.tag_name;
const currentVersion = '{{ current_version }}';
statusDiv.classList.remove('hidden'); statusDiv.classList.remove('hidden');
if (latestVersion !== currentVersion && latestVersion) { 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.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)'; statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url; downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
downloadBtn.classList.remove('hidden'); downloadBtn.classList.remove('hidden');
applyBtn.classList.remove('hidden');
} else { } else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`; statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)'; statusDiv.style.border = '1px solid var(--border-color)';
} }
} catch (err) { } catch (err) {
showToast('Failed to check for updates: ' + err.message, 'error'); if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
} finally { } finally {
btn.disabled = false; if (!silent) {
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}"; btn.disabled = false;
spinner.classList.add('hidden'); 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 ========== */ /* ========== API Tokens ========== */
function fmtTokenDate(iso) { function fmtTokenDate(iso) {
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ _('nav_support') }}{% endblock %}
{% block head_extra %}
<style>
.support-hero {
text-align: center;
padding: var(--space-xl) var(--space-md);
margin-bottom: var(--space-xl);
}
.support-hero-icon {
font-size: 2.5rem;
margin-bottom: var(--space-md);
color: var(--accent);
}
.support-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-lg);
max-width: 960px;
margin: 0 auto;
}
.support-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.support-card-title {
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.support-details {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
padding: var(--space-sm);
border-radius: var(--radius-md);
background: var(--bg-primary);
border: 1px dashed var(--border-color);
min-height: 4rem;
color: var(--text-muted);
}
.support-details.has-content {
color: var(--text-primary);
border-style: solid;
}
</style>
{% endblock %}
{% block content %}
<section class="support-hero">
<div class="support-hero-icon">{{ icon('heart') }}</div>
<h1 class="section-title" style="justify-content: center; margin-bottom: var(--space-sm);">
{{ _('support_title') }}
</h1>
<p class="text-muted" style="max-width: 560px; margin: 0 auto; line-height: 1.5;">
{% if donate.get('intro') %}
{{ donate.intro }}
{% else %}
{{ _('support_intro') }}
{% endif %}
</p>
</section>
<div class="support-grid">
{% set methods = [
('sbp', _('donate_sbp'), '💳'),
('card', _('donate_card'), '🏦'),
('crypto', _('donate_crypto'), '₿'),
] %}
{% for key, default_title, emoji in methods %}
{% set method = donate.get(key) or {} %}
{% if method.get('enabled', True) %}
<div class="card support-card">
<div class="support-card-title">
<span aria-hidden="true">{{ emoji }}</span>
<span>{{ method.get('title') or default_title }}</span>
</div>
{% set details = (method.get('details') or '').strip() %}
<div class="support-details{% if details %} has-content{% endif %}" id="donate-{{ key }}-details">{{ details or _('donate_details_soon') }}</div>
{% if details %}
<button type="button" class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('donate-{{ key }}-details').textContent)">
{{ icon('copy') }} {{ _('copy') }}
</button>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<p class="text-muted text-sm" style="text-align: center; margin-top: var(--space-xl); max-width: 520px; margin-left: auto; margin-right: auto;">
{{ _('support_footer') }}
</p>
{% endblock %}
+61 -20
View File
@@ -143,9 +143,10 @@
<select class="form-select" id="newUserServer"> <select class="form-select" id="newUserServer">
<option value="">{{ _('no_create_conn') }}</option> <option value="">{{ _('no_create_conn') }}</option>
{% for s in servers %} {% for s in servers %}
<option value="{{ loop.index0 }}">{{ s.name }} ({{ s.host }})</option> <option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div> </div>
<div class="form-group" id="newUserProtoGroup" style="display:none;"> <div class="form-group" id="newUserProtoGroup" style="display:none;">
@@ -293,10 +294,14 @@
<div class="form-group" id="ucServerGroup"> <div class="form-group" id="ucServerGroup">
<label class="form-label">{{ _('server_label') }}</label> <label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="ucServer"> <select class="form-select" id="ucServer">
{% if xui_servers %}
<option value="xui">3x-ui</option>
{% endif %}
{% for s in servers %} {% for s in servers %}
<option value="{{ loop.index0 }}">{{ s.name }} ({{ s.host }})</option> <option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div> </div>
<div class="form-group" id="ucProtoGroup"> <div class="form-group" id="ucProtoGroup">
@@ -626,30 +631,58 @@
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
const group = groupId ? document.getElementById(groupId) : null; const group = groupId ? document.getElementById(groupId) : null;
select.innerHTML = ''; select.innerHTML = '';
const vpnBases = new Set(['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']); const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
const titles = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
mieru: 'Mieru',
xui: '3x-ui VLESS',
};
const protoTitle = (key) => {
const base = String(key || '').split('__')[0];
const title = titles[base] || base.toUpperCase();
const m = String(key || '').match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
};
let count = 0; let count = 0;
// Special source: 3x-ui (server picker value "xui")
if (String(serverId) === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = titles.xui;
select.appendChild(opt);
count = 1;
if (group) group.style.display = '';
if (selectId === 'ucProtocol') updateXuiInboundVisibility();
return;
}
const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null; const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null;
const protocols = (server && server.protocols) || {}; const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(key => {
const info = protocols[key] || {};
if (!info.installed) return false;
return vpnOrder.includes(key.split('__')[0]);
}).sort((a, b) => {
const ia = vpnOrder.indexOf(a.split('__')[0]);
const ib = vpnOrder.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
for (const [key, info] of Object.entries(protocols)) { installed.forEach(key => {
if (!info.installed) continue;
const base = key.split('__')[0];
if (!vpnBases.has(base)) continue;
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = key; opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : (key === 'wireguard' ? 'WireGuard' : (key === 'telemt' ? 'Telemt' : key.toUpperCase()))))); opt.textContent = protoTitle(key);
select.appendChild(opt); select.appendChild(opt);
count++; count++;
} });
if (xuiConfigured && selectId === 'ucProtocol') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = '3x-ui VLESS';
select.appendChild(opt);
count++;
}
if (count > 0) { if (count > 0) {
if (group) group.style.display = ''; if (group) group.style.display = '';
@@ -724,9 +757,12 @@
document.getElementById('ucServer').addEventListener('change', (e) => { document.getElementById('ucServer').addEventListener('change', (e) => {
updateProtocols(e.target.value, 'ucProtocol'); updateProtocols(e.target.value, 'ucProtocol');
if (document.getElementById('ucMode').value === 'existing') { const mode = document.getElementById('ucMode').value;
if (mode === 'existing' && e.target.value !== 'xui') {
fetchExistingClients(); fetchExistingClients();
} }
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
updateXuiInboundVisibility();
}); });
document.getElementById('ucProtocol').addEventListener('change', (e) => { document.getElementById('ucProtocol').addEventListener('change', (e) => {
@@ -898,13 +934,18 @@
spinner.classList.remove('hidden'); spinner.classList.remove('hidden');
try { try {
const serverRaw = document.getElementById('ucServer').value;
const protocol = document.getElementById('ucProtocol').value;
const body = { const body = {
server_id: parseInt(document.getElementById('ucServer').value) || 0, server_id: serverRaw === 'xui' ? 0 : (parseInt(serverRaw, 10) || 0),
protocol: document.getElementById('ucProtocol').value, protocol: protocol,
name: document.getElementById('ucName').value || 'VPN Connection', name: document.getElementById('ucName').value || 'VPN Connection',
}; };
if (mode === 'existing') { if (mode === 'existing') {
if (serverRaw === 'xui' || protocol === 'xui') {
throw new Error(_('link_existing_xui_unsupported') || 'Link existing is only for Amnezia server protocols');
}
const clientId = document.getElementById('ucExistingClient').value; const clientId = document.getElementById('ucExistingClient').value;
if (!clientId) throw new Error(_('select_connection')); if (!clientId) throw new Error(_('select_connection'));
body.client_id = clientId; body.client_id = clientId;
+663 -551
View File
File diff suppressed because it is too large Load Diff
+590 -495
View File
File diff suppressed because it is too large Load Diff
+590 -495
View File
File diff suppressed because it is too large Load Diff
+663 -551
View File
File diff suppressed because it is too large Load Diff
+590 -495
View File
File diff suppressed because it is too large Load Diff