Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d62758716 | ||
|
|
d26843a0d1 | ||
|
|
706d2c9c0d | ||
|
|
2746f981af | ||
|
|
4f5a1d7554 | ||
|
|
ff7cc5c592 | ||
|
|
2808a49fa5 | ||
|
|
2973b96713 | ||
|
|
5d63e5d6ef | ||
|
|
a5b8f26db1 | ||
|
|
599e0a487c | ||
|
|
77c6f0dab7 | ||
|
|
fd257a59f7 | ||
|
|
6206697e3b | ||
|
|
4c19099b04 | ||
|
|
80160d0ef4 |
+10
-3
@@ -2,11 +2,18 @@
|
||||
APP_PORT=5000
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
|
||||
# PostgreSQL (used by docker-compose)
|
||||
# PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
|
||||
POSTGRES_USER=amnezia
|
||||
POSTGRES_PASSWORD=amnezia
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=amnezia_panel
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
|
||||
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
|
||||
|
||||
# Full DSN (override if panel runs outside compose)
|
||||
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel
|
||||
# DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
|
||||
|
||||
# Dokploy / reverse proxy (optional — set in compose for Docker)
|
||||
# PANEL_IN_DOCKER=1
|
||||
# PANEL_BEHIND_PROXY=1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
+6
-3
@@ -5,6 +5,9 @@ FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_PORT=5000 \
|
||||
PORT=5000 \
|
||||
PANEL_IN_DOCKER=1 \
|
||||
PANEL_BEHIND_PROXY=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
@@ -20,7 +23,7 @@ COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:5000/health" >/dev/null || exit 1
|
||||
|
||||
CMD ["python3", "app.py"]
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Amnezia Web Panel
|
||||
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **NordVPN**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
|
||||
> ### 🔄 Compatibility with Official Amnezia Client
|
||||
>
|
||||
@@ -66,6 +66,7 @@ Configuration panel for system parameters and preferences:
|
||||
* **NaiveProxy** (stable): HTTPS/HTTP2 camouflage proxy via [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) (Caddy + [klzgrad/forwardproxy](https://github.com/klzgrad/forwardproxy) naïve fork). ACME TLS on the domain, per-client basic auth, `naive+https://` share links. Requires free TCP **80** and **443**. **Use Karing** as the client — do **not** use v2rayN; other clients are untested.
|
||||
* **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed.
|
||||
* **Cloudflare WARP**: Add and manage WARP-powered connectivity from the panel for routing and network flexibility.
|
||||
* **NordVPN**: Connect/disconnect outbound NordVPN from Settings via the official CLI (optional country/city).
|
||||
* **🛠 Services**:
|
||||
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings.
|
||||
* **AdGuard Home**: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host.
|
||||
@@ -79,17 +80,17 @@ Configuration panel for system parameters and preferences:
|
||||
* **Reboot** the server directly from the UI.
|
||||
* 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.
|
||||
* **🔁 Cascade (double VPN)**:
|
||||
* Route clients through an **entry** AmneziaWG/WireGuard server to an **exit** server (country-block bypass).
|
||||
* Approach inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade) (AWG2 entry→exit); applied via panel SSH — **no remote curl|bash** script download.
|
||||
* Handshake wait + optional egress verify (curl --interface → ifconfig.me); exit return route persisted like ryderams vps2.
|
||||
* Users keep using the **ENTRY** client config only — do not share the exit cascade peer.
|
||||
* **🧩 Marketplace & Templates**:
|
||||
* 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.
|
||||
* **🌐 Internationalization (i18n)**:
|
||||
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
|
||||
* Native **RTL (Right-to-Left)** support for Persian language.
|
||||
* **🔄 Auto-update**:
|
||||
* Settings → About checks releases on [git.evilfox.cc](https://git.evilfox.cc/test2/Amnezia-Web-Panel-main) and can install the latest tag via git + restart (when the panel runs from a git checkout).
|
||||
* **🌍 Tunnels & outbound VPN**:
|
||||
* **Cloudflare Quick Tunnel** and **ngrok** expose the local panel to the internet.
|
||||
* **Cloudflare WARP** and **NordVPN** route the host outbound (no public panel URL).
|
||||
* **👥 Advanced User Management**:
|
||||
* Role-based access (Admin, Support, Regular User).
|
||||
* Traffic limits, status monitoring, and account expiration.
|
||||
@@ -187,22 +188,71 @@ Mac
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set SECRET_KEY and strong POSTGRES_PASSWORD in .env
|
||||
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create dokploy-network
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
|
||||
|
||||
### Dokploy (recommended)
|
||||
|
||||
The compose file connects the panel to Dokploy's Traefik network (`dokploy-network`) and exposes the app on **container port 5000** (not 80).
|
||||
|
||||
1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically).
|
||||
2. In Dokploy → your app → **Environment** (set **before first deploy**):
|
||||
- `SECRET_KEY` — long random string
|
||||
- `POSTGRES_PASSWORD` — strong password (same value is used for `db` and `amnezia_panel`)
|
||||
3. In Dokploy → **Domains**:
|
||||
- **Service**: `amnezia_panel` (not `db`)
|
||||
- **Container port**: `5000`
|
||||
- **Path**: `/`
|
||||
4. **Deploy / Rebuild** (not Restart) after code or env changes.
|
||||
|
||||
**Verify on the server** (replace `PROJECT` and path with yours):
|
||||
|
||||
```bash
|
||||
COMPOSE_DIR=/etc/dokploy/compose/home-vpn-g6rfpd/code
|
||||
PROJECT=home-vpn-g6rfpd
|
||||
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml exec amnezia_panel \
|
||||
curl -fsS http://127.0.0.1:5000/health
|
||||
# → {"status":"ok","version":"v2.6.0"}
|
||||
|
||||
docker inspect ${PROJECT}-amnezia_panel-1 \
|
||||
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||
# must include: dokploy-network
|
||||
```
|
||||
|
||||
**Full redeploy after git update:**
|
||||
|
||||
```bash
|
||||
cd $COMPOSE_DIR && git pull origin main
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml build --no-cache amnezia_panel
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
**Postgres `password authentication failed`:** the volume keeps the password from the **first** deploy. If you change `POSTGRES_PASSWORD` in Dokploy later, reset the DB volume (wipes panel DB data):
|
||||
|
||||
```bash
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
|
||||
docker volume rm ${PROJECT}_amnezia_pgdata
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
**Domain shows `404 page not found` (plain text):** Traefik has no route to the panel — check Domains (service `amnezia_panel`, port `5000`) and that the container is on `dokploy-network` (see verify commands above).
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `Dockerfile` | Production image (Python 3.12) |
|
||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks |
|
||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks, Dokploy/Traefik labels |
|
||||
| `.env.example` | Environment template |
|
||||
|
||||
**Environment**
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `APP_PORT` | `5000` | Host port / in-container listen port |
|
||||
| `APP_PORT` / `PORT` | `5000` | Host port / in-container listen port |
|
||||
| `SECRET_KEY` | (random) | Session signing key — set in production |
|
||||
| `DATABASE_URL` | compose DSN | PostgreSQL connection string |
|
||||
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
|
||||
@@ -227,11 +277,62 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
|
||||
## 📋 Fix / changelog (this fork)
|
||||
|
||||
### 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** — stable entry→exit tunnel for AmneziaWG / WireGuard.
|
||||
* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic runs over panel SSH (**no remote curl|bash**).
|
||||
* Safety: require exit **handshake** when enabled; optional **egress verify** via cascade Address; subnet conflict check; exit `/32` return route with start.sh markers.
|
||||
* UI on the server page with per-step settings (pin exit route, MSS clamp, verify egress, …). Defaults prefer **awg2**.
|
||||
* **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`.
|
||||
@@ -264,7 +365,7 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
### v1.6.0
|
||||
* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui).
|
||||
* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow.
|
||||
* **Cascade** was temporarily removed in this release (redesigned and restored in **v2.2.0**).
|
||||
* **Cascade** was temporarily removed in this release (briefly restored in v2.2.x, removed again in **v2.3.0**).
|
||||
* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
|
||||
* **API performance**: in-memory data cache, faster server check/stats.
|
||||
* **Share / guest**: one-tap “Copy key”.
|
||||
|
||||
+26
-7
@@ -1,18 +1,21 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
container_name: amnezia_panel_db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
||||
volumes:
|
||||
- amnezia_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && PGPASSWORD=$${POSTGRES_PASSWORD} psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' >/dev/null",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -23,7 +26,6 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: amnezia-panel:local
|
||||
container_name: amnezia_panel
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -33,15 +35,32 @@ services:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
||||
SECRET_KEY: ${SECRET_KEY:-}
|
||||
APP_PORT: "5000"
|
||||
PORT: "5000"
|
||||
PANEL_IN_DOCKER: "1"
|
||||
PANEL_BEHIND_PROXY: "1"
|
||||
volumes:
|
||||
- amnezia_data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
- dokploy-network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=dokploy-network
|
||||
expose:
|
||||
- "5000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
internal:
|
||||
dokploy-network:
|
||||
external: true
|
||||
name: dokploy-network
|
||||
|
||||
volumes:
|
||||
amnezia_data:
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
"""Cascade (double-VPN) between two panel-managed WireGuard/AWG servers.
|
||||
|
||||
Traffic path:
|
||||
Clients → Entry server (accessible) → Exit server (blocked / target) → Internet
|
||||
|
||||
Implemented inside the entry Docker container as a second AWG/WG interface
|
||||
(`cascade.conf`) with source-based routing for the VPN client subnet.
|
||||
|
||||
Inspired by ryderams/amneziawg-cascade (AWG2 entry→exit), but executed via the
|
||||
panel SSH session — no remote curl|bash script download.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import shlex
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
WG_FAMILY = {'awg', 'awg2', 'awg_legacy', 'wireguard'}
|
||||
CASCADE_MARKER = '# amnezia-web-panel-cascade'
|
||||
EXIT_ROUTE_BEGIN = '# BEGIN awg-cascade-vps2'
|
||||
EXIT_ROUTE_END = '# END awg-cascade-vps2'
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
'pin_exit_route': True,
|
||||
'remove_eth_masquerade': True,
|
||||
'force_forward': True,
|
||||
'mss_clamp': True,
|
||||
'wait_handshake': True,
|
||||
'handshake_timeout_sec': 25,
|
||||
'allowed_ips': '0.0.0.0/0',
|
||||
'keep_exit_dns': False,
|
||||
'vpn_subnet_override': '',
|
||||
'table_id': 200,
|
||||
'rule_priority': 100,
|
||||
'verify_egress': True,
|
||||
}
|
||||
|
||||
|
||||
def normalize_settings(raw: Optional[dict]) -> dict:
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
if isinstance(raw, dict):
|
||||
for key, default in DEFAULT_SETTINGS.items():
|
||||
if key not in raw or raw[key] is None:
|
||||
continue
|
||||
if isinstance(default, bool):
|
||||
settings[key] = bool(raw[key])
|
||||
elif isinstance(default, int):
|
||||
try:
|
||||
settings[key] = int(raw[key])
|
||||
except (TypeError, ValueError):
|
||||
settings[key] = default
|
||||
else:
|
||||
settings[key] = str(raw[key]).strip()
|
||||
settings['handshake_timeout_sec'] = max(5, min(120, int(settings['handshake_timeout_sec'])))
|
||||
settings['table_id'] = max(1, min(252, int(settings['table_id'])))
|
||||
settings['rule_priority'] = max(1, min(32765, int(settings['rule_priority'])))
|
||||
if not settings.get('allowed_ips'):
|
||||
settings['allowed_ips'] = DEFAULT_SETTINGS['allowed_ips']
|
||||
return settings
|
||||
|
||||
|
||||
class CascadeManager:
|
||||
def __init__(self, ssh_manager):
|
||||
self.ssh = ssh_manager
|
||||
|
||||
@staticmethod
|
||||
def proto_base(protocol: str) -> str:
|
||||
return str(protocol or '').split('__', 1)[0]
|
||||
|
||||
@staticmethod
|
||||
def is_wg_family(protocol: str) -> bool:
|
||||
return CascadeManager.proto_base(protocol) in WG_FAMILY
|
||||
|
||||
@staticmethod
|
||||
def _container_name(protocol: str) -> str:
|
||||
base = CascadeManager.proto_base(protocol)
|
||||
match = re.search(r'__(\d+)$', str(protocol or ''))
|
||||
idx = int(match.group(1)) if match else 1
|
||||
names = {
|
||||
'awg': 'amnezia-awg',
|
||||
'awg2': 'amnezia-awg2',
|
||||
'awg_legacy': 'amnezia-awg-legacy',
|
||||
'wireguard': 'amnezia-wireguard',
|
||||
}
|
||||
name = names.get(base)
|
||||
if not name:
|
||||
return ''
|
||||
return name if idx <= 1 else f'{name}-{idx}'
|
||||
|
||||
@staticmethod
|
||||
def _wg_binary(protocol: str) -> str:
|
||||
return 'wg' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg'
|
||||
|
||||
@staticmethod
|
||||
def _quick_binary(protocol: str) -> str:
|
||||
return 'wg-quick' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg-quick'
|
||||
|
||||
@staticmethod
|
||||
def _conf_dir(protocol: str) -> str:
|
||||
return '/opt/amnezia/wireguard' if CascadeManager.proto_base(protocol) == 'wireguard' else '/opt/amnezia/awg'
|
||||
|
||||
@classmethod
|
||||
def _cascade_conf(cls, protocol: str) -> str:
|
||||
return f'{cls._conf_dir(protocol)}/cascade.conf'
|
||||
|
||||
@classmethod
|
||||
def _cascade_up(cls, protocol: str) -> str:
|
||||
return f'{cls._conf_dir(protocol)}/cascade-up.sh'
|
||||
|
||||
@staticmethod
|
||||
def _server_conf(protocol: str) -> str:
|
||||
base = CascadeManager.proto_base(protocol)
|
||||
if base == 'wireguard':
|
||||
return '/opt/amnezia/wireguard/wg0.conf'
|
||||
if base == 'awg_legacy':
|
||||
return '/opt/amnezia/awg/wg0.conf'
|
||||
return '/opt/amnezia/awg/awg0.conf'
|
||||
|
||||
@staticmethod
|
||||
def _server_iface(protocol: str) -> str:
|
||||
base = CascadeManager.proto_base(protocol)
|
||||
if base in ('wireguard', 'awg_legacy'):
|
||||
return 'wg0'
|
||||
return 'awg0'
|
||||
|
||||
@staticmethod
|
||||
def _extract_endpoint_host(config_text: str) -> str:
|
||||
for line in (config_text or '').splitlines():
|
||||
if line.strip().lower().startswith('endpoint'):
|
||||
rhs = line.split('=', 1)[-1].strip()
|
||||
return rhs.rsplit(':', 1)[0].strip().strip('[]')
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def _extract_address_cidr(config_text: str) -> str:
|
||||
for line in (config_text or '').splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.lower().startswith('address'):
|
||||
rhs = stripped.split('=', 1)[-1].strip()
|
||||
return rhs.split(',')[0].strip()
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def _extract_address_ip(config_text: str) -> str:
|
||||
cidr = CascadeManager._extract_address_cidr(config_text)
|
||||
if not cidr:
|
||||
return ''
|
||||
return cidr.split('/', 1)[0].strip()
|
||||
|
||||
@staticmethod
|
||||
def prepare_exit_client_config(
|
||||
raw_config: str,
|
||||
endpoint_host: str,
|
||||
*,
|
||||
keep_dns: bool = False,
|
||||
allowed_ips: str = '0.0.0.0/0',
|
||||
) -> str:
|
||||
"""Adapt a normal client config for use as an entry→exit cascade tunnel."""
|
||||
effective_allowed = (allowed_ips or '').strip() or '0.0.0.0/0'
|
||||
if effective_allowed in ('0.0.0.0/0, ::/0', '::/0, 0.0.0.0/0'):
|
||||
effective_allowed = '0.0.0.0/0'
|
||||
|
||||
lines = []
|
||||
in_interface = False
|
||||
in_peer = False
|
||||
has_table = False
|
||||
has_mtu = False
|
||||
for raw in (raw_config or '').splitlines():
|
||||
stripped = raw.strip()
|
||||
lower = stripped.lower()
|
||||
if stripped.startswith('[') and stripped.endswith(']'):
|
||||
in_interface = stripped.lower() == '[interface]'
|
||||
in_peer = stripped.lower() == '[peer]'
|
||||
lines.append(stripped)
|
||||
continue
|
||||
if in_interface and lower.startswith('dns') and not keep_dns:
|
||||
continue
|
||||
if in_interface and lower.startswith('table'):
|
||||
has_table = True
|
||||
lines.append('Table = off')
|
||||
continue
|
||||
if in_interface and lower.startswith('mtu'):
|
||||
has_mtu = True
|
||||
lines.append(stripped)
|
||||
continue
|
||||
if lower.startswith('endpoint'):
|
||||
port = '51820'
|
||||
if '=' in stripped:
|
||||
rhs = stripped.split('=', 1)[1].strip()
|
||||
if ':' in rhs:
|
||||
port = rhs.rsplit(':', 1)[-1]
|
||||
host = (endpoint_host or '').strip()
|
||||
if host:
|
||||
lines.append(f'Endpoint = {host}:{port}')
|
||||
else:
|
||||
lines.append(stripped)
|
||||
continue
|
||||
if in_peer and lower.startswith('allowedips'):
|
||||
lines.append(f'AllowedIPs = {effective_allowed}')
|
||||
continue
|
||||
lines.append(stripped)
|
||||
|
||||
out = []
|
||||
inserted_iface = False
|
||||
for line in lines:
|
||||
out.append(line)
|
||||
if not inserted_iface and line.strip().lower() == '[interface]':
|
||||
if not has_table:
|
||||
out.append('Table = off')
|
||||
if not has_mtu:
|
||||
out.append('MTU = 1280')
|
||||
inserted_iface = True
|
||||
return '\n'.join(out).strip() + '\n'
|
||||
|
||||
@staticmethod
|
||||
def _subnet_contains_ip(subnet: str, ip: str) -> bool:
|
||||
try:
|
||||
net = ipaddress.ip_network(subnet, strict=False)
|
||||
return ipaddress.ip_address(ip) in net
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def status(self, entry_protocol: str) -> dict:
|
||||
container = self._container_name(entry_protocol)
|
||||
if not container:
|
||||
return {'enabled': False, 'up': False, 'handshake': False, 'error': 'Unsupported protocol'}
|
||||
conf = self._cascade_conf(entry_protocol)
|
||||
wg_bin = self._wg_binary(entry_protocol)
|
||||
out, _, _ = self.ssh.run_sudo_command(
|
||||
f"docker exec {shlex.quote(container)} bash -lc "
|
||||
f"'echo HAS=$(test -f {shlex.quote(conf)} && echo 1 || echo 0); "
|
||||
f"echo IFACES=$({wg_bin} show interfaces 2>/dev/null); "
|
||||
f"echo HANDSHAKE=$({wg_bin} show cascade latest-handshakes 2>/dev/null | awk \"{{print \\$2}}\" | head -1); "
|
||||
f"echo TRANSFER=$({wg_bin} show cascade transfer 2>/dev/null | head -1); "
|
||||
f"ip rule show 2>/dev/null | head -20; "
|
||||
f"ip route show table 200 2>/dev/null | head -10'"
|
||||
)
|
||||
text = out or ''
|
||||
has_conf = 'HAS=1' in text
|
||||
ifaces_line = ''
|
||||
handshake_ts = 0
|
||||
for line in text.splitlines():
|
||||
if line.startswith('IFACES='):
|
||||
ifaces_line = line.split('=', 1)[1].strip()
|
||||
if line.startswith('HANDSHAKE='):
|
||||
try:
|
||||
handshake_ts = int(line.split('=', 1)[1].strip() or '0')
|
||||
except ValueError:
|
||||
handshake_ts = 0
|
||||
up = 'cascade' in ifaces_line.split()
|
||||
handshake_ok = handshake_ts > 0
|
||||
return {
|
||||
'enabled': has_conf,
|
||||
'up': up,
|
||||
'handshake': handshake_ok,
|
||||
'handshake_ts': handshake_ts,
|
||||
'raw': text.strip()[:3000],
|
||||
}
|
||||
|
||||
def _vpn_subnet(self, entry_protocol: str, container: str, override: str = '') -> str:
|
||||
if override and re.match(r'^\d+\.\d+\.\d+\.\d+/\d+$', override.strip()):
|
||||
return override.strip()
|
||||
conf = self._server_conf(entry_protocol)
|
||||
out, _, _ = self.ssh.run_sudo_command(
|
||||
f"docker exec {shlex.quote(container)} bash -lc "
|
||||
f"\"grep -E '^Address' {shlex.quote(conf)} | head -1 | cut -d= -f2 | tr -d ' '\""
|
||||
)
|
||||
addr = (out or '').strip()
|
||||
if not addr:
|
||||
return '10.8.1.0/24'
|
||||
if '/' in addr:
|
||||
ip, cidr = addr.split('/', 1)
|
||||
parts = ip.split('.')
|
||||
if len(parts) == 4 and cidr.isdigit() and int(cidr) >= 24:
|
||||
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/{cidr}"
|
||||
return addr
|
||||
parts = addr.split('.')
|
||||
if len(parts) == 4:
|
||||
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
|
||||
return '10.8.1.0/24'
|
||||
|
||||
def ensure_exit_return_route(self, protocol: str, peer_ip: str) -> dict:
|
||||
"""Add /32 return route for the cascade peer on the exit container (ryderams vps2)."""
|
||||
peer_ip = (peer_ip or '').strip().split('/', 1)[0]
|
||||
if not re.match(r'^\d+\.\d+\.\d+\.\d+$', peer_ip):
|
||||
return {'status': 'error', 'message': 'Invalid cascade peer IP'}
|
||||
container = self._container_name(protocol)
|
||||
if not container:
|
||||
return {'status': 'error', 'message': 'Unsupported exit protocol'}
|
||||
iface = self._server_iface(protocol)
|
||||
route_cmd = f'ip route replace {peer_ip}/32 dev {iface}'
|
||||
script = f"""#!/bin/bash
|
||||
set -e
|
||||
START=/opt/amnezia/start.sh
|
||||
{route_cmd} 2>/dev/null || true
|
||||
[ -f "$START" ] || {{ echo CASCADE_EXIT_ROUTE_OK; exit 0; }}
|
||||
if grep -q '{EXIT_ROUTE_BEGIN}' "$START" 2>/dev/null; then
|
||||
tmp=$(mktemp)
|
||||
awk -v b='{EXIT_ROUTE_BEGIN}' -v e='{EXIT_ROUTE_END}' '
|
||||
$0==b {{skip=1; next}}
|
||||
$0==e {{skip=0; next}}
|
||||
!skip {{print}}
|
||||
' "$START" > "$tmp" && mv "$tmp" "$START"
|
||||
fi
|
||||
printf '\\n%s\\n%s\\n%s\\n' '{EXIT_ROUTE_BEGIN}' '{route_cmd} || true' '{EXIT_ROUTE_END}' >> "$START"
|
||||
echo CASCADE_EXIT_ROUTE_OK
|
||||
"""
|
||||
self.ssh.upload_file(script, '/tmp/_amnz_cascade_exit_route.sh')
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_amnz_cascade_exit_route.sh {shlex.quote(container)}:/tmp/_exit_route.sh && "
|
||||
f"docker exec {shlex.quote(container)} bash /tmp/_exit_route.sh && "
|
||||
f"docker exec {shlex.quote(container)} rm -f /tmp/_exit_route.sh",
|
||||
timeout=60,
|
||||
)
|
||||
self.ssh.run_command('rm -f /tmp/_amnz_cascade_exit_route.sh')
|
||||
if code != 0 or 'CASCADE_EXIT_ROUTE_OK' not in (out or ''):
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': ((err or out) or 'Failed to set exit return route').strip()[:400],
|
||||
}
|
||||
return {'status': 'success', 'peer_ip': peer_ip, 'iface': iface}
|
||||
|
||||
def apply(
|
||||
self,
|
||||
entry_protocol: str,
|
||||
exit_client_config: str,
|
||||
exit_host: str,
|
||||
settings: Optional[dict] = None,
|
||||
) -> dict:
|
||||
opts = normalize_settings(settings)
|
||||
container = self._container_name(entry_protocol)
|
||||
if not container:
|
||||
return {'status': 'error', 'message': 'Unsupported entry protocol'}
|
||||
|
||||
exists, _, code = self.ssh.run_sudo_command(
|
||||
f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(container)} 2>/dev/null"
|
||||
)
|
||||
if code != 0 or exists.strip().lower() != 'true':
|
||||
return {'status': 'error', 'message': f'Entry container {container} is not running'}
|
||||
|
||||
cascade_conf_path = self._cascade_conf(entry_protocol)
|
||||
cascade_up_path = self._cascade_up(entry_protocol)
|
||||
cascade_conf = self.prepare_exit_client_config(
|
||||
exit_client_config,
|
||||
exit_host,
|
||||
keep_dns=bool(opts['keep_exit_dns']),
|
||||
allowed_ips=str(opts['allowed_ips']),
|
||||
)
|
||||
endpoint_host = self._extract_endpoint_host(cascade_conf) or exit_host
|
||||
cascade_addr_cidr = self._extract_address_cidr(cascade_conf)
|
||||
cascade_peer_ip = self._extract_address_ip(cascade_conf)
|
||||
subnet = self._vpn_subnet(entry_protocol, container, opts.get('vpn_subnet_override') or '')
|
||||
|
||||
if cascade_peer_ip and self._subnet_contains_ip(subnet, cascade_peer_ip):
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': (
|
||||
f'Cascade tunnel Address {cascade_addr_cidr or cascade_peer_ip} is inside '
|
||||
f'entry client subnet {subnet}. Use a different VPN subnet on the exit server '
|
||||
f'(e.g. 10.99.0.0/24) as in ryderams/amneziawg-cascade.'
|
||||
),
|
||||
}
|
||||
|
||||
wg_bin = self._wg_binary(entry_protocol)
|
||||
quick_bin = self._quick_binary(entry_protocol)
|
||||
conf_dir = self._conf_dir(entry_protocol)
|
||||
table_id = int(opts['table_id'])
|
||||
prio = int(opts['rule_priority'])
|
||||
hs_timeout = int(opts['handshake_timeout_sec'])
|
||||
|
||||
pin_block = ''
|
||||
if opts['pin_exit_route']:
|
||||
pin_block = f"""
|
||||
EXIT_IP="$EXIT_HOST"
|
||||
if ! printf '%s' "$EXIT_IP" | grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then
|
||||
EXIT_IP=$(getent ahostsv4 "$EXIT_HOST" 2>/dev/null | awk '{{print $1; exit}}')
|
||||
if [ -z "$EXIT_IP" ]; then
|
||||
EXIT_IP=$(python3 - <<'PY' 2>/dev/null || true
|
||||
import socket
|
||||
print(socket.gethostbyname("{endpoint_host}"))
|
||||
PY
|
||||
)
|
||||
fi
|
||||
fi
|
||||
GW=$(ip route | awk '/default/ {{print $3; exit}}')
|
||||
DEV=$(ip route | awk '/default/ {{print $5; exit}}')
|
||||
if [ -n "$EXIT_IP" ] && [ -n "$GW" ] && [ -n "$DEV" ]; then
|
||||
ip route replace "$EXIT_IP/32" via "$GW" dev "$DEV" 2>/dev/null || true
|
||||
fi
|
||||
"""
|
||||
|
||||
remove_masq = ''
|
||||
if opts['remove_eth_masquerade']:
|
||||
remove_masq = f"""
|
||||
for ETH in eth0 eth1 eth2 ens3 ens5 enp0s3 enp1s0; do
|
||||
while iptables -t nat -D POSTROUTING -s "$SUBNET" -o "$ETH" -j MASQUERADE 2>/dev/null; do :; done
|
||||
done
|
||||
"""
|
||||
|
||||
forward_block = ''
|
||||
if opts['force_forward']:
|
||||
forward_block = f"""
|
||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||
for SRC_IF in awg0 wg0; do
|
||||
iptables -C FORWARD -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null \\
|
||||
|| iptables -I FORWARD 1 -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null || true
|
||||
iptables -C FORWARD -i "$IFACE" -o "$SRC_IF" -j ACCEPT 2>/dev/null \\
|
||||
|| iptables -I FORWARD 1 -i "$IFACE" -o "$SRC_IF" -j ACCEPT 2>/dev/null || true
|
||||
done
|
||||
iptables -C FORWARD -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \\
|
||||
|| iptables -I FORWARD 1 -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||
"""
|
||||
|
||||
mss_block = ''
|
||||
if opts['mss_clamp']:
|
||||
mss_block = """
|
||||
iptables -t mangle -C FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null \\
|
||||
|| iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||
"""
|
||||
|
||||
handshake_block = ''
|
||||
if opts['wait_handshake']:
|
||||
handshake_block = f"""
|
||||
OK=0
|
||||
for i in $(seq 1 {hs_timeout}); do
|
||||
HS=$({shlex.quote(wg_bin)} show "$IFACE" latest-handshakes 2>/dev/null | awk '{{print $2}}' | head -1)
|
||||
if [ -n "$HS" ] && [ "$HS" != "0" ]; then OK=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$OK" != "1" ]; then
|
||||
echo "CASCADE_NO_HANDSHAKE" >&2
|
||||
echo "Tunnel interface is up but exit peer did not handshake. Check exit server / UDP port / keys." >&2
|
||||
exit 42
|
||||
fi
|
||||
"""
|
||||
|
||||
up_script = f"""#!/bin/bash
|
||||
{CASCADE_MARKER}
|
||||
set -e
|
||||
CONF={shlex.quote(cascade_conf_path)}
|
||||
QUICK={shlex.quote(quick_bin)}
|
||||
SUBNET={shlex.quote(subnet)}
|
||||
EXIT_HOST={shlex.quote(endpoint_host)}
|
||||
IFACE=cascade
|
||||
TABLE={table_id}
|
||||
PRIO={prio}
|
||||
|
||||
"$QUICK" down "$CONF" 2>/dev/null || true
|
||||
ip link delete "$IFACE" 2>/dev/null || true
|
||||
ip rule del from "$SUBNET" table "$TABLE" 2>/dev/null || true
|
||||
ip route flush table "$TABLE" 2>/dev/null || true
|
||||
|
||||
{pin_block}
|
||||
|
||||
"$QUICK" up "$CONF"
|
||||
|
||||
ip route replace default dev "$IFACE" table "$TABLE" 2>/dev/null || ip route add default dev "$IFACE" table "$TABLE"
|
||||
ip rule del from "$SUBNET" table "$TABLE" 2>/dev/null || true
|
||||
ip rule add from "$SUBNET" table "$TABLE" priority "$PRIO"
|
||||
|
||||
{remove_masq}
|
||||
{forward_block}
|
||||
iptables -t nat -C POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE 2>/dev/null \\
|
||||
|| iptables -t nat -A POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE
|
||||
{mss_block}
|
||||
{handshake_block}
|
||||
|
||||
echo CASCADE_UP_OK
|
||||
"""
|
||||
|
||||
self.ssh.run_sudo_command(f"docker exec {shlex.quote(container)} mkdir -p {shlex.quote(conf_dir)}")
|
||||
self.ssh.upload_file(cascade_conf, '/tmp/_amnz_cascade.conf')
|
||||
self.ssh.upload_file(up_script, '/tmp/_amnz_cascade_up.sh')
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_amnz_cascade.conf {shlex.quote(container)}:{shlex.quote(cascade_conf_path)} && "
|
||||
f"docker cp /tmp/_amnz_cascade_up.sh {shlex.quote(container)}:{shlex.quote(cascade_up_path)} && "
|
||||
f"docker exec {shlex.quote(container)} chmod 644 {shlex.quote(cascade_conf_path)} && "
|
||||
f"docker exec {shlex.quote(container)} chmod +x {shlex.quote(cascade_up_path)}"
|
||||
)
|
||||
self.ssh.run_command('rm -f /tmp/_amnz_cascade.conf /tmp/_amnz_cascade_up.sh')
|
||||
|
||||
self._ensure_start_hook(container, cascade_up_path)
|
||||
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker exec {shlex.quote(container)} bash {shlex.quote(cascade_up_path)}",
|
||||
timeout=max(90, hs_timeout + 40),
|
||||
)
|
||||
combined = ((out or '') + '\n' + (err or '')).strip()
|
||||
if code != 0 or 'CASCADE_UP_OK' not in (out or ''):
|
||||
msg = combined
|
||||
if 'CASCADE_NO_HANDSHAKE' in combined:
|
||||
msg = (
|
||||
'Туннель к серверу выхода поднят, но handshake не прошёл. '
|
||||
'Проверьте, что на выходе открыт UDP-порт, протокол совпадает, '
|
||||
'и входной сервер может достучаться до IP выхода.'
|
||||
)
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': (msg or 'Failed to bring cascade interface up').strip()[:900],
|
||||
'log': combined[:2000],
|
||||
}
|
||||
|
||||
st = self.status(entry_protocol)
|
||||
handshake_ok = bool(st.get('handshake'))
|
||||
|
||||
if opts.get('verify_egress', True) and handshake_ok and cascade_peer_ip:
|
||||
curl_cmd = (
|
||||
f"curl --interface {shlex.quote(cascade_peer_ip)} -4 -s --max-time 12 "
|
||||
f"https://ifconfig.me || true"
|
||||
)
|
||||
egress_out, egress_err, _egress_code = self.ssh.run_sudo_command(
|
||||
f"docker exec {shlex.quote(container)} bash -lc {shlex.quote(curl_cmd)}",
|
||||
timeout=30,
|
||||
)
|
||||
egress_ip = (egress_out or '').strip().splitlines()[-1].strip() if (egress_out or '').strip() else ''
|
||||
if not egress_ip or not re.match(r'^\d+\.\d+\.\d+\.\d+$', egress_ip):
|
||||
try:
|
||||
self.disable(entry_protocol, settings=opts)
|
||||
except Exception:
|
||||
pass
|
||||
detail = ((egress_err or egress_out) or 'empty response').strip()[:400]
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': (
|
||||
'Cascade handshake OK but egress verify failed '
|
||||
f'(curl --interface {cascade_peer_ip} → ifconfig.me). '
|
||||
f'Detail: {detail}'
|
||||
),
|
||||
'handshake': True,
|
||||
'up': bool(st.get('up')),
|
||||
'log': combined[:1500],
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'subnet': subnet,
|
||||
'endpoint': endpoint_host,
|
||||
'container': container,
|
||||
'cascade_peer_ip': cascade_peer_ip,
|
||||
'cascade_address': cascade_addr_cidr,
|
||||
'up': bool(st.get('up')),
|
||||
'handshake': handshake_ok,
|
||||
'settings': opts,
|
||||
'applied_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'diagnostics': st.get('raw', '')[:1500],
|
||||
}
|
||||
|
||||
def _ensure_start_hook(self, container: str, cascade_up_path: str) -> None:
|
||||
marker = CASCADE_MARKER
|
||||
installer = f"""#!/bin/bash
|
||||
set -e
|
||||
START=/opt/amnezia/start.sh
|
||||
[ -f "$START" ] || exit 0
|
||||
grep -q '{marker}' "$START" 2>/dev/null && exit 0
|
||||
printf '\\n%s\\n' '{marker}' >> "$START"
|
||||
printf '%s\\n' 'if [ -x {cascade_up_path} ]; then bash {cascade_up_path} || true; fi' >> "$START"
|
||||
"""
|
||||
self.ssh.upload_file(installer, '/tmp/_amnz_cascade_hook.sh')
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_amnz_cascade_hook.sh {shlex.quote(container)}:/tmp/_hook.sh && "
|
||||
f"docker exec {shlex.quote(container)} bash /tmp/_hook.sh && "
|
||||
f"docker exec {shlex.quote(container)} rm -f /tmp/_hook.sh"
|
||||
)
|
||||
self.ssh.run_command('rm -f /tmp/_amnz_cascade_hook.sh')
|
||||
|
||||
def disable(self, entry_protocol: str, settings: Optional[dict] = None) -> dict:
|
||||
opts = normalize_settings(settings)
|
||||
container = self._container_name(entry_protocol)
|
||||
if not container:
|
||||
return {'status': 'error', 'message': 'Unsupported entry protocol'}
|
||||
conf = self._cascade_conf(entry_protocol)
|
||||
up = self._cascade_up(entry_protocol)
|
||||
quick_bin = self._quick_binary(entry_protocol)
|
||||
table_id = int(opts['table_id'])
|
||||
script = f"""#!/bin/bash
|
||||
set +e
|
||||
QUICK={shlex.quote(quick_bin)}
|
||||
CONF={shlex.quote(conf)}
|
||||
UP={shlex.quote(up)}
|
||||
TABLE={table_id}
|
||||
"$QUICK" down "$CONF" 2>/dev/null
|
||||
ip link delete cascade 2>/dev/null
|
||||
ip rule del table "$TABLE" 2>/dev/null
|
||||
ip rule del table "$TABLE" 2>/dev/null
|
||||
ip route flush table "$TABLE" 2>/dev/null
|
||||
rm -f "$CONF" "$UP"
|
||||
echo CASCADE_DOWN_OK
|
||||
"""
|
||||
self.ssh.upload_file(script, '/tmp/_amnz_cascade_down.sh')
|
||||
out, err, code = self.ssh.run_sudo_command(
|
||||
f"docker cp /tmp/_amnz_cascade_down.sh {shlex.quote(container)}:/tmp/_cascade_down.sh && "
|
||||
f"docker exec {shlex.quote(container)} bash /tmp/_cascade_down.sh && "
|
||||
f"docker exec {shlex.quote(container)} rm -f /tmp/_cascade_down.sh",
|
||||
timeout=60,
|
||||
)
|
||||
self.ssh.run_command('rm -f /tmp/_amnz_cascade_down.sh')
|
||||
if code != 0 and 'CASCADE_DOWN_OK' not in (out or ''):
|
||||
return {'status': 'success', 'message': (err or out or 'Cascade cleared (best effort)').strip()[:400]}
|
||||
return {'status': 'success'}
|
||||
@@ -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()
|
||||
@@ -1282,6 +1282,7 @@ a:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1294,6 +1295,45 @@ a:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.conn-select-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conn-select {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.connections-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.connections-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connections-selected-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.client-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1623,6 +1663,319 @@ a:hover {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ===== Tunnels (Settings) ===== */
|
||||
.tunnels-card .tunnels-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tunnels-card .tunnels-header .card-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-card .tunnels-subtitle {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tunnels-local {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-focus);
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.06));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tunnels-local-icon {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-gradient);
|
||||
font-size: 1.25rem;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.tunnels-local-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tunnels-local-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnels-local-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-local-url code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tunnels-section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tunnels-section:last-of-type {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnels-section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-sm) var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.tunnel-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.tunnel-card--running {
|
||||
border-color: var(--success-border);
|
||||
box-shadow: 0 0 0 1px var(--success-border), inset 0 1px 0 rgba(34, 197, 94, 0.06);
|
||||
}
|
||||
|
||||
.tunnel-card--connected {
|
||||
border-color: var(--success-border);
|
||||
box-shadow: 0 0 0 1px var(--success-border);
|
||||
}
|
||||
|
||||
.tunnel-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnel-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tunnel-card-icon--cloudflare { background: linear-gradient(135deg, #f38020, #faae40); }
|
||||
.tunnel-card-icon--ngrok { background: linear-gradient(135deg, #1f2d3d, #3d5a80); }
|
||||
.tunnel-card-icon--warp { background: linear-gradient(135deg, #f38020, #e85d04); }
|
||||
.tunnel-card-icon--nordvpn { background: linear-gradient(135deg, #4687ff, #2b4eff); }
|
||||
.tunnel-card-icon--local { background: var(--accent-gradient); }
|
||||
|
||||
.tunnel-card-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tunnel-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tunnel-card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: var(--space-xs) 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tunnel-status-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tunnel-status-pill--ok {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.tunnel-status-pill--info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.tunnel-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnel-url-box {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tunnel-url-box--empty {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tunnel-url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnel-url-row:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tunnel-url-row code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-accent);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tunnel-status-line {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tunnel-status-line--server {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-accent);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tunnel-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tunnel-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.tunnel-card-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 7rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tunnel-card-actions--triple .btn {
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.tunnel-nordvpn-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-footnote {
|
||||
margin: var(--space-md) 0 0;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--accent);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tunnels-local {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tunnels-local-url {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tunnel-nordvpn-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
+137
-321
@@ -157,153 +157,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cascade (double VPN) -->
|
||||
<section class="card" id="cascadeSection" style="margin-bottom: var(--space-xl);">
|
||||
<div class="card-header" style="align-items:flex-start; flex-wrap:wrap; gap:var(--space-sm);">
|
||||
<div>
|
||||
<h2 class="card-title" style="margin:0;">{{ _('cascade_title') }}</h2>
|
||||
<p class="form-hint" style="margin:var(--space-xs) 0 0;">{{ _('cascade_desc') }}</p>
|
||||
</div>
|
||||
<span class="badge badge-warn" id="cascadeStatusBadge">{{ _('cascade_off') }}</span>
|
||||
</div>
|
||||
<div style="padding: 0 var(--space-md) var(--space-md);">
|
||||
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('cascade_entry_protocol') }}</label>
|
||||
<select class="form-select" id="cascadeEntryProtocol">
|
||||
<option value="awg2" selected>AmneziaWG 2.0</option>
|
||||
<option value="awg">AmneziaWG</option>
|
||||
<option value="awg_legacy">AWG Legacy</option>
|
||||
<option value="wireguard">WireGuard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('cascade_exit_server') }}</label>
|
||||
<select class="form-select" id="cascadeExitServer" onchange="fillCascadeExitProtocols()">
|
||||
<option value="">{{ _('cascade_exit_server_none') }}</option>
|
||||
{% for s in all_servers %}
|
||||
{% if loop.index0 != server_id %}
|
||||
<option value="{{ loop.index0 }}"
|
||||
data-protocols="{% for k in (s.protocols or {}).keys() %}{{ k }}{% if not loop.last %},{% endif %}{% endfor %}">
|
||||
{{ s.name or s.host }} ({{ s.host }})
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('cascade_exit_protocol') }}</label>
|
||||
<select class="form-select" id="cascadeExitProtocol">
|
||||
<option value="awg2" selected>AmneziaWG 2.0</option>
|
||||
<option value="awg">AmneziaWG</option>
|
||||
<option value="awg_legacy">AWG Legacy</option>
|
||||
<option value="wireguard">WireGuard</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm);">{{ _('cascade_hint') }}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm);">{{ _('cascade_client_note') }}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('cascade_security_note') }}</div>
|
||||
|
||||
<details id="cascadeSettingsPanel" open style="margin-bottom:var(--space-md); border:1px solid var(--border); border-radius:var(--radius-md); padding:var(--space-sm) var(--space-md);">
|
||||
<summary style="cursor:pointer; font-weight:600; margin-bottom:var(--space-sm);">{{ _('cascade_settings_title') }}</summary>
|
||||
<p class="form-hint" style="margin:0 0 var(--space-md);">{{ _('cascade_settings_desc') }}</p>
|
||||
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm) var(--space-md);">
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadePinExitRoute" checked>
|
||||
<span>
|
||||
<strong>{{ _('cascade_opt_pin_exit') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_opt_pin_exit_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadeRemoveEthMasq" checked>
|
||||
<span>
|
||||
<strong>{{ _('cascade_opt_remove_eth_masq') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_opt_remove_eth_masq_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadeForceForward" checked>
|
||||
<span>
|
||||
<strong>{{ _('cascade_opt_force_forward') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_opt_force_forward_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadeMssClamp" checked>
|
||||
<span>
|
||||
<strong>{{ _('cascade_opt_mss_clamp') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_opt_mss_clamp_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadeWaitHandshake" checked>
|
||||
<span>
|
||||
<strong>{{ _('cascade_opt_wait_handshake') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_opt_wait_handshake_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadeVerifyEgress" checked>
|
||||
<span>
|
||||
<strong>{{ _('cascade_verify_egress') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_verify_egress_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
|
||||
<input type="checkbox" id="cascadeKeepExitDns">
|
||||
<span>
|
||||
<strong>{{ _('cascade_opt_keep_dns') }}</strong>
|
||||
<span class="form-hint" style="display:block;">{{ _('cascade_opt_keep_dns_hint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:var(--space-md); margin-top:var(--space-md);">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label class="form-label" for="cascadeHandshakeTimeout">{{ _('cascade_opt_handshake_timeout') }}</label>
|
||||
<input type="number" class="form-input" id="cascadeHandshakeTimeout" min="5" max="120" value="25">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label class="form-label" for="cascadeTableId">{{ _('cascade_opt_table_id') }}</label>
|
||||
<input type="number" class="form-input" id="cascadeTableId" min="1" max="252" value="200">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label class="form-label" for="cascadeRulePriority">{{ _('cascade_opt_rule_priority') }}</label>
|
||||
<input type="number" class="form-input" id="cascadeRulePriority" min="1" max="32765" value="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md); margin-top:var(--space-md);">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label class="form-label" for="cascadeAllowedIps">{{ _('cascade_opt_allowed_ips') }}</label>
|
||||
<input type="text" class="form-input" id="cascadeAllowedIps" value="0.0.0.0/0">
|
||||
<span class="form-hint">{{ _('cascade_opt_allowed_ips_hint') }}</span>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label class="form-label" for="cascadeVpnSubnet">{{ _('cascade_opt_vpn_subnet') }}</label>
|
||||
<input type="text" class="form-input" id="cascadeVpnSubnet" placeholder="10.8.1.0/24" value="">
|
||||
<span class="form-hint">{{ _('cascade_opt_vpn_subnet_hint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="cascadeDiagBox" class="hidden" style="margin-bottom:var(--space-md); padding:var(--space-sm) var(--space-md); background:var(--bg-muted, rgba(0,0,0,.04)); border-radius:var(--radius-md); font-family:ui-monospace,monospace; font-size:12px; white-space:pre-wrap; max-height:160px; overflow:auto;"></div>
|
||||
|
||||
<div class="flex gap-sm" style="flex-wrap:wrap;">
|
||||
<button type="button" class="btn btn-primary" id="cascadeEnableBtn" onclick="enableCascade()">
|
||||
{{ _('cascade_enable') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="cascadeDisableBtn" onclick="disableCascade()">
|
||||
{{ _('cascade_disable') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="loadCascade()">{{ _('refresh') }}</button>
|
||||
</div>
|
||||
<div id="cascadeBusy" class="hidden" style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-md);">
|
||||
<div class="spinner" style="width:18px;height:18px;border-width:2px;"></div>
|
||||
<span id="cascadeBusyText">{{ _('cascade_working') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Installed Applications Section -->
|
||||
|
||||
<div class="apps-loading" id="installedAppsLoading">
|
||||
@@ -691,12 +544,23 @@
|
||||
<option value="telemt">Telemt</option>
|
||||
<option value="wireguard">WireGuard</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="moveConnectionsBtn" onclick="openMoveConnectionsModal()">
|
||||
<span>{{ icon('share') }}</span> {{ _('move_connections') }}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
|
||||
<span>{{ icon('plus') }}</span> {{ _('add') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connectionsBulkBar" class="connections-bulk-bar hidden">
|
||||
<label class="connections-select-all">
|
||||
<input type="checkbox" id="connSelectAll" onchange="toggleSelectAllConnections(this.checked)">
|
||||
<span>{{ _('select_all') }}</span>
|
||||
</label>
|
||||
<span class="connections-selected-count" id="connSelectedCount">0</span>
|
||||
</div>
|
||||
|
||||
<div id="connectionsList">
|
||||
<div class="loading-overlay" id="connectionsLoading" style="display:none;">
|
||||
<div class="loading-spinner"></div>
|
||||
@@ -1116,6 +980,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Move Connections Modal ===== -->
|
||||
<div class="modal-backdrop" id="moveConnectionsModal">
|
||||
<div class="modal" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('move_connections_title') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('moveConnectionsModal')">×</button>
|
||||
</div>
|
||||
<p class="form-hint">{{ _('move_connections_hint') }}</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="moveTargetServer">{{ _('move_connections_target') }}</label>
|
||||
<select class="form-select" id="moveTargetServer"></select>
|
||||
</div>
|
||||
<label class="form-check" style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom: var(--space-md);">
|
||||
<input type="checkbox" id="moveDeleteSource" checked>
|
||||
<span>{{ _('move_connections_delete_source') }}</span>
|
||||
</label>
|
||||
<div class="form-hint" style="border-left: 3px solid var(--warning); padding-left: var(--space-sm);">
|
||||
{{ _('move_connections_warning') }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('moveConnectionsModal')">{{ _('cancel') }}</button>
|
||||
<button type="button" class="btn btn-primary" onclick="moveSelectedConnections()" id="moveConnectionsSubmitBtn">
|
||||
<span id="moveConnectionsSubmitText">{{ _('move_connections') }}</span>
|
||||
<div class="spinner hidden" id="moveConnectionsSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Connection Config Modal (with Tabs) ===== -->
|
||||
<div class="modal-backdrop" id="configModal">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
@@ -1237,6 +1130,7 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script>
|
||||
const SERVER_ID = {{ server_id }};
|
||||
const ALL_SERVERS = {{ servers_for_move | tojson }};
|
||||
const SERVER_HOST = "{{ server.host }}";
|
||||
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
|
||||
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
|
||||
@@ -1576,7 +1470,6 @@
|
||||
}
|
||||
updateStatusCounts(data.protocols || {});
|
||||
showToast(_('server_check_complete'), 'success');
|
||||
loadCascade();
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
const statusDiv = document.getElementById('serverStatus');
|
||||
@@ -2756,6 +2649,95 @@
|
||||
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
const selectedConnectionIds = new Set();
|
||||
|
||||
function updateMoveSelection() {
|
||||
selectedConnectionIds.clear();
|
||||
document.querySelectorAll('.conn-select:checked').forEach((el) => {
|
||||
if (el.dataset.clientId) selectedConnectionIds.add(el.dataset.clientId);
|
||||
});
|
||||
const count = selectedConnectionIds.size;
|
||||
const bulkBar = document.getElementById('connectionsBulkBar');
|
||||
const moveBtn = document.getElementById('moveConnectionsBtn');
|
||||
const countEl = document.getElementById('connSelectedCount');
|
||||
const selectAll = document.getElementById('connSelectAll');
|
||||
const boxes = document.querySelectorAll('.conn-select');
|
||||
if (bulkBar) bulkBar.classList.toggle('hidden', boxes.length === 0);
|
||||
if (moveBtn) moveBtn.classList.toggle('hidden', boxes.length === 0);
|
||||
if (countEl) countEl.textContent = _('connections_selected_count').replace('{}', String(count));
|
||||
if (selectAll) {
|
||||
selectAll.indeterminate = count > 0 && count < boxes.length;
|
||||
selectAll.checked = boxes.length > 0 && count === boxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllConnections(checked) {
|
||||
document.querySelectorAll('.conn-select').forEach((el) => { el.checked = checked; });
|
||||
updateMoveSelection();
|
||||
}
|
||||
|
||||
function openMoveConnectionsModal() {
|
||||
if (!selectedConnectionIds.size) {
|
||||
showToast(_('move_connections_none_selected'), 'error');
|
||||
return;
|
||||
}
|
||||
const select = document.getElementById('moveTargetServer');
|
||||
select.innerHTML = '';
|
||||
(ALL_SERVERS || []).forEach((srv) => {
|
||||
if (srv.id === SERVER_ID) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(srv.id);
|
||||
opt.textContent = `${srv.name} (${srv.host})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (!select.options.length) {
|
||||
showToast(_('move_connections_no_targets'), 'error');
|
||||
return;
|
||||
}
|
||||
openModal('moveConnectionsModal');
|
||||
}
|
||||
|
||||
async function moveSelectedConnections() {
|
||||
const targetServerId = parseInt(document.getElementById('moveTargetServer').value, 10);
|
||||
const deleteSource = document.getElementById('moveDeleteSource').checked;
|
||||
const proto = document.getElementById('connProtoSelect').value;
|
||||
const clientIds = Array.from(selectedConnectionIds);
|
||||
if (!clientIds.length || Number.isNaN(targetServerId)) {
|
||||
showToast(_('move_connections_none_selected'), 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm(_('move_connections_confirm').replace('{}', String(clientIds.length)))) return;
|
||||
|
||||
const btn = document.getElementById('moveConnectionsSubmitBtn');
|
||||
const text = document.getElementById('moveConnectionsSubmitText');
|
||||
const spinner = document.getElementById('moveConnectionsSpinner');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections/move`, 'POST', {
|
||||
protocol: proto,
|
||||
target_server_id: targetServerId,
|
||||
client_ids: clientIds,
|
||||
delete_source: deleteSource,
|
||||
});
|
||||
const moved = (data.moved || []).length;
|
||||
const failed = (data.failed || []).length;
|
||||
let msg = _('move_connections_success').replace('{}', String(moved));
|
||||
if (failed) msg += '. ' + _('move_connections_partial_fail').replace('{}', String(failed));
|
||||
showToast(msg, failed && !moved ? 'error' : 'success');
|
||||
closeModal('moveConnectionsModal');
|
||||
selectedConnectionIds.clear();
|
||||
loadConnections();
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
text.textContent = _('move_connections');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
const proto = document.getElementById('connProtoSelect').value;
|
||||
const loading = document.getElementById('connectionsLoading');
|
||||
@@ -2770,11 +2752,14 @@
|
||||
loading.style.display = '';
|
||||
emptyEl.classList.add('hidden');
|
||||
listEl.innerHTML = '';
|
||||
selectedConnectionIds.clear();
|
||||
updateMoveSelection();
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
|
||||
loading.style.display = 'none';
|
||||
if (!data.clients || data.clients.length === 0) {
|
||||
emptyEl.classList.remove('hidden');
|
||||
updateMoveSelection();
|
||||
return;
|
||||
}
|
||||
window.connectionsStore = {};
|
||||
@@ -2818,6 +2803,9 @@
|
||||
|
||||
listEl.innerHTML += `
|
||||
<div class="client-item" style="${disabledStyle}">
|
||||
<label class="conn-select-wrap">
|
||||
<input type="checkbox" class="conn-select" data-client-id="${escapeJs(client.clientId)}" onchange="updateMoveSelection()">
|
||||
</label>
|
||||
<div class="client-info">
|
||||
<div class="client-avatar">${initial}</div>
|
||||
<div>
|
||||
@@ -2834,6 +2822,7 @@
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
updateMoveSelection();
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
showToast(_('connections_load_error') + ': ' + err.message, 'error');
|
||||
@@ -3114,181 +3103,8 @@
|
||||
|
||||
|
||||
|
||||
// ========== Cascade (double VPN) ==========
|
||||
const CASCADE_PROTOS = ['awg2', 'awg', 'awg_legacy', 'wireguard'];
|
||||
|
||||
function fillCascadeExitProtocols() {
|
||||
const sel = document.getElementById('cascadeExitServer');
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
const protoSel = document.getElementById('cascadeExitProtocol');
|
||||
const raw = (opt && opt.dataset.protocols) ? opt.dataset.protocols.split(',').filter(Boolean) : [];
|
||||
const available = raw.filter(p => CASCADE_PROTOS.includes(protoBase(p)));
|
||||
const prev = protoSel.value;
|
||||
protoSel.innerHTML = '';
|
||||
const list = available.length ? available : CASCADE_PROTOS;
|
||||
list.forEach(p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p;
|
||||
o.textContent = getProtoTitle(p);
|
||||
protoSel.appendChild(o);
|
||||
});
|
||||
if (list.includes(prev)) protoSel.value = prev;
|
||||
else if (list.includes('awg2')) protoSel.value = 'awg2';
|
||||
}
|
||||
|
||||
function setCascadeBusy(on, text) {
|
||||
const busy = document.getElementById('cascadeBusy');
|
||||
const busyText = document.getElementById('cascadeBusyText');
|
||||
const en = document.getElementById('cascadeEnableBtn');
|
||||
const dis = document.getElementById('cascadeDisableBtn');
|
||||
if (busyText && text) busyText.textContent = text;
|
||||
if (busy) {
|
||||
busy.classList.toggle('hidden', !on);
|
||||
busy.style.display = on ? 'flex' : 'none';
|
||||
}
|
||||
if (en) en.disabled = !!on;
|
||||
if (dis) dis.disabled = !!on;
|
||||
}
|
||||
|
||||
function cascadeBool(id, fallback) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? !!el.checked : !!fallback;
|
||||
}
|
||||
|
||||
function cascadeNum(id, fallback) {
|
||||
const el = document.getElementById(id);
|
||||
const n = el ? parseInt(el.value, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function collectCascadeSettings() {
|
||||
return {
|
||||
pin_exit_route: cascadeBool('cascadePinExitRoute', true),
|
||||
remove_eth_masquerade: cascadeBool('cascadeRemoveEthMasq', true),
|
||||
force_forward: cascadeBool('cascadeForceForward', true),
|
||||
mss_clamp: cascadeBool('cascadeMssClamp', true),
|
||||
wait_handshake: cascadeBool('cascadeWaitHandshake', true),
|
||||
verify_egress: cascadeBool('cascadeVerifyEgress', true),
|
||||
keep_exit_dns: cascadeBool('cascadeKeepExitDns', false),
|
||||
handshake_timeout_sec: cascadeNum('cascadeHandshakeTimeout', 25),
|
||||
table_id: cascadeNum('cascadeTableId', 200),
|
||||
rule_priority: cascadeNum('cascadeRulePriority', 100),
|
||||
allowed_ips: (document.getElementById('cascadeAllowedIps') || {}).value || '0.0.0.0/0',
|
||||
vpn_subnet_override: (document.getElementById('cascadeVpnSubnet') || {}).value || '',
|
||||
};
|
||||
}
|
||||
|
||||
function applyCascadeSettingsToForm(settings, defaults) {
|
||||
const s = Object.assign({}, defaults || {}, settings || {});
|
||||
const setCheck = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; };
|
||||
const setVal = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.value = val; };
|
||||
setCheck('cascadePinExitRoute', s.pin_exit_route !== false);
|
||||
setCheck('cascadeRemoveEthMasq', s.remove_eth_masquerade !== false);
|
||||
setCheck('cascadeForceForward', s.force_forward !== false);
|
||||
setCheck('cascadeMssClamp', s.mss_clamp !== false);
|
||||
setCheck('cascadeWaitHandshake', s.wait_handshake !== false);
|
||||
setCheck('cascadeVerifyEgress', s.verify_egress !== false);
|
||||
setCheck('cascadeKeepExitDns', !!s.keep_exit_dns);
|
||||
setVal('cascadeHandshakeTimeout', s.handshake_timeout_sec ?? 25);
|
||||
setVal('cascadeTableId', s.table_id ?? 200);
|
||||
setVal('cascadeRulePriority', s.rule_priority ?? 100);
|
||||
setVal('cascadeAllowedIps', s.allowed_ips || '0.0.0.0/0');
|
||||
setVal('cascadeVpnSubnet', s.vpn_subnet_override || '');
|
||||
}
|
||||
|
||||
function updateCascadeDiag(cascade, live) {
|
||||
const box = document.getElementById('cascadeDiagBox');
|
||||
if (!box) return;
|
||||
const parts = [];
|
||||
if (cascade && cascade.enabled) {
|
||||
parts.push('handshake: ' + ((live && live.handshake) || cascade.handshake ? 'ok' : 'NONE'));
|
||||
if (cascade.diagnostics) parts.push(String(cascade.diagnostics).trim());
|
||||
else if (live && live.raw) parts.push(String(live.raw).trim());
|
||||
if (cascade.last_error) parts.push('error: ' + cascade.last_error);
|
||||
}
|
||||
const text = parts.filter(Boolean).join('\n\n');
|
||||
box.textContent = text;
|
||||
box.classList.toggle('hidden', !text);
|
||||
}
|
||||
|
||||
function updateCascadeBadge(cascade, live) {
|
||||
const badge = document.getElementById('cascadeStatusBadge');
|
||||
if (!badge) return;
|
||||
const enabled = !!(cascade && cascade.enabled);
|
||||
const up = !!(live && live.up);
|
||||
const hs = !!(live && live.handshake) || !!(cascade && cascade.handshake);
|
||||
badge.className = 'badge ' + (enabled && up && hs ? 'badge-success' : (enabled ? 'badge-warn' : 'badge-warn'));
|
||||
if (!enabled) badge.textContent = _('cascade_off');
|
||||
else if (up && hs) badge.textContent = _('cascade_on');
|
||||
else if (up && !hs) badge.textContent = _('cascade_no_handshake');
|
||||
else badge.textContent = _('cascade_configured_down');
|
||||
updateCascadeDiag(cascade, live);
|
||||
}
|
||||
|
||||
async function loadCascade() {
|
||||
try {
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/cascade`, 'POST', {});
|
||||
const c = res.cascade || {};
|
||||
if (c.entry_protocol) document.getElementById('cascadeEntryProtocol').value = c.entry_protocol;
|
||||
if (c.exit_server_id !== undefined && c.exit_server_id !== null && c.exit_server_id !== '') {
|
||||
document.getElementById('cascadeExitServer').value = String(c.exit_server_id);
|
||||
fillCascadeExitProtocols();
|
||||
}
|
||||
if (c.exit_protocol) {
|
||||
const exitProto = document.getElementById('cascadeExitProtocol');
|
||||
if ([...exitProto.options].some(o => o.value === c.exit_protocol)) {
|
||||
exitProto.value = c.exit_protocol;
|
||||
}
|
||||
}
|
||||
applyCascadeSettingsToForm(c.settings || {}, res.defaults || {});
|
||||
updateCascadeBadge(c, res.live || {});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupCascade(enable) {
|
||||
const entryProtocol = document.getElementById('cascadeEntryProtocol').value;
|
||||
const exitServerId = document.getElementById('cascadeExitServer').value;
|
||||
const exitProtocol = document.getElementById('cascadeExitProtocol').value;
|
||||
if (enable && exitServerId === '') {
|
||||
showToast(_('cascade_exit_required'), 'error');
|
||||
return;
|
||||
}
|
||||
if (enable && !confirm(_('cascade_enable_confirm'))) return;
|
||||
if (!enable && !confirm(_('cascade_disable_confirm'))) return;
|
||||
|
||||
setCascadeBusy(true, enable ? _('cascade_working') : _('cascade_disabling'));
|
||||
showToast(enable ? _('cascade_working') : _('cascade_disabling'), 'info');
|
||||
try {
|
||||
const settings = collectCascadeSettings();
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/cascade/setup`, 'POST', {
|
||||
enabled: !!enable,
|
||||
entry_protocol: entryProtocol,
|
||||
exit_server_id: parseInt(exitServerId || '0', 10),
|
||||
exit_protocol: exitProtocol,
|
||||
...settings,
|
||||
});
|
||||
updateCascadeBadge(res.cascade || {}, {
|
||||
up: !!(res.cascade && res.cascade.up),
|
||||
handshake: !!(res.cascade && res.cascade.handshake),
|
||||
});
|
||||
showToast(enable ? _('cascade_enabled') : _('cascade_disabled'), 'success');
|
||||
await loadCascade();
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
setCascadeBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function enableCascade() { return setupCascade(true); }
|
||||
function disableCascade() { return setupCascade(false); }
|
||||
|
||||
// ========== Init ==========
|
||||
applyInstalledAppsVisibility();
|
||||
fillCascadeExitProtocols();
|
||||
loadCascade();
|
||||
checkServer();
|
||||
loadServerStats();
|
||||
</script>
|
||||
|
||||
+391
-97
@@ -204,88 +204,147 @@
|
||||
</div>
|
||||
|
||||
<!-- BLOCK Tunnels -->
|
||||
<div class="card">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>{{ _('local_server') }}</strong>
|
||||
<span class="badge badge-info">{{ _('active') }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:var(--space-sm); align-items:center;">
|
||||
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
|
||||
<div class="card tunnels-card">
|
||||
<div class="tunnels-header">
|
||||
<h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
|
||||
<p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tunnels-local">
|
||||
<div class="tunnels-local-icon" aria-hidden="true">🏠</div>
|
||||
<div class="tunnels-local-body">
|
||||
<div class="tunnels-local-label">{{ _('local_server') }}</div>
|
||||
<div class="tunnels-local-url">
|
||||
<code id="localServerUrl">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tunnel-status-pill tunnel-status-pill--ok">{{ _('active') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>Cloudflare Quick Tunnel</strong>
|
||||
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
|
||||
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
|
||||
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
|
||||
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnels-section">
|
||||
<h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
|
||||
<div class="tunnels-grid">
|
||||
<article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true">☁</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions tunnel-card-actions--triple">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
|
||||
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
|
||||
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
|
||||
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>ngrok Tunnel + Authtoken</strong>
|
||||
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: var(--space-sm);">
|
||||
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
|
||||
</div>
|
||||
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
|
||||
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
|
||||
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
|
||||
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>Cloudflare WARP</strong>
|
||||
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
|
||||
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
|
||||
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
|
||||
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
|
||||
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
<article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">ngrok</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
|
||||
<div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions tunnel-card-actions--triple">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
|
||||
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
|
||||
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
|
||||
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
|
||||
|
||||
<div class="tunnels-section">
|
||||
<h4 class="tunnels-section-title">{{ _('tunnels_section_outbound') }}</h4>
|
||||
<div class="tunnels-grid">
|
||||
<article class="tunnel-card tunnel-row" data-provider="warp" id="tunnelCard-warp">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--warp" aria-hidden="true">W</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">Cloudflare WARP</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_warp_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="warpInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<p id="warpStatusText" class="tunnel-status-line">{{ _('warp_status_unknown') }}</p>
|
||||
<p id="warpHintText" class="tunnel-hint">{{ _('warp_hint') }}</p>
|
||||
</div>
|
||||
<div class="tunnel-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="warpConnectBtn" onclick="connectWarp()">
|
||||
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
|
||||
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
|
||||
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tunnel-card tunnel-row" data-provider="nordvpn" id="tunnelCard-nordvpn">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--nordvpn" aria-hidden="true">N</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">NordVPN</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_nordvpn_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<p id="nordvpnStatusText" class="tunnel-status-line">{{ _('nordvpn_status_unknown') }}</p>
|
||||
<p id="nordvpnServerText" class="tunnel-status-line tunnel-status-line--server hidden"></p>
|
||||
<p id="nordvpnHintText" class="tunnel-hint">{{ _('nordvpn_hint') }}</p>
|
||||
<div class="tunnel-nordvpn-fields">
|
||||
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
|
||||
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="nordvpnConnectBtn" onclick="connectNordvpn()">
|
||||
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
|
||||
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
|
||||
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tunnels-footnote">{{ _('tunnels_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK SSL/HTTPS -->
|
||||
@@ -597,15 +656,24 @@
|
||||
<!-- Updated via JS -->
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
|
||||
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;">
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-primary" onclick="upgradePanel()" id="upgradePanelBtn" style="flex:1; min-width:180px;">
|
||||
<span id="upgradePanelBtnText">⬆ {{ _('upgrade_panel') }}</span>
|
||||
<div class="spinner hidden" id="upgradePanelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;">
|
||||
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
|
||||
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-primary hidden" id="downloadUpdateBtn" style="flex:1; text-decoration: none; justify-content: center;">
|
||||
<button type="button" class="btn btn-secondary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
|
||||
<span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span>
|
||||
<div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-secondary hidden" id="downloadUpdateBtn" style="flex:1; min-width:140px; text-decoration: none; justify-content: center;">
|
||||
⬇️ {{ _('download_update') }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -916,31 +984,45 @@
|
||||
const el = document.getElementById(`${provider}PublicUrls`);
|
||||
if (!el) return;
|
||||
if (!urls || !urls.length) {
|
||||
el.className = 'tunnel-url-box tunnel-url-box--empty';
|
||||
el.textContent = _('tunnel_no_public_url');
|
||||
return;
|
||||
}
|
||||
el.className = 'tunnel-url-box';
|
||||
el.innerHTML = urls.map((url, idx) => `
|
||||
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
|
||||
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
|
||||
<div class="tunnel-url-row">
|
||||
<code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setTunnelStatusPill(badge, { ok, info, text }) {
|
||||
if (!badge) return;
|
||||
badge.className = 'tunnel-status-pill' + (ok ? ' tunnel-status-pill--ok' : (info ? ' tunnel-status-pill--info' : ''));
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
function updateTunnelProvider(provider, status) {
|
||||
const badge = document.getElementById(`${provider}InstallBadge`);
|
||||
const text = document.getElementById(`${provider}TunnelBtnText`);
|
||||
const startBtn = document.getElementById(`${provider}TunnelBtn`);
|
||||
const stopBtn = document.getElementById(`${provider}StopBtn`);
|
||||
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
|
||||
const card = document.getElementById(`tunnelCard-${provider}`);
|
||||
if (!badge || !text) return;
|
||||
|
||||
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
|
||||
badge.textContent = status.installed ? _('installed') : _('not_installed');
|
||||
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
|
||||
if (startBtn) startBtn.disabled = !!status.running;
|
||||
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
|
||||
const running = !!status.running;
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: running,
|
||||
info: !running && status.installed,
|
||||
text: running ? _('tunnel_running') : (status.installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
text.textContent = running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
|
||||
if (startBtn) startBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
|
||||
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
|
||||
if (card) card.classList.toggle('tunnel-card--running', running);
|
||||
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
|
||||
}
|
||||
|
||||
@@ -951,6 +1033,7 @@
|
||||
updateTunnelProvider('cloudflare', data.cloudflare || {});
|
||||
updateTunnelProvider('ngrok', data.ngrok || {});
|
||||
updateWarpStatus(data.warp || {});
|
||||
updateNordvpnStatus(data.nordvpn || {});
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
}
|
||||
@@ -963,12 +1046,16 @@
|
||||
const connectBtn = document.getElementById('warpConnectBtn');
|
||||
const disconnectBtn = document.getElementById('warpDisconnectBtn');
|
||||
const connectText = document.getElementById('warpConnectBtnText');
|
||||
const card = document.getElementById('tunnelCard-warp');
|
||||
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
|
||||
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected || status.status === 'connected';
|
||||
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
|
||||
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: connected,
|
||||
info: !connected && installed,
|
||||
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
|
||||
const statusKey = `warp_status_${status.status || 'unknown'}`;
|
||||
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
|
||||
@@ -976,6 +1063,7 @@
|
||||
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.classList.toggle('hidden', !connected);
|
||||
if (card) card.classList.toggle('tunnel-card--connected', connected);
|
||||
}
|
||||
|
||||
async function connectWarp() {
|
||||
@@ -1020,6 +1108,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateNordvpnStatus(status) {
|
||||
const badge = document.getElementById('nordvpnInstallBadge');
|
||||
const statusText = document.getElementById('nordvpnStatusText');
|
||||
const serverText = document.getElementById('nordvpnServerText');
|
||||
const hintText = document.getElementById('nordvpnHintText');
|
||||
const connectBtn = document.getElementById('nordvpnConnectBtn');
|
||||
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
|
||||
const connectText = document.getElementById('nordvpnConnectBtnText');
|
||||
const card = document.getElementById('tunnelCard-nordvpn');
|
||||
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
|
||||
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected || status.status === 'connected';
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: connected,
|
||||
info: !connected && installed,
|
||||
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
|
||||
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
|
||||
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
|
||||
if (serverText) {
|
||||
const serverLine = status.server || '';
|
||||
serverText.textContent = serverLine;
|
||||
serverText.classList.toggle('hidden', !serverLine);
|
||||
}
|
||||
hintText.textContent = status.last_error || (installed ? _('nordvpn_hint') : (status.install_hint || _('nordvpn_install_hint')));
|
||||
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.classList.toggle('hidden', !connected);
|
||||
if (card) card.classList.toggle('tunnel-card--connected', connected);
|
||||
}
|
||||
|
||||
async function connectNordvpn() {
|
||||
const btn = document.getElementById('nordvpnConnectBtn');
|
||||
const spinner = document.getElementById('nordvpnConnectSpinner');
|
||||
const text = document.getElementById('nordvpnConnectBtnText');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
|
||||
try {
|
||||
const country = (document.getElementById('nordvpnCountry') || {}).value || '';
|
||||
const city = (document.getElementById('nordvpnCity') || {}).value || '';
|
||||
const status = await apiCall('/api/settings/nordvpn/connect', 'POST', {
|
||||
country: country.trim(),
|
||||
city: city.trim(),
|
||||
});
|
||||
updateNordvpnStatus(status);
|
||||
showToast(_('nordvpn_connected'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
await loadTunnelStatus();
|
||||
} finally {
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectNordvpn() {
|
||||
const btn = document.getElementById('nordvpnDisconnectBtn');
|
||||
const spinner = document.getElementById('nordvpnDisconnectSpinner');
|
||||
const text = document.getElementById('nordvpnDisconnectBtnText');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
|
||||
try {
|
||||
const status = await apiCall('/api/settings/nordvpn/disconnect', 'POST');
|
||||
updateNordvpnStatus(status);
|
||||
showToast(_('nordvpn_disconnected'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
await loadTunnelStatus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = _('nordvpn_disconnect');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTunnel(provider) {
|
||||
const btn = document.getElementById(`${provider}TunnelBtn`);
|
||||
const spinner = document.getElementById(`${provider}TunnelSpinner`);
|
||||
@@ -1482,42 +1650,168 @@
|
||||
updateProtocolsForSync();
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
let latestUpdateInfo = null;
|
||||
|
||||
async function checkForUpdates(silent) {
|
||||
const btn = document.getElementById('checkUpdateBtn');
|
||||
const text = document.getElementById('checkUpdateBtnText');
|
||||
const spinner = document.getElementById('checkUpdateSpinner');
|
||||
const statusDiv = document.getElementById('updateStatus');
|
||||
const downloadBtn = document.getElementById('downloadUpdateBtn');
|
||||
|
||||
btn.disabled = true;
|
||||
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
|
||||
spinner.classList.remove('hidden');
|
||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||
const modeHint = document.getElementById('updateModeHint');
|
||||
|
||||
if (!silent) {
|
||||
btn.disabled = true;
|
||||
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
|
||||
spinner.classList.remove('hidden');
|
||||
}
|
||||
statusDiv.classList.add('hidden');
|
||||
downloadBtn.classList.add('hidden');
|
||||
applyBtn.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const data = await apiCall('/api/settings/check_updates');
|
||||
latestUpdateInfo = data;
|
||||
const latestVersion = data.latest_version || '';
|
||||
statusDiv.classList.remove('hidden');
|
||||
|
||||
|
||||
if (modeHint) {
|
||||
const method = data.update_mode || (data.mode && data.mode.update_method) || (data.mode && data.mode.mode) || '';
|
||||
if (!data.can_auto_update) {
|
||||
if (method === 'docker') {
|
||||
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker: on the host run git pull && docker compose up -d --build.') }}";
|
||||
} else {
|
||||
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a writable install directory or git clone.') }}";
|
||||
}
|
||||
} else if (method === 'archive') {
|
||||
modeHint.textContent = "{{ _('auto_update_archive')|default('One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.') }}";
|
||||
} else {
|
||||
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
|
||||
}
|
||||
}
|
||||
|
||||
const upgradeBtn = document.getElementById('upgradePanelBtn');
|
||||
if (upgradeBtn) {
|
||||
upgradeBtn.disabled = false;
|
||||
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
|
||||
}
|
||||
|
||||
if (data.update_available && latestVersion) {
|
||||
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
|
||||
statusDiv.style.border = '1px solid var(--success)';
|
||||
downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
|
||||
downloadBtn.classList.remove('hidden');
|
||||
applyBtn.classList.remove('hidden');
|
||||
} else {
|
||||
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
|
||||
statusDiv.style.border = '1px solid var(--border-color)';
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to check for updates: ' + err.message, 'error');
|
||||
if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
|
||||
spinner.classList.add('hidden');
|
||||
if (!silent) {
|
||||
btn.disabled = false;
|
||||
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPanelRestart(timeoutMs = 120000, panelUrl) {
|
||||
const base = String(panelUrl || window.location.origin || '').replace(/\/$/, '');
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/health`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
window.location.href = base + '/';
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
const hint = base ? ` ${base}` : '';
|
||||
showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}" + hint, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applyPanelUpdate() {
|
||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||
const applyText = document.getElementById('applyUpdateBtnText');
|
||||
const applySpinner = document.getElementById('applyUpdateSpinner');
|
||||
const target = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
|
||||
if (!target) {
|
||||
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
|
||||
return;
|
||||
}
|
||||
applyBtn.disabled = true;
|
||||
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
|
||||
applySpinner.classList.remove('hidden');
|
||||
let restarting = false;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/apply_update', 'POST', {
|
||||
target_version: target,
|
||||
allow_dirty: true,
|
||||
});
|
||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||
restarting = true;
|
||||
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||
await waitForPanelRestart(120000, data.panel_url);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
if (!restarting) {
|
||||
applyBtn.disabled = false;
|
||||
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
|
||||
applySpinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradePanel() {
|
||||
const btn = document.getElementById('upgradePanelBtn');
|
||||
const text = document.getElementById('upgradePanelBtnText');
|
||||
const spinner = document.getElementById('upgradePanelSpinner');
|
||||
const latest = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
|
||||
const confirmMsg = latest
|
||||
? ("{{ _('upgrade_panel_confirm')|default('Download and install') }} " + latest + "?")
|
||||
: "{{ _('upgrade_panel_confirm_latest')|default('Check for updates and install the latest release?') }}";
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
|
||||
let restarting = false;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
|
||||
if (data.up_to_date) {
|
||||
showToast("{{ _('upgrade_panel_up_to_date')|default('Already on the latest version') }}", 'info');
|
||||
await checkForUpdates(true);
|
||||
return;
|
||||
}
|
||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||
restarting = true;
|
||||
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||
await waitForPanelRestart(120000, data.panel_url);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
await checkForUpdates(true);
|
||||
} finally {
|
||||
if (!restarting) {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check once on Settings load
|
||||
setTimeout(() => checkForUpdates(true), 800);
|
||||
|
||||
/* ========== API Tokens ========== */
|
||||
|
||||
function fmtTokenDate(iso) {
|
||||
|
||||
+55
-46
@@ -101,6 +101,19 @@
|
||||
"connection_created": "Connection \"{}\" created",
|
||||
"delete_connection_confirm": "Delete this connection? The configuration will stop working.",
|
||||
"connection_deleted": "Connection deleted",
|
||||
"select_all": "Select all",
|
||||
"connections_selected_count": "{} selected",
|
||||
"move_connections": "Move",
|
||||
"move_connections_title": "Move connections to another server",
|
||||
"move_connections_target": "Target server",
|
||||
"move_connections_confirm": "Move {} connection(s) to the selected server? Users will need new configs.",
|
||||
"move_connections_hint": "Recreates selected connections on the target server and updates user assignments.",
|
||||
"move_connections_warning": "Clients get new keys and IPs. Old configs stop working after move.",
|
||||
"move_connections_delete_source": "Remove from current server after move",
|
||||
"move_connections_success": "Moved {} connection(s)",
|
||||
"move_connections_partial_fail": "{} could not be moved",
|
||||
"move_connections_none_selected": "Select at least one connection",
|
||||
"move_connections_no_targets": "No other servers available",
|
||||
"config_tab": "📄 Config",
|
||||
"vpn_key_tab": "🔑 VPN-key",
|
||||
"qr_code_tab": "📱 QR-code",
|
||||
@@ -267,6 +280,13 @@
|
||||
"api_docs_title": "🔌 API Documentation",
|
||||
"api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities",
|
||||
"tunnels_title": "Tunnels",
|
||||
"tunnels_subtitle": "Expose the panel to the internet or route outbound traffic through a VPN.",
|
||||
"tunnels_section_inbound": "Public access",
|
||||
"tunnels_section_outbound": "Outbound VPN",
|
||||
"tunnel_cloudflare_desc": "Free HTTPS URL via Cloudflare — no account required.",
|
||||
"tunnel_ngrok_desc": "Stable tunnel with your ngrok authtoken.",
|
||||
"tunnel_warp_desc": "Route server traffic through Cloudflare WARP.",
|
||||
"tunnel_nordvpn_desc": "Connect this host to NordVPN servers.",
|
||||
"local_server": "Local Server",
|
||||
"tunnel_install_enable": "Install \u0026 Enable",
|
||||
"tunnel_enable": "Enable Tunnel",
|
||||
@@ -481,6 +501,23 @@
|
||||
"update_available": "New version available",
|
||||
"download_update": "Download update",
|
||||
"up_to_date": "You have the latest version",
|
||||
"apply_update": "Install update",
|
||||
"applying_update": "Updating…",
|
||||
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
|
||||
"update_restarting": "Updated. Restarting panel…",
|
||||
"update_waiting_restart": "Waiting for panel to restart…",
|
||||
"update_restart_timeout": "Panel did not come back in time. Refresh the page manually.",
|
||||
"update_no_target": "No target version. Check for updates first.",
|
||||
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
|
||||
"auto_update_ready": "Git auto-update is available for this install.",
|
||||
"auto_update_docker": "Docker: one-click update downloads the release ZIP into the container. Rebuild the image to persist across container recreation.",
|
||||
"auto_update_manual": "One-click update needs a writable install directory or git clone. You can still download a release.",
|
||||
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
|
||||
"upgrade_panel": "Update panel",
|
||||
"upgrade_panel_confirm": "Download and install",
|
||||
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
|
||||
"upgrade_panel_working": "Updating panel…",
|
||||
"upgrade_panel_up_to_date": "Already on the latest version",
|
||||
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
|
||||
"warp_install_required": "Install WARP first",
|
||||
@@ -496,6 +533,23 @@
|
||||
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
|
||||
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
|
||||
"nordvpn_install_required": "Install NordVPN first",
|
||||
"nordvpn_connect": "Connect NordVPN",
|
||||
"nordvpn_disconnect": "Disconnect",
|
||||
"nordvpn_connected": "NordVPN connected",
|
||||
"nordvpn_disconnected": "NordVPN disconnected",
|
||||
"nordvpn_status_unknown": "NordVPN status is unknown.",
|
||||
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
|
||||
"nordvpn_status_connected": "NordVPN is connected.",
|
||||
"nordvpn_status_connecting": "NordVPN is connecting.",
|
||||
"nordvpn_status_disconnected": "NordVPN is disconnected.",
|
||||
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
|
||||
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
|
||||
"nordvpn_status_error": "NordVPN returned an error.",
|
||||
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
|
||||
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
|
||||
"install_starting": "Starting installation...",
|
||||
"server_check_complete": "Check completed",
|
||||
"server_connection_error": "Connection error",
|
||||
@@ -575,50 +629,5 @@
|
||||
"xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).",
|
||||
"xui_server_select_label": "3x-ui server",
|
||||
"xui_server_select_hint": "Pick a 3x-ui panel, then an inbound from the list that panel returns via API.",
|
||||
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?",
|
||||
"cascade_title": "Cascade (double VPN)",
|
||||
"cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.",
|
||||
"cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.",
|
||||
"cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.",
|
||||
"cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.",
|
||||
"cascade_entry_protocol": "Protocol on this server (entry)",
|
||||
"cascade_exit_server": "Exit server",
|
||||
"cascade_exit_server_none": "— Select server —",
|
||||
"cascade_exit_protocol": "Protocol on exit server",
|
||||
"cascade_enable": "Enable cascade",
|
||||
"cascade_disable": "Disable cascade",
|
||||
"cascade_working": "Setting up cascade…",
|
||||
"cascade_disabling": "Disabling cascade…",
|
||||
"cascade_enabled": "Cascade enabled",
|
||||
"cascade_disabled": "Cascade disabled",
|
||||
"cascade_off": "Off",
|
||||
"cascade_on": "Active",
|
||||
"cascade_configured_down": "Configured, tunnel down",
|
||||
"cascade_exit_required": "Select an exit server",
|
||||
"cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.",
|
||||
"cascade_disable_confirm": "Disable cascade on this server?",
|
||||
"cascade_no_handshake": "Up, no handshake",
|
||||
"cascade_settings_title": "Cascade settings (per step)",
|
||||
"cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.",
|
||||
"cascade_opt_pin_exit": "Pin route to exit server",
|
||||
"cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.",
|
||||
"cascade_opt_remove_eth_masq": "Remove client NAT on eth0",
|
||||
"cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.",
|
||||
"cascade_opt_force_forward": "Force FORWARD between interfaces",
|
||||
"cascade_opt_force_forward_hint": "Client traffic → cascade → exit.",
|
||||
"cascade_opt_mss_clamp": "TCP MSS clamp",
|
||||
"cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.",
|
||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||
"cascade_verify_egress": "Verify egress IP",
|
||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||
"cascade_opt_table_id": "Routing table id",
|
||||
"cascade_opt_rule_priority": "ip rule priority",
|
||||
"cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel",
|
||||
"cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.",
|
||||
"cascade_opt_vpn_subnet": "Client subnet override",
|
||||
"cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address"
|
||||
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?"
|
||||
}
|
||||
|
||||
+62
-47
@@ -100,6 +100,19 @@
|
||||
"connection_created": "اتصال \"{}\" ایجاد شد",
|
||||
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
|
||||
"connection_deleted": "اتصال حذف شد",
|
||||
"select_all": "انتخاب همه",
|
||||
"connections_selected_count": "{} انتخاب شده",
|
||||
"move_connections": "انتقال",
|
||||
"move_connections_title": "انتقال اتصالها به سرور دیگر",
|
||||
"move_connections_target": "سرور مقصد",
|
||||
"move_connections_confirm": "{} اتصال به سرور انتخابشده منتقل شود؟ کاربران به پیکربندی جدید نیاز دارند.",
|
||||
"move_connections_hint": "اتصالهای انتخابشده روی سرور مقصد دوباره ساخته میشوند و پیوند کاربران حفظ میشود.",
|
||||
"move_connections_warning": "کلیدها و IP جدید صادر میشود. پیکربندیهای قدیمی کار نمیکنند.",
|
||||
"move_connections_delete_source": "پس از انتقال از سرور فعلی حذف شود",
|
||||
"move_connections_success": "{} اتصال منتقل شد",
|
||||
"move_connections_partial_fail": "{} منتقل نشد",
|
||||
"move_connections_none_selected": "حداقل یک اتصال انتخاب کنید",
|
||||
"move_connections_no_targets": "سرور دیگری برای انتقال وجود ندارد",
|
||||
"config_tab": "📄 فایل",
|
||||
"vpn_key_tab": "🔑 کلید-VPN",
|
||||
"qr_code_tab": "📱 کد-QR",
|
||||
@@ -256,7 +269,14 @@
|
||||
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراهاندازی شود.",
|
||||
"api_docs_title": "🔌 مستندات API",
|
||||
"api_docs_hint": "از این رابطها برای بررسی قابلیتهای API استفاده کنید",
|
||||
"tunnels_title": "Tunnels",
|
||||
"tunnels_title": "تونلها",
|
||||
"tunnels_subtitle": "پنل را در اینترنت منتشر کنید یا ترافیک خروجی را از طریق VPN هدایت کنید.",
|
||||
"tunnels_section_inbound": "دسترسی عمومی",
|
||||
"tunnels_section_outbound": "VPN خروجی",
|
||||
"tunnel_cloudflare_desc": "آدرس HTTPS رایگان از Cloudflare — بدون ثبتنام.",
|
||||
"tunnel_ngrok_desc": "تونل پایدار با authtoken ngrok شما.",
|
||||
"tunnel_warp_desc": "هدایت ترافیک سرور از طریق Cloudflare WARP.",
|
||||
"tunnel_nordvpn_desc": "اتصال این میزبان به سرورهای NordVPN.",
|
||||
"local_server": "سرور محلی",
|
||||
"tunnel_install_enable": "نصب و فعالسازی",
|
||||
"tunnel_enable": "فعالسازی تونل",
|
||||
@@ -448,6 +468,23 @@
|
||||
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
|
||||
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
|
||||
"nordvpn_install_required": "Install NordVPN first",
|
||||
"nordvpn_connect": "Connect NordVPN",
|
||||
"nordvpn_disconnect": "Disconnect",
|
||||
"nordvpn_connected": "NordVPN connected",
|
||||
"nordvpn_disconnected": "NordVPN disconnected",
|
||||
"nordvpn_status_unknown": "NordVPN status is unknown.",
|
||||
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
|
||||
"nordvpn_status_connected": "NordVPN is connected.",
|
||||
"nordvpn_status_connecting": "NordVPN is connecting.",
|
||||
"nordvpn_status_disconnected": "NordVPN is disconnected.",
|
||||
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
|
||||
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
|
||||
"nordvpn_status_error": "NordVPN returned an error.",
|
||||
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
|
||||
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
|
||||
"install_starting": "Starting installation...",
|
||||
"server_check_complete": "Check completed",
|
||||
"server_connection_error": "Connection error",
|
||||
@@ -518,50 +555,28 @@
|
||||
"xui_server_name_hint": "در دعوتها نمایش داده میشود (مثل US، NL).",
|
||||
"xui_server_select_label": "سرور 3x-ui",
|
||||
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک میدهد را انتخاب کنید.",
|
||||
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟",
|
||||
"cascade_title": "Cascade (double VPN)",
|
||||
"cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.",
|
||||
"cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.",
|
||||
"cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.",
|
||||
"cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.",
|
||||
"cascade_entry_protocol": "Protocol on this server (entry)",
|
||||
"cascade_exit_server": "Exit server",
|
||||
"cascade_exit_server_none": "— Select server —",
|
||||
"cascade_exit_protocol": "Protocol on exit server",
|
||||
"cascade_enable": "Enable cascade",
|
||||
"cascade_disable": "Disable cascade",
|
||||
"cascade_working": "Setting up cascade…",
|
||||
"cascade_disabling": "Disabling cascade…",
|
||||
"cascade_enabled": "Cascade enabled",
|
||||
"cascade_disabled": "Cascade disabled",
|
||||
"cascade_off": "Off",
|
||||
"cascade_on": "Active",
|
||||
"cascade_configured_down": "Configured, tunnel down",
|
||||
"cascade_exit_required": "Select an exit server",
|
||||
"cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.",
|
||||
"cascade_disable_confirm": "Disable cascade on this server?",
|
||||
"cascade_no_handshake": "Up, no handshake",
|
||||
"cascade_settings_title": "Cascade settings (per step)",
|
||||
"cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.",
|
||||
"cascade_opt_pin_exit": "Pin route to exit server",
|
||||
"cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.",
|
||||
"cascade_opt_remove_eth_masq": "Remove client NAT on eth0",
|
||||
"cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.",
|
||||
"cascade_opt_force_forward": "Force FORWARD between interfaces",
|
||||
"cascade_opt_force_forward_hint": "Client traffic → cascade → exit.",
|
||||
"cascade_opt_mss_clamp": "TCP MSS clamp",
|
||||
"cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.",
|
||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||
"cascade_verify_egress": "Verify egress IP",
|
||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||
"cascade_opt_table_id": "Routing table id",
|
||||
"cascade_opt_rule_priority": "ip rule priority",
|
||||
"cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel",
|
||||
"cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.",
|
||||
"cascade_opt_vpn_subnet": "Client subnet override",
|
||||
"cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address"
|
||||
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟"
|
||||
,
|
||||
"about_title": "About & Updates",
|
||||
"current_version": "Current version",
|
||||
"check_updates": "Check for updates",
|
||||
"checking_updates": "Checking...",
|
||||
"update_available": "New version available",
|
||||
"download_update": "Download update",
|
||||
"up_to_date": "You have the latest version",
|
||||
"apply_update": "Install update",
|
||||
"applying_update": "Updating…",
|
||||
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
|
||||
"update_restarting": "Updated. Restarting panel…",
|
||||
"update_no_target": "No target version. Check for updates first.",
|
||||
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
|
||||
"auto_update_ready": "Git auto-update is available for this install.",
|
||||
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
|
||||
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
|
||||
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
|
||||
"upgrade_panel": "Update panel",
|
||||
"upgrade_panel_confirm": "Download and install",
|
||||
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
|
||||
"upgrade_panel_working": "Updating panel…",
|
||||
"upgrade_panel_up_to_date": "Already on the latest version"
|
||||
}
|
||||
|
||||
+61
-46
@@ -100,6 +100,19 @@
|
||||
"connection_created": "Connexion \"{}\" créée",
|
||||
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
|
||||
"connection_deleted": "Connexion supprimée",
|
||||
"select_all": "Tout sélectionner",
|
||||
"connections_selected_count": "{} sélectionné(s)",
|
||||
"move_connections": "Déplacer",
|
||||
"move_connections_title": "Déplacer les connexions vers un autre serveur",
|
||||
"move_connections_target": "Serveur cible",
|
||||
"move_connections_confirm": "Déplacer {} connexion(s) vers le serveur sélectionné ? Les utilisateurs auront besoin de nouveaux configs.",
|
||||
"move_connections_hint": "Recrée les connexions sur le serveur cible et conserve les affectations utilisateur.",
|
||||
"move_connections_warning": "Les clients reçoivent de nouvelles clés et IP. Les anciens configs cessent de fonctionner.",
|
||||
"move_connections_delete_source": "Supprimer du serveur actuel après le déplacement",
|
||||
"move_connections_success": "{} connexion(s) déplacée(s)",
|
||||
"move_connections_partial_fail": "{} n'ont pas pu être déplacées",
|
||||
"move_connections_none_selected": "Sélectionnez au moins une connexion",
|
||||
"move_connections_no_targets": "Aucun autre serveur disponible",
|
||||
"config_tab": "📄 Config",
|
||||
"vpn_key_tab": "🔑 Clé-VPN",
|
||||
"qr_code_tab": "📱 Code-QR",
|
||||
@@ -257,6 +270,13 @@
|
||||
"api_docs_title": "🔌 Documentation API",
|
||||
"api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API",
|
||||
"tunnels_title": "Tunnels",
|
||||
"tunnels_subtitle": "Exposez le panneau sur Internet ou routez le trafic sortant via un VPN.",
|
||||
"tunnels_section_inbound": "Accès public",
|
||||
"tunnels_section_outbound": "VPN sortant",
|
||||
"tunnel_cloudflare_desc": "URL HTTPS gratuite via Cloudflare — sans compte.",
|
||||
"tunnel_ngrok_desc": "Tunnel stable avec votre authtoken ngrok.",
|
||||
"tunnel_warp_desc": "Routez le trafic du serveur via Cloudflare WARP.",
|
||||
"tunnel_nordvpn_desc": "Connectez cet hôte aux serveurs NordVPN.",
|
||||
"local_server": "Serveur local",
|
||||
"tunnel_install_enable": "Installer et activer",
|
||||
"tunnel_enable": "Activer le tunnel",
|
||||
@@ -448,6 +468,23 @@
|
||||
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
|
||||
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
|
||||
"nordvpn_install_required": "Install NordVPN first",
|
||||
"nordvpn_connect": "Connect NordVPN",
|
||||
"nordvpn_disconnect": "Disconnect",
|
||||
"nordvpn_connected": "NordVPN connected",
|
||||
"nordvpn_disconnected": "NordVPN disconnected",
|
||||
"nordvpn_status_unknown": "NordVPN status is unknown.",
|
||||
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
|
||||
"nordvpn_status_connected": "NordVPN is connected.",
|
||||
"nordvpn_status_connecting": "NordVPN is connecting.",
|
||||
"nordvpn_status_disconnected": "NordVPN is disconnected.",
|
||||
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
|
||||
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
|
||||
"nordvpn_status_error": "NordVPN returned an error.",
|
||||
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
|
||||
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
|
||||
"install_starting": "Starting installation...",
|
||||
"server_check_complete": "Check completed",
|
||||
"server_connection_error": "Connection error",
|
||||
@@ -518,50 +555,28 @@
|
||||
"xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).",
|
||||
"xui_server_select_label": "Serveur 3x-ui",
|
||||
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l\u0027URL d\u0027abonnement.",
|
||||
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?",
|
||||
"cascade_title": "Cascade (double VPN)",
|
||||
"cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.",
|
||||
"cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.",
|
||||
"cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.",
|
||||
"cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.",
|
||||
"cascade_entry_protocol": "Protocol on this server (entry)",
|
||||
"cascade_exit_server": "Exit server",
|
||||
"cascade_exit_server_none": "— Select server —",
|
||||
"cascade_exit_protocol": "Protocol on exit server",
|
||||
"cascade_enable": "Enable cascade",
|
||||
"cascade_disable": "Disable cascade",
|
||||
"cascade_working": "Setting up cascade…",
|
||||
"cascade_disabling": "Disabling cascade…",
|
||||
"cascade_enabled": "Cascade enabled",
|
||||
"cascade_disabled": "Cascade disabled",
|
||||
"cascade_off": "Off",
|
||||
"cascade_on": "Active",
|
||||
"cascade_configured_down": "Configured, tunnel down",
|
||||
"cascade_exit_required": "Select an exit server",
|
||||
"cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.",
|
||||
"cascade_disable_confirm": "Disable cascade on this server?",
|
||||
"cascade_no_handshake": "Up, no handshake",
|
||||
"cascade_settings_title": "Cascade settings (per step)",
|
||||
"cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.",
|
||||
"cascade_opt_pin_exit": "Pin route to exit server",
|
||||
"cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.",
|
||||
"cascade_opt_remove_eth_masq": "Remove client NAT on eth0",
|
||||
"cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.",
|
||||
"cascade_opt_force_forward": "Force FORWARD between interfaces",
|
||||
"cascade_opt_force_forward_hint": "Client traffic → cascade → exit.",
|
||||
"cascade_opt_mss_clamp": "TCP MSS clamp",
|
||||
"cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.",
|
||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||
"cascade_verify_egress": "Verify egress IP",
|
||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||
"cascade_opt_table_id": "Routing table id",
|
||||
"cascade_opt_rule_priority": "ip rule priority",
|
||||
"cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel",
|
||||
"cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.",
|
||||
"cascade_opt_vpn_subnet": "Client subnet override",
|
||||
"cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address"
|
||||
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?"
|
||||
,
|
||||
"about_title": "About & Updates",
|
||||
"current_version": "Current version",
|
||||
"check_updates": "Check for updates",
|
||||
"checking_updates": "Checking...",
|
||||
"update_available": "New version available",
|
||||
"download_update": "Download update",
|
||||
"up_to_date": "You have the latest version",
|
||||
"apply_update": "Install update",
|
||||
"applying_update": "Updating…",
|
||||
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
|
||||
"update_restarting": "Updated. Restarting panel…",
|
||||
"update_no_target": "No target version. Check for updates first.",
|
||||
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
|
||||
"auto_update_ready": "Git auto-update is available for this install.",
|
||||
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
|
||||
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
|
||||
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
|
||||
"upgrade_panel": "Update panel",
|
||||
"upgrade_panel_confirm": "Download and install",
|
||||
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
|
||||
"upgrade_panel_working": "Updating panel…",
|
||||
"upgrade_panel_up_to_date": "Already on the latest version"
|
||||
}
|
||||
|
||||
+55
-46
@@ -101,6 +101,19 @@
|
||||
"connection_created": "Подключение \"{}\" создано",
|
||||
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
|
||||
"connection_deleted": "Подключение удалено",
|
||||
"select_all": "Выбрать все",
|
||||
"connections_selected_count": "Выбрано: {}",
|
||||
"move_connections": "Перенести",
|
||||
"move_connections_title": "Перенос подключений на другой сервер",
|
||||
"move_connections_target": "Целевой сервер",
|
||||
"move_connections_confirm": "Перенести {} подключение(й) на выбранный сервер? Пользователям понадобятся новые конфиги.",
|
||||
"move_connections_hint": "Подключения будут созданы заново на целевом сервере, привязки к пользователям сохранятся.",
|
||||
"move_connections_warning": "У клиентов будут новые ключи и IP. Старые конфиги перестанут работать.",
|
||||
"move_connections_delete_source": "Удалить с текущего сервера после переноса",
|
||||
"move_connections_success": "Перенесено подключений: {}",
|
||||
"move_connections_partial_fail": "Не удалось перенести: {}",
|
||||
"move_connections_none_selected": "Выберите хотя бы одно подключение",
|
||||
"move_connections_no_targets": "Нет других серверов для переноса",
|
||||
"config_tab": "📄 Конфиг",
|
||||
"vpn_key_tab": "🔑 VPN-ключ",
|
||||
"qr_code_tab": "📱 QR-код",
|
||||
@@ -267,6 +280,13 @@
|
||||
"api_docs_title": "🔌 API Документация",
|
||||
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
|
||||
"tunnels_title": "Туннели",
|
||||
"tunnels_subtitle": "Публикация панели в интернете или исходящий трафик через VPN.",
|
||||
"tunnels_section_inbound": "Публичный доступ",
|
||||
"tunnels_section_outbound": "Исходящий VPN",
|
||||
"tunnel_cloudflare_desc": "Бесплатный HTTPS-адрес через Cloudflare — без регистрации.",
|
||||
"tunnel_ngrok_desc": "Стабильный туннель с вашим authtoken ngrok.",
|
||||
"tunnel_warp_desc": "Маршрутизация трафика сервера через Cloudflare WARP.",
|
||||
"tunnel_nordvpn_desc": "Подключение хоста к серверам NordVPN.",
|
||||
"local_server": "Локальный сервер",
|
||||
"tunnel_install_enable": "Установить и включить",
|
||||
"tunnel_enable": "Включить туннель",
|
||||
@@ -481,6 +501,23 @@
|
||||
"update_available": "Доступна новая версия",
|
||||
"download_update": "Скачать обновление",
|
||||
"up_to_date": "У вас установлена последняя версия",
|
||||
"apply_update": "Установить обновление",
|
||||
"applying_update": "Обновление…",
|
||||
"apply_update_confirm": "Установить обновление с git.evilfox.cc и перезапустить панель?",
|
||||
"update_restarting": "Обновлено. Перезапуск панели…",
|
||||
"update_waiting_restart": "Ожидание перезапуска панели…",
|
||||
"update_restart_timeout": "Панель не ответила вовремя. Обновите страницу вручную.",
|
||||
"update_no_target": "Нет целевой версии. Сначала проверьте обновления.",
|
||||
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
|
||||
"auto_update_ready": "Для этой установки доступно автообновление через git.",
|
||||
"auto_update_docker": "Docker: обновление в один клик скачивает ZIP релиза в контейнер. Пересоберите образ, чтобы сохранить изменения при пересоздании контейнера.",
|
||||
"auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.",
|
||||
"auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.",
|
||||
"upgrade_panel": "Обновить панель",
|
||||
"upgrade_panel_confirm": "Скачать и установить",
|
||||
"upgrade_panel_confirm_latest": "Проверить обновления и установить последний релиз?",
|
||||
"upgrade_panel_working": "Обновление панели…",
|
||||
"upgrade_panel_up_to_date": "Уже установлена последняя версия",
|
||||
"warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.",
|
||||
"warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.",
|
||||
"warp_install_required": "Сначала установите WARP",
|
||||
@@ -496,6 +533,23 @@
|
||||
"warp_status_not_registered": "Cloudflare WARP установлен, но ещё не зарегистрирован. Подключение попробует зарегистрировать его автоматически.",
|
||||
"warp_status_service_unavailable": "Сервис Cloudflare WARP недоступен. Убедитесь, что warp-svc запущен.",
|
||||
"warp_status_error": "Cloudflare WARP вернул ошибку.",
|
||||
"nordvpn_hint": "NordVPN направляет исходящий трафик этого хоста через серверы NordVPN. Не создаёт публичный URL панели как Quick Tunnel или ngrok. Перед подключением один раз выполните nordvpn login на хосте.",
|
||||
"nordvpn_install_hint": "Установите официальный клиент/CLI NordVPN на этом хосте, выполните nordvpn login и перезапустите панель.",
|
||||
"nordvpn_install_required": "Сначала установите NordVPN",
|
||||
"nordvpn_connect": "Подключить NordVPN",
|
||||
"nordvpn_disconnect": "Отключить",
|
||||
"nordvpn_connected": "NordVPN подключён",
|
||||
"nordvpn_disconnected": "NordVPN отключён",
|
||||
"nordvpn_status_unknown": "Статус NordVPN неизвестен.",
|
||||
"nordvpn_status_not_installed": "CLI NordVPN не установлен на этом хосте.",
|
||||
"nordvpn_status_connected": "NordVPN подключён.",
|
||||
"nordvpn_status_connecting": "NordVPN подключается.",
|
||||
"nordvpn_status_disconnected": "NordVPN отключён.",
|
||||
"nordvpn_status_not_logged_in": "NordVPN установлен, но не выполнен вход. Запустите nordvpn login на хосте.",
|
||||
"nordvpn_status_service_unavailable": "Демон NordVPN недоступен. Убедитесь, что nordvpnd запущен.",
|
||||
"nordvpn_status_error": "NordVPN вернул ошибку.",
|
||||
"nordvpn_country_placeholder": "Страна (необяз., напр. Germany или de)",
|
||||
"nordvpn_city_placeholder": "Город (необяз., напр. Berlin)",
|
||||
"install_starting": "Начинаем установку...",
|
||||
"server_check_complete": "Проверка завершена",
|
||||
"server_connection_error": "Ошибка подключения",
|
||||
@@ -575,50 +629,5 @@
|
||||
"xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).",
|
||||
"xui_server_select_label": "Сервер 3x-ui",
|
||||
"xui_server_select_hint": "Выберите панель 3x-ui, затем inbound из списка, который она отдаёт по API.",
|
||||
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?",
|
||||
"cascade_title": "Каскад (двойной VPN)",
|
||||
"cascade_desc": "Клиенты подключаются к этому серверу (вход), а интернет выходит через другой сервер (выход) — чтобы обойти блокировку страны.",
|
||||
"cascade_hint": "Каскад в стиле ryderams (AWG2): на входе и выходе должен быть AmneziaWG/WireGuard. Клиенты продолжают использовать только конфиг ВХОДНОГО сервера.",
|
||||
"cascade_client_note": "Пользователи подключаются только к ВХОДНОМУ серверу. Не выдавайте им служебный peer выхода.",
|
||||
"cascade_security_note": "Логика выполняется через SSH панели (без удалённого curl|bash). Подход на основе ryderams/amneziawg-cascade.",
|
||||
"cascade_entry_protocol": "Протокол на этом сервере (вход)",
|
||||
"cascade_exit_server": "Сервер выхода",
|
||||
"cascade_exit_server_none": "— Выберите сервер —",
|
||||
"cascade_exit_protocol": "Протокол на сервере выхода",
|
||||
"cascade_enable": "Включить каскад",
|
||||
"cascade_disable": "Отключить каскад",
|
||||
"cascade_working": "Настраиваю каскад…",
|
||||
"cascade_disabling": "Отключаю каскад…",
|
||||
"cascade_enabled": "Каскад включён",
|
||||
"cascade_disabled": "Каскад отключён",
|
||||
"cascade_off": "Выключен",
|
||||
"cascade_on": "Активен",
|
||||
"cascade_configured_down": "Настроен, туннель вниз",
|
||||
"cascade_exit_required": "Выберите сервер выхода",
|
||||
"cascade_enable_confirm": "Включить каскад? На выходном сервере будет создан служебный клиент, а на этом — туннель к нему.",
|
||||
"cascade_disable_confirm": "Отключить каскад на этом сервере?",
|
||||
"cascade_no_handshake": "Туннель вверх, нет handshake",
|
||||
"cascade_settings_title": "Настройки каскада (по шагам)",
|
||||
"cascade_settings_desc": "Если страницы «крутятся», а интернета нет — оставьте все галочки включёнными и снова нажмите «Включить каскад».",
|
||||
"cascade_opt_pin_exit": "Закрепить маршрут к серверу выхода",
|
||||
"cascade_opt_pin_exit_hint": "Чтобы туннель к выходу не ушёл в петлю через сам VPN.",
|
||||
"cascade_opt_remove_eth_masq": "Убрать NAT клиентов в eth0",
|
||||
"cascade_opt_remove_eth_masq_hint": "Клиенты не должны выходить в интернет напрямую с входного сервера.",
|
||||
"cascade_opt_force_forward": "Разрешить FORWARD между интерфейсами",
|
||||
"cascade_opt_force_forward_hint": "Трафик клиентов → cascade → выход.",
|
||||
"cascade_opt_mss_clamp": "TCP MSS clamp",
|
||||
"cascade_opt_mss_clamp_hint": "Чинит «вечную загрузку» сайтов при двойном VPN.",
|
||||
"cascade_opt_wait_handshake": "Ждать handshake с выходом",
|
||||
"cascade_opt_wait_handshake_hint": "Не помечать каскад «активным», если выход не отвечает.",
|
||||
"cascade_verify_egress": "Проверять внешний IP",
|
||||
"cascade_verify_egress_hint": "После handshake — curl ifconfig.me через Address каскада; при пустом ответе откат.",
|
||||
"cascade_opt_keep_dns": "Оставить DNS из конфига выхода",
|
||||
"cascade_opt_keep_dns_hint": "Обычно выключено — DNS берёт клиент сам.",
|
||||
"cascade_opt_handshake_timeout": "Таймаут handshake (сек)",
|
||||
"cascade_opt_table_id": "Таблица маршрутов",
|
||||
"cascade_opt_rule_priority": "Приоритет ip rule",
|
||||
"cascade_opt_allowed_ips": "AllowedIPs туннеля вход→выход",
|
||||
"cascade_opt_allowed_ips_hint": "Лучше 0.0.0.0/0 (IPv4); ::/0 часто ломает двойной VPN.",
|
||||
"cascade_opt_vpn_subnet": "Подсеть клиентов (override)",
|
||||
"cascade_opt_vpn_subnet_hint": "Пусто = определить автоматически из Address сервера"
|
||||
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?"
|
||||
}
|
||||
|
||||
+61
-46
@@ -100,6 +100,19 @@
|
||||
"connection_created": "连接 \"{}\" 已创建",
|
||||
"delete_connection_confirm": "确定删除此连接?配置将失效。",
|
||||
"connection_deleted": "连接已删除",
|
||||
"select_all": "全选",
|
||||
"connections_selected_count": "已选 {}",
|
||||
"move_connections": "迁移",
|
||||
"move_connections_title": "将连接迁移到另一台服务器",
|
||||
"move_connections_target": "目标服务器",
|
||||
"move_connections_confirm": "将 {} 个连接迁移到所选服务器?用户需要新的配置。",
|
||||
"move_connections_hint": "在目标服务器上重新创建所选连接,并保留用户绑定。",
|
||||
"move_connections_warning": "客户端将获得新密钥和 IP,旧配置将失效。",
|
||||
"move_connections_delete_source": "迁移后从当前服务器删除",
|
||||
"move_connections_success": "已迁移 {} 个连接",
|
||||
"move_connections_partial_fail": "{} 个无法迁移",
|
||||
"move_connections_none_selected": "请至少选择一个连接",
|
||||
"move_connections_no_targets": "没有其他可用服务器",
|
||||
"config_tab": "📄 配置文件",
|
||||
"vpn_key_tab": "🔑 VPN 密钥",
|
||||
"qr_code_tab": "📱 二维码",
|
||||
@@ -257,6 +270,13 @@
|
||||
"api_docs_title": "🔌 API 文档",
|
||||
"api_docs_hint": "使用这些接口了解面板功能",
|
||||
"tunnels_title": "Tunnels",
|
||||
"tunnels_subtitle": "将面板暴露到互联网,或通过 VPN 路由出站流量。",
|
||||
"tunnels_section_inbound": "公网访问",
|
||||
"tunnels_section_outbound": "出站 VPN",
|
||||
"tunnel_cloudflare_desc": "通过 Cloudflare 获取免费 HTTPS 地址,无需注册。",
|
||||
"tunnel_ngrok_desc": "使用 ngrok authtoken 的稳定隧道。",
|
||||
"tunnel_warp_desc": "通过 Cloudflare WARP 路由服务器流量。",
|
||||
"tunnel_nordvpn_desc": "将此主机连接到 NordVPN 服务器。",
|
||||
"local_server": "本地服务器",
|
||||
"tunnel_install_enable": "安装并启用",
|
||||
"tunnel_enable": "启用隧道",
|
||||
@@ -448,6 +468,23 @@
|
||||
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
|
||||
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
|
||||
"nordvpn_install_required": "Install NordVPN first",
|
||||
"nordvpn_connect": "Connect NordVPN",
|
||||
"nordvpn_disconnect": "Disconnect",
|
||||
"nordvpn_connected": "NordVPN connected",
|
||||
"nordvpn_disconnected": "NordVPN disconnected",
|
||||
"nordvpn_status_unknown": "NordVPN status is unknown.",
|
||||
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
|
||||
"nordvpn_status_connected": "NordVPN is connected.",
|
||||
"nordvpn_status_connecting": "NordVPN is connecting.",
|
||||
"nordvpn_status_disconnected": "NordVPN is disconnected.",
|
||||
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
|
||||
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
|
||||
"nordvpn_status_error": "NordVPN returned an error.",
|
||||
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
|
||||
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
|
||||
"install_starting": "Starting installation...",
|
||||
"server_check_complete": "Check completed",
|
||||
"server_connection_error": "Connection error",
|
||||
@@ -518,50 +555,28 @@
|
||||
"xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。",
|
||||
"xui_server_select_label": "3x-ui 服务器",
|
||||
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
|
||||
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?",
|
||||
"cascade_title": "Cascade (double VPN)",
|
||||
"cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.",
|
||||
"cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.",
|
||||
"cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.",
|
||||
"cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.",
|
||||
"cascade_entry_protocol": "Protocol on this server (entry)",
|
||||
"cascade_exit_server": "Exit server",
|
||||
"cascade_exit_server_none": "— Select server —",
|
||||
"cascade_exit_protocol": "Protocol on exit server",
|
||||
"cascade_enable": "Enable cascade",
|
||||
"cascade_disable": "Disable cascade",
|
||||
"cascade_working": "Setting up cascade…",
|
||||
"cascade_disabling": "Disabling cascade…",
|
||||
"cascade_enabled": "Cascade enabled",
|
||||
"cascade_disabled": "Cascade disabled",
|
||||
"cascade_off": "Off",
|
||||
"cascade_on": "Active",
|
||||
"cascade_configured_down": "Configured, tunnel down",
|
||||
"cascade_exit_required": "Select an exit server",
|
||||
"cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.",
|
||||
"cascade_disable_confirm": "Disable cascade on this server?",
|
||||
"cascade_no_handshake": "Up, no handshake",
|
||||
"cascade_settings_title": "Cascade settings (per step)",
|
||||
"cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.",
|
||||
"cascade_opt_pin_exit": "Pin route to exit server",
|
||||
"cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.",
|
||||
"cascade_opt_remove_eth_masq": "Remove client NAT on eth0",
|
||||
"cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.",
|
||||
"cascade_opt_force_forward": "Force FORWARD between interfaces",
|
||||
"cascade_opt_force_forward_hint": "Client traffic → cascade → exit.",
|
||||
"cascade_opt_mss_clamp": "TCP MSS clamp",
|
||||
"cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.",
|
||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||
"cascade_verify_egress": "Verify egress IP",
|
||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||
"cascade_opt_table_id": "Routing table id",
|
||||
"cascade_opt_rule_priority": "ip rule priority",
|
||||
"cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel",
|
||||
"cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.",
|
||||
"cascade_opt_vpn_subnet": "Client subnet override",
|
||||
"cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address"
|
||||
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?"
|
||||
,
|
||||
"about_title": "About & Updates",
|
||||
"current_version": "Current version",
|
||||
"check_updates": "Check for updates",
|
||||
"checking_updates": "Checking...",
|
||||
"update_available": "New version available",
|
||||
"download_update": "Download update",
|
||||
"up_to_date": "You have the latest version",
|
||||
"apply_update": "Install update",
|
||||
"applying_update": "Updating…",
|
||||
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
|
||||
"update_restarting": "Updated. Restarting panel…",
|
||||
"update_no_target": "No target version. Check for updates first.",
|
||||
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
|
||||
"auto_update_ready": "Git auto-update is available for this install.",
|
||||
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
|
||||
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
|
||||
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
|
||||
"upgrade_panel": "Update panel",
|
||||
"upgrade_panel_confirm": "Download and install",
|
||||
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
|
||||
"upgrade_panel_working": "Updating panel…",
|
||||
"upgrade_panel_up_to_date": "Already on the latest version"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user