Compare commits

...
7 Commits
14 changed files with 878 additions and 167 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+6 -3
View File
@@ -5,6 +5,9 @@ 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
@@ -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", "*"]
+52 -2
View File
@@ -188,22 +188,48 @@ 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
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 → **Domains**:
- **Service**: `amnezia_panel` (not `db`)
- **Container port**: `5000`
- **Path**: `/`
3. Set env vars in Dokploy: `SECRET_KEY` (long random string), strong `POSTGRES_PASSWORD`.
4. Redeploy after changing domains or env.
If the domain shows **404 page not found** (plain text, Go-style), Traefik has no route to a healthy backend. After redeploying **v2.5.8+**, on the server run:
```bash
# 1) Panel must answer HTTP inside the container
docker compose -p home-vpn-g6rfpd exec amnezia_panel curl -fsS http://127.0.0.1:5000/health
# 2) Panel container must be on dokploy-network (required for Traefik)
docker inspect "$(docker compose -p home-vpn-g6rfpd ps -q amnezia_panel)" \
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
```
You should see `dokploy-network` in the network list. If it is missing, Dokploy cannot route the domain — redeploy with the latest `docker-compose.yml` or run `docker network connect dokploy-network <container>` and redeploy Traefik.
| 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,6 +254,30 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### v2.5.10
* **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy.
### v2.5.9
* **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4).
### v2.5.8
* **Docker/Dokploy: panel actually serves HTTP** — entrypoint waits for Postgres, runs `uvicorn` with proxy headers; SSL inside the container is forced off when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY` is set (fixes empty replies on port 5000 behind Traefik).
### v2.5.7
* **Dokploy / Traefik 404 fix** — `docker-compose.yml` joins `dokploy-network`, Traefik labels point to port **5000**; removed fixed `container_name`; README Dokploy domain setup.
### v2.5.6
* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link.
### v2.5.5
* **Fix 404 after panel update** — correct tunnel/local port when `APP_PORT` is set (Docker), health check before reload, auto-restart quick tunnels after reboot, reliable archive download URLs.
### v2.5.3
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
### v2.5.2
* **Tunnels UI redesign** — Settings → Tunnels: grouped layout (public access / outbound VPN), provider cards, status pills, responsive grid.
### v2.5.1 ### v2.5.1
* **One-click panel update** — Settings → About → **Update panel**: downloads release ZIP from Gitea (or `git checkout` when `.git` exists), runs `pip install`, restarts. * **One-click panel update** — Settings → About → **Update panel**: downloads release ZIP from Gitea (or `git checkout` when `.git` exists), runs `pip install`, restarts.
+128 -21
View File
@@ -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.1" CURRENT_VERSION = "v2.5.10"
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'))
@@ -224,12 +225,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 +277,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>Если вы только что обновили панель, подождите 1030 секунд и откройте:</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:
@@ -2254,6 +2339,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 = []
@@ -2457,7 +2560,13 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/') return RedirectResponse(url='/')
server = data['servers'][server_id] server = data['servers'][server_id]
users_list = data.get('users', []) users_list = data.get('users', [])
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list) return tpl(
request,
'server.html',
server=server,
server_id=server_id,
users=users_list,
)
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"]) @app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
@@ -5617,11 +5726,11 @@ 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('/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', {})
@@ -5725,8 +5834,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"])
@@ -5763,10 +5872,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"])
@@ -6331,17 +6440,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
+21 -6
View File
@@ -1,16 +1,15 @@
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:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
interval: 10s interval: 10s
@@ -23,7 +22,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 +31,32 @@ 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
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:
+68 -10
View File
@@ -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
@@ -105,7 +106,10 @@ class UpdateManager:
return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1' return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _docker_update_allowed(self) -> bool: def _docker_update_allowed(self) -> bool:
return os.environ.get('PANEL_UPDATE_ALLOW_DOCKER', '').strip().lower() in ('1', 'true', 'yes') if os.environ.get('PANEL_UPDATE_DISABLE_DOCKER', '').strip().lower() in ('1', 'true', 'yes'):
return False
# In-container archive updates are allowed by default when /app is writable.
return True
def _app_root_writable(self) -> bool: def _app_root_writable(self) -> bool:
probe = os.path.join(self.app_root, '.panel_update_write_probe') probe = os.path.join(self.app_root, '.panel_update_write_probe')
@@ -275,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'
@@ -308,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]
@@ -333,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'}
@@ -380,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()
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -e
PORT="${PORT:-${APP_PORT:-5000}}"
echo "Waiting for PostgreSQL at ${DATABASE_URL%%@*}@…"
python3 - <<'PY'
import os
import sys
import time
url = os.environ.get("DATABASE_URL", "").strip()
if not url:
print("DATABASE_URL is not set", file=sys.stderr)
sys.exit(1)
import psycopg
deadline = time.time() + 120
last_err = None
while time.time() < deadline:
try:
with psycopg.connect(url, connect_timeout=5):
break
except Exception as exc:
last_err = exc
time.sleep(2)
else:
print(f"PostgreSQL not ready: {last_err}", file=sys.stderr)
sys.exit(1)
PY
echo "Starting Amnezia Web Panel on 0.0.0.0:${PORT}"
exec uvicorn app:app \
--host 0.0.0.0 \
--port "${PORT}" \
--proxy-headers \
--forwarded-allow-ips='*'
+314
View File
@@ -1282,6 +1282,7 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1623,6 +1624,319 @@ a:hover {
gap: var(--space-md); gap: var(--space-md);
} }
/* ===== Tunnels (Settings) ===== */
.tunnels-card .tunnels-header {
margin-bottom: var(--space-lg);
}
.tunnels-card .tunnels-header .card-title {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.tunnels-card .tunnels-subtitle {
margin: var(--space-xs) 0 0;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.45;
}
.tunnels-local {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-lg);
border-radius: var(--radius-md);
border: 1px solid var(--border-focus);
background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.06));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.tunnels-local-icon {
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: var(--accent-gradient);
font-size: 1.25rem;
box-shadow: var(--shadow-glow);
}
.tunnels-local-body {
flex: 1;
min-width: 0;
}
.tunnels-local-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.tunnels-local-url {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.tunnels-local-url code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
color: var(--text-primary);
background: transparent;
padding: 0;
}
.tunnels-section {
margin-bottom: var(--space-lg);
}
.tunnels-section:last-of-type {
margin-bottom: var(--space-md);
}
.tunnels-section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 var(--space-sm) var(--space-xs);
}
.tunnels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
}
.tunnel-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
.tunnel-card:hover {
border-color: var(--border-hover);
}
.tunnel-card--running {
border-color: var(--success-border);
box-shadow: 0 0 0 1px var(--success-border), inset 0 1px 0 rgba(34, 197, 94, 0.06);
}
.tunnel-card--connected {
border-color: var(--success-border);
box-shadow: 0 0 0 1px var(--success-border);
}
.tunnel-card-head {
display: flex;
align-items: flex-start;
gap: var(--space-md);
}
.tunnel-card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 1.1rem;
font-weight: 700;
color: #fff;
}
.tunnel-card-icon--cloudflare { background: linear-gradient(135deg, #f38020, #faae40); }
.tunnel-card-icon--ngrok { background: linear-gradient(135deg, #1f2d3d, #3d5a80); }
.tunnel-card-icon--warp { background: linear-gradient(135deg, #f38020, #e85d04); }
.tunnel-card-icon--nordvpn { background: linear-gradient(135deg, #4687ff, #2b4eff); }
.tunnel-card-icon--local { background: var(--accent-gradient); }
.tunnel-card-meta {
flex: 1;
min-width: 0;
}
.tunnel-card-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.tunnel-card-desc {
font-size: 0.78rem;
color: var(--text-muted);
margin: var(--space-xs) 0 0;
line-height: 1.4;
}
.tunnel-status-pill {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--radius-full);
white-space: nowrap;
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.tunnel-status-pill--ok {
background: var(--success-bg);
color: var(--success);
border-color: var(--success-border);
}
.tunnel-status-pill--info {
background: var(--info-bg);
color: var(--info);
border-color: rgba(59, 130, 246, 0.25);
}
.tunnel-card-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tunnel-url-box {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.45;
min-height: 2.5rem;
}
.tunnel-url-box--empty {
font-style: italic;
color: var(--text-muted);
}
.tunnel-url-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.tunnel-url-row:first-child {
margin-top: 0;
}
.tunnel-url-row code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
color: var(--text-accent);
background: transparent;
padding: 0;
}
.tunnel-status-line {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.45;
}
.tunnel-status-line--server {
font-family: ui-monospace, monospace;
font-size: 0.78rem;
color: var(--text-accent);
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.tunnel-hint {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
margin: 0;
}
.tunnel-card-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: auto;
}
.tunnel-card-actions .btn {
flex: 1;
min-width: 7rem;
justify-content: center;
}
.tunnel-card-actions--triple .btn {
min-width: 5.5rem;
}
.tunnel-nordvpn-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
}
.tunnels-footnote {
margin: var(--space-md) 0 0;
padding: var(--space-md);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border-left: 3px solid var(--accent);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 640px) {
.tunnels-local {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.tunnels-local-url {
flex-direction: column;
}
.tunnels-grid {
grid-template-columns: 1fr;
}
.tunnel-nordvpn-fields {
grid-template-columns: 1fr;
}
}
.mt-md { .mt-md {
margin-top: var(--space-md); margin-top: var(--space-md);
} }
+208 -122
View File
@@ -204,112 +204,147 @@
</div> </div>
<!-- BLOCK Tunnels --> <!-- BLOCK Tunnels -->
<div class="card"> <div class="card tunnels-card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3> <div class="tunnels-header">
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </div>
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span> <div class="tunnels-local">
</div> <div class="tunnels-local-icon" aria-hidden="true">🏠</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;"> <div class="tunnels-local-body">
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code> <div class="tunnels-local-label">{{ _('local_server') }}</div>
<div class="tunnels-local-url">
<code id="localServerUrl">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div> </div>
</div> </div>
<span class="tunnel-status-pill tunnel-status-pill--ok">{{ _('active') }}</span>
</div>
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <div class="tunnels-section">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
<strong>Cloudflare Quick Tunnel</strong> <div class="tunnels-grid">
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span> <article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
</div> <div class="tunnel-card-head">
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> <div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true"></div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-meta">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')"> <h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')"> </div>
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span> <div class="tunnel-card-body">
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div> <div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</button> </div>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')"> <div class="tunnel-card-actions tunnel-card-actions--triple">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span> <button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div> <span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
</button> <div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</div> </button>
</div> <button type="button" class="btn btn-secondary btn-sm hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <div class="tunnel-card-head">
<strong>ngrok Tunnel + Authtoken</strong> <div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span> <div class="tunnel-card-meta">
</div> <h5 class="tunnel-card-title">ngrok</h5>
<div class="form-group" style="margin-bottom: var(--space-sm);"> <p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off"> </div>
</div> <span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> </div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-body">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')"> <input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')"> <button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span> <span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')"> <button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span> <span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button> </button>
</div> <button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
</div> <span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> </div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </article>
<strong>Cloudflare WARP</strong>
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="nordvpn" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>NordVPN</strong>
<span class="badge badge-secondary" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="nordvpnStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_status_unknown') }}</div>
<div id="nordvpnServerText" class="form-hint hidden" style="margin-bottom: var(--space-sm);"></div>
<div id="nordvpnHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_hint') }}</div>
<div class="grid" style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div> </div>
</div> </div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
<div class="tunnels-section">
<h4 class="tunnels-section-title">{{ _('tunnels_section_outbound') }}</h4>
<div class="tunnels-grid">
<article class="tunnel-card tunnel-row" data-provider="warp" id="tunnelCard-warp">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--warp" aria-hidden="true">W</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">Cloudflare WARP</h5>
<p class="tunnel-card-desc">{{ _('tunnel_warp_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<p id="warpStatusText" class="tunnel-status-line">{{ _('warp_status_unknown') }}</p>
<p id="warpHintText" class="tunnel-hint">{{ _('warp_hint') }}</p>
</div>
<div class="tunnel-card-actions">
<button type="button" class="btn btn-primary btn-sm" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
<article class="tunnel-card tunnel-row" data-provider="nordvpn" id="tunnelCard-nordvpn">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--nordvpn" aria-hidden="true">N</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">NordVPN</h5>
<p class="tunnel-card-desc">{{ _('tunnel_nordvpn_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<p id="nordvpnStatusText" class="tunnel-status-line">{{ _('nordvpn_status_unknown') }}</p>
<p id="nordvpnServerText" class="tunnel-status-line tunnel-status-line--server hidden"></p>
<p id="nordvpnHintText" class="tunnel-hint">{{ _('nordvpn_hint') }}</p>
<div class="tunnel-nordvpn-fields">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
</div>
<div class="tunnel-card-actions">
<button type="button" class="btn btn-primary btn-sm" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
</div>
</div>
<p class="tunnels-footnote">{{ _('tunnels_hint') }}</p>
</div> </div>
<!-- BLOCK SSL/HTTPS --> <!-- BLOCK SSL/HTTPS -->
@@ -949,31 +984,45 @@
const el = document.getElementById(`${provider}PublicUrls`); const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return; if (!el) return;
if (!urls || !urls.length) { if (!urls || !urls.length) {
el.className = 'tunnel-url-box tunnel-url-box--empty';
el.textContent = _('tunnel_no_public_url'); el.textContent = _('tunnel_no_public_url');
return; return;
} }
el.className = 'tunnel-url-box';
el.innerHTML = urls.map((url, idx) => ` el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};"> <div class="tunnel-url-row">
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code> <code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div> </div>
`).join(''); `).join('');
} }
function setTunnelStatusPill(badge, { ok, info, text }) {
if (!badge) return;
badge.className = 'tunnel-status-pill' + (ok ? ' tunnel-status-pill--ok' : (info ? ' tunnel-status-pill--info' : ''));
badge.textContent = text;
}
function updateTunnelProvider(provider, status) { function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`); const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`); const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`); const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`); const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`); const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
const card = document.getElementById(`tunnelCard-${provider}`);
if (!badge || !text) return; if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary'; const running = !!status.running;
badge.textContent = status.installed ? _('installed') : _('not_installed'); setTunnelStatusPill(badge, {
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable')); ok: running,
if (startBtn) startBtn.disabled = !!status.running; info: !running && status.installed,
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running); text: running ? _('tunnel_running') : (status.installed ? _('installed') : _('not_installed')),
});
text.textContent = running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = running;
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed); if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
if (card) card.classList.toggle('tunnel-card--running', running);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : [])); renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
} }
@@ -997,12 +1046,16 @@
const connectBtn = document.getElementById('warpConnectBtn'); const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn'); const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText'); const connectText = document.getElementById('warpConnectBtnText');
const card = document.getElementById('tunnelCard-warp');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return; if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed; const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected'; const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary'); setTunnelStatusPill(badge, {
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')); ok: connected,
info: !connected && installed,
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `warp_status_${status.status || 'unknown'}`; const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown'); statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
@@ -1010,6 +1063,7 @@
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required'); connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected; connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected); disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
} }
async function connectWarp() { async function connectWarp() {
@@ -1062,12 +1116,16 @@
const connectBtn = document.getElementById('nordvpnConnectBtn'); const connectBtn = document.getElementById('nordvpnConnectBtn');
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn'); const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
const connectText = document.getElementById('nordvpnConnectBtnText'); const connectText = document.getElementById('nordvpnConnectBtnText');
const card = document.getElementById('tunnelCard-nordvpn');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return; if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed; const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected'; const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary'); setTunnelStatusPill(badge, {
badge.textContent = connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')); ok: connected,
info: !connected && installed,
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `nordvpn_status_${status.status || 'unknown'}`; const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown'); statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
@@ -1080,6 +1138,7 @@
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required'); connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
connectBtn.disabled = connected; connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected); disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
} }
async function connectNordvpn() { async function connectNordvpn() {
@@ -1634,7 +1693,7 @@
const upgradeBtn = document.getElementById('upgradePanelBtn'); const upgradeBtn = document.getElementById('upgradePanelBtn');
if (upgradeBtn) { if (upgradeBtn) {
upgradeBtn.disabled = !data.can_auto_update; upgradeBtn.disabled = false;
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : ''); upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
} }
@@ -1643,9 +1702,7 @@
statusDiv.style.border = '1px solid var(--success)'; statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases'; downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
downloadBtn.classList.remove('hidden'); downloadBtn.classList.remove('hidden');
if (data.can_auto_update) { applyBtn.classList.remove('hidden');
applyBtn.classList.remove('hidden');
}
} else { } else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`; statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)'; statusDiv.style.border = '1px solid var(--border-color)';
@@ -1661,6 +1718,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');
@@ -1676,18 +1751,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');
}
} }
} }
@@ -1704,6 +1785,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) {
@@ -1712,14 +1794,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') }}";
}
} }
} }
+10 -1
View File
@@ -267,6 +267,13 @@
"api_docs_title": "🔌 API Documentation", "api_docs_title": "🔌 API Documentation",
"api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities", "api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities",
"tunnels_title": "Tunnels", "tunnels_title": "Tunnels",
"tunnels_subtitle": "Expose the panel to the internet or route outbound traffic through a VPN.",
"tunnels_section_inbound": "Public access",
"tunnels_section_outbound": "Outbound VPN",
"tunnel_cloudflare_desc": "Free HTTPS URL via Cloudflare — no account required.",
"tunnel_ngrok_desc": "Stable tunnel with your ngrok authtoken.",
"tunnel_warp_desc": "Route server traffic through Cloudflare WARP.",
"tunnel_nordvpn_desc": "Connect this host to NordVPN servers.",
"local_server": "Local Server", "local_server": "Local Server",
"tunnel_install_enable": "Install \u0026 Enable", "tunnel_install_enable": "Install \u0026 Enable",
"tunnel_enable": "Enable Tunnel", "tunnel_enable": "Enable Tunnel",
@@ -485,10 +492,12 @@
"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.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.", "auto_update_docker": "Docker: one-click update downloads the release ZIP into the container. Rebuild the image to persist across container recreation.",
"auto_update_manual": "One-click update needs a writable install directory or git clone. You can still download a release.", "auto_update_manual": "One-click update needs a writable install directory or git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.", "auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel", "upgrade_panel": "Update panel",
+8 -1
View File
@@ -256,7 +256,14 @@
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.", "bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.",
"api_docs_title": "🔌 مستندات API", "api_docs_title": "🔌 مستندات API",
"api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید", "api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید",
"tunnels_title": "Tunnels", "tunnels_title": "تونل‌ها",
"tunnels_subtitle": "پنل را در اینترنت منتشر کنید یا ترافیک خروجی را از طریق VPN هدایت کنید.",
"tunnels_section_inbound": "دسترسی عمومی",
"tunnels_section_outbound": "VPN خروجی",
"tunnel_cloudflare_desc": "آدرس HTTPS رایگان از Cloudflare — بدون ثبت‌نام.",
"tunnel_ngrok_desc": "تونل پایدار با authtoken ngrok شما.",
"tunnel_warp_desc": "هدایت ترافیک سرور از طریق Cloudflare WARP.",
"tunnel_nordvpn_desc": "اتصال این میزبان به سرورهای NordVPN.",
"local_server": "سرور محلی", "local_server": "سرور محلی",
"tunnel_install_enable": "نصب و فعال‌سازی", "tunnel_install_enable": "نصب و فعال‌سازی",
"tunnel_enable": "فعال‌سازی تونل", "tunnel_enable": "فعال‌سازی تونل",
+7
View File
@@ -257,6 +257,13 @@
"api_docs_title": "🔌 Documentation API", "api_docs_title": "🔌 Documentation API",
"api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API", "api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API",
"tunnels_title": "Tunnels", "tunnels_title": "Tunnels",
"tunnels_subtitle": "Exposez le panneau sur Internet ou routez le trafic sortant via un VPN.",
"tunnels_section_inbound": "Accès public",
"tunnels_section_outbound": "VPN sortant",
"tunnel_cloudflare_desc": "URL HTTPS gratuite via Cloudflare — sans compte.",
"tunnel_ngrok_desc": "Tunnel stable avec votre authtoken ngrok.",
"tunnel_warp_desc": "Routez le trafic du serveur via Cloudflare WARP.",
"tunnel_nordvpn_desc": "Connectez cet hôte aux serveurs NordVPN.",
"local_server": "Serveur local", "local_server": "Serveur local",
"tunnel_install_enable": "Installer et activer", "tunnel_install_enable": "Installer et activer",
"tunnel_enable": "Activer le tunnel", "tunnel_enable": "Activer le tunnel",
+10 -1
View File
@@ -267,6 +267,13 @@
"api_docs_title": "🔌 API Документация", "api_docs_title": "🔌 API Документация",
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели", "api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
"tunnels_title": "Туннели", "tunnels_title": "Туннели",
"tunnels_subtitle": "Публикация панели в интернете или исходящий трафик через VPN.",
"tunnels_section_inbound": "Публичный доступ",
"tunnels_section_outbound": "Исходящий VPN",
"tunnel_cloudflare_desc": "Бесплатный HTTPS-адрес через Cloudflare — без регистрации.",
"tunnel_ngrok_desc": "Стабильный туннель с вашим authtoken ngrok.",
"tunnel_warp_desc": "Маршрутизация трафика сервера через Cloudflare WARP.",
"tunnel_nordvpn_desc": "Подключение хоста к серверам NordVPN.",
"local_server": "Локальный сервер", "local_server": "Локальный сервер",
"tunnel_install_enable": "Установить и включить", "tunnel_install_enable": "Установить и включить",
"tunnel_enable": "Включить туннель", "tunnel_enable": "Включить туннель",
@@ -485,10 +492,12 @@
"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.",
"auto_update_docker": "Docker: пересоберите/подтяните образ или используйте git clone для обновления в один клик.", "auto_update_docker": "Docker: обновление в один клик скачивает ZIP релиза в контейнер. Пересоберите образ, чтобы сохранить изменения при пересоздании контейнера.",
"auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.", "auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.",
"auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.", "auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.",
"upgrade_panel": "Обновить панель", "upgrade_panel": "Обновить панель",
+7
View File
@@ -257,6 +257,13 @@
"api_docs_title": "🔌 API 文档", "api_docs_title": "🔌 API 文档",
"api_docs_hint": "使用这些接口了解面板功能", "api_docs_hint": "使用这些接口了解面板功能",
"tunnels_title": "Tunnels", "tunnels_title": "Tunnels",
"tunnels_subtitle": "将面板暴露到互联网,或通过 VPN 路由出站流量。",
"tunnels_section_inbound": "公网访问",
"tunnels_section_outbound": "出站 VPN",
"tunnel_cloudflare_desc": "通过 Cloudflare 获取免费 HTTPS 地址,无需注册。",
"tunnel_ngrok_desc": "使用 ngrok authtoken 的稳定隧道。",
"tunnel_warp_desc": "通过 Cloudflare WARP 路由服务器流量。",
"tunnel_nordvpn_desc": "将此主机连接到 NordVPN 服务器。",
"local_server": "本地服务器", "local_server": "本地服务器",
"tunnel_install_enable": "安装并启用", "tunnel_install_enable": "安装并启用",
"tunnel_enable": "启用隧道", "tunnel_enable": "启用隧道",