Template
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53bffbd4fc | ||
|
|
53638cf122 | ||
|
|
1f1234d217 | ||
|
|
9890e8cc3f | ||
|
|
32a0e90e19 | ||
|
|
73d0ee277d | ||
|
|
9b4ef0f9f0 | ||
|
|
78e69d899a | ||
|
|
2d62758716 | ||
|
|
d26843a0d1 | ||
|
|
706d2c9c0d | ||
|
|
2746f981af | ||
|
|
4f5a1d7554 | ||
|
|
ff7cc5c592 | ||
|
|
2808a49fa5 |
+10
-3
@@ -2,11 +2,18 @@
|
|||||||
APP_PORT=5000
|
APP_PORT=5000
|
||||||
SECRET_KEY=change-me-to-a-long-random-string
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
# PostgreSQL (used by docker-compose)
|
# PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
|
||||||
POSTGRES_USER=amnezia
|
POSTGRES_USER=amnezia
|
||||||
POSTGRES_PASSWORD=amnezia
|
POSTGRES_PASSWORD=change-me-strong-password
|
||||||
POSTGRES_DB=amnezia_panel
|
POSTGRES_DB=amnezia_panel
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
|
||||||
|
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
|
||||||
|
|
||||||
# Full DSN (override if panel runs outside compose)
|
# Full DSN (override if panel runs outside compose)
|
||||||
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel
|
# DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
|
||||||
|
|
||||||
|
# Dokploy / reverse proxy (optional — set in compose for Docker)
|
||||||
|
# PANEL_IN_DOCKER=1
|
||||||
|
# PANEL_BEHIND_PROXY=1
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
+7
-4
@@ -5,12 +5,15 @@ FROM python:3.12-slim
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
APP_PORT=5000 \
|
APP_PORT=5000 \
|
||||||
|
PORT=5000 \
|
||||||
|
PANEL_IN_DOCKER=1 \
|
||||||
|
PANEL_BEHIND_PROXY=1 \
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl \
|
&& apt-get install -y --no-install-recommends curl postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -20,7 +23,7 @@ COPY . .
|
|||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1
|
CMD curl -fsS "http://127.0.0.1:5000/health" >/dev/null || exit 1
|
||||||
|
|
||||||
CMD ["python3", "app.py"]
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||||
|
|||||||
@@ -188,22 +188,71 @@ Mac
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# set SECRET_KEY and strong POSTGRES_PASSWORD in .env
|
# set SECRET_KEY and strong POSTGRES_PASSWORD in .env
|
||||||
|
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create dokploy-network
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
|
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
|
||||||
|
|
||||||
|
### Dokploy (recommended)
|
||||||
|
|
||||||
|
The compose file connects the panel to Dokploy's Traefik network (`dokploy-network`) and exposes the app on **container port 5000** (not 80).
|
||||||
|
|
||||||
|
1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically).
|
||||||
|
2. In Dokploy → your app → **Environment** (set **before first deploy**):
|
||||||
|
- `SECRET_KEY` — long random string
|
||||||
|
- `POSTGRES_PASSWORD` — strong password (same value is used for `db` and `amnezia_panel`)
|
||||||
|
3. In Dokploy → **Domains**:
|
||||||
|
- **Service**: `amnezia_panel` (not `db`)
|
||||||
|
- **Container port**: `5000`
|
||||||
|
- **Path**: `/`
|
||||||
|
4. **Deploy / Rebuild** (not Restart) after code or env changes.
|
||||||
|
|
||||||
|
**Verify on the server** (replace `PROJECT` and path with yours):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_DIR=/etc/dokploy/compose/home-vpn-g6rfpd/code
|
||||||
|
PROJECT=home-vpn-g6rfpd
|
||||||
|
|
||||||
|
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml exec amnezia_panel \
|
||||||
|
curl -fsS http://127.0.0.1:5000/health
|
||||||
|
# → {"status":"ok","version":"v2.6.0"}
|
||||||
|
|
||||||
|
docker inspect ${PROJECT}-amnezia_panel-1 \
|
||||||
|
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||||
|
# must include: dokploy-network
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full redeploy after git update:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd $COMPOSE_DIR && git pull origin main
|
||||||
|
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
|
||||||
|
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml build --no-cache amnezia_panel
|
||||||
|
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Postgres `password authentication failed`:** the volume keeps the password from the **first** deploy. If you change `POSTGRES_PASSWORD` in Dokploy later, reset the DB volume (wipes panel DB data):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
|
||||||
|
docker volume rm ${PROJECT}_amnezia_pgdata
|
||||||
|
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Domain shows `404 page not found` (plain text):** Traefik has no route to the panel — check Domains (service `amnezia_panel`, port `5000`) and that the container is on `dokploy-network` (see verify commands above).
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Dockerfile` | Production image (Python 3.12) |
|
| `Dockerfile` | Production image (Python 3.12) |
|
||||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks |
|
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks, Dokploy/Traefik labels |
|
||||||
| `.env.example` | Environment template |
|
| `.env.example` | Environment template |
|
||||||
|
|
||||||
**Environment**
|
**Environment**
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_PORT` | `5000` | Host port / in-container listen port |
|
| `APP_PORT` / `PORT` | `5000` | Host port / in-container listen port |
|
||||||
| `SECRET_KEY` | (random) | Session signing key — set in production |
|
| `SECRET_KEY` | (random) | Session signing key — set in production |
|
||||||
| `DATABASE_URL` | compose DSN | PostgreSQL connection string |
|
| `DATABASE_URL` | compose DSN | PostgreSQL connection string |
|
||||||
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
|
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
|
||||||
@@ -228,8 +277,51 @@ GitHub Actions workflows in `.github/workflows/`:
|
|||||||
|
|
||||||
## 📋 Fix / changelog (this fork)
|
## 📋 Fix / changelog (this fork)
|
||||||
|
|
||||||
### v2.5.4
|
### v2.6.5
|
||||||
* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links).
|
* **Mieru install fix** — wait for `/var/run/mita.sock`, seed a bootstrap user (empty `users` caused `mita start` RPC EOF), retry apply/start with systemd restart.
|
||||||
|
|
||||||
|
### v2.6.4
|
||||||
|
* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag).
|
||||||
|
|
||||||
|
### v2.6.3
|
||||||
|
* **Client connection domain (AWG / WireGuard)** — set a DNS name per server (or per AWG/WG protocol) instead of IP in client configs (`Endpoint = domain:port`). Survives server IP changes — update the A-record only. UI on server list, server page, and when adding a server.
|
||||||
|
* **Legacy `data.json` → PostgreSQL** — import normalizes old backups (UUIDs, duplicate usernames/tokens, string `server_info`); failed auto-import no longer bricks panel startup; JSON restore returns clear errors.
|
||||||
|
* **Dokploy / Traefik 404** — `traefik.http.services.amnezia_panel.loadbalancer.server.port=5000` in compose (Traefik must route to container port **5000**).
|
||||||
|
* **Support the project** — `/support` page for all users (SBP / card / crypto placeholders); admin configures requisites in **Settings** (no popups).
|
||||||
|
|
||||||
|
### v2.6.2
|
||||||
|
* **Panel backup → PostgreSQL dump** — Settings backup downloads `.sql` via `pg_dump`; restore accepts `.sql` or legacy `data.json`. JSON export kept as secondary link.
|
||||||
|
|
||||||
|
### v2.6.1
|
||||||
|
* **Move connections between servers** — restored: select configs on a server page and move to another server (recreates peers, keeps user links). `ToggleConnectionRequest` kept intact.
|
||||||
|
|
||||||
|
### v2.6.0 — stable Dokploy / Docker release
|
||||||
|
* **Dokploy-ready compose** — `dokploy-network`, Traefik labels, port **5000**, no fixed `container_name`.
|
||||||
|
* **Docker startup** — `uvicorn` with proxy headers; SSL inside container disabled when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY`.
|
||||||
|
* **Postgres healthcheck** — verifies password (catches `POSTGRES_PASSWORD` vs volume mismatch).
|
||||||
|
* **Rollback move-connections** — removed server-to-server config move (v2.5.4); restored `ToggleConnectionRequest` (fixes crash loop on deploy).
|
||||||
|
* **Docs** — full Dokploy deploy/redeploy/password-reset guide in README.
|
||||||
|
|
||||||
|
### v2.5.11
|
||||||
|
* **Postgres healthcheck** — verifies DB password (surfaces `POSTGRES_PASSWORD` / volume mismatch before panel starts).
|
||||||
|
|
||||||
|
### v2.5.10
|
||||||
|
* **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy.
|
||||||
|
|
||||||
|
### v2.5.9
|
||||||
|
* **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4).
|
||||||
|
|
||||||
|
### v2.5.8
|
||||||
|
* **Docker/Dokploy: panel actually serves HTTP** — entrypoint waits for Postgres, runs `uvicorn` with proxy headers; SSL inside the container is forced off when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY` is set (fixes empty replies on port 5000 behind Traefik).
|
||||||
|
|
||||||
|
### v2.5.7
|
||||||
|
* **Dokploy / Traefik 404 fix** — `docker-compose.yml` joins `dokploy-network`, Traefik labels point to port **5000**; removed fixed `container_name`; README Dokploy domain setup.
|
||||||
|
|
||||||
|
### v2.5.6
|
||||||
|
* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link.
|
||||||
|
|
||||||
|
### v2.5.5
|
||||||
|
* **Fix 404 after panel update** — correct tunnel/local port when `APP_PORT` is set (Docker), health check before reload, auto-restart quick tunnels after reboot, reliable archive download URLs.
|
||||||
|
|
||||||
### v2.5.3
|
### v2.5.3
|
||||||
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
|
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Stre
|
|||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi import FastAPI, Request, Query, UploadFile, File
|
from fastapi import FastAPI, Request, Query, UploadFile, File, HTTPException
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
@@ -102,7 +103,7 @@ else:
|
|||||||
application_path = os.path.dirname(__file__)
|
application_path = os.path.dirname(__file__)
|
||||||
|
|
||||||
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
||||||
CURRENT_VERSION = "v2.5.4"
|
CURRENT_VERSION = "v2.6.5"
|
||||||
RELEASES_REPO_URL = repo_url()
|
RELEASES_REPO_URL = repo_url()
|
||||||
RELEASES_API_LATEST = api_latest_url()
|
RELEASES_API_LATEST = api_latest_url()
|
||||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
||||||
@@ -110,11 +111,16 @@ TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application
|
|||||||
|
|
||||||
# Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API.
|
# Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API.
|
||||||
from db import ( # noqa: E402
|
from db import ( # noqa: E402
|
||||||
|
backup_filename,
|
||||||
clear_tunnel_state,
|
clear_tunnel_state,
|
||||||
ensure_db_ready,
|
ensure_db_ready,
|
||||||
export_data_dict,
|
export_data_dict,
|
||||||
|
export_database_sql,
|
||||||
|
invalidate_data_cache,
|
||||||
load_data,
|
load_data,
|
||||||
load_tunnel_state,
|
load_tunnel_state,
|
||||||
|
normalize_import_data,
|
||||||
|
restore_database_sql,
|
||||||
save_data,
|
save_data,
|
||||||
save_tunnel_state,
|
save_tunnel_state,
|
||||||
update_tunnel_state,
|
update_tunnel_state,
|
||||||
@@ -193,6 +199,34 @@ def get_ssh(server):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WG_AWG_PROTOCOLS = frozenset({'awg', 'awg2', 'awg_legacy', 'wireguard'})
|
||||||
|
|
||||||
|
|
||||||
|
def get_server_connect_host(server: dict, protocol: Optional[str] = None) -> str:
|
||||||
|
"""Hostname embedded in client VPN configs (Endpoint, vless://, etc.).
|
||||||
|
|
||||||
|
SSH still uses server['host']; this value can be a DNS name so clients
|
||||||
|
keep working after the server IP changes — update the A-record only.
|
||||||
|
Per-protocol connect_domain (AWG / WireGuard) overrides the server default.
|
||||||
|
"""
|
||||||
|
protocols = server.get('protocols') or {}
|
||||||
|
if protocol:
|
||||||
|
base = protocol_base(protocol)
|
||||||
|
if base in WG_AWG_PROTOCOLS:
|
||||||
|
for key in (protocol, base):
|
||||||
|
pinfo = protocols.get(key) if key else None
|
||||||
|
if isinstance(pinfo, dict):
|
||||||
|
domain = (pinfo.get('connect_domain') or '').strip()
|
||||||
|
if domain:
|
||||||
|
return domain.lower().rstrip('.')
|
||||||
|
info = server.get('server_info') or {}
|
||||||
|
for key in ('connect_domain', 'ssl_domain'):
|
||||||
|
val = (info.get(key) or '').strip()
|
||||||
|
if val:
|
||||||
|
return val.lower().rstrip('.')
|
||||||
|
return (server.get('host') or '').strip()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request, data: Optional[dict] = None):
|
def get_current_user(request: Request, data: Optional[dict] = None):
|
||||||
user_id = request.session.get('user_id')
|
user_id = request.session.get('user_id')
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -207,12 +241,14 @@ def get_current_user(request: Request, data: Optional[dict] = None):
|
|||||||
def tpl(request, template, **kwargs):
|
def tpl(request, template, **kwargs):
|
||||||
data = load_data()
|
data = load_data()
|
||||||
lang = request.cookies.get('lang', 'en')
|
lang = request.cookies.get('lang', 'en')
|
||||||
|
donate_cfg = (data.get('settings') or {}).get('donate') or {}
|
||||||
ctx = {
|
ctx = {
|
||||||
'request': request,
|
'request': request,
|
||||||
'current_user': get_current_user(request, data),
|
'current_user': get_current_user(request, data),
|
||||||
'site_settings': data.get('settings', {}).get('appearance', {}),
|
'site_settings': data.get('settings', {}).get('appearance', {}),
|
||||||
'captcha_settings': data.get('settings', {}).get('captcha', {}),
|
'captcha_settings': data.get('settings', {}).get('captcha', {}),
|
||||||
'telegram_settings': data.get('settings', {}).get('telegram', {}),
|
'telegram_settings': data.get('settings', {}).get('telegram', {}),
|
||||||
|
'donate_enabled': bool(donate_cfg.get('enabled', True)),
|
||||||
'bot_running': tg_bot.is_running(),
|
'bot_running': tg_bot.is_running(),
|
||||||
'lang': lang,
|
'lang': lang,
|
||||||
'_': lambda text_id: _t(text_id, lang),
|
'_': lambda text_id: _t(text_id, lang),
|
||||||
@@ -224,12 +260,49 @@ def tpl(request, template, **kwargs):
|
|||||||
return templates.TemplateResponse(template, ctx)
|
return templates.TemplateResponse(template, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def get_panel_listen_port(data: Optional[dict] = None) -> int:
|
||||||
|
for env_key in ('APP_PORT', 'PORT'):
|
||||||
|
try:
|
||||||
|
env_port = int(os.environ.get(env_key, '0') or '0')
|
||||||
|
except ValueError:
|
||||||
|
env_port = 0
|
||||||
|
if env_port:
|
||||||
|
return env_port
|
||||||
|
if data is None:
|
||||||
|
data = load_data()
|
||||||
|
ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
|
||||||
|
try:
|
||||||
|
return int(ssl_conf.get('panel_port', 5000) or 5000)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 5000
|
||||||
|
|
||||||
|
|
||||||
|
def panel_ssl_active(data: Optional[dict] = None) -> bool:
|
||||||
|
# Behind Docker / reverse proxy TLS is always terminated outside the app.
|
||||||
|
if os.environ.get('PANEL_IN_DOCKER') == '1' or os.environ.get('PANEL_BEHIND_PROXY') == '1':
|
||||||
|
return False
|
||||||
|
if os.environ.get('APP_PORT') or os.environ.get('PORT'):
|
||||||
|
return False
|
||||||
|
if data is None:
|
||||||
|
data = load_data()
|
||||||
|
ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
|
||||||
|
if not ssl_conf.get('enabled'):
|
||||||
|
return False
|
||||||
|
cert_path = ssl_conf.get('cert_path')
|
||||||
|
key_path = ssl_conf.get('key_path')
|
||||||
|
if ssl_conf.get('cert_text') or ssl_conf.get('key_text'):
|
||||||
|
return True
|
||||||
|
return bool(
|
||||||
|
cert_path and key_path
|
||||||
|
and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_panel_local_url(request: Optional[Request] = None):
|
def get_panel_local_url(request: Optional[Request] = None):
|
||||||
data = load_data()
|
data = load_data()
|
||||||
ssl_conf = data.get('settings', {}).get('ssl', {})
|
scheme = 'https' if panel_ssl_active(data) else 'http'
|
||||||
scheme = 'https' if ssl_conf.get('enabled') else 'http'
|
|
||||||
host = '127.0.0.1'
|
host = '127.0.0.1'
|
||||||
port = ssl_conf.get('panel_port', 5000) or 5000
|
port = get_panel_listen_port(data)
|
||||||
if request:
|
if request:
|
||||||
scheme = request.url.scheme or scheme
|
scheme = request.url.scheme or scheme
|
||||||
host = request.url.hostname or host
|
host = request.url.hostname or host
|
||||||
@@ -239,12 +312,59 @@ def get_panel_local_url(request: Optional[Request] = None):
|
|||||||
|
|
||||||
def get_panel_tunnel_target_url():
|
def get_panel_tunnel_target_url():
|
||||||
data = load_data()
|
data = load_data()
|
||||||
ssl_conf = data.get('settings', {}).get('ssl', {})
|
scheme = 'https' if panel_ssl_active(data) else 'http'
|
||||||
scheme = 'https' if ssl_conf.get('enabled') else 'http'
|
port = get_panel_listen_port(data)
|
||||||
port = ssl_conf.get('panel_port', 5000) or 5000
|
|
||||||
return f"{scheme}://127.0.0.1:{port}"
|
return f"{scheme}://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_panel_public_url(request: Optional[Request] = None) -> str:
|
||||||
|
if request is not None:
|
||||||
|
forwarded_proto = (request.headers.get('x-forwarded-proto') or '').split(',')[0].strip()
|
||||||
|
scheme = forwarded_proto or request.url.scheme or 'http'
|
||||||
|
host = (
|
||||||
|
(request.headers.get('x-forwarded-host') or '').split(',')[0].strip()
|
||||||
|
or request.headers.get('host')
|
||||||
|
or request.url.netloc
|
||||||
|
)
|
||||||
|
if host:
|
||||||
|
return f"{scheme}://{host}/"
|
||||||
|
return get_panel_local_url(request).rstrip('/') + '/'
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_update_result(request: Request, result: dict) -> dict:
|
||||||
|
result['panel_url'] = get_panel_public_url(request)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||||
|
if exc.status_code != 404:
|
||||||
|
detail = exc.detail if isinstance(exc.detail, str) else 'Error'
|
||||||
|
if request.url.path.startswith('/api/'):
|
||||||
|
return JSONResponse({'error': detail}, status_code=exc.status_code)
|
||||||
|
return HTMLResponse(f'<h1>{exc.status_code}</h1><p>{detail}</p>', status_code=exc.status_code)
|
||||||
|
if request.url.path.startswith('/api/') or 'application/json' in (request.headers.get('accept') or '').lower():
|
||||||
|
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||||
|
home = get_panel_public_url(request)
|
||||||
|
return HTMLResponse(
|
||||||
|
f'''<!DOCTYPE html><html><head><meta charset="utf-8"><title>Amnezia Panel</title></head>
|
||||||
|
<body style="font-family:sans-serif;max-width:520px;margin:3rem auto;padding:0 1rem;">
|
||||||
|
<h1>Страница не найдена</h1>
|
||||||
|
<p>Адрес <code>{request.url.path}</code> не существует в панели.</p>
|
||||||
|
<p>Если вы только что обновили панель, подождите 10–30 секунд и откройте:</p>
|
||||||
|
<p><a href="{home}">{home}</a></p>
|
||||||
|
<p><a href="/health">/health</a> — проверка, что панель запущена.</p>
|
||||||
|
</body></html>''',
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/health', include_in_schema=False)
|
||||||
|
@app.get('/api/health', include_in_schema=False)
|
||||||
|
async def health_check():
|
||||||
|
return {'status': 'ok', 'version': CURRENT_VERSION}
|
||||||
|
|
||||||
|
|
||||||
def get_warp_cli_binary():
|
def get_warp_cli_binary():
|
||||||
found = shutil.which(WARP_CLI_COMMAND)
|
found = shutil.which(WARP_CLI_COMMAND)
|
||||||
if found:
|
if found:
|
||||||
@@ -991,8 +1111,9 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20):
|
|||||||
return get_tunnel_status(provider)
|
return get_tunnel_status(provider)
|
||||||
|
|
||||||
|
|
||||||
BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
|
BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
|
||||||
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'}
|
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'}
|
||||||
|
NON_DOCKER_PROTOCOLS = frozenset({'mieru'})
|
||||||
|
|
||||||
|
|
||||||
def protocol_base(protocol: str) -> str:
|
def protocol_base(protocol: str) -> str:
|
||||||
@@ -1034,6 +1155,7 @@ def protocol_display_name(protocol: str) -> str:
|
|||||||
'telemt': 'Telemt',
|
'telemt': 'Telemt',
|
||||||
'hysteria': 'Hysteria 2',
|
'hysteria': 'Hysteria 2',
|
||||||
'naiveproxy': 'NaiveProxy',
|
'naiveproxy': 'NaiveProxy',
|
||||||
|
'mieru': 'Mieru',
|
||||||
'dns': 'AmneziaDNS',
|
'dns': 'AmneziaDNS',
|
||||||
'wireguard': 'WireGuard',
|
'wireguard': 'WireGuard',
|
||||||
'socks5': 'SOCKS5',
|
'socks5': 'SOCKS5',
|
||||||
@@ -1055,6 +1177,7 @@ def protocol_container_name(protocol: str) -> Optional[str]:
|
|||||||
'telemt': 'telemt',
|
'telemt': 'telemt',
|
||||||
'hysteria': 'amnezia-hysteria',
|
'hysteria': 'amnezia-hysteria',
|
||||||
'naiveproxy': 'amnezia-naiveproxy',
|
'naiveproxy': 'amnezia-naiveproxy',
|
||||||
|
'mieru': 'mita',
|
||||||
'dns': 'amnezia-dns',
|
'dns': 'amnezia-dns',
|
||||||
'wireguard': 'amnezia-wireguard',
|
'wireguard': 'amnezia-wireguard',
|
||||||
'socks5': 'amnezia-socks5proxy',
|
'socks5': 'amnezia-socks5proxy',
|
||||||
@@ -1100,6 +1223,9 @@ def get_protocol_manager(ssh, protocol: str):
|
|||||||
elif base == 'naiveproxy':
|
elif base == 'naiveproxy':
|
||||||
from managers.naiveproxy_manager import NaiveProxyManager
|
from managers.naiveproxy_manager import NaiveProxyManager
|
||||||
return NaiveProxyManager(ssh, protocol)
|
return NaiveProxyManager(ssh, protocol)
|
||||||
|
elif base == 'mieru':
|
||||||
|
from managers.mieru_manager import MieruManager
|
||||||
|
return MieruManager(ssh, protocol)
|
||||||
from managers.awg_manager import AWGManager
|
from managers.awg_manager import AWGManager
|
||||||
return AWGManager(ssh)
|
return AWGManager(ssh)
|
||||||
|
|
||||||
@@ -1161,7 +1287,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
# Protocols that own VPN clients (can list/link connections)
|
# Protocols that own VPN clients (can list/link connections)
|
||||||
CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'naiveproxy', 'xui'}
|
CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'naiveproxy', 'mieru', 'xui'}
|
||||||
|
|
||||||
|
|
||||||
def generate_vpn_link(config_text):
|
def generate_vpn_link(config_text):
|
||||||
@@ -1342,17 +1468,19 @@ def _create_remote_client(manager, protocol: str, server: dict, name: str, sourc
|
|||||||
base = protocol_base(protocol)
|
base = protocol_base(protocol)
|
||||||
if base == 'telemt':
|
if base == 'telemt':
|
||||||
user_data = (source_client or {}).get('userData') or {}
|
user_data = (source_client or {}).get('userData') or {}
|
||||||
|
connect_host = get_server_connect_host(server, protocol)
|
||||||
return manager.add_client(
|
return manager.add_client(
|
||||||
protocol, name, server['host'], port,
|
protocol, name, connect_host, port,
|
||||||
telemt_quota=user_data.get('quota'),
|
telemt_quota=user_data.get('quota'),
|
||||||
telemt_expiry=user_data.get('expiry'),
|
telemt_expiry=user_data.get('expiry'),
|
||||||
secret=user_data.get('token'),
|
secret=user_data.get('token'),
|
||||||
user_ad_tag=user_data.get('user_ad_tag'),
|
user_ad_tag=user_data.get('user_ad_tag'),
|
||||||
max_tcp_conns=user_data.get('max_tcp_conns'),
|
max_tcp_conns=user_data.get('max_tcp_conns'),
|
||||||
)
|
)
|
||||||
|
connect_host = get_server_connect_host(server, protocol)
|
||||||
if base == 'wireguard':
|
if base == 'wireguard':
|
||||||
return manager.add_client(name, server['host'])
|
return manager.add_client(name, connect_host)
|
||||||
return manager.add_client(protocol, name, server['host'], port)
|
return manager.add_client(protocol, name, connect_host, port)
|
||||||
|
|
||||||
|
|
||||||
def _move_connections_sync(
|
def _move_connections_sync(
|
||||||
@@ -1557,9 +1685,9 @@ async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: Li
|
|||||||
manager = get_protocol_manager(ssh, c_req['protocol'])
|
manager = get_protocol_manager(ssh, c_req['protocol'])
|
||||||
|
|
||||||
if c_req['protocol'] == 'wireguard':
|
if c_req['protocol'] == 'wireguard':
|
||||||
res = await asyncio.to_thread(manager.add_client, c_req['name'], srv['host'])
|
res = await asyncio.to_thread(manager.add_client, c_req['name'], get_server_connect_host(srv, 'wireguard'))
|
||||||
else:
|
else:
|
||||||
res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], srv['host'], port)
|
res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], get_server_connect_host(srv, c_req['protocol']), port)
|
||||||
|
|
||||||
if res.get('client_id'):
|
if res.get('client_id'):
|
||||||
new_conn = {
|
new_conn = {
|
||||||
@@ -2005,6 +2133,7 @@ class AddServerRequest(BaseModel):
|
|||||||
name: str = ''
|
name: str = ''
|
||||||
ssl_domain: str = ''
|
ssl_domain: str = ''
|
||||||
ssl_email: str = ''
|
ssl_email: str = ''
|
||||||
|
connect_domain: str = ''
|
||||||
|
|
||||||
|
|
||||||
class EditServerRequest(BaseModel):
|
class EditServerRequest(BaseModel):
|
||||||
@@ -2019,6 +2148,12 @@ class EditServerRequest(BaseModel):
|
|||||||
private_key: Optional[str] = None
|
private_key: Optional[str] = None
|
||||||
ssl_domain: Optional[str] = None
|
ssl_domain: Optional[str] = None
|
||||||
ssl_email: Optional[str] = None
|
ssl_email: Optional[str] = None
|
||||||
|
connect_domain: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectDomainRequest(BaseModel):
|
||||||
|
connect_domain: str = ''
|
||||||
|
protocol: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ReorderServersRequest(BaseModel):
|
class ReorderServersRequest(BaseModel):
|
||||||
@@ -2109,6 +2244,12 @@ class ConnectionActionRequest(BaseModel):
|
|||||||
client_id: str = ''
|
client_id: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleConnectionRequest(BaseModel):
|
||||||
|
protocol: str = 'awg'
|
||||||
|
client_id: str = ''
|
||||||
|
enable: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MoveConnectionsRequest(BaseModel):
|
class MoveConnectionsRequest(BaseModel):
|
||||||
protocol: str = 'awg'
|
protocol: str = 'awg'
|
||||||
target_server_id: int
|
target_server_id: int
|
||||||
@@ -2226,6 +2367,20 @@ class GuestSettings(BaseModel):
|
|||||||
create_xui_panel_id: str = ''
|
create_xui_panel_id: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
class DonateMethodSettings(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
title: str = ''
|
||||||
|
details: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
class DonateSettings(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
intro: str = ''
|
||||||
|
sbp: DonateMethodSettings = DonateMethodSettings()
|
||||||
|
card: DonateMethodSettings = DonateMethodSettings()
|
||||||
|
crypto: DonateMethodSettings = DonateMethodSettings()
|
||||||
|
|
||||||
|
|
||||||
class UpdateUserRequest(BaseModel):
|
class UpdateUserRequest(BaseModel):
|
||||||
telegramId: Optional[str] = None
|
telegramId: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
@@ -2246,6 +2401,7 @@ class SaveSettingsRequest(BaseModel):
|
|||||||
telegram: TelegramSettings
|
telegram: TelegramSettings
|
||||||
ssl: SSLSettings
|
ssl: SSLSettings
|
||||||
guest: GuestSettings = GuestSettings()
|
guest: GuestSettings = GuestSettings()
|
||||||
|
donate: DonateSettings = DonateSettings()
|
||||||
|
|
||||||
|
|
||||||
class ToggleUserRequest(BaseModel):
|
class ToggleUserRequest(BaseModel):
|
||||||
@@ -2436,6 +2592,24 @@ async def startup():
|
|||||||
logger.info("Starting Telegram bot from saved settings...")
|
logger.info("Starting Telegram bot from saved settings...")
|
||||||
tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link, save_data)
|
tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link, save_data)
|
||||||
|
|
||||||
|
# Reattach quick tunnels after panel restart so public URLs keep working.
|
||||||
|
try:
|
||||||
|
tunnel_state = await asyncio.to_thread(load_tunnel_state)
|
||||||
|
local_url = get_panel_tunnel_target_url()
|
||||||
|
for provider in ('cloudflare', 'ngrok'):
|
||||||
|
state = tunnel_state.get(provider) or {}
|
||||||
|
if not (state.get('public_url') or state.get('pid') or state.get('started_at')):
|
||||||
|
continue
|
||||||
|
if state.get('pid') and pid_is_running(state.get('pid')):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(start_tunnel, provider, local_url)
|
||||||
|
logger.info('Restarted %s tunnel after panel boot -> %s', provider, local_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Could not restart %s tunnel after panel boot: %s', provider, exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Tunnel auto-restart skipped: %s', exc)
|
||||||
|
|
||||||
|
|
||||||
def _scrape_server_traffic(server, sid, my_conns):
|
def _scrape_server_traffic(server, sid, my_conns):
|
||||||
server_updates = []
|
server_updates = []
|
||||||
@@ -2817,10 +2991,13 @@ async def api_add_server(request: Request, req: AddServerRequest):
|
|||||||
}
|
}
|
||||||
ssl_domain = (req.ssl_domain or '').strip().lower()
|
ssl_domain = (req.ssl_domain or '').strip().lower()
|
||||||
ssl_email = (req.ssl_email or '').strip()
|
ssl_email = (req.ssl_email or '').strip()
|
||||||
|
connect_domain = (req.connect_domain or '').strip().lower()
|
||||||
if ssl_domain:
|
if ssl_domain:
|
||||||
server_info['ssl_domain'] = ssl_domain
|
server_info['ssl_domain'] = ssl_domain
|
||||||
if ssl_email:
|
if ssl_email:
|
||||||
server_info['ssl_email'] = ssl_email
|
server_info['ssl_email'] = ssl_email
|
||||||
|
if connect_domain:
|
||||||
|
server_info['connect_domain'] = connect_domain
|
||||||
server['server_info'] = server_info
|
server['server_info'] = server_info
|
||||||
data = load_data()
|
data = load_data()
|
||||||
data['servers'].append(server)
|
data['servers'].append(server)
|
||||||
@@ -2886,7 +3063,7 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
|
|||||||
info = dict(server.get('server_info') or {})
|
info = dict(server.get('server_info') or {})
|
||||||
if isinstance(server_info, dict):
|
if isinstance(server_info, dict):
|
||||||
for k, v in server_info.items():
|
for k, v in server_info.items():
|
||||||
if k not in ('ssl_domain', 'ssl_email'):
|
if k not in ('ssl_domain', 'ssl_email', 'connect_domain'):
|
||||||
info[k] = v
|
info[k] = v
|
||||||
if req.ssl_domain is not None:
|
if req.ssl_domain is not None:
|
||||||
domain = (req.ssl_domain or '').strip().lower()
|
domain = (req.ssl_domain or '').strip().lower()
|
||||||
@@ -2900,6 +3077,12 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
|
|||||||
info['ssl_email'] = email
|
info['ssl_email'] = email
|
||||||
else:
|
else:
|
||||||
info.pop('ssl_email', None)
|
info.pop('ssl_email', None)
|
||||||
|
if req.connect_domain is not None:
|
||||||
|
domain = (req.connect_domain or '').strip().lower()
|
||||||
|
if domain:
|
||||||
|
info['connect_domain'] = domain
|
||||||
|
else:
|
||||||
|
info.pop('connect_domain', None)
|
||||||
server['server_info'] = info
|
server['server_info'] = info
|
||||||
save_data(data)
|
save_data(data)
|
||||||
return {'status': 'success', 'server_info': info}
|
return {'status': 'success', 'server_info': info}
|
||||||
@@ -2908,6 +3091,75 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
|
|||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/servers/{server_id}/connect-domain', tags=["Servers"])
|
||||||
|
async def api_get_connect_domain(request: Request, server_id: int, protocol: Optional[str] = None):
|
||||||
|
if not _check_admin(request):
|
||||||
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
|
data = load_data()
|
||||||
|
if server_id >= len(data['servers']):
|
||||||
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
|
server = data['servers'][server_id]
|
||||||
|
proto = (protocol or '').strip()
|
||||||
|
info = server.get('server_info') or {}
|
||||||
|
proto_domain = ''
|
||||||
|
if proto:
|
||||||
|
pinfo = (server.get('protocols') or {}).get(proto) or {}
|
||||||
|
proto_domain = (pinfo.get('connect_domain') or '').strip()
|
||||||
|
return {
|
||||||
|
'connect_domain': (info.get('connect_domain') or '').strip(),
|
||||||
|
'protocol_connect_domain': proto_domain,
|
||||||
|
'protocol': proto,
|
||||||
|
'effective_host': get_server_connect_host(server, proto or None),
|
||||||
|
'ssh_host': server.get('host') or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/api/servers/{server_id}/connect-domain', tags=["Servers"])
|
||||||
|
async def api_set_connect_domain(request: Request, server_id: int, req: ConnectDomainRequest):
|
||||||
|
"""Set client Endpoint hostname for AWG / WireGuard without re-verifying SSH."""
|
||||||
|
if not _check_admin(request):
|
||||||
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
|
try:
|
||||||
|
data = load_data()
|
||||||
|
if server_id >= len(data['servers']):
|
||||||
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
|
server = data['servers'][server_id]
|
||||||
|
domain = (req.connect_domain or '').strip().lower().rstrip('.')
|
||||||
|
proto = (req.protocol or '').strip()
|
||||||
|
|
||||||
|
if proto:
|
||||||
|
base = protocol_base(proto)
|
||||||
|
if base not in WG_AWG_PROTOCOLS:
|
||||||
|
return JSONResponse(
|
||||||
|
{'error': 'Per-protocol domain is only supported for AmneziaWG / WireGuard'},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
protocols = server.setdefault('protocols', {})
|
||||||
|
proto_info = protocols.setdefault(proto, {})
|
||||||
|
if domain:
|
||||||
|
proto_info['connect_domain'] = domain
|
||||||
|
else:
|
||||||
|
proto_info.pop('connect_domain', None)
|
||||||
|
else:
|
||||||
|
info = dict(server.get('server_info') or {})
|
||||||
|
if domain:
|
||||||
|
info['connect_domain'] = domain
|
||||||
|
else:
|
||||||
|
info.pop('connect_domain', None)
|
||||||
|
server['server_info'] = info
|
||||||
|
|
||||||
|
save_data(data)
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'connect_domain': domain,
|
||||||
|
'protocol': proto,
|
||||||
|
'effective_host': get_server_connect_host(server, proto or None),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error saving connect domain')
|
||||||
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/servers/{server_id}/ping', tags=["Servers"])
|
@app.get('/api/servers/{server_id}/ping', tags=["Servers"])
|
||||||
async def api_server_ping(request: Request, server_id: int):
|
async def api_server_ping(request: Request, server_id: int):
|
||||||
"""Cheap reachability check: opens a TCP connection to the SSH port,
|
"""Cheap reachability check: opens a TCP connection to the SSH port,
|
||||||
@@ -3220,6 +3472,12 @@ async def api_check_server(request: Request, server_id: int):
|
|||||||
merged[key] = db_proto[key]
|
merged[key] = db_proto[key]
|
||||||
if db_proto.get('port') and not merged.get('port'):
|
if db_proto.get('port') and not merged.get('port'):
|
||||||
merged['port'] = db_proto['port']
|
merged['port'] = db_proto['port']
|
||||||
|
if protocol_base(proto) == 'mieru':
|
||||||
|
for key in ('release',):
|
||||||
|
if db_proto.get(key) not in (None, '') and not merged.get(key):
|
||||||
|
merged[key] = db_proto[key]
|
||||||
|
if db_proto.get('port') and not merged.get('port'):
|
||||||
|
merged['port'] = db_proto['port']
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
def should_preserve_saved_protocol(proto, result=None, err=None):
|
def should_preserve_saved_protocol(proto, result=None, err=None):
|
||||||
@@ -3234,8 +3492,9 @@ async def api_check_server(request: Request, server_id: int):
|
|||||||
|
|
||||||
def check_proto(proto):
|
def check_proto(proto):
|
||||||
cname = protocol_container_name(proto)
|
cname = protocol_container_name(proto)
|
||||||
|
base = protocol_base(proto)
|
||||||
# Fast path: skip deep manager probes when container is absent
|
# Fast path: skip deep manager probes when container is absent
|
||||||
if cname and cname not in inventory:
|
if cname and cname not in inventory and base not in NON_DOCKER_PROTOCOLS:
|
||||||
return proto, merge_saved_protocol_status(proto, {
|
return proto, merge_saved_protocol_status(proto, {
|
||||||
'container_exists': False,
|
'container_exists': False,
|
||||||
'container_running': False,
|
'container_running': False,
|
||||||
@@ -3328,7 +3587,9 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
|||||||
|
|
||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
docker_install_log = ensure_docker_installed(ssh)
|
docker_install_log = ''
|
||||||
|
if install_base not in NON_DOCKER_PROTOCOLS:
|
||||||
|
docker_install_log = ensure_docker_installed(ssh)
|
||||||
manager = get_protocol_manager(ssh, install_protocol)
|
manager = get_protocol_manager(ssh, install_protocol)
|
||||||
|
|
||||||
# Pass parameters to installer
|
# Pass parameters to installer
|
||||||
@@ -3385,6 +3646,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
|||||||
domain=req.naiveproxy_domain,
|
domain=req.naiveproxy_domain,
|
||||||
email=req.naiveproxy_email,
|
email=req.naiveproxy_email,
|
||||||
)
|
)
|
||||||
|
elif install_base == 'mieru':
|
||||||
|
result = manager.install_protocol(
|
||||||
|
protocol_type=install_protocol,
|
||||||
|
port=req.port,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = manager.install_protocol(install_protocol, port=req.port)
|
result = manager.install_protocol(install_protocol, port=req.port)
|
||||||
|
|
||||||
@@ -3434,6 +3700,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
|
|||||||
proto_record['email'] = result.get('email')
|
proto_record['email'] = result.get('email')
|
||||||
if result.get('port'):
|
if result.get('port'):
|
||||||
proto_record['port'] = str(result['port'])
|
proto_record['port'] = str(result['port'])
|
||||||
|
if install_base == 'mieru':
|
||||||
|
if result.get('port'):
|
||||||
|
proto_record['port'] = str(result['port'])
|
||||||
|
if result.get('release'):
|
||||||
|
proto_record['release'] = result.get('release')
|
||||||
proto_record['base_protocol'] = install_base
|
proto_record['base_protocol'] = install_base
|
||||||
proto_record['instance'] = protocol_instance(install_protocol)
|
proto_record['instance'] = protocol_instance(install_protocol)
|
||||||
proto_record['display_name'] = protocol_display_name(install_protocol)
|
proto_record['display_name'] = protocol_display_name(install_protocol)
|
||||||
@@ -3621,6 +3892,7 @@ CONTAINER_NAMES = {
|
|||||||
'telemt': 'telemt',
|
'telemt': 'telemt',
|
||||||
'hysteria': 'amnezia-hysteria',
|
'hysteria': 'amnezia-hysteria',
|
||||||
'naiveproxy': 'amnezia-naiveproxy',
|
'naiveproxy': 'amnezia-naiveproxy',
|
||||||
|
'mieru': 'mita',
|
||||||
'dns': 'amnezia-dns',
|
'dns': 'amnezia-dns',
|
||||||
'wireguard': 'amnezia-wireguard',
|
'wireguard': 'amnezia-wireguard',
|
||||||
'socks5': 'amnezia-socks5proxy',
|
'socks5': 'amnezia-socks5proxy',
|
||||||
@@ -3842,7 +4114,7 @@ async def api_protocol_export_clients(request: Request, server_id: int, req: Pro
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
config = _manager_call(
|
config = _manager_call(
|
||||||
manager, 'get_client_config', req.protocol, client_id, server['host'], port
|
manager, 'get_client_config', req.protocol, client_id, get_server_connect_host(server, req.protocol), port
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
skipped.append(f'{client_name}: {exc}')
|
skipped.append(f'{client_name}: {exc}')
|
||||||
@@ -3918,6 +4190,35 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
|
|||||||
server = data['servers'][server_id]
|
server = data['servers'][server_id]
|
||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
|
base = protocol_base(req.protocol)
|
||||||
|
if base in NON_DOCKER_PROTOCOLS:
|
||||||
|
manager = get_protocol_manager(ssh, req.protocol)
|
||||||
|
is_running = manager.check_container_running(req.protocol)
|
||||||
|
if is_running:
|
||||||
|
manager.stop_service()
|
||||||
|
action = 'stopped'
|
||||||
|
else:
|
||||||
|
manager.start_service()
|
||||||
|
action = 'started'
|
||||||
|
diag = manager.get_container_diagnostics(req.protocol)
|
||||||
|
error = diag.get('error_summary') or ''
|
||||||
|
recent_logs = diag.get('recent_logs') or ''
|
||||||
|
if action == 'started' and not diag.get('running'):
|
||||||
|
ssh.disconnect()
|
||||||
|
return JSONResponse({
|
||||||
|
'error': error or 'Mieru failed to start',
|
||||||
|
'action': action,
|
||||||
|
'container': container,
|
||||||
|
'recent_logs': recent_logs,
|
||||||
|
}, status_code=400)
|
||||||
|
ssh.disconnect()
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'action': action,
|
||||||
|
'container': container,
|
||||||
|
'error': error,
|
||||||
|
'recent_logs': recent_logs,
|
||||||
|
}
|
||||||
# Check current state
|
# Check current state
|
||||||
out, _, _ = ssh.run_sudo_command(
|
out, _, _ = ssh.run_sudo_command(
|
||||||
f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null"
|
f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null"
|
||||||
@@ -3981,6 +4282,20 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog
|
|||||||
container = protocol_container_name(req.protocol)
|
container = protocol_container_name(req.protocol)
|
||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
|
if protocol_base(req.protocol) in NON_DOCKER_PROTOCOLS:
|
||||||
|
mgr = get_protocol_manager(ssh, req.protocol)
|
||||||
|
logs = mgr.get_logs(req.protocol, tail=req.tail or 200)
|
||||||
|
diag = mgr.get_container_diagnostics(req.protocol)
|
||||||
|
ssh.disconnect()
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': req.protocol,
|
||||||
|
'container': container,
|
||||||
|
'logs': logs,
|
||||||
|
'running': bool(diag.get('running')),
|
||||||
|
'error': diag.get('error_summary') or '',
|
||||||
|
'container_status': diag.get('status') or '',
|
||||||
|
}
|
||||||
if protocol_base(req.protocol) == 'hysteria':
|
if protocol_base(req.protocol) == 'hysteria':
|
||||||
from managers.hysteria_manager import HysteriaManager
|
from managers.hysteria_manager import HysteriaManager
|
||||||
mgr = HysteriaManager(ssh, req.protocol)
|
mgr = HysteriaManager(ssh, req.protocol)
|
||||||
@@ -4046,6 +4361,17 @@ async def api_container_logs_stream(request: Request, server_id: int, protocol:
|
|||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
try:
|
try:
|
||||||
|
if protocol_base(protocol) in NON_DOCKER_PROTOCOLS:
|
||||||
|
mgr = get_protocol_manager(ssh, protocol)
|
||||||
|
logs = mgr.get_logs(protocol, tail=tail)
|
||||||
|
diag = mgr.get_container_diagnostics(protocol)
|
||||||
|
state = '|'.join([
|
||||||
|
diag.get('status') or '',
|
||||||
|
'true' if diag.get('running') else 'false',
|
||||||
|
'',
|
||||||
|
diag.get('error_summary') or '',
|
||||||
|
])
|
||||||
|
return logs, state
|
||||||
out, err, _ = ssh.run_sudo_command(
|
out, err, _ = ssh.run_sudo_command(
|
||||||
f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
|
f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
|
||||||
timeout=25,
|
timeout=25,
|
||||||
@@ -4326,7 +4652,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
|
|||||||
|
|
||||||
if protocol_base(req.protocol) == 'telemt':
|
if protocol_base(req.protocol) == 'telemt':
|
||||||
result = manager.add_client(
|
result = manager.add_client(
|
||||||
req.protocol, req.name, server['host'], port,
|
req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
|
||||||
telemt_quota=req.telemt_quota,
|
telemt_quota=req.telemt_quota,
|
||||||
telemt_max_ips=req.telemt_max_ips,
|
telemt_max_ips=req.telemt_max_ips,
|
||||||
telemt_expiry=req.telemt_expiry,
|
telemt_expiry=req.telemt_expiry,
|
||||||
@@ -4335,9 +4661,9 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
|
|||||||
max_tcp_conns=req.telemt_max_conns
|
max_tcp_conns=req.telemt_max_conns
|
||||||
)
|
)
|
||||||
elif protocol_base(req.protocol) == 'wireguard':
|
elif protocol_base(req.protocol) == 'wireguard':
|
||||||
result = manager.add_client(req.name, server['host'])
|
result = manager.add_client(req.name, get_server_connect_host(server, req.protocol))
|
||||||
else:
|
else:
|
||||||
result = manager.add_client(req.protocol, req.name, server['host'], port)
|
result = manager.add_client(req.protocol, req.name, get_server_connect_host(server, req.protocol), port)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
|
|
||||||
if result.get('config'):
|
if result.get('config'):
|
||||||
@@ -4460,7 +4786,7 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
|
|||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
manager = get_protocol_manager(ssh, req.protocol)
|
manager = get_protocol_manager(ssh, req.protocol)
|
||||||
config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, server['host'], port)
|
config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
vpn_link = generate_vpn_link(config) if config else ''
|
vpn_link = generate_vpn_link(config) if config else ''
|
||||||
return {'config': config, 'vpn_link': vpn_link}
|
return {'config': config, 'vpn_link': vpn_link}
|
||||||
@@ -4643,7 +4969,7 @@ async def api_add_user(request: Request, req: AddUserRequest):
|
|||||||
manager = get_protocol_manager(ssh, req.protocol)
|
manager = get_protocol_manager(ssh, req.protocol)
|
||||||
if protocol_base(req.protocol) == 'telemt':
|
if protocol_base(req.protocol) == 'telemt':
|
||||||
conn_result = manager.add_client(
|
conn_result = manager.add_client(
|
||||||
req.protocol, conn_name, server['host'], port,
|
req.protocol, conn_name, get_server_connect_host(server, req.protocol), port,
|
||||||
telemt_quota=req.telemt_quota,
|
telemt_quota=req.telemt_quota,
|
||||||
telemt_max_ips=req.telemt_max_ips,
|
telemt_max_ips=req.telemt_max_ips,
|
||||||
telemt_expiry=req.telemt_expiry,
|
telemt_expiry=req.telemt_expiry,
|
||||||
@@ -4652,7 +4978,7 @@ async def api_add_user(request: Request, req: AddUserRequest):
|
|||||||
max_tcp_conns=req.telemt_max_conns
|
max_tcp_conns=req.telemt_max_conns
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
conn_result = manager.add_client(req.protocol, conn_name, server['host'], port)
|
conn_result = manager.add_client(req.protocol, conn_name, get_server_connect_host(server, req.protocol), port)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
|
|
||||||
if conn_result.get('client_id'):
|
if conn_result.get('client_id'):
|
||||||
@@ -4860,12 +5186,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
|
|||||||
# Link existing client
|
# Link existing client
|
||||||
config = await asyncio.to_thread(
|
config = await asyncio.to_thread(
|
||||||
_manager_call, manager, 'get_client_config',
|
_manager_call, manager, 'get_client_config',
|
||||||
req.protocol, req.client_id, server['host'], port,
|
req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port,
|
||||||
)
|
)
|
||||||
result = {'client_id': req.client_id, 'config': config}
|
result = {'client_id': req.client_id, 'config': config}
|
||||||
elif protocol_base(req.protocol) == 'telemt':
|
elif protocol_base(req.protocol) == 'telemt':
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
manager.add_client, req.protocol, req.name, server['host'], port,
|
manager.add_client, req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
|
||||||
telemt_quota=req.telemt_quota,
|
telemt_quota=req.telemt_quota,
|
||||||
telemt_max_ips=req.telemt_max_ips,
|
telemt_max_ips=req.telemt_max_ips,
|
||||||
telemt_expiry=req.telemt_expiry,
|
telemt_expiry=req.telemt_expiry,
|
||||||
@@ -4874,11 +5200,11 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
|
|||||||
max_tcp_conns=req.telemt_max_conns,
|
max_tcp_conns=req.telemt_max_conns,
|
||||||
)
|
)
|
||||||
elif protocol_base(req.protocol) == 'wireguard':
|
elif protocol_base(req.protocol) == 'wireguard':
|
||||||
result = await asyncio.to_thread(manager.add_client, req.name, server['host'])
|
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol))
|
||||||
else:
|
else:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
_manager_call, manager, 'add_client',
|
_manager_call, manager, 'add_client',
|
||||||
req.protocol, req.name, server['host'], port,
|
req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(ssh.disconnect)
|
await asyncio.to_thread(ssh.disconnect)
|
||||||
@@ -5069,7 +5395,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
|
|||||||
ssh.connect()
|
ssh.connect()
|
||||||
# Use appropriate manager for the protocol
|
# Use appropriate manager for the protocol
|
||||||
manager = get_protocol_manager(ssh, conn['protocol'])
|
manager = get_protocol_manager(ssh, conn['protocol'])
|
||||||
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
|
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
vpn_link = generate_vpn_link(config) if config else ''
|
vpn_link = generate_vpn_link(config) if config else ''
|
||||||
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')}
|
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')}
|
||||||
@@ -5229,7 +5555,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
|
|||||||
ssh = get_ssh(server)
|
ssh = get_ssh(server)
|
||||||
ssh.connect()
|
ssh.connect()
|
||||||
manager = get_protocol_manager(ssh, conn['protocol'])
|
manager = get_protocol_manager(ssh, conn['protocol'])
|
||||||
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
|
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
vpn_link = generate_vpn_link(config) if config else ''
|
vpn_link = generate_vpn_link(config) if config else ''
|
||||||
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
|
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
|
||||||
@@ -5293,11 +5619,11 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
|
|||||||
try:
|
try:
|
||||||
manager = get_protocol_manager(ssh, protocol)
|
manager = get_protocol_manager(ssh, protocol)
|
||||||
if protocol_base(protocol) == 'wireguard':
|
if protocol_base(protocol) == 'wireguard':
|
||||||
result = await asyncio.to_thread(manager.add_client, name, server['host'])
|
result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server, protocol))
|
||||||
else:
|
else:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
_manager_call, manager, 'add_client',
|
_manager_call, manager, 'add_client',
|
||||||
protocol, name, server['host'], port,
|
protocol, name, get_server_connect_host(server, protocol), port,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(ssh.disconnect)
|
await asyncio.to_thread(ssh.disconnect)
|
||||||
@@ -5455,11 +5781,11 @@ async def _create_config_for_protocol(
|
|||||||
try:
|
try:
|
||||||
manager = get_protocol_manager(ssh, protocol)
|
manager = get_protocol_manager(ssh, protocol)
|
||||||
if protocol_base(protocol) == 'wireguard':
|
if protocol_base(protocol) == 'wireguard':
|
||||||
result = await asyncio.to_thread(manager.add_client, name, server['host'])
|
result = await asyncio.to_thread(manager.add_client, name, get_server_connect_host(server, protocol))
|
||||||
else:
|
else:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
_manager_call, manager, 'add_client',
|
_manager_call, manager, 'add_client',
|
||||||
protocol, name, server['host'], port,
|
protocol, name, get_server_connect_host(server, protocol), port,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(ssh.disconnect)
|
await asyncio.to_thread(ssh.disconnect)
|
||||||
@@ -5830,7 +6156,7 @@ async def api_my_connection_config(request: Request, connection_id: str):
|
|||||||
ssh.connect()
|
ssh.connect()
|
||||||
# Use appropriate manager for the protocol (fixes Telemt/Xray not working for users)
|
# Use appropriate manager for the protocol (fixes Telemt/Xray not working for users)
|
||||||
manager = get_protocol_manager(ssh, conn['protocol'])
|
manager = get_protocol_manager(ssh, conn['protocol'])
|
||||||
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
|
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], get_server_connect_host(server, conn['protocol']), port)
|
||||||
ssh.disconnect()
|
ssh.disconnect()
|
||||||
vpn_link = generate_vpn_link(config) if config else ''
|
vpn_link = generate_vpn_link(config) if config else ''
|
||||||
expires_at = None
|
expires_at = None
|
||||||
@@ -5843,11 +6169,23 @@ async def api_my_connection_config(request: Request, connection_id: str):
|
|||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@app.get('/settings', tags=["System Templates"])
|
@app.get('/support', response_class=HTMLResponse, tags=["System Templates"])
|
||||||
|
async def support_page(request: Request):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse('/login', status_code=302)
|
||||||
|
data = load_data()
|
||||||
|
donate = dict((data.get('settings') or {}).get('donate') or {})
|
||||||
|
if not donate.get('enabled', True):
|
||||||
|
return RedirectResponse('/my', status_code=302)
|
||||||
|
return tpl(request, 'support.html', donate=donate)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/settings', response_class=HTMLResponse, tags=["System Templates"])
|
||||||
async def settings_page(request: Request):
|
async def settings_page(request: Request):
|
||||||
user = _check_admin(request)
|
user = _check_admin(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse('/login')
|
return RedirectResponse('/login', status_code=302)
|
||||||
data = load_data()
|
data = load_data()
|
||||||
from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers
|
from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers
|
||||||
settings = data.setdefault('settings', {})
|
settings = data.setdefault('settings', {})
|
||||||
@@ -5951,8 +6289,8 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
|
|||||||
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
|
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
|
||||||
|
|
||||||
if result.get('restart'):
|
if result.get('restart'):
|
||||||
UpdateManager.schedule_restart(1.5)
|
UpdateManager.schedule_restart(application_path, 1.5)
|
||||||
return result
|
return enrich_update_result(request, result)
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
|
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
|
||||||
@@ -5989,10 +6327,10 @@ async def api_upgrade_panel(request: Request):
|
|||||||
if result.get('status') != 'success':
|
if result.get('status') != 'success':
|
||||||
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500)
|
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500)
|
||||||
if result.get('restart'):
|
if result.get('restart'):
|
||||||
UpdateManager.schedule_restart(1.5)
|
UpdateManager.schedule_restart(application_path, 1.5)
|
||||||
result['up_to_date'] = False
|
result['up_to_date'] = False
|
||||||
result['previous_version'] = CURRENT_VERSION
|
result['previous_version'] = CURRENT_VERSION
|
||||||
return result
|
return enrich_update_result(request, result)
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/settings/tunnels/status', tags=["Settings"])
|
@app.get('/api/settings/tunnels/status', tags=["Settings"])
|
||||||
@@ -6152,6 +6490,7 @@ async def save_settings(request: Request, payload: SaveSettingsRequest):
|
|||||||
else:
|
else:
|
||||||
guest['password_hash'] = existing_guest.get('password_hash')
|
guest['password_hash'] = existing_guest.get('password_hash')
|
||||||
data['settings']['guest'] = guest
|
data['settings']['guest'] = guest
|
||||||
|
data['settings']['donate'] = payload.donate.dict()
|
||||||
|
|
||||||
save_data(data)
|
save_data(data)
|
||||||
logger.info("Settings saved (including captcha and telegram)")
|
logger.info("Settings saved (including captcha and telegram)")
|
||||||
@@ -6482,6 +6821,24 @@ async def api_revoke_token(request: Request, token_id: str):
|
|||||||
|
|
||||||
@app.get('/api/settings/backup/download', tags=["Settings"])
|
@app.get('/api/settings/backup/download', tags=["Settings"])
|
||||||
async def api_backup_download(request: Request):
|
async def api_backup_download(request: Request):
|
||||||
|
if not _check_admin(request):
|
||||||
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
|
try:
|
||||||
|
content = await asyncio.to_thread(export_database_sql)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Database backup failed')
|
||||||
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
filename = backup_filename()
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content),
|
||||||
|
media_type='application/sql',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/api/settings/backup/download/json', tags=["Settings"])
|
||||||
|
async def api_backup_download_json(request: Request):
|
||||||
|
"""Legacy JSON export (same shape as old data.json)."""
|
||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
payload = await asyncio.to_thread(export_data_dict)
|
payload = await asyncio.to_thread(export_data_dict)
|
||||||
@@ -6501,33 +6858,45 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
|
|||||||
content = await file.read()
|
content = await file.read()
|
||||||
if not content:
|
if not content:
|
||||||
return JSONResponse({'error': 'Empty file'}, status_code=400)
|
return JSONResponse({'error': 'Empty file'}, status_code=400)
|
||||||
|
|
||||||
|
filename = (file.filename or '').lower()
|
||||||
|
is_json = filename.endswith('.json') or content.lstrip().startswith(b'{')
|
||||||
|
|
||||||
|
if is_json:
|
||||||
|
try:
|
||||||
|
backup_data = json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
|
||||||
|
|
||||||
|
required_keys = ['servers', 'users']
|
||||||
|
missing = [k for k in required_keys if k not in backup_data]
|
||||||
|
if missing:
|
||||||
|
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
|
||||||
|
|
||||||
|
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
|
||||||
|
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
|
||||||
|
|
||||||
|
backup_data.setdefault('user_connections', [])
|
||||||
|
backup_data.setdefault('api_tokens', [])
|
||||||
|
backup_data.setdefault('invite_links', [])
|
||||||
|
backup_data.setdefault('settings', {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup_data = normalize_import_data(backup_data)
|
||||||
|
async with DATA_LOCK:
|
||||||
|
save_data(backup_data)
|
||||||
|
except Exception as e:
|
||||||
|
invalidate_data_cache()
|
||||||
|
logger.exception('JSON backup restore failed')
|
||||||
|
return JSONResponse({'error': f'Import failed: {e}'}, status_code=400)
|
||||||
|
return {'status': 'success', 'format': 'json'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
backup_data = json.loads(content)
|
await asyncio.to_thread(restore_database_sql, content)
|
||||||
except json.JSONDecodeError:
|
except Exception as e:
|
||||||
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
|
logger.exception('Database restore failed')
|
||||||
|
return JSONResponse({'error': str(e)}, status_code=400)
|
||||||
# Basic structure validation
|
return {'status': 'success', 'format': 'sql'}
|
||||||
required_keys = ['servers', 'users']
|
|
||||||
missing = [k for k in required_keys if k not in backup_data]
|
|
||||||
if missing:
|
|
||||||
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
|
|
||||||
|
|
||||||
# Ensure types are correct
|
|
||||||
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
|
|
||||||
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
|
|
||||||
|
|
||||||
backup_data.setdefault('user_connections', [])
|
|
||||||
backup_data.setdefault('api_tokens', [])
|
|
||||||
backup_data.setdefault('invite_links', [])
|
|
||||||
backup_data.setdefault('settings', {})
|
|
||||||
|
|
||||||
# Save the new data
|
|
||||||
async with DATA_LOCK:
|
|
||||||
save_data(backup_data)
|
|
||||||
|
|
||||||
# In a real app we might want to restart or re-init background tasks
|
|
||||||
return {'status': 'success'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error during restore")
|
logger.exception("Error during restore")
|
||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
@@ -6557,17 +6926,15 @@ if __name__ == '__main__':
|
|||||||
with open(key_file, 'w') as f:
|
with open(key_file, 'w') as f:
|
||||||
f.write(ssl_conf['key_text'].strip() + '\n')
|
f.write(ssl_conf['key_text'].strip() + '\n')
|
||||||
|
|
||||||
try:
|
port = get_panel_listen_port(data)
|
||||||
env_port = int(os.environ.get('APP_PORT', '0') or '0')
|
|
||||||
except ValueError:
|
|
||||||
env_port = 0
|
|
||||||
uvicorn_kwargs = {
|
uvicorn_kwargs = {
|
||||||
"app": app,
|
"app": app,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": env_port or ssl_conf.get('panel_port', 5000) or 5000,
|
"port": port,
|
||||||
}
|
}
|
||||||
|
logger.info(f"Amnezia Web Panel {CURRENT_VERSION} listening on http://0.0.0.0:{port}")
|
||||||
|
|
||||||
if ssl_conf.get('enabled') and cert_file and key_file:
|
if panel_ssl_active(data) and cert_file and key_file:
|
||||||
if os.path.exists(cert_file) and os.path.exists(key_file):
|
if os.path.exists(cert_file) and os.path.exists(key_file):
|
||||||
logger.info(f"Starting panel with HTTPS enabled on domain: {ssl_conf.get('domain')} at port {uvicorn_kwargs['port']}")
|
logger.info(f"Starting panel with HTTPS enabled on domain: {ssl_conf.get('domain')} at port {uvicorn_kwargs['port']}")
|
||||||
uvicorn_kwargs["ssl_certfile"] = cert_file
|
uvicorn_kwargs["ssl_certfile"] = cert_file
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
"""Database package for Amnezia Web Panel (PostgreSQL 17)."""
|
"""Database package for Amnezia Web Panel (PostgreSQL 17)."""
|
||||||
|
|
||||||
|
from .backup import backup_filename, export_database_sql, restore_database_sql
|
||||||
from .connection import close_pool, get_database_url, init_schema
|
from .connection import close_pool, get_database_url, init_schema
|
||||||
from .store import (
|
from .store import (
|
||||||
clear_tunnel_state,
|
clear_tunnel_state,
|
||||||
ensure_db_ready,
|
ensure_db_ready,
|
||||||
export_data_dict,
|
export_data_dict,
|
||||||
import_from_json_file,
|
import_from_json_file,
|
||||||
|
invalidate_data_cache,
|
||||||
load_data,
|
load_data,
|
||||||
load_tunnel_state,
|
load_tunnel_state,
|
||||||
|
normalize_import_data,
|
||||||
save_data,
|
save_data,
|
||||||
save_tunnel_state,
|
save_tunnel_state,
|
||||||
update_tunnel_state,
|
update_tunnel_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'backup_filename',
|
||||||
'clear_tunnel_state',
|
'clear_tunnel_state',
|
||||||
'close_pool',
|
'close_pool',
|
||||||
'ensure_db_ready',
|
'ensure_db_ready',
|
||||||
'export_data_dict',
|
'export_data_dict',
|
||||||
|
'export_database_sql',
|
||||||
'get_database_url',
|
'get_database_url',
|
||||||
'import_from_json_file',
|
'import_from_json_file',
|
||||||
'init_schema',
|
'init_schema',
|
||||||
|
'invalidate_data_cache',
|
||||||
'load_data',
|
'load_data',
|
||||||
'load_tunnel_state',
|
'load_tunnel_state',
|
||||||
|
'normalize_import_data',
|
||||||
|
'restore_database_sql',
|
||||||
'save_data',
|
'save_data',
|
||||||
'save_tunnel_state',
|
'save_tunnel_state',
|
||||||
'update_tunnel_state',
|
'update_tunnel_state',
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""PostgreSQL backup/restore for the panel database."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .connection import get_database_url
|
||||||
|
from .store import invalidate_data_cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def backup_filename() -> str:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
|
||||||
|
return f'amnezia_panel_backup_{stamp}.sql'
|
||||||
|
|
||||||
|
|
||||||
|
def export_database_sql() -> bytes:
|
||||||
|
"""Create a plain SQL dump of the panel PostgreSQL database."""
|
||||||
|
url = get_database_url()
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
'pg_dump',
|
||||||
|
'--dbname', url,
|
||||||
|
'--no-owner',
|
||||||
|
'--no-acl',
|
||||||
|
'--clean',
|
||||||
|
'--if-exists',
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
||||||
|
raise RuntimeError(err or 'pg_dump failed')
|
||||||
|
if not proc.stdout:
|
||||||
|
raise RuntimeError('pg_dump returned empty dump')
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def restore_database_sql(data: bytes) -> None:
|
||||||
|
"""Restore panel data from a plain SQL dump produced by pg_dump."""
|
||||||
|
if not data or not data.strip():
|
||||||
|
raise ValueError('Empty backup file')
|
||||||
|
url = get_database_url()
|
||||||
|
proc = subprocess.run(
|
||||||
|
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
|
||||||
|
input=data,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
||||||
|
out = proc.stdout.decode('utf-8', errors='replace').strip()
|
||||||
|
raise RuntimeError(err or out or 'psql restore failed')
|
||||||
|
invalidate_data_cache()
|
||||||
|
logger.info('PostgreSQL backup restored successfully')
|
||||||
+157
-7
@@ -10,8 +10,10 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -52,6 +54,13 @@ DEFAULT_SETTINGS = {
|
|||||||
'create_protocol': 'awg',
|
'create_protocol': 'awg',
|
||||||
'create_server_id': 0,
|
'create_server_id': 0,
|
||||||
},
|
},
|
||||||
|
'donate': {
|
||||||
|
'enabled': True,
|
||||||
|
'intro': '',
|
||||||
|
'sbp': {'enabled': True, 'title': '', 'details': ''},
|
||||||
|
'card': {'enabled': True, 'title': '', 'details': ''},
|
||||||
|
'crypto': {'enabled': True, 'title': '', 'details': ''},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +92,147 @@ def _as_uuid(value: Any) -> UUID:
|
|||||||
return UUID(str(value))
|
return UUID(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_uuid(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
UUID(str(value))
|
||||||
|
return True
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_uuid(value: Any, *, default: Optional[str] = None) -> str:
|
||||||
|
if _is_valid_uuid(value):
|
||||||
|
return str(UUID(str(value)))
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list:
|
||||||
|
return list(value) if isinstance(value, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_import_data(data: dict) -> dict:
|
||||||
|
"""Prepare legacy data.json (or partial exports) for PostgreSQL constraints."""
|
||||||
|
data = copy.deepcopy(data or {})
|
||||||
|
data['servers'] = [s for s in _as_list(data.get('servers')) if isinstance(s, dict)]
|
||||||
|
data['users'] = [u for u in _as_list(data.get('users')) if isinstance(u, dict)]
|
||||||
|
data['user_connections'] = [c for c in _as_list(data.get('user_connections')) if isinstance(c, dict)]
|
||||||
|
data['api_tokens'] = [t for t in _as_list(data.get('api_tokens')) if isinstance(t, dict)]
|
||||||
|
data['invite_links'] = [l for l in _as_list(data.get('invite_links')) if isinstance(l, dict)]
|
||||||
|
if not isinstance(data.get('settings'), dict):
|
||||||
|
data['settings'] = {}
|
||||||
|
|
||||||
|
id_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
def map_user_id(value: Any) -> Optional[str]:
|
||||||
|
if value is None or value == '':
|
||||||
|
return None
|
||||||
|
key = str(value)
|
||||||
|
if _is_valid_uuid(key):
|
||||||
|
resolved = str(UUID(key))
|
||||||
|
id_map.setdefault(key, resolved)
|
||||||
|
return resolved
|
||||||
|
if key not in id_map:
|
||||||
|
id_map[key] = str(uuid.uuid4())
|
||||||
|
return id_map[key]
|
||||||
|
|
||||||
|
used_usernames: set[str] = set()
|
||||||
|
for index, user in enumerate(data['users']):
|
||||||
|
if not isinstance(user, dict):
|
||||||
|
continue
|
||||||
|
old_id = user.get('id')
|
||||||
|
if old_id is None or old_id == '':
|
||||||
|
user['id'] = str(uuid.uuid4())
|
||||||
|
else:
|
||||||
|
key = str(old_id)
|
||||||
|
if _is_valid_uuid(key):
|
||||||
|
user['id'] = str(UUID(key))
|
||||||
|
id_map.setdefault(key, user['id'])
|
||||||
|
else:
|
||||||
|
user['id'] = map_user_id(key) or str(uuid.uuid4())
|
||||||
|
|
||||||
|
username = (user.get('username') or '').strip() or f'user{index + 1}'
|
||||||
|
base = username
|
||||||
|
suffix = 2
|
||||||
|
while username.lower() in used_usernames:
|
||||||
|
username = f'{base}_{suffix}'
|
||||||
|
suffix += 1
|
||||||
|
user['username'] = username
|
||||||
|
used_usernames.add(username.lower())
|
||||||
|
|
||||||
|
user.setdefault('password_hash', '')
|
||||||
|
user.setdefault('role', 'user')
|
||||||
|
user.setdefault('enabled', True)
|
||||||
|
|
||||||
|
valid_user_ids = {str(u['id']) for u in data['users'] if isinstance(u, dict) and u.get('id')}
|
||||||
|
|
||||||
|
for server in data['servers']:
|
||||||
|
if not isinstance(server, dict):
|
||||||
|
continue
|
||||||
|
server['server_info'] = _as_dict(server.get('server_info'))
|
||||||
|
server['protocols'] = _as_dict(server.get('protocols'))
|
||||||
|
|
||||||
|
for conn in data['user_connections']:
|
||||||
|
if not isinstance(conn, dict):
|
||||||
|
continue
|
||||||
|
conn['id'] = _coerce_uuid(conn.get('id'))
|
||||||
|
mapped_uid = map_user_id(conn.get('user_id'))
|
||||||
|
if mapped_uid:
|
||||||
|
conn['user_id'] = mapped_uid
|
||||||
|
|
||||||
|
data['user_connections'] = [
|
||||||
|
c for c in data['user_connections']
|
||||||
|
if isinstance(c, dict) and str(c.get('user_id')) in valid_user_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
seen_hashes: set[str] = set()
|
||||||
|
normalized_tokens = []
|
||||||
|
for token in data['api_tokens']:
|
||||||
|
if not isinstance(token, dict):
|
||||||
|
continue
|
||||||
|
mapped_uid = map_user_id(token.get('user_id'))
|
||||||
|
if not mapped_uid or mapped_uid not in valid_user_ids:
|
||||||
|
continue
|
||||||
|
token['id'] = _coerce_uuid(token.get('id'))
|
||||||
|
token['user_id'] = mapped_uid
|
||||||
|
token_hash = (token.get('token_hash') or '').strip()
|
||||||
|
if not token_hash or token_hash in seen_hashes:
|
||||||
|
token_hash = secrets.token_hex(32)
|
||||||
|
token['token_hash'] = token_hash
|
||||||
|
seen_hashes.add(token_hash)
|
||||||
|
token.setdefault('token_prefix', (token_hash[:8] if token_hash else ''))
|
||||||
|
normalized_tokens.append(token)
|
||||||
|
data['api_tokens'] = normalized_tokens
|
||||||
|
|
||||||
|
seen_invite_tokens: set[str] = set()
|
||||||
|
for link in data['invite_links']:
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
continue
|
||||||
|
link['id'] = _coerce_uuid(link.get('id'))
|
||||||
|
mapped_uid = map_user_id(link.get('user_id'))
|
||||||
|
link['user_id'] = mapped_uid if mapped_uid in valid_user_ids else ''
|
||||||
|
token = (link.get('token') or '').strip()
|
||||||
|
if not token or token in seen_invite_tokens:
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
while token in seen_invite_tokens:
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
link['token'] = token
|
||||||
|
seen_invite_tokens.add(token)
|
||||||
|
|
||||||
|
guest = data['settings'].get('guest')
|
||||||
|
if isinstance(guest, dict):
|
||||||
|
mapped_guest = map_user_id(guest.get('user_id'))
|
||||||
|
if mapped_guest in valid_user_ids:
|
||||||
|
guest['user_id'] = mapped_guest
|
||||||
|
elif guest.get('user_id'):
|
||||||
|
guest['user_id'] = ''
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _merge_settings(raw: Optional[dict]) -> dict:
|
def _merge_settings(raw: Optional[dict]) -> dict:
|
||||||
settings = json.loads(json.dumps(DEFAULT_SETTINGS))
|
settings = json.loads(json.dumps(DEFAULT_SETTINGS))
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@@ -293,6 +443,7 @@ def load_data() -> dict:
|
|||||||
def save_data(data: dict) -> None:
|
def save_data(data: dict) -> None:
|
||||||
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
||||||
global _DATA_CACHE
|
global _DATA_CACHE
|
||||||
|
data = normalize_import_data(data)
|
||||||
init_schema()
|
init_schema()
|
||||||
servers = data.get('servers') or []
|
servers = data.get('servers') or []
|
||||||
users = data.get('users') or []
|
users = data.get('users') or []
|
||||||
@@ -484,12 +635,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
|
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
|
||||||
|
|
||||||
data.setdefault('servers', [])
|
data = normalize_import_data(data)
|
||||||
data.setdefault('users', [])
|
|
||||||
data.setdefault('user_connections', [])
|
|
||||||
data.setdefault('api_tokens', [])
|
|
||||||
data.setdefault('invite_links', [])
|
|
||||||
data.setdefault('settings', {})
|
|
||||||
|
|
||||||
save_data(data)
|
save_data(data)
|
||||||
|
|
||||||
@@ -555,4 +701,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
|
|||||||
init_schema()
|
init_schema()
|
||||||
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
|
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
|
||||||
logger.info('Empty database — importing legacy %s', legacy_data_file)
|
logger.info('Empty database — importing legacy %s', legacy_data_file)
|
||||||
import_from_json_file(legacy_data_file)
|
try:
|
||||||
|
import_from_json_file(legacy_data_file)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Legacy data.json import failed — panel will start with empty DB')
|
||||||
|
invalidate_data_cache()
|
||||||
|
|||||||
+27
-7
@@ -1,18 +1,21 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: amnezia_panel_db
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
||||||
volumes:
|
volumes:
|
||||||
- amnezia_pgdata:/var/lib/postgresql/data
|
- amnezia_pgdata:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && PGPASSWORD=$${POSTGRES_PASSWORD} psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' >/dev/null",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -23,7 +26,6 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: amnezia-panel:local
|
image: amnezia-panel:local
|
||||||
container_name: amnezia_panel
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -33,15 +35,33 @@ services:
|
|||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
||||||
SECRET_KEY: ${SECRET_KEY:-}
|
SECRET_KEY: ${SECRET_KEY:-}
|
||||||
APP_PORT: "5000"
|
APP_PORT: "5000"
|
||||||
|
PORT: "5000"
|
||||||
|
PANEL_IN_DOCKER: "1"
|
||||||
|
PANEL_BEHIND_PROXY: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- amnezia_data:/app/data
|
- amnezia_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- dokploy-network
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=dokploy-network
|
||||||
|
- traefik.http.services.amnezia_panel.loadbalancer.server.port=5000
|
||||||
|
expose:
|
||||||
|
- "5000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 60s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
name: dokploy-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
amnezia_data:
|
amnezia_data:
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class BackupManager:
|
|||||||
remote_dir = inst_path('/opt/amnezia/naiveproxy')
|
remote_dir = inst_path('/opt/amnezia/naiveproxy')
|
||||||
paths['host'] = [remote_dir]
|
paths['host'] = [remote_dir]
|
||||||
paths['container'] = ['/etc/caddy']
|
paths['container'] = ['/etc/caddy']
|
||||||
|
elif base == 'mieru':
|
||||||
|
remote_dir = inst_path('/opt/amnezia/mieru')
|
||||||
|
paths['host'] = [remote_dir]
|
||||||
elif base == 'dns':
|
elif base == 'dns':
|
||||||
paths['host'] = ['/opt/amnezia/dns']
|
paths['host'] = ['/opt/amnezia/dns']
|
||||||
paths['container'] = ['/opt/amnezia/dns']
|
paths['container'] = ['/opt/amnezia/dns']
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
"""
|
||||||
|
Mieru protocol manager — native mita server package (systemd).
|
||||||
|
|
||||||
|
Installs pinned release from https://github.com/enfein/mieru
|
||||||
|
Server binary: mita (systemd service), client: mieru.
|
||||||
|
Share links: mierus://user:pass@host?port=...&protocol=TCP&profile=default
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shlex
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MIERU_RELEASE = '3.28.0'
|
||||||
|
GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}'
|
||||||
|
MITA_SOCK = '/var/run/mita.sock'
|
||||||
|
|
||||||
|
|
||||||
|
def _q(value):
|
||||||
|
return shlex.quote(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _rand_token(length=16):
|
||||||
|
alphabet = string.ascii_lowercase + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_username(name):
|
||||||
|
base = re.sub(r'[^a-zA-Z0-9_-]', '', (name or 'user').strip())[:24] or 'user'
|
||||||
|
return f'{base}_{_rand_token(4)}'
|
||||||
|
|
||||||
|
|
||||||
|
class MieruManager:
|
||||||
|
PROTOCOL = 'mieru'
|
||||||
|
SERVICE_NAME = 'mita'
|
||||||
|
BASE_DIR = '/opt/amnezia/mieru'
|
||||||
|
DEFAULT_PORT = 2999
|
||||||
|
|
||||||
|
def __init__(self, ssh, protocol='mieru'):
|
||||||
|
self.ssh = ssh
|
||||||
|
self.protocol = protocol or self.PROTOCOL
|
||||||
|
self.base_dir = self.BASE_DIR
|
||||||
|
self.clients_path = f'{self.base_dir}/clients.json'
|
||||||
|
self.meta_path = f'{self.base_dir}/metadata.json'
|
||||||
|
self.config_path = f'{self.base_dir}/server_config.json'
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _mita_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command('command -v mita 2>/dev/null')
|
||||||
|
return code == 0 and bool(out.strip())
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type=None):
|
||||||
|
return self._mita_installed() and self._panel_installed()
|
||||||
|
|
||||||
|
def _panel_installed(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"test -f {_q(self.meta_path)} && echo yes")
|
||||||
|
return code == 0 and 'yes' in out
|
||||||
|
|
||||||
|
def _proxy_running(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command('mita status 2>/dev/null')
|
||||||
|
if code != 0:
|
||||||
|
return False
|
||||||
|
return 'RUNNING' in (out or '').upper()
|
||||||
|
|
||||||
|
def check_container_running(self, protocol_type=None):
|
||||||
|
return self._proxy_running()
|
||||||
|
|
||||||
|
def get_logs(self, protocol_type=None, tail=200):
|
||||||
|
tail = max(20, min(int(tail or 200), 2000))
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"journalctl -u {self.SERVICE_NAME} -n {tail} --no-pager 2>&1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
text = (out or err or '').strip()
|
||||||
|
if not text:
|
||||||
|
status, _, _ = self.ssh.run_sudo_command('mita status 2>&1')
|
||||||
|
text = (status or '').strip()
|
||||||
|
if not text:
|
||||||
|
text = f'(no logs: exit {code})'
|
||||||
|
return text
|
||||||
|
|
||||||
|
def get_container_diagnostics(self, protocol_type=None):
|
||||||
|
running = self._proxy_running()
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null"
|
||||||
|
)
|
||||||
|
daemon_active = (out or '').strip() == 'active'
|
||||||
|
diag = {
|
||||||
|
'status': 'running' if running else ('idle' if daemon_active else 'stopped'),
|
||||||
|
'running': running,
|
||||||
|
'error_summary': '',
|
||||||
|
'recent_logs': self.get_logs(protocol_type, tail=40),
|
||||||
|
}
|
||||||
|
if not self._mita_installed():
|
||||||
|
diag['status'] = 'missing'
|
||||||
|
diag['error_summary'] = 'mita package not installed'
|
||||||
|
elif not running and daemon_active:
|
||||||
|
status_out, _, _ = self.ssh.run_sudo_command('mita status 2>&1')
|
||||||
|
if 'IDLE' in (status_out or '').upper():
|
||||||
|
diag['error_summary'] = 'Proxy stopped (mita status IDLE)'
|
||||||
|
elif status_out.strip():
|
||||||
|
diag['error_summary'] = status_out.strip().splitlines()[-1][:160]
|
||||||
|
elif not daemon_active:
|
||||||
|
diag['error_summary'] = f'{self.SERVICE_NAME} systemd service is not active'
|
||||||
|
return diag
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type=None):
|
||||||
|
protocol_type = protocol_type or self.protocol
|
||||||
|
exists = self.check_protocol_installed(protocol_type)
|
||||||
|
running = self.check_container_running(protocol_type) if exists else False
|
||||||
|
meta = self._read_metadata() if exists else {}
|
||||||
|
clients = self._read_clients() if exists else []
|
||||||
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'port': port,
|
||||||
|
'release': meta.get('release') or MIERU_RELEASE,
|
||||||
|
'clients_count': len(clients),
|
||||||
|
'protocol': protocol_type,
|
||||||
|
'base_protocol': self.PROTOCOL,
|
||||||
|
'instance': 1,
|
||||||
|
'container_name': self.SERVICE_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== IO HELPERS =====================
|
||||||
|
|
||||||
|
def _read_file(self, path):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
|
||||||
|
return out if code == 0 else ''
|
||||||
|
|
||||||
|
def _write_file(self, path, content):
|
||||||
|
import base64
|
||||||
|
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
|
||||||
|
script = (
|
||||||
|
f"mkdir -p $(dirname {_q(path)}) && "
|
||||||
|
f"echo {_q(b64)} | base64 -d > {_q(path)} && "
|
||||||
|
f"chmod 644 {_q(path)}"
|
||||||
|
)
|
||||||
|
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f'Failed to write {path}: {err or out}')
|
||||||
|
|
||||||
|
def _read_metadata(self):
|
||||||
|
raw = self._read_file(self.meta_path).strip()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _write_metadata(self, meta):
|
||||||
|
self._write_file(self.meta_path, json.dumps(meta, indent=2))
|
||||||
|
|
||||||
|
def _read_clients(self):
|
||||||
|
raw = self._read_file(self.clients_path).strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _write_clients(self, clients):
|
||||||
|
self._write_file(self.clients_path, json.dumps(clients, indent=2))
|
||||||
|
|
||||||
|
def _ensure_daemon(self, log=None):
|
||||||
|
"""Ensure mita systemd unit is up and RPC socket answers."""
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl enable {self.SERVICE_NAME} 2>/dev/null || true",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl start {self.SERVICE_NAME} 2>/dev/null || "
|
||||||
|
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
# Official package expects the operating user in group `mita`.
|
||||||
|
user_out, _, _ = self.ssh.run_command('id -un 2>/dev/null || echo root')
|
||||||
|
op_user = (user_out or 'root').strip() or 'root'
|
||||||
|
if op_user != 'root':
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"usermod -a -G mita {_q(op_user)} 2>/dev/null || true",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if not self._wait_for_rpc(timeout=45):
|
||||||
|
# Stale socket / crashed daemon — hard restart once.
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl stop {self.SERVICE_NAME} 2>/dev/null || true; "
|
||||||
|
f"rm -f {_q(MITA_SOCK)} /var/run/mita/*.sock 2>/dev/null || true; "
|
||||||
|
f"systemctl start {self.SERVICE_NAME}",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if not self._wait_for_rpc(timeout=45):
|
||||||
|
journal, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"journalctl -u {self.SERVICE_NAME} -n 40 --no-pager 2>&1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
'mita systemd daemon is not ready (RPC socket missing). '
|
||||||
|
f'journal: {(journal or "").strip()[-500:]}'
|
||||||
|
)
|
||||||
|
if log is not None:
|
||||||
|
log.append('mita daemon is active')
|
||||||
|
|
||||||
|
def _wait_for_rpc(self, timeout=30):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
sock_out, _, sock_code = self.ssh.run_sudo_command(
|
||||||
|
f"test -S {_q(MITA_SOCK)} && echo ok"
|
||||||
|
)
|
||||||
|
if sock_code == 0 and 'ok' in (sock_out or ''):
|
||||||
|
status_out, _, status_code = self.ssh.run_sudo_command(
|
||||||
|
'mita status 2>&1',
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
text = (status_out or '').upper()
|
||||||
|
if status_code == 0 and ('IDLE' in text or 'RUNNING' in text):
|
||||||
|
return True
|
||||||
|
# Socket exists but CLI still races — brief pause.
|
||||||
|
time.sleep(1.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _mita_cli(self, args, timeout=60):
|
||||||
|
"""Run mita CLI as root so group/socket ACL is not an issue."""
|
||||||
|
cmd = f"mita {' '.join(args)} 2>&1"
|
||||||
|
return self.ssh.run_sudo_command(cmd, timeout=timeout)
|
||||||
|
|
||||||
|
def _build_server_config(self, port, clients):
|
||||||
|
users = []
|
||||||
|
for c in clients:
|
||||||
|
if not c.get('enabled', True):
|
||||||
|
continue
|
||||||
|
username = (c.get('username') or c.get('name') or c.get('id') or '').strip()
|
||||||
|
password = (c.get('password') or '').strip()
|
||||||
|
if not username or not password:
|
||||||
|
continue
|
||||||
|
users.append({'name': username, 'password': password})
|
||||||
|
# mita rejects / crashes on empty users during `mita start` (RPC EOF).
|
||||||
|
if not users:
|
||||||
|
users = [{
|
||||||
|
'name': f'panel_{_rand_token(6)}',
|
||||||
|
'password': _rand_token(20),
|
||||||
|
}]
|
||||||
|
return {
|
||||||
|
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
||||||
|
'users': users,
|
||||||
|
'loggingLevel': 'INFO',
|
||||||
|
'mtu': 1400,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_config(self, config, reload_only=False):
|
||||||
|
self._ensure_daemon()
|
||||||
|
self._write_file(self.config_path, json.dumps(config, indent=2))
|
||||||
|
out, err, code = self._mita_cli(
|
||||||
|
['apply', 'config', _q(self.config_path)],
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
# Recover from transient EOF / unavailable RPC.
|
||||||
|
if self._is_rpc_error(out, err):
|
||||||
|
self._ensure_daemon()
|
||||||
|
out, err, code = self._mita_cli(
|
||||||
|
['apply', 'config', _q(self.config_path)],
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError((err or out or 'mita apply config failed').strip())
|
||||||
|
|
||||||
|
if reload_only:
|
||||||
|
# users/loggingLevel can hot-reload; fall back to full restart.
|
||||||
|
reload_out, reload_err, reload_code = self._mita_cli(['reload'], timeout=30)
|
||||||
|
if reload_code == 0:
|
||||||
|
return
|
||||||
|
logger.info('mita reload failed, falling back to stop/start: %s',
|
||||||
|
(reload_err or reload_out or '').strip())
|
||||||
|
|
||||||
|
self._restart_proxy()
|
||||||
|
|
||||||
|
def _is_rpc_error(self, *parts):
|
||||||
|
text = ' '.join(str(p or '') for p in parts).lower()
|
||||||
|
return any(token in text for token in (
|
||||||
|
'rpc error',
|
||||||
|
'unavailable',
|
||||||
|
'error reading from server',
|
||||||
|
'eof',
|
||||||
|
'no such file or directory',
|
||||||
|
'mita.sock',
|
||||||
|
'connection refused',
|
||||||
|
))
|
||||||
|
|
||||||
|
def _restart_proxy(self):
|
||||||
|
self._mita_cli(['stop'], timeout=30)
|
||||||
|
time.sleep(1)
|
||||||
|
last_err = ''
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
out, err, code = self._mita_cli(['start'], timeout=60)
|
||||||
|
if code == 0:
|
||||||
|
# Confirm RUNNING (daemon may report success then die).
|
||||||
|
time.sleep(1)
|
||||||
|
if self._proxy_running():
|
||||||
|
return
|
||||||
|
last_err = (out or err or 'mita start returned ok but status is not RUNNING').strip()
|
||||||
|
else:
|
||||||
|
last_err = (err or out or 'mita start failed').strip()
|
||||||
|
if self._is_rpc_error(last_err) or attempt < 3:
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"systemctl restart {self.SERVICE_NAME} 2>/dev/null || true",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
self._wait_for_rpc(timeout=30)
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
journal, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"journalctl -u {self.SERVICE_NAME} -n 30 --no-pager 2>&1",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f'{last_err}. journal: {(journal or "").strip()[-400:]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_server(self, reload_only=True):
|
||||||
|
meta = self._read_metadata()
|
||||||
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
clients = self._read_clients()
|
||||||
|
config = self._build_server_config(port, clients)
|
||||||
|
self._apply_config(config, reload_only=reload_only)
|
||||||
|
|
||||||
|
def _open_firewall_port(self, port):
|
||||||
|
script = f"""
|
||||||
|
PORT={int(port)}
|
||||||
|
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi active; then
|
||||||
|
ufw allow "$PORT"/tcp || true
|
||||||
|
fi
|
||||||
|
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||||
|
firewall-cmd --permanent --add-port="$PORT"/tcp 2>/dev/null || true
|
||||||
|
firewall-cmd --reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
self.ssh.run_sudo_script(script, timeout=60)
|
||||||
|
|
||||||
|
def _package_url(self):
|
||||||
|
arch_out, _, _ = self.ssh.run_command('uname -m')
|
||||||
|
arch = (arch_out or '').strip().lower()
|
||||||
|
_, _, deb_code = self.ssh.run_command('command -v dpkg 2>/dev/null')
|
||||||
|
_, _, rpm_code = self.ssh.run_command('command -v rpm 2>/dev/null')
|
||||||
|
use_deb = deb_code == 0 or rpm_code != 0
|
||||||
|
if use_deb:
|
||||||
|
if arch in ('aarch64', 'arm64'):
|
||||||
|
return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_arm64.deb', 'deb'
|
||||||
|
return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_amd64.deb', 'deb'
|
||||||
|
if arch in ('aarch64', 'arm64'):
|
||||||
|
return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.aarch64.rpm', 'rpm'
|
||||||
|
return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.x86_64.rpm', 'rpm'
|
||||||
|
|
||||||
|
def _install_package(self, log):
|
||||||
|
url, pkg_type = self._package_url()
|
||||||
|
tmp = f'/tmp/mita_{MIERU_RELEASE}'
|
||||||
|
if pkg_type == 'deb':
|
||||||
|
tmp += '.deb'
|
||||||
|
install_cmd = f"dpkg -i {_q(tmp)} || apt-get install -f -y"
|
||||||
|
else:
|
||||||
|
tmp += '.rpm'
|
||||||
|
install_cmd = f"rpm -Uvh --force {_q(tmp)}"
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"curl -fL {_q(url)} -o {_q(tmp)} 2>&1",
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f'Failed to download mita package: {err or out}')
|
||||||
|
log.append(f'Downloaded mita v{MIERU_RELEASE}')
|
||||||
|
out, err, code = self.ssh.run_sudo_command(install_cmd, timeout=180)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f'Failed to install mita package: {err or out}')
|
||||||
|
log.append('Installed mita package')
|
||||||
|
self._ensure_daemon(log)
|
||||||
|
|
||||||
|
def _build_share_uri(self, host, port, username, password, name=''):
|
||||||
|
user = quote(username or '', safe='')
|
||||||
|
pw = quote(password or '', safe='')
|
||||||
|
params = f"port={int(port)}&protocol=TCP&profile=default"
|
||||||
|
link = f"mierus://{user}:{pw}@{host}?{params}"
|
||||||
|
if name:
|
||||||
|
link += f"#{quote(name, safe='')}"
|
||||||
|
return link
|
||||||
|
|
||||||
|
def _build_client_json(self, host, port, username, password):
|
||||||
|
return json.dumps({
|
||||||
|
'profiles': [{
|
||||||
|
'profileName': 'default',
|
||||||
|
'user': {'name': username, 'password': password},
|
||||||
|
'servers': [{
|
||||||
|
'ipAddress': host,
|
||||||
|
'domainName': '',
|
||||||
|
'portBindings': [{'port': int(port), 'protocol': 'TCP'}],
|
||||||
|
}],
|
||||||
|
'mtu': 1400,
|
||||||
|
'multiplexing': {'level': 'MULTIPLEXING_LOW'},
|
||||||
|
'handshakeMode': 'HANDSHAKE_STANDARD',
|
||||||
|
}],
|
||||||
|
'activeProfile': 'default',
|
||||||
|
'rpcPort': 8964,
|
||||||
|
'socks5Port': 1080,
|
||||||
|
'loggingLevel': 'INFO',
|
||||||
|
'socks5ListenLAN': False,
|
||||||
|
}, indent=2)
|
||||||
|
|
||||||
|
# ===================== INSTALL / REMOVE =====================
|
||||||
|
|
||||||
|
def install_protocol(self, protocol_type=None, port=None):
|
||||||
|
protocol_type = protocol_type or self.protocol
|
||||||
|
port = int(port or self.DEFAULT_PORT)
|
||||||
|
if port < 1025 or port > 65535:
|
||||||
|
return {'status': 'error', 'message': 'Port must be between 1025 and 65535'}
|
||||||
|
|
||||||
|
log = []
|
||||||
|
try:
|
||||||
|
if not self._mita_installed():
|
||||||
|
self._install_package(log)
|
||||||
|
else:
|
||||||
|
log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})')
|
||||||
|
self._ensure_daemon(log)
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
|
||||||
|
meta = {'port': port, 'release': MIERU_RELEASE}
|
||||||
|
self._write_metadata(meta)
|
||||||
|
# Keep clients empty in panel DB, but seed a real mita user so start works.
|
||||||
|
self._write_clients([])
|
||||||
|
bootstrap = {
|
||||||
|
'id': secrets.token_hex(8),
|
||||||
|
'name': 'panel-bootstrap',
|
||||||
|
'username': f'panel_{_rand_token(6)}',
|
||||||
|
'password': _rand_token(20),
|
||||||
|
'enabled': True,
|
||||||
|
'bootstrap': True,
|
||||||
|
}
|
||||||
|
self._write_clients([bootstrap])
|
||||||
|
log.append(f'Prepared {self.base_dir}')
|
||||||
|
|
||||||
|
config = self._build_server_config(port, [bootstrap])
|
||||||
|
self._apply_config(config, reload_only=False)
|
||||||
|
self._open_firewall_port(port)
|
||||||
|
log.append(f'Started mita proxy on TCP {port}')
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Mieru v{MIERU_RELEASE} installed',
|
||||||
|
'log': log,
|
||||||
|
'port': str(port),
|
||||||
|
'release': MIERU_RELEASE,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e), 'log': log}
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type=None):
|
||||||
|
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start_service(self):
|
||||||
|
self._ensure_daemon()
|
||||||
|
self._restart_proxy()
|
||||||
|
|
||||||
|
def stop_service(self):
|
||||||
|
self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30)
|
||||||
|
|
||||||
|
def get_server_config(self, protocol_type=None):
|
||||||
|
out, _, code = self.ssh.run_sudo_command('mita describe config 2>/dev/null')
|
||||||
|
if code == 0 and (out or '').strip():
|
||||||
|
return out
|
||||||
|
return self._read_file(self.config_path)
|
||||||
|
|
||||||
|
def save_server_config(self, protocol_type=None, config_text=''):
|
||||||
|
raw = (config_text or '').strip()
|
||||||
|
if not raw:
|
||||||
|
raise RuntimeError('Config is empty')
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f'Invalid JSON config: {e}') from e
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise RuntimeError('Config must be a JSON object')
|
||||||
|
self._apply_config(parsed, reload_only=False)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ===================== CLIENTS =====================
|
||||||
|
|
||||||
|
def get_clients(self, protocol_type=None):
|
||||||
|
clients = self._read_clients()
|
||||||
|
result = []
|
||||||
|
for c in clients:
|
||||||
|
if c.get('bootstrap'):
|
||||||
|
continue
|
||||||
|
cname = c.get('name') or c.get('id')
|
||||||
|
result.append({
|
||||||
|
'clientId': c.get('id'),
|
||||||
|
'client_id': c.get('id'),
|
||||||
|
'id': c.get('id'),
|
||||||
|
'name': cname,
|
||||||
|
'email': cname,
|
||||||
|
'enabled': c.get('enabled', True),
|
||||||
|
'userData': {'clientName': cname, 'enabled': c.get('enabled', True)},
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def add_client(self, protocol_type, name, host, port=None):
|
||||||
|
meta = self._read_metadata()
|
||||||
|
port = int(port or meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
client_id = secrets.token_hex(8)
|
||||||
|
username = _sanitize_username(name)
|
||||||
|
password = _rand_token(20)
|
||||||
|
clients = self._read_clients()
|
||||||
|
clients.append({
|
||||||
|
'id': client_id,
|
||||||
|
'name': name or username,
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'enabled': True,
|
||||||
|
})
|
||||||
|
self._write_clients(clients)
|
||||||
|
self._sync_server(reload_only=True)
|
||||||
|
config = self._build_share_uri(host, port, username, password, name or username)
|
||||||
|
json_config = self._build_client_json(host, port, username, password)
|
||||||
|
return {
|
||||||
|
'clientId': client_id,
|
||||||
|
'client_id': client_id,
|
||||||
|
'id': client_id,
|
||||||
|
'name': name or username,
|
||||||
|
'config': config,
|
||||||
|
'json_config': json_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_client_config(self, protocol_type, client_id, host, port=None):
|
||||||
|
meta = self._read_metadata()
|
||||||
|
port = int(port or meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
clients = self._read_clients()
|
||||||
|
client = next((c for c in clients if c.get('id') == client_id), None)
|
||||||
|
if not client or client.get('bootstrap'):
|
||||||
|
return ''
|
||||||
|
if not client.get('enabled', True):
|
||||||
|
return ''
|
||||||
|
username = client.get('username') or client.get('name') or client_id
|
||||||
|
password = client.get('password') or ''
|
||||||
|
name = client.get('name') or username
|
||||||
|
return self._build_share_uri(host, port, username, password, name)
|
||||||
|
|
||||||
|
def remove_client(self, protocol_type, client_id):
|
||||||
|
clients = [c for c in self._read_clients() if c.get('id') != client_id]
|
||||||
|
# Keep at least bootstrap so mita never has empty users.
|
||||||
|
if not any(not c.get('bootstrap') for c in clients) and not any(c.get('bootstrap') for c in clients):
|
||||||
|
clients.append({
|
||||||
|
'id': secrets.token_hex(8),
|
||||||
|
'name': 'panel-bootstrap',
|
||||||
|
'username': f'panel_{_rand_token(6)}',
|
||||||
|
'password': _rand_token(20),
|
||||||
|
'enabled': True,
|
||||||
|
'bootstrap': True,
|
||||||
|
})
|
||||||
|
self._write_clients(clients)
|
||||||
|
self._sync_server(reload_only=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_client(self, protocol_type, client_id, enabled):
|
||||||
|
clients = self._read_clients()
|
||||||
|
for c in clients:
|
||||||
|
if c.get('id') == client_id:
|
||||||
|
c['enabled'] = bool(enabled)
|
||||||
|
self._write_clients(clients)
|
||||||
|
self._sync_server(reload_only=True)
|
||||||
|
return True
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -278,12 +279,33 @@ class UpdateManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _archive_urls(self, tag: str) -> list[str]:
|
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()
|
base = repo_url()
|
||||||
return [
|
urls.extend([
|
||||||
f'{base}/archive/{tag}.zip',
|
f'{base}/archive/{tag}.zip',
|
||||||
f'{base}/archive/{tag.lstrip("v")}.zip',
|
f'{api_base}/archive/{tag}.zip',
|
||||||
f'{api_latest_url().rsplit("/releases/latest", 1)[0]}/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:
|
def _download_archive(self, tag: str, dest_path: str) -> None:
|
||||||
last_err = 'unknown error'
|
last_err = 'unknown error'
|
||||||
@@ -311,6 +333,15 @@ class UpdateManager:
|
|||||||
return only
|
return only
|
||||||
return extract_dir
|
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:
|
def _sync_tree(self, src_root: str) -> None:
|
||||||
for root, dirs, files in os.walk(src_root):
|
for root, dirs, files in os.walk(src_root):
|
||||||
dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS]
|
dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS]
|
||||||
@@ -336,6 +367,9 @@ class UpdateManager:
|
|||||||
src_root = self._extract_zip_root(extract_dir)
|
src_root = self._extract_zip_root(extract_dir)
|
||||||
if not os.path.isdir(src_root):
|
if not os.path.isdir(src_root):
|
||||||
return {'status': 'error', 'message': 'Invalid release archive layout'}
|
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)
|
self._sync_tree(src_root)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'status': 'error', 'message': str(e), 'method': 'archive'}
|
return {'status': 'error', 'message': str(e), 'method': 'archive'}
|
||||||
@@ -383,15 +417,36 @@ class UpdateManager:
|
|||||||
return {'status': 'error', 'message': hint, 'mode': mode}
|
return {'status': 'error', 'message': hint, 'mode': mode}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def schedule_restart(delay_sec: float = 1.5) -> None:
|
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():
|
def _restart():
|
||||||
time.sleep(max(0.5, float(delay_sec)))
|
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:
|
try:
|
||||||
argv = [sys.executable] + sys.argv
|
os.chdir(cwd)
|
||||||
logger.info('Restarting panel after update: %s', argv)
|
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)
|
os.execv(sys.executable, argv)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Failed to restart after update; exiting')
|
logger.exception('Failed to restart after update')
|
||||||
os._exit(0)
|
os._exit(1)
|
||||||
|
|
||||||
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
|
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
|
||||||
|
|||||||
@@ -949,6 +949,11 @@ a:hover {
|
|||||||
color: #38bdf8;
|
color: #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protocol-mieru .protocol-icon {
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.1));
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
.protocol-dns .protocol-icon {
|
.protocol-dns .protocol-icon {
|
||||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
@@ -2207,6 +2212,15 @@ a:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link-support {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-support:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: #f472b6;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-user {
|
.nav-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -108,4 +108,7 @@
|
|||||||
<symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
<symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>
|
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
|
||||||
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
+7
-3
@@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None
|
|||||||
_callback_refs = {}
|
_callback_refs = {}
|
||||||
_pending_inputs = {}
|
_pending_inputs = {}
|
||||||
|
|
||||||
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "wireguard"}
|
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "mieru", "wireguard"}
|
||||||
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
|
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
|
||||||
|
|
||||||
|
|
||||||
@@ -139,6 +139,7 @@ def _protocol_display_name(protocol: str) -> str:
|
|||||||
"telemt": "Telemt",
|
"telemt": "Telemt",
|
||||||
"hysteria": "Hysteria 2",
|
"hysteria": "Hysteria 2",
|
||||||
"naiveproxy": "NaiveProxy",
|
"naiveproxy": "NaiveProxy",
|
||||||
|
"mieru": "Mieru",
|
||||||
"dns": "AmneziaDNS",
|
"dns": "AmneziaDNS",
|
||||||
"wireguard": "WireGuard",
|
"wireguard": "WireGuard",
|
||||||
"socks5": "SOCKS5",
|
"socks5": "SOCKS5",
|
||||||
@@ -342,6 +343,7 @@ def _get_ssh_and_manager(server: dict, proto: str):
|
|||||||
from managers.telemt_manager import TelemtManager
|
from managers.telemt_manager import TelemtManager
|
||||||
from managers.hysteria_manager import HysteriaManager
|
from managers.hysteria_manager import HysteriaManager
|
||||||
from managers.naiveproxy_manager import NaiveProxyManager
|
from managers.naiveproxy_manager import NaiveProxyManager
|
||||||
|
from managers.mieru_manager import MieruManager
|
||||||
from managers.wireguard_manager import WireGuardManager
|
from managers.wireguard_manager import WireGuardManager
|
||||||
from managers.dns_manager import DNSManager
|
from managers.dns_manager import DNSManager
|
||||||
from managers.socks5_manager import Socks5Manager
|
from managers.socks5_manager import Socks5Manager
|
||||||
@@ -364,6 +366,8 @@ def _get_ssh_and_manager(server: dict, proto: str):
|
|||||||
manager = HysteriaManager(ssh, proto)
|
manager = HysteriaManager(ssh, proto)
|
||||||
elif base == "naiveproxy":
|
elif base == "naiveproxy":
|
||||||
manager = NaiveProxyManager(ssh, proto)
|
manager = NaiveProxyManager(ssh, proto)
|
||||||
|
elif base == "mieru":
|
||||||
|
manager = MieruManager(ssh, proto)
|
||||||
elif base == "wireguard":
|
elif base == "wireguard":
|
||||||
manager = WireGuardManager(ssh)
|
manager = WireGuardManager(ssh)
|
||||||
elif base == "dns":
|
elif base == "dns":
|
||||||
@@ -569,7 +573,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p
|
|||||||
server_name = server.get("name") or server.get("host", "Unknown")
|
server_name = server.get("name") or server.get("host", "Unknown")
|
||||||
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
|
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
|
||||||
|
|
||||||
is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy")
|
is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru")
|
||||||
if is_link_proto:
|
if is_link_proto:
|
||||||
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
|
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
|
||||||
else:
|
else:
|
||||||
@@ -926,7 +930,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int,
|
|||||||
|
|
||||||
async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable):
|
async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable):
|
||||||
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
|
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
|
||||||
if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy"):
|
if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru"):
|
||||||
await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>")
|
await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>")
|
||||||
else:
|
else:
|
||||||
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>")
|
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>")
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
<a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a>
|
<a href="/settings" class="nav-link">{{ icon('settings') }} {{ _('nav_settings') }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/my" class="nav-link">{{ icon('layers') }} {{ _('nav_connections') }}</a>
|
<a href="/my" class="nav-link">{{ icon('layers') }} {{ _('nav_connections') }}</a>
|
||||||
|
{% if donate_enabled %}
|
||||||
|
<a href="/support" class="nav-link nav-link-support">{{ icon('heart') }} {{ _('nav_support') }}</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
if (p === 'xui') return '3x-ui VLESS';
|
if (p === 'xui') return '3x-ui VLESS';
|
||||||
if (p === 'hysteria') return 'Hysteria 2';
|
if (p === 'hysteria') return 'Hysteria 2';
|
||||||
if (p === 'naiveproxy') return 'NaiveProxy';
|
if (p === 'naiveproxy') return 'NaiveProxy';
|
||||||
|
if (p === 'mieru') return 'Mieru';
|
||||||
if (p === 'telemt') return 'Telemt';
|
if (p === 'telemt') return 'Telemt';
|
||||||
return (p || '').toUpperCase();
|
return (p || '').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
|
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
|
||||||
data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
|
data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
|
||||||
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
|
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
|
||||||
|
data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}"
|
||||||
draggable="true">
|
draggable="true">
|
||||||
<div class="server-meta">
|
<div class="server-meta">
|
||||||
<div class="server-icon">{{ icon('server') }}</div>
|
<div class="server-icon">{{ icon('server') }}</div>
|
||||||
@@ -32,6 +33,10 @@
|
|||||||
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
|
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
|
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
|
||||||
|
{% set connect_domain = (server.server_info or {}).get('connect_domain') %}
|
||||||
|
{% if connect_domain and connect_domain != server.host %}
|
||||||
|
<div class="server-host text-muted" title="{{ _('server_connect_domain_hint') }}">→ {{ connect_domain }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +144,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: var(--space-md)">
|
||||||
|
<label class="form-label">{{ _('server_connect_domain') }}</label>
|
||||||
|
<input class="form-input" type="text" id="editServerConnectDomain" placeholder="vpn.example.com">
|
||||||
|
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: var(--space-md)">
|
<div class="form-group" style="margin-top: var(--space-md)">
|
||||||
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
||||||
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
|
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
|
||||||
@@ -214,6 +225,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: var(--space-md)">
|
||||||
|
<label class="form-label">{{ _('server_connect_domain') }}</label>
|
||||||
|
<input class="form-input" type="text" id="serverConnectDomain" placeholder="vpn.example.com">
|
||||||
|
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: var(--space-md)">
|
<div class="form-group" style="margin-top: var(--space-md)">
|
||||||
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
||||||
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
|
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
|
||||||
@@ -268,6 +285,7 @@
|
|||||||
private_key: document.getElementById('serverKey').value,
|
private_key: document.getElementById('serverKey').value,
|
||||||
ssl_domain: document.getElementById('serverSslDomain').value.trim(),
|
ssl_domain: document.getElementById('serverSslDomain').value.trim(),
|
||||||
ssl_email: document.getElementById('serverSslEmail').value.trim(),
|
ssl_email: document.getElementById('serverSslEmail').value.trim(),
|
||||||
|
connect_domain: document.getElementById('serverConnectDomain').value.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await apiCall('/api/servers/add', 'POST', data);
|
const result = await apiCall('/api/servers/add', 'POST', data);
|
||||||
@@ -318,6 +336,7 @@
|
|||||||
document.getElementById('editServerKey').value = '';
|
document.getElementById('editServerKey').value = '';
|
||||||
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
|
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
|
||||||
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || '';
|
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || '';
|
||||||
|
document.getElementById('editServerConnectDomain').value = card.dataset.connectDomain || '';
|
||||||
|
|
||||||
// Pre-select the tab matching the server's current auth method.
|
// Pre-select the tab matching the server's current auth method.
|
||||||
const authIsKey = card.dataset.auth === 'key';
|
const authIsKey = card.dataset.auth === 'key';
|
||||||
@@ -349,6 +368,7 @@
|
|||||||
private_key: document.getElementById('editServerKey').value || null,
|
private_key: document.getElementById('editServerKey').value || null,
|
||||||
ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
|
ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
|
||||||
ssl_email: document.getElementById('editServerSslEmail').value.trim(),
|
ssl_email: document.getElementById('editServerSslEmail').value.trim(),
|
||||||
|
connect_domain: document.getElementById('editServerConnectDomain').value.trim(),
|
||||||
};
|
};
|
||||||
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
|
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
|
||||||
showToast(_('server_saved'), 'success');
|
showToast(_('server_saved'), 'success');
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
||||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
|
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||||
const PROTO_TITLES = {
|
const PROTO_TITLES = {
|
||||||
awg2: 'AmneziaWG 2.0',
|
awg2: 'AmneziaWG 2.0',
|
||||||
awg: 'AmneziaWG',
|
awg: 'AmneziaWG',
|
||||||
@@ -206,6 +206,7 @@
|
|||||||
telemt: 'Telemt',
|
telemt: 'Telemt',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria 2',
|
||||||
naiveproxy: 'NaiveProxy',
|
naiveproxy: 'NaiveProxy',
|
||||||
|
mieru: 'Mieru',
|
||||||
xui: '3x-ui VLESS',
|
xui: '3x-ui VLESS',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
const vpnTab = document.querySelectorAll('.config-tab')[1];
|
const vpnTab = document.querySelectorAll('.config-tab')[1];
|
||||||
const vpnPanel = document.getElementById('panel-vpn');
|
const vpnPanel = document.getElementById('panel-vpn');
|
||||||
const base = String(proto || '').split('__')[0];
|
const base = String(proto || '').split('__')[0];
|
||||||
if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy') {
|
if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy' || base === 'mieru') {
|
||||||
vpnTab.style.display = 'none';
|
vpnTab.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
vpnTab.style.display = '';
|
vpnTab.style.display = '';
|
||||||
|
|||||||
+188
-2
@@ -155,6 +155,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="connectDomainSection" class="hidden" style="margin-top: var(--space-md);">
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">{{ _('server_connect_domain') }}</label>
|
||||||
|
<div class="flex gap-sm" style="flex-wrap: wrap; align-items: flex-end;">
|
||||||
|
<div style="flex: 0 0 200px; min-width: 160px;">
|
||||||
|
<select class="form-input" id="connectDomainProto" onchange="refreshConnectDomainFields()"></select>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<input class="form-input" type="text" id="connectDomainInput" placeholder="vpn.example.com">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="connectDomainSaveBtn" onclick="saveConnectDomain()">
|
||||||
|
{{ _('save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('server_connect_domain_awg_hint') }}</div>
|
||||||
|
<div class="text-muted text-sm" id="connectDomainEffective" style="margin-top: var(--space-xs);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Installed Applications Section -->
|
<!-- Installed Applications Section -->
|
||||||
@@ -295,6 +314,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mieru Card -->
|
||||||
|
<div class="card card-hover protocol-card protocol-mieru" id="proto-mieru">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||||||
|
<div class="protocol-icon">{{ icon('zap') }}</div>
|
||||||
|
<div class="flex gap-sm" id="mieru-ctrl" style="display:none!important;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="protocol-name">Mieru <span
|
||||||
|
style="font-size:0.65rem; background:var(--accent, #6366f1); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">v3.28.0</span></div>
|
||||||
|
<div class="protocol-desc">
|
||||||
|
{{ _('mieru_desc') }}
|
||||||
|
</div>
|
||||||
|
<div class="protocol-status" id="mieru-status">
|
||||||
|
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||||||
|
</div>
|
||||||
|
<div id="mieru-info" class="hidden">
|
||||||
|
<div class="protocol-info" id="mieru-info-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-sm" id="mieru-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openInstallModal('mieru')" id="mieru-install-btn"
|
||||||
|
style="flex:1">
|
||||||
|
{{ _('install') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hysteria Card -->
|
<!-- Hysteria Card -->
|
||||||
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
|
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||||||
@@ -758,6 +802,20 @@
|
|||||||
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('naiveproxy_client_hint') }}</div>
|
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('naiveproxy_client_hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="mieruOptions"
|
||||||
|
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||||||
|
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
|
||||||
|
{{ _('mieru_ports_warning') }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('port') }} (TCP) *</label>
|
||||||
|
<input class="form-input" type="number" id="installMieruPort" value="2999" min="1025" max="65535">
|
||||||
|
<div class="form-hint">{{ _('mieru_port_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint">{{ _('mieru_install_hint') }}</div>
|
||||||
|
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('mieru_client_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
|
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
|
||||||
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
|
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
|
||||||
@@ -1132,6 +1190,7 @@
|
|||||||
const SERVER_ID = {{ server_id }};
|
const SERVER_ID = {{ server_id }};
|
||||||
const ALL_SERVERS = {{ servers_for_move | tojson }};
|
const ALL_SERVERS = {{ servers_for_move | tojson }};
|
||||||
const SERVER_HOST = "{{ server.host }}";
|
const SERVER_HOST = "{{ server.host }}";
|
||||||
|
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
|
||||||
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
|
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
|
||||||
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
|
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
|
||||||
const MARKETPLACE_APPS = [
|
const MARKETPLACE_APPS = [
|
||||||
@@ -1140,6 +1199,7 @@
|
|||||||
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
|
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
|
||||||
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
|
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
|
||||||
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
|
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
|
||||||
|
{ proto: 'mieru', category: 'protocols', icon: 'zap', title: 'Mieru', descKey: 'mieru_desc', badge: 'v3.28.0' },
|
||||||
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
|
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
|
||||||
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
|
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
|
||||||
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
|
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
|
||||||
@@ -1153,6 +1213,8 @@
|
|||||||
{ id: 'services', icon: 'wrench', titleKey: 'services' },
|
{ id: 'services', icon: 'wrench', titleKey: 'services' },
|
||||||
{ id: 'web_servers', icon: 'globe', titleKey: 'web_servers' },
|
{ id: 'web_servers', icon: 'globe', titleKey: 'web_servers' },
|
||||||
];
|
];
|
||||||
|
const WG_AWG_BASES = new Set(['awg', 'awg2', 'awg_legacy', 'wireguard']);
|
||||||
|
let connectDomainEffective = {};
|
||||||
let currentInstallProto = 'awg';
|
let currentInstallProto = 'awg';
|
||||||
let currentInstallAnother = false;
|
let currentInstallAnother = false;
|
||||||
let currentConfig = '';
|
let currentConfig = '';
|
||||||
@@ -1212,6 +1274,94 @@
|
|||||||
return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]);
|
return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasWgAwgInstalled() {
|
||||||
|
return Object.entries(installedProtocols || {}).some(([key, installed]) => installed && WG_AWG_BASES.has(protoBase(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listInstalledWgAwgProtocols() {
|
||||||
|
const protos = new Set();
|
||||||
|
Object.entries(installedProtocols || {}).forEach(([key, installed]) => {
|
||||||
|
if (installed && WG_AWG_BASES.has(protoBase(key))) protos.add(key);
|
||||||
|
});
|
||||||
|
return Array.from(protos).sort((a, b) => {
|
||||||
|
const ao = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(a));
|
||||||
|
const bo = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(b));
|
||||||
|
if (ao !== bo) return ao - bo;
|
||||||
|
return protoInstance(a) - protoInstance(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectDomainSection() {
|
||||||
|
const section = document.getElementById('connectDomainSection');
|
||||||
|
if (!section) return;
|
||||||
|
const show = hasWgAwgInstalled();
|
||||||
|
section.classList.toggle('hidden', !show);
|
||||||
|
if (!show) return;
|
||||||
|
const select = document.getElementById('connectDomainProto');
|
||||||
|
if (!select) return;
|
||||||
|
const prev = select.value;
|
||||||
|
const installed = listInstalledWgAwgProtocols();
|
||||||
|
select.innerHTML = `<option value="">${_('server_connect_domain_all')}</option>` +
|
||||||
|
installed.map(p => `<option value="${p}">${getProtoTitle(p)}</option>`).join('');
|
||||||
|
if (prev && [...select.options].some(opt => opt.value === prev)) {
|
||||||
|
select.value = prev;
|
||||||
|
}
|
||||||
|
refreshConnectDomainFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConnectDomainFields() {
|
||||||
|
const proto = document.getElementById('connectDomainProto')?.value || '';
|
||||||
|
const input = document.getElementById('connectDomainInput');
|
||||||
|
const eff = document.getElementById('connectDomainEffective');
|
||||||
|
try {
|
||||||
|
const query = proto ? `?protocol=${encodeURIComponent(proto)}` : '';
|
||||||
|
const data = await apiCall(`/api/servers/${SERVER_ID}/connect-domain${query}`, 'GET');
|
||||||
|
connectDomainEffective = data;
|
||||||
|
if (input) {
|
||||||
|
input.value = proto ? (data.protocol_connect_domain || '') : (data.connect_domain || '');
|
||||||
|
}
|
||||||
|
if (eff) {
|
||||||
|
if (data.effective_host && data.effective_host !== data.ssh_host) {
|
||||||
|
eff.textContent = `${_('server_connect_domain_current')}: ${data.effective_host} (${_('server_connect_domain_ssh')}: ${data.ssh_host})`;
|
||||||
|
} else {
|
||||||
|
eff.textContent = `${_('server_connect_domain_current')}: ${data.ssh_host || SERVER_HOST}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
|
||||||
|
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
|
||||||
|
updateProtocolCard(p, info);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (input && !proto) input.value = SERVER_CONNECT_DOMAIN || '';
|
||||||
|
if (eff) eff.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConnectDomain() {
|
||||||
|
const btn = document.getElementById('connectDomainSaveBtn');
|
||||||
|
const proto = document.getElementById('connectDomainProto')?.value || '';
|
||||||
|
const domain = document.getElementById('connectDomainInput')?.value.trim() || '';
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiCall(`/api/servers/${SERVER_ID}/connect-domain`, 'POST', {
|
||||||
|
connect_domain: domain,
|
||||||
|
protocol: proto || null,
|
||||||
|
});
|
||||||
|
showToast(_('server_connect_domain_saved'), 'success');
|
||||||
|
await refreshConnectDomainFields();
|
||||||
|
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
|
||||||
|
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
|
||||||
|
updateProtocolCard(p, info);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function switchCategory(cat) {
|
function switchCategory(cat) {
|
||||||
if (cat === 'management') {
|
if (cat === 'management') {
|
||||||
openManagementModal();
|
openManagementModal();
|
||||||
@@ -1370,6 +1520,7 @@
|
|||||||
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
|
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
|
||||||
const loading = document.getElementById('installedAppsLoading');
|
const loading = document.getElementById('installedAppsLoading');
|
||||||
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
|
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
|
||||||
|
updateConnectDomainSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProtoTitle(proto) {
|
function getProtoTitle(proto) {
|
||||||
@@ -1384,6 +1535,7 @@
|
|||||||
case 'telemt': title = 'Telemt'; break;
|
case 'telemt': title = 'Telemt'; break;
|
||||||
case 'hysteria': title = 'Hysteria 2'; break;
|
case 'hysteria': title = 'Hysteria 2'; break;
|
||||||
case 'naiveproxy': title = 'NaiveProxy'; break;
|
case 'naiveproxy': title = 'NaiveProxy'; break;
|
||||||
|
case 'mieru': title = 'Mieru'; break;
|
||||||
case 'wireguard': title = 'WireGuard'; break;
|
case 'wireguard': title = 'WireGuard'; break;
|
||||||
case 'dns': title = 'AmneziaDNS'; break;
|
case 'dns': title = 'AmneziaDNS'; break;
|
||||||
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
||||||
@@ -1443,7 +1595,7 @@
|
|||||||
|
|
||||||
for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
|
for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
|
||||||
updateProtocolCard(proto, info);
|
updateProtocolCard(proto, info);
|
||||||
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'wireguard'].includes(protoBase(proto));
|
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru', 'wireguard'].includes(protoBase(proto));
|
||||||
if (info.container_running && isVPN) {
|
if (info.container_running && isVPN) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = proto;
|
opt.value = proto;
|
||||||
@@ -1671,13 +1823,22 @@
|
|||||||
if (isService) {
|
if (isService) {
|
||||||
grid = buildServiceInfoGrid(proto, info);
|
grid = buildServiceInfoGrid(proto, info);
|
||||||
} else if (info.port) {
|
} else if (info.port) {
|
||||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt', 'naiveproxy'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
|
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt', 'naiveproxy', 'mieru'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
|
||||||
|
if (WG_AWG_BASES.has(protoBase(proto))) {
|
||||||
|
const endpoint = (connectDomainEffective.effective_host && connectDomainEffective.effective_host !== SERVER_HOST)
|
||||||
|
? connectDomainEffective.effective_host
|
||||||
|
: SERVER_HOST;
|
||||||
|
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('server_endpoint_label')}</span><span class="protocol-info-value">${endpoint}</span></div>`;
|
||||||
|
}
|
||||||
if (protoBase(proto) === 'hysteria' && info.domain) {
|
if (protoBase(proto) === 'hysteria' && info.domain) {
|
||||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
||||||
}
|
}
|
||||||
if (protoBase(proto) === 'naiveproxy' && info.domain) {
|
if (protoBase(proto) === 'naiveproxy' && info.domain) {
|
||||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
||||||
}
|
}
|
||||||
|
if (protoBase(proto) === 'mieru' && info.release) {
|
||||||
|
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('mieru_version')}</span><span class="protocol-info-value">v${info.release}</span></div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!isService && info.clients_count !== undefined) {
|
if (!isService && info.clients_count !== undefined) {
|
||||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
|
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
|
||||||
@@ -1727,6 +1888,13 @@
|
|||||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||||
`;
|
`;
|
||||||
showConnectionsSection();
|
showConnectionsSection();
|
||||||
|
} else if (protoBase(proto) === 'mieru') {
|
||||||
|
actionsEl.innerHTML = `
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||||
|
`;
|
||||||
|
showConnectionsSection();
|
||||||
} else {
|
} else {
|
||||||
actionsEl.innerHTML = `
|
actionsEl.innerHTML = `
|
||||||
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
||||||
@@ -2130,6 +2298,7 @@
|
|||||||
const nginxOpts = document.getElementById('nginxOptions');
|
const nginxOpts = document.getElementById('nginxOptions');
|
||||||
const hysteriaOpts = document.getElementById('hysteriaOptions');
|
const hysteriaOpts = document.getElementById('hysteriaOptions');
|
||||||
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
|
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
|
||||||
|
const mieruOpts = document.getElementById('mieruOptions');
|
||||||
|
|
||||||
telemtOpts.style.display = 'none';
|
telemtOpts.style.display = 'none';
|
||||||
socks5Opts.style.display = 'none';
|
socks5Opts.style.display = 'none';
|
||||||
@@ -2137,6 +2306,7 @@
|
|||||||
nginxOpts.style.display = 'none';
|
nginxOpts.style.display = 'none';
|
||||||
hysteriaOpts.style.display = 'none';
|
hysteriaOpts.style.display = 'none';
|
||||||
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
|
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
|
||||||
|
if (mieruOpts) mieruOpts.style.display = 'none';
|
||||||
if (portGroup) portGroup.style.display = '';
|
if (portGroup) portGroup.style.display = '';
|
||||||
|
|
||||||
if (base === 'dns') {
|
if (base === 'dns') {
|
||||||
@@ -2204,6 +2374,17 @@
|
|||||||
if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN;
|
if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN;
|
||||||
if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL;
|
if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL;
|
||||||
updateNaiveproxyDnsHint();
|
updateNaiveproxyDnsHint();
|
||||||
|
} else if (base === 'mieru') {
|
||||||
|
if (portGroup) portGroup.style.display = 'none';
|
||||||
|
const suggested = '2999';
|
||||||
|
portInput.value = suggested;
|
||||||
|
portInput.disabled = false;
|
||||||
|
if (mieruOpts) mieruOpts.style.display = 'block';
|
||||||
|
const miPort = document.getElementById('installMieruPort');
|
||||||
|
if (miPort) {
|
||||||
|
miPort.value = suggested;
|
||||||
|
miPort.oninput = () => { portInput.value = miPort.value; };
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
portLabel.textContent = _('port') + ' (UDP)';
|
portLabel.textContent = _('port') + ' (UDP)';
|
||||||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
|
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
|
||||||
@@ -2281,6 +2462,11 @@
|
|||||||
params.port = '443';
|
params.port = '443';
|
||||||
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
|
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
|
||||||
params.naiveproxy_email = document.getElementById('installNaiveproxyEmail').value.trim();
|
params.naiveproxy_email = document.getElementById('installNaiveproxyEmail').value.trim();
|
||||||
|
} else if (protoBase(currentInstallProto) === 'mieru') {
|
||||||
|
const miPortEl = document.getElementById('installMieruPort');
|
||||||
|
if (miPortEl && miPortEl.value) {
|
||||||
|
params.port = miPortEl.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
|
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
|||||||
+98
-13
@@ -604,15 +604,19 @@
|
|||||||
<div class="card" style="margin-top: var(--space-lg);">
|
<div class="card" style="margin-top: var(--space-lg);">
|
||||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
||||||
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||||
<div style="display: flex; gap: var(--space-sm);">
|
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
|
||||||
<a href="/api/settings/backup/download" class="btn btn-secondary"
|
<a href="/api/settings/backup/download" class="btn btn-secondary"
|
||||||
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||||
<span>⬇️</span> {{ _('download_backup') }}
|
<span>⬇️</span> {{ _('download_backup') }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
|
||||||
|
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||||
|
<span>📄</span> {{ _('download_backup_json') }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
||||||
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||||||
<input type="file" id="backupFile" accept=".json" style="display: none;"
|
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
|
||||||
onchange="handleRestore(event)">
|
onchange="handleRestore(event)">
|
||||||
<button type="button" class="btn btn-secondary"
|
<button type="button" class="btn btn-secondary"
|
||||||
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
|
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
|
||||||
@@ -623,6 +627,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint" style="margin-top: var(--space-sm);">
|
<p class="form-hint" style="margin-top: var(--space-sm);">
|
||||||
|
{{ _('backup_hint') }}
|
||||||
|
</p>
|
||||||
|
<p class="form-hint" style="margin-top: var(--space-xs);">
|
||||||
{{ _('restore_confirm') }}
|
{{ _('restore_confirm') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -643,6 +650,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- BLOCK: Support / Donate (admin) -->
|
||||||
|
{% set donate_cfg = settings.get('donate') or {} %}
|
||||||
|
<div class="card" style="margin-top: var(--space-lg);">
|
||||||
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ icon('heart') }} {{ _('donate_settings_title') }}</h3>
|
||||||
|
<p class="form-hint" style="margin-top: 0; margin-bottom: var(--space-md);">{{ _('donate_settings_hint') }}</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" style="display:flex; align-items:center; gap:8px; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="donate_enabled" {% if donate_cfg.get('enabled', True) %}checked{% endif %}>
|
||||||
|
{{ _('donate_enabled') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('donate_intro') }}</label>
|
||||||
|
<textarea class="form-textarea" id="donate_intro" rows="2" placeholder="{{ _('support_intro') }}">{{ donate_cfg.get('intro') or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% for key, label_key in [('sbp', 'donate_sbp'), ('card', 'donate_card'), ('crypto', 'donate_crypto')] %}
|
||||||
|
{% set method = donate_cfg.get(key) or {} %}
|
||||||
|
<div style="border:1px solid var(--border-color); border-radius:var(--radius-md); padding:var(--space-md); margin-bottom:var(--space-md);">
|
||||||
|
<label class="form-label" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:var(--space-sm);">
|
||||||
|
<input type="checkbox" id="donate_{{ key }}_enabled" {% if method.get('enabled', True) %}checked{% endif %}>
|
||||||
|
{{ _(label_key) }}
|
||||||
|
</label>
|
||||||
|
<div class="form-group" style="margin-bottom:var(--space-sm);">
|
||||||
|
<label class="form-label">{{ _('donate_method_title') }}</label>
|
||||||
|
<input type="text" class="form-input" id="donate_{{ key }}_title" value="{{ method.get('title') or '' }}" placeholder="{{ _(label_key) }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
|
<label class="form-label">{{ _('donate_method_details') }}</label>
|
||||||
|
<textarea class="form-textarea" id="donate_{{ key }}_details" rows="3" placeholder="{{ _('donate_details_placeholder') }}">{{ method.get('details') or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<p class="form-hint">{{ _('donate_preview_hint') }} <a href="/support" target="_blank" rel="noopener">/support</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- BLOCK: About & Updates -->
|
<!-- BLOCK: About & Updates -->
|
||||||
<div class="card" style="margin-top: var(--space-lg);">
|
<div class="card" style="margin-top: var(--space-lg);">
|
||||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">ℹ️ {{ _('about_title') }}</h3>
|
<h3 class="card-title" style="margin-bottom: var(--space-lg);">ℹ️ {{ _('about_title') }}</h3>
|
||||||
@@ -1471,11 +1513,24 @@
|
|||||||
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const donateMethod = (key) => ({
|
||||||
|
enabled: document.getElementById(`donate_${key}_enabled`).checked,
|
||||||
|
title: document.getElementById(`donate_${key}_title`).value.trim(),
|
||||||
|
details: document.getElementById(`donate_${key}_details`).value.trim(),
|
||||||
|
});
|
||||||
|
const donate = {
|
||||||
|
enabled: document.getElementById('donate_enabled').checked,
|
||||||
|
intro: document.getElementById('donate_intro').value.trim(),
|
||||||
|
sbp: donateMethod('sbp'),
|
||||||
|
card: donateMethod('card'),
|
||||||
|
crypto: donateMethod('crypto'),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/save', {
|
const res = await fetch('/api/settings/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest })
|
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, guest, donate })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(_('error'));
|
if (!res.ok) throw new Error(_('error'));
|
||||||
showToast(_('settings_saved'), 'success');
|
showToast(_('settings_saved'), 'success');
|
||||||
@@ -1489,7 +1544,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const guestServersData = {{ servers | default([]) | tojson }};
|
const guestServersData = {{ servers | default([]) | tojson }};
|
||||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
|
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||||
const PROTO_TITLES = {
|
const PROTO_TITLES = {
|
||||||
awg2: 'AmneziaWG 2.0',
|
awg2: 'AmneziaWG 2.0',
|
||||||
awg: 'AmneziaWG',
|
awg: 'AmneziaWG',
|
||||||
@@ -1499,6 +1554,7 @@
|
|||||||
telemt: 'Telemt',
|
telemt: 'Telemt',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria 2',
|
||||||
naiveproxy: 'NaiveProxy',
|
naiveproxy: 'NaiveProxy',
|
||||||
|
mieru: 'Mieru',
|
||||||
xui: '3x-ui VLESS',
|
xui: '3x-ui VLESS',
|
||||||
};
|
};
|
||||||
function protoTitle(key) {
|
function protoTitle(key) {
|
||||||
@@ -1718,6 +1774,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function applyPanelUpdate() {
|
||||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||||
const applyText = document.getElementById('applyUpdateBtnText');
|
const applyText = document.getElementById('applyUpdateBtnText');
|
||||||
@@ -1733,18 +1807,24 @@
|
|||||||
applyBtn.disabled = true;
|
applyBtn.disabled = true;
|
||||||
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
|
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
|
||||||
applySpinner.classList.remove('hidden');
|
applySpinner.classList.remove('hidden');
|
||||||
|
let restarting = false;
|
||||||
try {
|
try {
|
||||||
const data = await apiCall('/api/settings/apply_update', 'POST', {
|
const data = await apiCall('/api/settings/apply_update', 'POST', {
|
||||||
target_version: target,
|
target_version: target,
|
||||||
allow_dirty: true,
|
allow_dirty: true,
|
||||||
});
|
});
|
||||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||||
setTimeout(() => { window.location.reload(); }, 3500);
|
restarting = true;
|
||||||
|
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||||
|
await waitForPanelRestart(120000, data.panel_url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(_('error') + ': ' + err.message, 'error');
|
showToast(_('error') + ': ' + err.message, 'error');
|
||||||
applyBtn.disabled = false;
|
} finally {
|
||||||
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
|
if (!restarting) {
|
||||||
applySpinner.classList.add('hidden');
|
applyBtn.disabled = false;
|
||||||
|
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
|
||||||
|
applySpinner.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1761,6 +1841,7 @@
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
spinner.classList.remove('hidden');
|
spinner.classList.remove('hidden');
|
||||||
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
|
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
|
||||||
|
let restarting = false;
|
||||||
try {
|
try {
|
||||||
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
|
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
|
||||||
if (data.up_to_date) {
|
if (data.up_to_date) {
|
||||||
@@ -1769,14 +1850,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||||
setTimeout(() => { window.location.reload(); }, 4000);
|
restarting = true;
|
||||||
|
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||||
|
await waitForPanelRestart(120000, data.panel_url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(_('error') + ': ' + err.message, 'error');
|
showToast(_('error') + ': ' + err.message, 'error');
|
||||||
await checkForUpdates(true);
|
await checkForUpdates(true);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
if (!restarting) {
|
||||||
spinner.classList.add('hidden');
|
btn.disabled = false;
|
||||||
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
|
spinner.classList.add('hidden');
|
||||||
|
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/icons.html" import icon %}
|
||||||
|
|
||||||
|
{% block title_extra %} — {{ _('nav_support') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.support-hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-xl) var(--space-md);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
.support-hero-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.support-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: var(--space-lg);
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.support-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
.support-card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
.support-details {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
min-height: 4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.support-details.has-content {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="support-hero">
|
||||||
|
<div class="support-hero-icon">{{ icon('heart') }}</div>
|
||||||
|
<h1 class="section-title" style="justify-content: center; margin-bottom: var(--space-sm);">
|
||||||
|
{{ _('support_title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted" style="max-width: 560px; margin: 0 auto; line-height: 1.5;">
|
||||||
|
{% if donate.get('intro') %}
|
||||||
|
{{ donate.intro }}
|
||||||
|
{% else %}
|
||||||
|
{{ _('support_intro') }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="support-grid">
|
||||||
|
{% set methods = [
|
||||||
|
('sbp', _('donate_sbp'), '💳'),
|
||||||
|
('card', _('donate_card'), '🏦'),
|
||||||
|
('crypto', _('donate_crypto'), '₿'),
|
||||||
|
] %}
|
||||||
|
{% for key, default_title, emoji in methods %}
|
||||||
|
{% set method = donate.get(key) or {} %}
|
||||||
|
{% if method.get('enabled', True) %}
|
||||||
|
<div class="card support-card">
|
||||||
|
<div class="support-card-title">
|
||||||
|
<span aria-hidden="true">{{ emoji }}</span>
|
||||||
|
<span>{{ method.get('title') or default_title }}</span>
|
||||||
|
</div>
|
||||||
|
{% set details = (method.get('details') or '').strip() %}
|
||||||
|
<div class="support-details{% if details %} has-content{% endif %}" id="donate-{{ key }}-details">{{ details or _('donate_details_soon') }}</div>
|
||||||
|
{% if details %}
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('donate-{{ key }}-details').textContent)">
|
||||||
|
{{ icon('copy') }} {{ _('copy') }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted text-sm" style="text-align: center; margin-top: var(--space-xl); max-width: 520px; margin-left: auto; margin-right: auto;">
|
||||||
|
{{ _('support_footer') }}
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -631,7 +631,7 @@
|
|||||||
const select = document.getElementById(selectId);
|
const select = document.getElementById(selectId);
|
||||||
const group = groupId ? document.getElementById(groupId) : null;
|
const group = groupId ? document.getElementById(groupId) : null;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
|
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||||
const titles = {
|
const titles = {
|
||||||
awg2: 'AmneziaWG 2.0',
|
awg2: 'AmneziaWG 2.0',
|
||||||
awg: 'AmneziaWG',
|
awg: 'AmneziaWG',
|
||||||
@@ -641,6 +641,7 @@
|
|||||||
telemt: 'Telemt',
|
telemt: 'Telemt',
|
||||||
hysteria: 'Hysteria 2',
|
hysteria: 'Hysteria 2',
|
||||||
naiveproxy: 'NaiveProxy',
|
naiveproxy: 'NaiveProxy',
|
||||||
|
mieru: 'Mieru',
|
||||||
xui: '3x-ui VLESS',
|
xui: '3x-ui VLESS',
|
||||||
};
|
};
|
||||||
const protoTitle = (key) => {
|
const protoTitle = (key) => {
|
||||||
|
|||||||
+39
-5
@@ -4,6 +4,7 @@
|
|||||||
"nav_invites": "Invite links",
|
"nav_invites": "Invite links",
|
||||||
"nav_settings": "Settings",
|
"nav_settings": "Settings",
|
||||||
"nav_connections": "Connections",
|
"nav_connections": "Connections",
|
||||||
|
"nav_support": "Support",
|
||||||
"nav_logout": "Logout",
|
"nav_logout": "Logout",
|
||||||
"theme_dark": "Dark",
|
"theme_dark": "Dark",
|
||||||
"theme_light": "Light",
|
"theme_light": "Light",
|
||||||
@@ -361,11 +362,13 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "Simple Backup",
|
"backup_title": "Simple Backup",
|
||||||
"download_backup": "Download data.json",
|
"download_backup": "Download PostgreSQL dump (.sql)",
|
||||||
"restore_backup": "Restore from file",
|
"download_backup_json": "Export JSON (legacy)",
|
||||||
"restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.",
|
"backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.",
|
||||||
|
"restore_backup": "Restore from .sql or .json",
|
||||||
|
"restore_confirm": "Restore will overwrite all current panel data in the database.",
|
||||||
"restore_success": "Restore successful! Restarting...",
|
"restore_success": "Restore successful! Restarting...",
|
||||||
"invalid_backup_file": "Invalid backup file or structure",
|
"invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)",
|
||||||
"config_unavailable": "Configuration unavailable",
|
"config_unavailable": "Configuration unavailable",
|
||||||
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
|
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
|
||||||
"client_public_key": "Client public key:",
|
"client_public_key": "Client public key:",
|
||||||
@@ -392,9 +395,38 @@
|
|||||||
"naiveproxy_dns_hint": "Create this DNS record before installation:",
|
"naiveproxy_dns_hint": "Create this DNS record before installation:",
|
||||||
"naiveproxy_ports_warning": "Warning: free TCP ports 80 and 443 are required for stable operation (Let\u0027s Encrypt on 80, HTTPS proxy on 443).",
|
"naiveproxy_ports_warning": "Warning: free TCP ports 80 and 443 are required for stable operation (Let\u0027s Encrypt on 80, HTTPS proxy on 443).",
|
||||||
"naiveproxy_client_hint": "Stable build. Do NOT use v2rayN. Confirmed working in Karing. Other clients are untested.",
|
"naiveproxy_client_hint": "Stable build. Do NOT use v2rayN. Confirmed working in Karing. Other clients are untested.",
|
||||||
|
"mieru_desc": "Mieru (mita v3.28.0) — native TCP proxy from enfein/mieru. No Docker required. Client: mieru app or Clash.Meta/mihomo.",
|
||||||
|
"mieru_version": "Version",
|
||||||
|
"mieru_port_hint": "TCP port for mita (1025–65535). Open it in the server firewall.",
|
||||||
|
"mieru_install_hint": "Installs the official mita package from GitHub release v3.28.0 (Debian/RPM). Requires Linux with systemd.",
|
||||||
|
"mieru_ports_warning": "Warning: ensure the chosen TCP port is free and allowed through the firewall.",
|
||||||
|
"mieru_client_hint": "Share links use mierus:// format. Import into the mieru client or Clash.Meta (type: mieru).",
|
||||||
"server_ssl_domain": "SSL domain (default)",
|
"server_ssl_domain": "SSL domain (default)",
|
||||||
"server_ssl_email": "SSL email (default)",
|
"server_ssl_email": "SSL email (default)",
|
||||||
"server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let\u0027s Encrypt).",
|
"server_ssl_hint": "Used when installing Hysteria / NGINX on this server (Let's Encrypt).",
|
||||||
|
"server_connect_domain": "Client connection domain",
|
||||||
|
"server_connect_domain_hint": "Used in VPN configs instead of IP (Endpoint, vless://, etc.). When moving the server, update the DNS A-record — clients keep working.",
|
||||||
|
"server_connect_domain_awg_hint": "Used in AmneziaWG / WireGuard Endpoint instead of server IP. Leave empty to use SSH host IP.",
|
||||||
|
"server_connect_domain_all": "All AWG / WireGuard (default)",
|
||||||
|
"server_connect_domain_current": "Endpoint in configs",
|
||||||
|
"server_connect_domain_ssh": "SSH",
|
||||||
|
"server_connect_domain_saved": "Connection domain saved",
|
||||||
|
"server_endpoint_label": "Endpoint",
|
||||||
|
"support_title": "Support the project",
|
||||||
|
"support_intro": "If this panel helps you, you can support development. Choose a convenient method below.",
|
||||||
|
"support_footer": "Thank you for using Amnezia Web Panel.",
|
||||||
|
"donate_sbp": "SBP (Fast Payments)",
|
||||||
|
"donate_card": "Bank card",
|
||||||
|
"donate_crypto": "Cryptocurrency",
|
||||||
|
"donate_details_soon": "Payment details will be added soon.",
|
||||||
|
"donate_settings_title": "Support / donations",
|
||||||
|
"donate_settings_hint": "Shown on the /support page for all users. No popups — only when they open the page.",
|
||||||
|
"donate_enabled": "Show support page in navigation",
|
||||||
|
"donate_intro": "Page intro text (optional)",
|
||||||
|
"donate_method_title": "Button title (optional)",
|
||||||
|
"donate_method_details": "Payment details",
|
||||||
|
"donate_details_placeholder": "Phone, card number, wallet address…",
|
||||||
|
"donate_preview_hint": "Preview:",
|
||||||
"container_logs": "Container logs",
|
"container_logs": "Container logs",
|
||||||
"logs_btn": "📋 Logs",
|
"logs_btn": "📋 Logs",
|
||||||
"logs_live": "Live",
|
"logs_live": "Live",
|
||||||
@@ -505,6 +537,8 @@
|
|||||||
"applying_update": "Updating…",
|
"applying_update": "Updating…",
|
||||||
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
|
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
|
||||||
"update_restarting": "Updated. Restarting 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.",
|
"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_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_ready": "Git auto-update is available for this install.",
|
||||||
|
|||||||
+14
-4
@@ -344,11 +344,13 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "پشتیبانگیری ساده",
|
"backup_title": "پشتیبانگیری ساده",
|
||||||
"download_backup": "دانلود data.json",
|
"download_backup": "دانلود dump PostgreSQL (.sql)",
|
||||||
"restore_backup": "بازیابی از فایل",
|
"download_backup_json": "خروجی JSON (قدیمی)",
|
||||||
"restore_confirm": "آیا مطمئن هستید؟ دادههای فعلی بازنویسی میشوند و برنامه دوباره راهاندازی خواهد شد.",
|
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخههای قدیمی.",
|
||||||
|
"restore_backup": "بازیابی از .sql یا .json",
|
||||||
|
"restore_confirm": "بازیابی تمام دادههای فعلی پنل در پایگاه داده را بازنویسی میکند.",
|
||||||
"restore_success": "بازیابی موفقیتآمیز بود! در حال راهاندازی مجدد...",
|
"restore_success": "بازیابی موفقیتآمیز بود! در حال راهاندازی مجدد...",
|
||||||
"invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است",
|
"invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)",
|
||||||
"config_unavailable": "پیکربندی در دسترس نیست",
|
"config_unavailable": "پیکربندی در دسترس نیست",
|
||||||
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره میشود و توسط سرور قابل بازیابی نیست.",
|
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره میشود و توسط سرور قابل بازیابی نیست.",
|
||||||
"client_public_key": "کلید عمومی کلاینت:",
|
"client_public_key": "کلید عمومی کلاینت:",
|
||||||
@@ -382,9 +384,17 @@
|
|||||||
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
|
"naiveproxy_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
|
||||||
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورتهای TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
|
"naiveproxy_ports_warning": "هشدار: برای کار پایدار پورتهای TCP آزاد ۸۰ و ۴۴۳ لازم است (Let\u0027s Encrypt روی ۸۰، پروکسی HTTPS روی ۴۴۳).",
|
||||||
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینتها آزمایش نشدهاند.",
|
"naiveproxy_client_hint": "نسخه پایدار. از v2rayN استفاده نکنید. در Karing تأیید شده. سایر کلاینتها آزمایش نشدهاند.",
|
||||||
|
"mieru_desc": "Mieru (mita v3.28.0) — پروکسی TCP بومی enfein/mieru. بدون Docker. کلاینت: mieru یا Clash.Meta/mihomo.",
|
||||||
|
"mieru_version": "نسخه",
|
||||||
|
"mieru_port_hint": "پورت TCP برای mita (۱۰۲۵–۶۵۵۳۵). در فایروال سرور باز کنید.",
|
||||||
|
"mieru_install_hint": "بسته رسمی mita را از GitHub v3.28.0 نصب میکند (Debian/RPM). لینوکس با systemd لازم است.",
|
||||||
|
"mieru_ports_warning": "هشدار: پورت TCP انتخابی باید آزاد و در فایروال مجاز باشد.",
|
||||||
|
"mieru_client_hint": "لینکها با فرمت mierus://. در کلاینت mieru یا Clash.Meta (type: mieru) وارد کنید.",
|
||||||
"server_ssl_domain": "دامنه SSL (پیشفرض)",
|
"server_ssl_domain": "دامنه SSL (پیشفرض)",
|
||||||
"server_ssl_email": "ایمیل SSL (پیشفرض)",
|
"server_ssl_email": "ایمیل SSL (پیشفرض)",
|
||||||
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده میشود (Let\u0027s Encrypt).",
|
"server_ssl_hint": "هنگام نصب Hysteria / NGINX روی این سرور استفاده میشود (Let\u0027s Encrypt).",
|
||||||
|
"server_connect_domain": "دامنه اتصال کلاینت",
|
||||||
|
"server_connect_domain_hint": "در پیکربندی VPN بهجای IP استفاده میشود. هنگام انتقال سرور، رکورد DNS A را بهروز کنید.",
|
||||||
"container_logs": "لاگهای کانتینر",
|
"container_logs": "لاگهای کانتینر",
|
||||||
"logs_btn": "📋 Logs",
|
"logs_btn": "📋 Logs",
|
||||||
"logs_live": "زنده",
|
"logs_live": "زنده",
|
||||||
|
|||||||
+14
-4
@@ -344,11 +344,13 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "Sauvegarde Simple",
|
"backup_title": "Sauvegarde Simple",
|
||||||
"download_backup": "Télécharger data.json",
|
"download_backup": "Télécharger le dump PostgreSQL (.sql)",
|
||||||
"restore_backup": "Restaurer depuis un fichier",
|
"download_backup_json": "Exporter JSON (ancien)",
|
||||||
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l\u0027application redémarrera.",
|
"backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.",
|
||||||
|
"restore_backup": "Restaurer depuis .sql ou .json",
|
||||||
|
"restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.",
|
||||||
"restore_success": "Restauration réussie ! Redémarrage...",
|
"restore_success": "Restauration réussie ! Redémarrage...",
|
||||||
"invalid_backup_file": "Fichier de sauvegarde ou structure invalide",
|
"invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)",
|
||||||
"config_unavailable": "Configuration indisponible",
|
"config_unavailable": "Configuration indisponible",
|
||||||
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
|
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
|
||||||
"client_public_key": "Clé publique du client :",
|
"client_public_key": "Clé publique du client :",
|
||||||
@@ -382,9 +384,17 @@
|
|||||||
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
|
"naiveproxy_dns_hint": "Créez cet enregistrement DNS avant l\u0027installation :",
|
||||||
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
|
"naiveproxy_ports_warning": "Attention : les ports TCP 80 et 443 doivent être libres pour un fonctionnement stable (Let\u0027s Encrypt sur 80, proxy HTTPS sur 443).",
|
||||||
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
|
"naiveproxy_client_hint": "Version stable. N\u0027utilisez PAS v2rayN. Fonctionne avec Karing. Autres clients non testés.",
|
||||||
|
"mieru_desc": "Mieru (mita v3.28.0) — proxy TCP natif enfein/mieru. Pas besoin de Docker. Client : mieru ou Clash.Meta/mihomo.",
|
||||||
|
"mieru_version": "Version",
|
||||||
|
"mieru_port_hint": "Port TCP pour mita (1025–65535). Ouvrez-le dans le pare-feu du serveur.",
|
||||||
|
"mieru_install_hint": "Installe le paquet officiel mita depuis GitHub v3.28.0 (Debian/RPM). Linux avec systemd requis.",
|
||||||
|
"mieru_ports_warning": "Attention : le port TCP choisi doit être libre et autorisé dans le pare-feu.",
|
||||||
|
"mieru_client_hint": "Liens au format mierus://. Importez dans le client mieru ou Clash.Meta (type: mieru).",
|
||||||
"server_ssl_domain": "Domaine SSL (par défaut)",
|
"server_ssl_domain": "Domaine SSL (par défaut)",
|
||||||
"server_ssl_email": "Email SSL (par défaut)",
|
"server_ssl_email": "Email SSL (par défaut)",
|
||||||
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
|
"server_ssl_hint": "Utilisé lors de l\u0027installation de Hysteria / NGINX sur ce serveur (Let\u0027s Encrypt).",
|
||||||
|
"server_connect_domain": "Domaine de connexion client",
|
||||||
|
"server_connect_domain_hint": "Utilisé dans les configs VPN à la place de l\u0027IP. Lors d\u0027un déplacement du serveur, mettez à jour l\u0027enregistrement DNS A.",
|
||||||
"container_logs": "Logs du conteneur",
|
"container_logs": "Logs du conteneur",
|
||||||
"logs_btn": "📋 Logs",
|
"logs_btn": "📋 Logs",
|
||||||
"logs_live": "Temps réel",
|
"logs_live": "Temps réel",
|
||||||
|
|||||||
+39
-5
@@ -4,6 +4,7 @@
|
|||||||
"nav_invites": "Ссылки",
|
"nav_invites": "Ссылки",
|
||||||
"nav_settings": "Настройки",
|
"nav_settings": "Настройки",
|
||||||
"nav_connections": "Подключения",
|
"nav_connections": "Подключения",
|
||||||
|
"nav_support": "Поддержать",
|
||||||
"nav_logout": "Выход",
|
"nav_logout": "Выход",
|
||||||
"theme_dark": "Темная",
|
"theme_dark": "Темная",
|
||||||
"theme_light": "Светлая",
|
"theme_light": "Светлая",
|
||||||
@@ -361,11 +362,13 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "Резервное копирование",
|
"backup_title": "Резервное копирование",
|
||||||
"download_backup": "Скачать data.json",
|
"download_backup": "Скачать дамп PostgreSQL (.sql)",
|
||||||
"restore_backup": "Восстановить из файла",
|
"download_backup_json": "Экспорт JSON (устар.)",
|
||||||
"restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.",
|
"backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.",
|
||||||
|
"restore_backup": "Восстановить из .sql или .json",
|
||||||
|
"restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.",
|
||||||
"restore_success": "Восстановление успешно! Перезагрузка...",
|
"restore_success": "Восстановление успешно! Перезагрузка...",
|
||||||
"invalid_backup_file": "Неверный файл резервной копии или структура",
|
"invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)",
|
||||||
"config_unavailable": "Конфигурация недоступна",
|
"config_unavailable": "Конфигурация недоступна",
|
||||||
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
|
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
|
||||||
"client_public_key": "Публичный ключ клиента:",
|
"client_public_key": "Публичный ключ клиента:",
|
||||||
@@ -392,9 +395,38 @@
|
|||||||
"naiveproxy_dns_hint": "Перед установкой создайте DNS-запись:",
|
"naiveproxy_dns_hint": "Перед установкой создайте DNS-запись:",
|
||||||
"naiveproxy_ports_warning": "Внимание: для стабильной работы нужны свободные TCP-порты 80 и 443 (Let\u0027s Encrypt на 80, HTTPS-прокси на 443).",
|
"naiveproxy_ports_warning": "Внимание: для стабильной работы нужны свободные TCP-порты 80 и 443 (Let\u0027s Encrypt на 80, HTTPS-прокси на 443).",
|
||||||
"naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.",
|
"naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.",
|
||||||
|
"mieru_desc": "Mieru (mita v3.28.0) — нативный TCP-прокси из enfein/mieru. Docker не нужен. Клиент: приложение mieru или Clash.Meta/mihomo.",
|
||||||
|
"mieru_version": "Версия",
|
||||||
|
"mieru_port_hint": "TCP-порт для mita (1025–65535). Откройте его в фаерволе сервера.",
|
||||||
|
"mieru_install_hint": "Устанавливает официальный пакет mita с GitHub релиза v3.28.0 (Debian/RPM). Нужен Linux со systemd.",
|
||||||
|
"mieru_ports_warning": "Внимание: выбранный TCP-порт должен быть свободен и разрешён в фаерволе.",
|
||||||
|
"mieru_client_hint": "Ссылки в формате mierus://. Импорт в клиент mieru или Clash.Meta (type: mieru).",
|
||||||
"server_ssl_domain": "SSL-домен (по умолчанию)",
|
"server_ssl_domain": "SSL-домен (по умолчанию)",
|
||||||
"server_ssl_email": "SSL-email (по умолчанию)",
|
"server_ssl_email": "SSL-email (по умолчанию)",
|
||||||
"server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let\u0027s Encrypt).",
|
"server_ssl_hint": "Подставляется при установке Hysteria / NGINX на этом сервере (Let's Encrypt).",
|
||||||
|
"server_connect_domain": "Домен для подключения клиентов",
|
||||||
|
"server_connect_domain_hint": "Подставляется в конфиги VPN вместо IP (Endpoint, vless:// и т.д.). При переносе сервера достаточно обновить A-запись DNS — клиенты продолжат работать.",
|
||||||
|
"server_connect_domain_awg_hint": "Подставляется в Endpoint конфигов AmneziaWG / WireGuard вместо IP сервера. Пусто — использовать IP из SSH.",
|
||||||
|
"server_connect_domain_all": "Все AWG / WireGuard (по умолчанию)",
|
||||||
|
"server_connect_domain_current": "Endpoint в конфигах",
|
||||||
|
"server_connect_domain_ssh": "SSH",
|
||||||
|
"server_connect_domain_saved": "Домен подключения сохранён",
|
||||||
|
"server_endpoint_label": "Endpoint",
|
||||||
|
"support_title": "Поддержать проект",
|
||||||
|
"support_intro": "Если панель вам помогает, можно поддержать разработку. Выберите удобный способ ниже.",
|
||||||
|
"support_footer": "Спасибо, что пользуетесь Amnezia Web Panel.",
|
||||||
|
"donate_sbp": "СБП",
|
||||||
|
"donate_card": "Банковская карта",
|
||||||
|
"donate_crypto": "Криптовалюта",
|
||||||
|
"donate_details_soon": "Реквизиты будут добавлены позже.",
|
||||||
|
"donate_settings_title": "Поддержка / донат",
|
||||||
|
"donate_settings_hint": "Страница /support для всех пользователей. Без всплывающих окон — только по ссылке в меню.",
|
||||||
|
"donate_enabled": "Показывать страницу поддержки в меню",
|
||||||
|
"donate_intro": "Текст в начале страницы (необязательно)",
|
||||||
|
"donate_method_title": "Название кнопки (необязательно)",
|
||||||
|
"donate_method_details": "Реквизиты",
|
||||||
|
"donate_details_placeholder": "Телефон, номер карты, адрес кошелька…",
|
||||||
|
"donate_preview_hint": "Предпросмотр:",
|
||||||
"container_logs": "Логи контейнера",
|
"container_logs": "Логи контейнера",
|
||||||
"logs_btn": "📋 Логи",
|
"logs_btn": "📋 Логи",
|
||||||
"logs_live": "В реальном времени",
|
"logs_live": "В реальном времени",
|
||||||
@@ -505,6 +537,8 @@
|
|||||||
"applying_update": "Обновление…",
|
"applying_update": "Обновление…",
|
||||||
"apply_update_confirm": "Установить обновление с git.evilfox.cc и перезапустить панель?",
|
"apply_update_confirm": "Установить обновление с git.evilfox.cc и перезапустить панель?",
|
||||||
"update_restarting": "Обновлено. Перезапуск панели…",
|
"update_restarting": "Обновлено. Перезапуск панели…",
|
||||||
|
"update_waiting_restart": "Ожидание перезапуска панели…",
|
||||||
|
"update_restart_timeout": "Панель не ответила вовремя. Обновите страницу вручную.",
|
||||||
"update_no_target": "Нет целевой версии. Сначала проверьте обновления.",
|
"update_no_target": "Нет целевой версии. Сначала проверьте обновления.",
|
||||||
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
|
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
|
||||||
"auto_update_ready": "Для этой установки доступно автообновление через git.",
|
"auto_update_ready": "Для этой установки доступно автообновление через git.",
|
||||||
|
|||||||
+14
-4
@@ -344,11 +344,13 @@
|
|||||||
"lang_zh": "中文 (Chinese)",
|
"lang_zh": "中文 (Chinese)",
|
||||||
"lang_fa": "فارسی (Persian)",
|
"lang_fa": "فارسی (Persian)",
|
||||||
"backup_title": "简易备份",
|
"backup_title": "简易备份",
|
||||||
"download_backup": "下载 data.json",
|
"download_backup": "下载 PostgreSQL 转储 (.sql)",
|
||||||
"restore_backup": "从文件恢复",
|
"download_backup_json": "导出 JSON(旧版)",
|
||||||
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。",
|
"backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。",
|
||||||
|
"restore_backup": "从 .sql 或 .json 恢复",
|
||||||
|
"restore_confirm": "恢复将覆盖数据库中所有当前面板数据。",
|
||||||
"restore_success": "恢复成功!正在重启...",
|
"restore_success": "恢复成功!正在重启...",
|
||||||
"invalid_backup_file": "备份文件无效或结构错误",
|
"invalid_backup_file": "无效的备份文件(.sql 转储或旧版 data.json)",
|
||||||
"config_unavailable": "配置文件不可用",
|
"config_unavailable": "配置文件不可用",
|
||||||
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
|
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
|
||||||
"client_public_key": "客户端公钥:",
|
"client_public_key": "客户端公钥:",
|
||||||
@@ -382,9 +384,17 @@
|
|||||||
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
|
"naiveproxy_dns_hint": "安装前请创建此 DNS 记录:",
|
||||||
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443(Let\u0027s Encrypt 使用 80,HTTPS 代理使用 443)。",
|
"naiveproxy_ports_warning": "注意:稳定运行需要空闲的 TCP 端口 80 和 443(Let\u0027s Encrypt 使用 80,HTTPS 代理使用 443)。",
|
||||||
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
|
"naiveproxy_client_hint": "稳定版。请勿使用 v2rayN。已确认 Karing 可用。其他客户端未测试。",
|
||||||
|
"mieru_desc": "Mieru(mita v3.28.0)— enfein/mieru 原生 TCP 代理,无需 Docker。客户端:mieru 或 Clash.Meta/mihomo。",
|
||||||
|
"mieru_version": "版本",
|
||||||
|
"mieru_port_hint": "mita 的 TCP 端口(1025–65535)。请在服务器防火墙中放行。",
|
||||||
|
"mieru_install_hint": "从 GitHub v3.28.0 安装官方 mita 包(Debian/RPM)。需要带 systemd 的 Linux。",
|
||||||
|
"mieru_ports_warning": "注意:所选 TCP 端口必须空闲,并在防火墙中允许。",
|
||||||
|
"mieru_client_hint": "分享链接为 mierus:// 格式。可导入 mieru 客户端或 Clash.Meta(type: mieru)。",
|
||||||
"server_ssl_domain": "SSL 域名(默认)",
|
"server_ssl_domain": "SSL 域名(默认)",
|
||||||
"server_ssl_email": "SSL 邮箱(默认)",
|
"server_ssl_email": "SSL 邮箱(默认)",
|
||||||
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
|
"server_ssl_hint": "安装 Hysteria / NGINX 时自动填入(Let\u0027s Encrypt)。",
|
||||||
|
"server_connect_domain": "客户端连接域名",
|
||||||
|
"server_connect_domain_hint": "在 VPN 配置中替代 IP 使用。迁移服务器时只需更新 DNS A 记录。",
|
||||||
"container_logs": "容器日志",
|
"container_logs": "容器日志",
|
||||||
"logs_btn": "📋 日志",
|
"logs_btn": "📋 日志",
|
||||||
"logs_live": "实时",
|
"logs_live": "实时",
|
||||||
|
|||||||
Reference in New Issue
Block a user