Compare commits

...
8 Commits
17 changed files with 762 additions and 115 deletions
+10 -3
View File
@@ -2,11 +2,18 @@
APP_PORT=5000 APP_PORT=5000
SECRET_KEY=change-me-to-a-long-random-string SECRET_KEY=change-me-to-a-long-random-string
# PostgreSQL (used by docker-compose) # PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
POSTGRES_USER=amnezia POSTGRES_USER=amnezia
POSTGRES_PASSWORD=amnezia POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=amnezia_panel POSTGRES_DB=amnezia_panel
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
# Full DSN (override if panel runs outside compose) # Full DSN (override if panel runs outside compose)
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel # DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
# Dokploy / reverse proxy (optional — set in compose for Docker)
# PANEL_IN_DOCKER=1
# PANEL_BEHIND_PROXY=1
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+6 -4
View File
@@ -5,13 +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_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 .
@@ -21,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}/health" >/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", "*"]
+79 -5
View File
@@ -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,15 +277,40 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### 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 ### v2.5.6
* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link. * **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link.
### v2.5.5 ### 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. * **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.4
* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links).
### 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.
+221 -64
View File
@@ -103,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.6" 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'))
@@ -111,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,
@@ -194,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:
@@ -226,12 +259,13 @@ def tpl(request, template, **kwargs):
def get_panel_listen_port(data: Optional[dict] = None) -> int: def get_panel_listen_port(data: Optional[dict] = None) -> int:
try: for env_key in ('APP_PORT', 'PORT'):
env_port = int(os.environ.get('APP_PORT', '0') or '0') try:
except ValueError: env_port = int(os.environ.get(env_key, '0') or '0')
env_port = 0 except ValueError:
if env_port: env_port = 0
return env_port if env_port:
return env_port
if data is None: if data is None:
data = load_data() data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {} ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
@@ -242,14 +276,16 @@ def get_panel_listen_port(data: Optional[dict] = None) -> int:
def panel_ssl_active(data: Optional[dict] = None) -> bool: 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: if data is None:
data = load_data() data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {} ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
if not ssl_conf.get('enabled'): if not ssl_conf.get('enabled'):
return False return False
# Docker/compose usually terminates TLS outside the app and serves plain HTTP inside.
if os.environ.get('APP_PORT'):
return False
cert_path = ssl_conf.get('cert_path') cert_path = ssl_conf.get('cert_path')
key_path = ssl_conf.get('key_path') key_path = ssl_conf.get('key_path')
if ssl_conf.get('cert_text') or ssl_conf.get('key_text'): if ssl_conf.get('cert_text') or ssl_conf.get('key_text'):
@@ -1424,17 +1460,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(
@@ -1639,9 +1677,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 = {
@@ -2087,6 +2125,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):
@@ -2101,6 +2140,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):
@@ -2191,6 +2236,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
@@ -2917,10 +2968,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)
@@ -2986,7 +3040,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()
@@ -3000,6 +3054,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}
@@ -3008,6 +3068,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,
@@ -3942,7 +4071,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}')
@@ -4426,7 +4555,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,
@@ -4435,9 +4564,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'):
@@ -4560,7 +4689,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}
@@ -4743,7 +4872,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,
@@ -4752,7 +4881,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'):
@@ -4960,12 +5089,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,
@@ -4974,11 +5103,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)
@@ -5169,7 +5298,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')}
@@ -5329,7 +5458,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')}
@@ -5393,11 +5522,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)
@@ -5555,11 +5684,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)
@@ -5930,7 +6059,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
@@ -6582,6 +6711,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)
@@ -6602,32 +6749,44 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
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)
@@ -6657,15 +6816,13 @@ 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 get_panel_listen_port(data), "port": port,
} }
logger.info(f"Amnezia Web Panel {CURRENT_VERSION} listening on http://0.0.0.0:{port}")
if panel_ssl_active(data) 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):
+8
View File
@@ -1,28 +1,36 @@
"""Database package for Amnezia Web Panel (PostgreSQL 17).""" """Database package for Amnezia Web Panel (PostgreSQL 17)."""
from .backup import backup_filename, export_database_sql, restore_database_sql
from .connection import close_pool, get_database_url, init_schema from .connection import close_pool, get_database_url, init_schema
from .store import ( from .store import (
clear_tunnel_state, clear_tunnel_state,
ensure_db_ready, ensure_db_ready,
export_data_dict, export_data_dict,
import_from_json_file, import_from_json_file,
invalidate_data_cache,
load_data, load_data,
load_tunnel_state, load_tunnel_state,
normalize_import_data,
save_data, save_data,
save_tunnel_state, save_tunnel_state,
update_tunnel_state, update_tunnel_state,
) )
__all__ = [ __all__ = [
'backup_filename',
'clear_tunnel_state', 'clear_tunnel_state',
'close_pool', 'close_pool',
'ensure_db_ready', 'ensure_db_ready',
'export_data_dict', 'export_data_dict',
'export_database_sql',
'get_database_url', 'get_database_url',
'import_from_json_file', 'import_from_json_file',
'init_schema', 'init_schema',
'invalidate_data_cache',
'load_data', 'load_data',
'load_tunnel_state', 'load_tunnel_state',
'normalize_import_data',
'restore_database_sql',
'save_data', 'save_data',
'save_tunnel_state', 'save_tunnel_state',
'update_tunnel_state', 'update_tunnel_state',
+59
View File
@@ -0,0 +1,59 @@
"""PostgreSQL backup/restore for the panel database."""
from __future__ import annotations
import logging
import subprocess
from datetime import datetime, timezone
from .connection import get_database_url
from .store import invalidate_data_cache
logger = logging.getLogger(__name__)
def backup_filename() -> str:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
return f'amnezia_panel_backup_{stamp}.sql'
def export_database_sql() -> bytes:
"""Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url()
proc = subprocess.run(
[
'pg_dump',
'--dbname', url,
'--no-owner',
'--no-acl',
'--clean',
'--if-exists',
],
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or 'pg_dump failed')
if not proc.stdout:
raise RuntimeError('pg_dump returned empty dump')
return proc.stdout
def restore_database_sql(data: bytes) -> None:
"""Restore panel data from a plain SQL dump produced by pg_dump."""
if not data or not data.strip():
raise ValueError('Empty backup file')
url = get_database_url()
proc = subprocess.run(
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
input=data,
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
out = proc.stdout.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or out or 'psql restore failed')
invalidate_data_cache()
logger.info('PostgreSQL backup restored successfully')
+150 -7
View File
@@ -10,8 +10,10 @@ import copy
import json import json
import logging import logging
import os import os
import secrets
import shutil import shutil
import threading import threading
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -83,6 +85,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 +436,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 +628,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 +694,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()
+26 -6
View File
@@ -1,18 +1,21 @@
services: services:
db: db:
image: postgres:17 image: postgres:17
container_name: amnezia_panel_db
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-amnezia} POSTGRES_USER: ${POSTGRES_USER:-amnezia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel} POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
volumes: volumes:
- amnezia_pgdata:/var/lib/postgresql/data - amnezia_pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped restart: unless-stopped
networks:
- internal
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"] test:
[
"CMD-SHELL",
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && PGPASSWORD=$${POSTGRES_PASSWORD} psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' >/dev/null",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -23,7 +26,6 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: amnezia-panel:local image: amnezia-panel:local
container_name: amnezia_panel
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -33,15 +35,33 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
SECRET_KEY: ${SECRET_KEY:-} SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000" APP_PORT: "5000"
PORT: "5000"
PANEL_IN_DOCKER: "1"
PANEL_BEHIND_PROXY: "1"
volumes: volumes:
- amnezia_data:/app/data - amnezia_data:/app/data
restart: unless-stopped restart: unless-stopped
networks:
- internal
- dokploy-network
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
- traefik.http.services.amnezia_panel.loadbalancer.server.port=5000
expose:
- "5000"
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/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:
+20
View File
@@ -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');
+117
View File
@@ -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 -->
@@ -1132,6 +1151,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 = [
@@ -1153,6 +1173,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 +1234,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 +1480,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) {
@@ -1672,6 +1783,12 @@
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'].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>`;
} }
+10 -3
View File
@@ -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>
+15 -5
View File
@@ -361,11 +361,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:",
@@ -394,7 +396,15 @@
"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.",
"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",
"container_logs": "Container logs", "container_logs": "Container logs",
"logs_btn": "📋 Logs", "logs_btn": "📋 Logs",
"logs_live": "Live", "logs_live": "Live",
+8 -4
View File
@@ -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": "کلید عمومی کلاینت:",
@@ -385,6 +387,8 @@
"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": "زنده",
+8 -4
View File
@@ -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 :",
@@ -385,6 +387,8 @@
"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",
+15 -5
View File
@@ -361,11 +361,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": "Публичный ключ клиента:",
@@ -394,7 +396,15 @@
"naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.", "naiveproxy_client_hint": "Стабильная версия. НЕ используйте v2rayN. Точно работает в Karing. Остальные клиенты под вопросом.",
"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",
"container_logs": "Логи контейнера", "container_logs": "Логи контейнера",
"logs_btn": "📋 Логи", "logs_btn": "📋 Логи",
"logs_live": "В реальном времени", "logs_live": "В реальном времени",
+8 -4
View File
@@ -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": "客户端公钥:",
@@ -385,6 +387,8 @@
"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": "实时",