Files
hell 69893ba340 feat(exit): route client traffic through another panel server
Today a client leaves to the internet from the same server it connected to.
Exit nodes split the two: clients connect to an *entry* node (reachable,
possibly behind DPI) and egress from an *exit* node (clean IP, other country).
One exit serves many entries and keeps serving its own AWG clients.

    client --AWG--> [entry] amnezia-awg2 --WG/AWG link--> [exit] amnezia-exit --> internet
                    awg0 10.8.1.0/24                     transit0 10.9.0.0/24
                    exit0 10.9.0.N (Table=off)           one [Peer] per entry, /32

Exit side: a new protocol `exit` = container `amnezia-exit`, an AmneziaWG
listener on 55520/udp over the transit subnet 10.9.0.0/24. Peers are panel
nodes, never end users, and live in their own peersTable. ExitManager
subclasses AWGManager and closes every client-facing method.

Entry side: a second interface `exit0` inside the existing AWG container with
`Table = off`, plus policy routing - `from <client subnet> lookup 200`,
`default dev exit0` in table 200, and `blackhole default metric 1000` as a
kill-switch installed *before* the client tunnel comes up, so a container
restart cannot leak. Clients are MASQUERADEd into the entry's transit /32, so
the identical 10.8.1.0/24 of every instance can never collide at the exit.
TCP MSS is clamped, and rules 190/191 keep client<->client and AmneziaDNS
traffic local. State is file-driven: exit0.conf present means linked, and the
same shell block runs at container start and on a live (un)link.

Link crypto is WireGuard; AWG obfuscation is a per-exit-node property for hops
that cross DPI, and S3/S4 are deliberately not written on the transit - they
pad every transport packet and would eat the MTU budget of a tunnel inside a
tunnel. Private keys are generated inside the containers; the panel carries
public keys and a per-peer PSK only.

exit_link_service.py orchestrates linking with per-(entry, protocol) locks,
SSH off the event loop, a 15-second handshake gate that rolls back, and
lifecycle hooks for every way a link can go stale: exit uninstalled, stopped,
deleted, host cleared, entry reinstalled, container gone, backup restored.
Servers get a stable `uid` (already upstream) so reorder and delete cannot
break a link.

IPv4 only for now: while linked, client IPv6 is refused with `unreachable`
rather than leaking out of the entry.

Also included: an egress check that curls a public IP service bound to the
tunnel gateway (the client's exact path) and compares it with the direct one;
optional DNS resolution at the exit node; a default exit node applied to new
AWG installs; and reconciliation after a protocol restore.

244 tests pass. CURRENT_VERSION is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:05:51 +07:00

1199 lines
66 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title_extra %} — {{ _('nav_settings') }}{% endblock %}
{% block page_class %}page page--settings{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="section-title page-header__title">
<span class="icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg></span>
{{ _('settings_title') }}
</h1>
</div>
<nav class="settings-nav" aria-label="{{ _('settings_title') }}">
<a href="#settings-appearance">{{ _('appearance') }}</a>
<a href="#settings-captcha">{{ _('captcha_title') }}</a>
<a href="#settings-telegram">{{ _('telegram_bot_title') }}</a>
<a href="#settings-tunnels">{{ _('tunnels_title') }}</a>
<a href="#settings-ssl">{{ _('ssl_title') }}</a>
<a href="#settings-import">{{ _('import_users_title') }}</a>
<a href="#settings-backup">{{ _('backup_title') }}</a>
<a href="#settings-tokens">{{ _('api_tokens_title') }}</a>
<a href="#settings-exit-nodes">{{ _('exit_defaults_title') }}</a>
<a href="#settings-self-service">{{ _('self_service_title') }}</a>
<a href="#settings-about">{{ _('about_title') }}</a>
</nav>
<div class="grid grid-2 settings-grid" style="gap: var(--space-lg); align-items: start;">
<!-- LEFT COLUMN -->
<div style="display: flex; flex-direction: column; gap: var(--space-lg);">
<!-- BLOCK 1: Appearance -->
<div class="card" id="settings-appearance">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('appearance') }}</h3>
<form id="appearanceForm">
<div class="form-group">
<label class="form-label">{{ _('title_label') }}</label>
<input type="text" class="form-input" name="title" value="{{ settings.appearance.title }}" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('logo_label') }}</label>
<input type="text" class="form-input" name="logo" value="{{ settings.appearance.logo }}" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('subtitle_label') }}</label>
<input type="text" class="form-input" name="subtitle" value="{{ settings.appearance.subtitle }}"
required>
</div>
</form>
</div>
<!-- BLOCK Captcha -->
<div class="card" id="settings-captcha">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('captcha_title') }}</h3>
<form id="captchaForm">
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="captcha_enabled" {% if captcha_settings.enabled %}checked{% endif %}>
{{ _('enable_captcha') }}
</label>
</div>
</form>
</div>
<!-- BLOCK Telegram Bot -->
<div class="card" id="settings-telegram">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('telegram_bot_title') }}</h3>
<form id="telegramForm">
<div class="form-group">
<label class="form-label">{{ _('bot_token_label') }}</label>
<input type="text" class="form-input" id="tg_token" value="{{ telegram_settings.token or '' }}"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
</div>
<div class="row-between" style="margin-top: var(--space-md);">
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<span style="font-size: 0.85rem; color: var(--text-muted);">{{ _('bot_status') }}:</span>
{% if bot_running %}
<span class="badge badge-success" id="botStatusBadge">{{ _('bot_running') }}</span>
{% else %}
<span class="badge badge-secondary" id="botStatusBadge">{{ _('bot_stopped') }}</span>
{% endif %}
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleBot()" id="toggleBotBtn">
<span id="toggleBotBtnText">{% if bot_running %}{{ _('bot_stop_btn') }}{% else %}{{
_('bot_start_btn') }}{% endif %}</span>
<div class="spinner hidden" id="toggleBotSpinner" style="width:14px;height:14px;"></div>
</button>
</div>
<p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('bot_hint') }}
</p>
</form>
</div>
<!-- BLOCK 4: API Docs -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('api_docs_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<a href="/docs" target="_blank" class="btn btn-secondary"
style="justify-content: flex-start; text-decoration: none;">
<span style="font-size: 1.2rem; margin-right: 10px;">📖</span>
<div style="text-align: left;">
<div style="font-weight: 600;">Swagger UI</div>
<div style="font-size: 0.75rem; opacity: 0.7;">{{ _('api_docs_hint') }}</div>
</div>
</a>
<a href="/redoc" target="_blank" class="btn btn-secondary"
style="justify-content: flex-start; text-decoration: none;">
<span style="font-size: 1.2rem; margin-right: 10px;">📑</span>
<div style="text-align: left;">
<div style="font-weight: 600;">ReDoc</div>
<div style="font-size: 0.75rem; opacity: 0.7;">{{ _('api_docs_hint') }}</div>
</div>
</a>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('api_docs_hint') }}</p>
</div>
<!-- BLOCK Tunnels -->
<div class="card" id="settings-tunnels">
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>{{ _('tunnels_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div class="row-between" style="margin-bottom: var(--space-sm);">
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span>
</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div>
</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="row-between" style="margin-bottom: var(--space-sm);">
<strong>Cloudflare Quick Tunnel</strong>
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div class="btn-group-3">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div class="row-between" style="margin-bottom: var(--space-sm);">
<strong>ngrok Tunnel + Authtoken</strong>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="form-group" style="margin-bottom: var(--space-sm);">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
</div>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div class="btn-group-3">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div class="row-between" style="margin-bottom: var(--space-sm);">
<strong>Cloudflare WARP</strong>
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
<div class="grid-2" style="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>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
</div>
<!-- BLOCK SSL/HTTPS -->
<div class="card" id="settings-ssl">
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>{{ _('ssl_title') }}</h3>
<form id="sslForm">
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="ssl_enabled" {% if settings.get('ssl', {}).get('enabled') %}checked{% endif %}>
{{ _('enable_https') }}
</label>
</div>
<div class="form-group" style="margin-top: var(--space-md);">
<label class="form-label">{{ _('panel_port_label') }}</label>
<input type="number" class="form-input" id="panel_port" value="{{ settings.get('ssl', {}).get('panel_port', 5000) }}" placeholder="5000">
</div>
<div id="sslFields" style="{% if not settings.get('ssl', {}).get('enabled') %}display:none;{% endif %} margin-top: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('domain_label') }}</label>
<input type="text" class="form-input" id="ssl_domain" value="{{ settings.get('ssl', {}).get('domain', '') }}" placeholder="vpn.example.com">
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">{{ _('cert_path_label') }}</label>
<input type="text" class="form-input" id="ssl_cert_path" value="{{ settings.get('ssl', {}).get('cert_path', '') }}" placeholder="/etc/amnezia/cert.pem">
</div>
<div class="form-group">
<label class="form-label">{{ _('key_path_label') }}</label>
<input type="text" class="form-input" id="ssl_key_path" value="{{ settings.get('ssl', {}).get('key_path', '') }}" placeholder="/etc/amnezia/key.pem">
</div>
</div>
<div class="divider" style="margin: var(--space-md) 0;">{{ _('or_paste_text') }}</div>
<div class="form-group">
<label class="form-label">{{ _('cert_text_label') }}</label>
<textarea class="form-input" id="ssl_cert_text" rows="4" placeholder="-----BEGIN CERTIFICATE----- ...">{{ settings.get('ssl', {}).get('cert_text', '') }}</textarea>
</div>
<div class="form-group">
<label class="form-label">{{ _('key_text_label') }}</label>
<textarea class="form-input" id="ssl_key_text" rows="4" placeholder="-----BEGIN PRIVATE KEY----- ...">{{ settings.get('ssl', {}).get('key_text', '') }}</textarea>
</div>
<p class="form-hint">{{ _('ssl_hint') }}</p>
</div>
</form>
</div>
</div>
<!-- RIGHT COLUMN -->
<div style="display: flex; flex-direction: column; gap: var(--space-lg);">
<!-- BLOCK 3: Import Users -->
<div class="card" id="settings-import">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('import_users_title') }}</h3>
<form id="syncForm">
<div class="form-group" style="margin-bottom: var(--space-lg);">
<label class="form-label">{{ _('import_source_label') }}</label>
<div class="grid-2" style="gap: var(--space-sm);">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="remnawave_sync" {% if settings.sync.remnawave_sync %}checked{%
endif %}> Remnawave
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Marzban
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> 3x-ui
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Hiddify
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> LDAP
</label>
</div>
</div>
<div id="remnawaveFields"
style="{% if not settings.sync.remnawave_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('remnawave_url_label') }}</label>
<input type="url" class="form-input" name="remnawave_url"
value="{{ settings.sync.remnawave_url }}" placeholder="https://vpn.example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('api_key_label') }}</label>
<input type="password" class="form-input" name="remnawave_api_key"
value="{{ settings.sync.remnawave_api_key }}" placeholder="Your secret key">
</div>
<div class="form-group">
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="checkbox" name="remnawave_sync_users" {% if settings.sync.remnawave_sync_users
%}checked{% endif %}>
{{ _('enable_sync') }}
</label>
<div class="form-hint"
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
{{ _('sync_hint') }}
</div>
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
<button type="button" class="btn btn-secondary btn-sm" onclick="syncRemnawaveNow()"
id="syncNowBtn">
<span id="syncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
<div class="spinner hidden" id="syncNowSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncRemnawave()"
id="syncDelBtn">
<span id="syncDelBtnText" class="btn-text-with-icon">
<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
{{ _('delete_sync_btn') }}
</span>
<div class="spinner hidden" id="syncDelSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="remnawave_create_conns" id="syncCreateConns" {% if
settings.sync.remnawave_create_conns %}checked{% endif %}>
{{ _('auto_create_conns') }}
</label>
</div>
<div id="autoConnFields"
style="{% if not settings.sync.remnawave_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('sync_server_label') }}</label>
<select class="form-select" name="remnawave_server_id" onchange="updateProtocolsForSync()">
{% for s in servers %}
<option value="{{ loop.index0 }}" {% if settings.sync.remnawave_server_id==loop.index0
%}selected{% endif %}>{{ s.name or s.host }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" name="remnawave_protocol" id="syncProtocolSelect">
<option value="awg" {% if settings.sync.remnawave_protocol=='awg' %}selected{% endif %}>
AmneziaWG</option>
<option value="awg2" {% if settings.sync.remnawave_protocol=='awg2' %}selected{% endif %}>
AmneziaWG 2.0</option>
<option value="awg_legacy" {% if settings.sync.remnawave_protocol=='awg_legacy' %}selected{% endif %}>
AWG Legacy</option>
<option value="xray" {% if settings.sync.remnawave_protocol=='xray' %}selected{% endif %}>
Xray</option>
</select>
</div>
</div>
</div>
</form>
</div>
<!-- BLOCK: Simple Backup -->
<div class="card" id="settings-backup" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>{{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm);">
<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);">
<span>⬇️</span> {{ _('download_backup') }}
</a>
</div>
<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);">
<input type="file" id="backupFile" accept=".json" style="display: none;"
onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
style="gap:var(--space-sm);">
<span>⬆️</span> {{ _('restore_backup') }}
</button>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<h4 style="margin: 0 0 var(--space-sm) 0; font-size: 0.95rem;">{{ _('auto_backup_title') }}</h4>
<form id="autoBackupForm">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="enabled" id="autoBackupEnabled" {% if settings.auto_backup.enabled %}checked{% endif %}>
{{ _('enable_auto_backup') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('auto_backup_interval_label') }}</label>
<select class="form-select" name="interval_hours" id="autoBackupInterval">
{% set auto_backup_interval = settings.get('auto_backup', {}).get('interval_hours', 24) | int %}
{% for hour in range(1, 25) %}
<option value="{{ hour }}" {% if auto_backup_interval == hour %}selected{% endif %}>{{ hour }} {{ _('hours_suffix') }}</option>
{% endfor %}
</select>
</div>
</form>
<p class="form-hint">{{ _('auto_backup_hint') }}</p>
{% if settings.auto_backup.last_run_at %}
<div class="form-hint" style="margin-top: var(--space-xs);">
{{ _('auto_backup_last_run') }}: {{ settings.auto_backup.last_run_at }} ·
{{ _('auto_backup_last_status') }}: {{ settings.auto_backup.last_status or '-' }} ·
{{ _('auto_backup_created_count') }}: {{ settings.auto_backup.last_created_count or 0 }}
</div>
{% endif %}
{% if settings.auto_backup.last_error %}
<div class="form-hint" style="margin-top: var(--space-xs); color: var(--danger);">
{{ settings.auto_backup.last_error }}
</div>
{% endif %}
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('restore_confirm') }}
</p>
</div>
<!-- BLOCK: API Tokens -->
<div class="card" id="settings-tokens" style="margin-top: var(--space-lg);">
<div class="row-between" style="margin-bottom: var(--space-md);">
<h3 class="card-title" style="margin:0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>{{ _('api_tokens_title') }}</h3>
<button type="button" class="btn btn-primary btn-sm" onclick="openCreateTokenModal()">
<span>+</span> {{ _('api_tokens_create') }}
</button>
</div>
<p class="form-hint" style="margin-bottom: var(--space-md);">
{{ _('api_tokens_hint') }}
</p>
<div id="tokensList">
<div class="form-hint" id="tokensEmptyState">{{ _('api_tokens_empty') }}</div>
</div>
</div>
<!-- BLOCK: Exit nodes -->
<div class="card" id="settings-exit-nodes" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🚪 {{ _('exit_defaults_title') }}</h3>
<div class="form-group">
<label class="form-label">{{ _('exit_default_node') }}</label>
<select class="form-input" id="exitDefaultNode"><option value="">{{ _('exit_default_none') }}</option></select>
<p class="form-hint">{{ _('exit_default_hint') }}</p>
</div>
</div>
<!-- BLOCK: Self-Service -->
<div class="card" id="settings-self-service" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>{{ _('self_service_title') }}</h3>
<form id="selfServiceForm">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="ss_enabled" {% if settings.self_service.enabled %}checked{% endif %}>
{{ _('self_service_enabled') }}
</label>
<p class="form-hint">{{ _('self_service_enabled_desc') }}</p>
</div>
<div id="selfServiceFields" {% if not settings.self_service.enabled %}style="display:none;"{% endif %}>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="ss_web_enabled" {% if settings.self_service.web_enabled %}checked{% endif %}>
{{ _('self_service_web') }}
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" id="ss_telegram_enabled" {% if settings.self_service.telegram_enabled %}checked{% endif %}>
{{ _('self_service_telegram') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('self_service_max_conns') }}</label>
<input type="number" class="form-input" id="ss_max_connections" value="{{ settings.self_service.max_connections_per_user | default(5) }}" min="1" max="100">
</div>
<div class="form-group">
<label class="form-label">{{ _('self_service_rate_limit') }}</label>
<input type="number" class="form-input" id="ss_rate_limit" value="{{ settings.self_service.rate_limit_count | default(3) }}" min="1" max="100">
</div>
<div class="form-group">
<label class="form-label">{{ _('self_service_rate_window') }}</label>
<input type="number" class="form-input" id="ss_rate_window" value="{{ settings.self_service.rate_limit_window_seconds | default(60) }}" min="1" max="86400">
</div>
<div class="form-group">
<label class="form-label">{{ _('self_service_protocols') }}</label>
<div class="flex gap-md" style="display:flex; gap:var(--space-md); flex-wrap:wrap;">
{% for choice in self_service_protocol_choices %}
<label style="display:flex; align-items:center; gap:var(--space-sm);">
<input type="checkbox" name="ss_proto" value="{{ choice.id }}" {% if choice.id in settings.self_service.allowed_protocols | default(['awg', 'awg2']) %}checked{% endif %}> {{ choice.name }}
</label>
{% endfor %}
</div>
</div>
</div>
</form>
</div>
<!-- BLOCK: About & Updates -->
<div class="card" id="settings-about" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>{{ _('about_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div class="row-between" style="padding: var(--space-sm) 0; border-bottom: 1px solid var(--border-color);">
<span style="font-weight: 500;">{{ _('current_version') }}</span>
<span class="badge badge-info" id="currentVersionBadge" style="font-size: 0.95rem;">{{ current_version }}</span>
</div>
<div id="updateStatus" class="hidden" style="padding: var(--space-md); border-radius: var(--radius-md); background: var(--bg-primary); text-align: center;">
<!-- Updated via JS -->
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;">
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<a href="https://github.com/PRVTPRO/Amnezia-Web-Panel/releases" target="_blank" class="btn btn-primary hidden" id="downloadUpdateBtn" style="flex:1; text-decoration: none; justify-content: center;">
⬇️ {{ _('download_update') }}
</a>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top: var(--space-xl); text-align: right;" class="btn-full-mobile">
<button class="btn btn-primary btn-lg" onclick="saveSettings()" id="saveBtn">
<span>{{ _('save_changes') }}</span>
<div class="spinner hidden" id="saveSpinner"></div>
</button>
</div>
<!-- ===== Create API Token Modal ===== -->
<div class="modal-backdrop" id="createTokenModal">
<div class="modal" style="max-width: 460px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('api_tokens_create_title') }}</h2>
<button class="modal-close" onclick="closeModal('createTokenModal')">×</button>
</div>
<div class="form-group">
<label class="form-label">{{ _('api_tokens_name_label') }}</label>
<input class="form-input" type="text" id="createTokenName"
placeholder="{{ _('api_tokens_name_placeholder') }}" maxlength="100">
<div class="form-hint">{{ _('api_tokens_name_hint') }}</div>
</div>
<div id="createTokenError" style="color: var(--danger, #ef4444); font-size: 0.85rem; margin-bottom: var(--space-sm);"></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('createTokenModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="submitCreateToken()" id="createTokenBtn">
{{ _('api_tokens_create') }}
</button>
</div>
</div>
</div>
<!-- ===== Newly-Created Token Modal (one-time display) ===== -->
<div class="modal-backdrop" id="newTokenModal">
<div class="modal" style="max-width: 560px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('api_tokens_created_title') }}</h2>
<button class="modal-close" onclick="closeModal('newTokenModal')">×</button>
</div>
<div class="form-group">
<label class="form-label">{{ _('api_tokens_name_label') }}</label>
<div id="newTokenName" style="font-weight: 600;"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('api_tokens_value_label') }}</label>
<input class="form-input" type="text" id="newTokenValue" readonly
style="font-family: 'SF Mono', monospace; font-size: 0.85rem;">
</div>
<div style="background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.4); border-radius: var(--radius-md); padding: var(--space-md); margin-bottom: var(--space-md);">
⚠️ <strong>{{ _('api_tokens_warning_title') }}</strong>
<div class="form-hint" style="margin-top: 4px;">{{ _('api_tokens_warning_body') }}</div>
</div>
<div class="form-hint" style="margin-bottom: var(--space-sm); font-family: 'SF Mono', monospace; font-size: 0.78rem; padding: var(--space-sm); background: var(--bg-primary); border-radius: var(--radius-sm); word-break: break-all;">
curl -H "Authorization: Bearer &lt;your_token&gt;" {{ request.url.scheme }}://{{ request.url.netloc }}/api/users
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="copyNewToken()">📋 {{ _('copy') }}</button>
<button class="btn btn-primary" onclick="closeModal('newTokenModal')">{{ _('done') }}</button>
</div>
</div>
</div>
<script>
const serversData = {{ servers | tojson }};
const botCurrentlyRunning = {{ 'true' if bot_running else 'false' }};
function updateProtocolsForSync() {
const serverIdx = document.querySelector('[name="remnawave_server_id"]').value;
const protoSelect = document.getElementById('syncProtocolSelect');
protoSelect.innerHTML = '';
if (serverIdx === '' || !serversData[serverIdx]) return;
const protocols = serversData[serverIdx].protocols || {};
let count = 0;
for (const [key, info] of Object.entries(protocols)) {
if (info.installed && key !== 'exit') { // exit node has no client connections
const opt = document.createElement('option');
opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
if (key === "{{ settings.sync.remnawave_protocol }}") opt.selected = true;
protoSelect.appendChild(opt);
count++;
}
}
if (count === 0) {
const opt = document.createElement('option');
opt.textContent = _('no_protocols');
opt.disabled = true;
protoSelect.appendChild(opt);
}
}
document.querySelector('[name="remnawave_sync"]').addEventListener('change', (e) => {
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
});
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForSync();
});
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
});
document.getElementById('ss_enabled').addEventListener('change', (e) => {
document.getElementById('selfServiceFields').style.display = e.target.checked ? 'block' : 'none';
});
let tunnelStatusTimer = null;
function copyElementText(id) {
const el = document.getElementById(id);
if (el) copyToClipboard(el.textContent.trim());
}
function renderTunnelUrls(provider, urls) {
const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return;
if (!urls || !urls.length) {
el.textContent = _('tunnel_no_public_url');
return;
}
el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div>
`).join('');
}
function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
badge.textContent = status.installed ? _('installed') : _('not_installed');
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = !!status.running;
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
}
async function loadTunnelStatus() {
try {
const data = await apiCall('/api/settings/tunnels/status');
if (data.local_server) document.getElementById('localServerUrl').textContent = data.local_server;
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
function updateWarpStatus(status) {
const badge = document.getElementById('warpInstallBadge');
const statusText = document.getElementById('warpStatusText');
const hintText = document.getElementById('warpHintText');
const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
hintText.textContent = status.last_error || (installed ? _('warp_hint') : (status.install_hint || _('warp_install_hint')));
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
}
async function connectWarp() {
const btn = document.getElementById('warpConnectBtn');
const spinner = document.getElementById('warpConnectSpinner');
const text = document.getElementById('warpConnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/warp/connect', 'POST');
updateWarpStatus(status);
showToast(_('warp_connected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
spinner.classList.add('hidden');
}
}
async function disconnectWarp() {
const btn = document.getElementById('warpDisconnectBtn');
const spinner = document.getElementById('warpDisconnectSpinner');
const text = document.getElementById('warpDisconnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/warp/disconnect', 'POST');
updateWarpStatus(status);
showToast(_('warp_disconnected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('warp_disconnect');
spinner.classList.add('hidden');
}
}
async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`);
const text = document.getElementById(`${provider}TunnelBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const current = await apiCall('/api/settings/tunnels/status');
const providerStatus = current[provider] || {};
if (!providerStatus.installed) {
text.textContent = _('installing');
await apiCall(`/api/settings/tunnels/${provider}/install`, 'POST');
}
text.textContent = _('tunnel_starting');
const body = provider === 'ngrok' ? { authtoken: document.getElementById('ngrokAuthtoken').value.trim() } : {};
const status = await apiCall(`/api/settings/tunnels/${provider}/start`, 'POST', body);
updateTunnelProvider(provider, status);
showToast(status.public_url ? _('tunnel_started') : _('tunnel_waiting_url'), 'success');
if (!tunnelStatusTimer) tunnelStatusTimer = setInterval(loadTunnelStatus, 5000);
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function stopTunnel(provider) {
const btn = document.getElementById(`${provider}StopBtn`);
const spinner = document.getElementById(`${provider}StopSpinner`);
const text = document.getElementById(`${provider}StopBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('tunnel_stopping');
try {
const status = await apiCall(`/api/settings/tunnels/${provider}/stop`, 'POST');
updateTunnelProvider(provider, status);
showToast(_('tunnel_stopped'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('tunnel_stop');
spinner.classList.add('hidden');
}
}
async function deleteTunnel(provider) {
if (!confirm(_('tunnel_delete_confirm'))) return;
const btn = document.getElementById(`${provider}DeleteBtn`);
const spinner = document.getElementById(`${provider}DeleteSpinner`);
const text = document.getElementById(`${provider}DeleteBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('deleting');
try {
const status = await apiCall(`/api/settings/tunnels/${provider}`, 'DELETE');
updateTunnelProvider(provider, status);
showToast(_('tunnel_deleted'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('tunnel_delete');
spinner.classList.add('hidden');
}
}
async function syncRemnawaveNow() {
const btn = document.getElementById('syncNowBtn');
const text = document.getElementById('syncNowBtnText');
const spinner = document.getElementById('syncNowSpinner');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/sync_now', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_success').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
throw new Error(data.message || 'Error occurred');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🔄 ${_('sync_now_btn')}`;
spinner.classList.add('hidden');
}
}
async function deleteSyncRemnawave() {
if (!confirm(_('delete_sync_confirm'))) return;
const btn = document.getElementById('syncDelBtn');
const text = document.getElementById('syncDelBtnText');
const spinner = document.getElementById('syncDelSpinner');
btn.disabled = true;
text.textContent = _('deleting');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/sync_delete', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_deleted').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.innerHTML = `${ICON_TRASH} ${_('delete_sync_btn')}`;
spinner.classList.add('hidden');
}
}
async function saveSettings() {
const btn = document.getElementById('saveBtn');
const spinner = document.getElementById('saveSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
const appForm = document.getElementById('appearanceForm');
const syncForm = document.getElementById('syncForm');
const appearance = {
title: appForm.title.value,
logo: appForm.logo.value,
subtitle: appForm.subtitle.value
};
const sync = {
remnawave_url: syncForm.remnawave_url.value,
remnawave_api_key: syncForm.remnawave_api_key.value,
remnawave_sync: syncForm.remnawave_sync.checked,
remnawave_sync_users: syncForm.remnawave_sync_users.checked,
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
remnawave_protocol: syncForm.remnawave_protocol.value
};
const captcha = {
enabled: document.getElementById('captcha_enabled').checked
};
const telegram = {
token: document.getElementById('tg_token').value.trim(),
enabled: botCurrentlyRunning
};
const ssl = {
enabled: document.getElementById('ssl_enabled').checked,
domain: document.getElementById('ssl_domain').value.trim(),
cert_path: document.getElementById('ssl_cert_path').value.trim(),
key_path: document.getElementById('ssl_key_path').value.trim(),
cert_text: document.getElementById('ssl_cert_text').value.trim(),
key_text: document.getElementById('ssl_key_text').value.trim(),
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
};
const autoBackupForm = document.getElementById('autoBackupForm');
const auto_backup = {
enabled: autoBackupForm.enabled.checked,
interval_hours: parseInt(autoBackupForm.interval_hours.value || '24')
};
const self_service = {
enabled: document.getElementById('ss_enabled').checked,
web_enabled: document.getElementById('ss_web_enabled').checked,
telegram_enabled: document.getElementById('ss_telegram_enabled').checked,
max_connections_per_user: parseInt(document.getElementById('ss_max_connections').value || '5'),
rate_limit_count: parseInt(document.getElementById('ss_rate_limit').value || '3'),
rate_limit_window_seconds: parseInt(document.getElementById('ss_rate_window').value || '60'),
allowed_protocols: Array.from(document.querySelectorAll('input[name="ss_proto"]:checked')).map(el => el.value),
};
const exit_nodes = { default_exit_uid: document.getElementById('exitDefaultNode').value || '' };
try {
const res = await fetch('/api/settings/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, auto_backup, self_service, exit_nodes })
});
if (!res.ok) throw new Error(_('error'));
const saved = await res.json();
(saved.warnings || []).forEach(w => showToast(_(w), 'info'));
showToast(_('settings_saved'), 'success');
setTimeout(() => window.location.reload(), 1000);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function toggleBot() {
const btn = document.getElementById('toggleBotBtn');
const text = document.getElementById('toggleBotBtnText');
const spinner = document.getElementById('toggleBotSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/telegram/toggle', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || _('error'));
const running = data.bot_running;
document.getElementById('botStatusBadge').className = running ? 'badge badge-success' : 'badge badge-secondary';
document.getElementById('botStatusBadge').textContent = running ? _('bot_running') : _('bot_stopped');
text.textContent = running ? _('bot_stop_btn') : _('bot_start_btn');
showToast(running ? _('bot_started') : _('bot_stopped_msg'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function handleRestore(e) {
const file = e.target.files[0];
if (!file) return;
if (!confirm(_('restore_confirm'))) {
e.target.value = '';
return;
}
const btn = document.getElementById('restoreBtn');
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = `<div class="spinner" style="width:14px; height:14px;"></div> ${_('loading')}`;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/settings/backup/restore', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.status === 'success') {
showToast(_('restore_success'), 'success');
setTimeout(() => window.location.reload(), 2000);
} else {
showToast(data.error || _('invalid_backup_file'), 'error');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
e.target.value = '';
}
}
// Init protocols on load
if (document.getElementById('syncCreateConns').checked) {
updateProtocolsForSync();
}
async function checkForUpdates() {
const btn = document.getElementById('checkUpdateBtn');
const text = document.getElementById('checkUpdateBtnText');
const spinner = document.getElementById('checkUpdateSpinner');
const statusDiv = document.getElementById('updateStatus');
const downloadBtn = document.getElementById('downloadUpdateBtn');
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
statusDiv.classList.add('hidden');
downloadBtn.classList.add('hidden');
try {
const response = await fetch('https://api.github.com/repos/PRVTPRO/Amnezia-Web-Panel/releases/latest');
if (!response.ok) throw new Error('API Error');
const data = await response.json();
const latestVersion = data.tag_name;
const currentVersion = '{{ current_version }}';
statusDiv.classList.remove('hidden');
if (latestVersion !== currentVersion && latestVersion) {
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url;
downloadBtn.classList.remove('hidden');
} else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)';
}
} catch (err) {
showToast('Failed to check for updates: ' + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
spinner.classList.add('hidden');
}
}
/* ========== API Tokens ========== */
function fmtTokenDate(iso) {
if (!iso) return _('api_tokens_never');
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
async function loadTokens() {
try {
const data = await apiCall('/api/settings/tokens');
renderTokens(data.tokens || []);
} catch (err) {
showToast(err.message, 'error');
}
}
function renderTokens(tokens) {
const container = document.getElementById('tokensList');
if (!container) return;
if (!tokens.length) {
container.innerHTML = `<div class="form-hint" id="tokensEmptyState">${_('api_tokens_empty')}</div>`;
return;
}
container.innerHTML = tokens.map(t => `
<div class="token-row row-between" style="padding:var(--space-sm) 0; border-bottom:1px solid var(--border-color);">
<div style="min-width:0; flex:1;">
<div style="font-weight:600;">${escapeHTML(t.name)}</div>
<div class="text-muted" style="font-size:0.78rem; font-family: 'SF Mono', monospace; display:flex; flex-wrap:wrap; gap:var(--space-sm);">
<span>${escapeHTML(t.token_prefix || '')}…</span>
<span>${_('api_tokens_created')}: ${fmtTokenDate(t.created_at)}</span>
<span>${_('api_tokens_last_used')}: ${fmtTokenDate(t.last_used_at)}</span>
${t.owner ? `<span>${_('api_tokens_owner')}: ${escapeHTML(t.owner)}</span>` : ''}
</div>
</div>
<button class="btn btn-danger btn-sm" onclick="revokeToken('${t.id}', '${escapeHTML(t.name)}')">
${_('api_tokens_revoke')}
</button>
</div>
`).join('');
}
function escapeHTML(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function openCreateTokenModal() {
document.getElementById('createTokenName').value = '';
document.getElementById('createTokenError').textContent = '';
openModal('createTokenModal');
setTimeout(() => document.getElementById('createTokenName').focus(), 50);
}
async function submitCreateToken() {
const name = document.getElementById('createTokenName').value.trim();
const errEl = document.getElementById('createTokenError');
if (!name) {
errEl.textContent = _('api_tokens_name_required');
return;
}
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;
try {
const res = await apiCall('/api/settings/tokens', 'POST', { name });
closeModal('createTokenModal');
// Show the raw token exactly once. The user must copy it now —
// we cannot retrieve it after this point.
document.getElementById('newTokenValue').value = res.token || '';
document.getElementById('newTokenName').textContent = res.name || '';
openModal('newTokenModal');
loadTokens();
} catch (err) {
errEl.textContent = err.message;
} finally {
btn.disabled = false;
}
}
function copyNewToken() {
const input = document.getElementById('newTokenValue');
input.select();
copyToClipboard(input.value);
}
async function revokeToken(id, name) {
if (!confirm(_('api_tokens_revoke_confirm').replace('{}', name))) return;
try {
await apiCall(`/api/settings/tokens/${id}`, 'DELETE');
showToast(_('api_tokens_revoked'), 'success');
loadTokens();
} catch (err) {
showToast(err.message, 'error');
}
}
async function loadExitNodes() {
const select = document.getElementById('exitDefaultNode');
const current = {{ (settings.exit_nodes.default_exit_uid if settings.exit_nodes else '') | tojson }};
try {
const data = await (await fetch('/api/exit-nodes')).json();
(data.exit_nodes || []).forEach(node => {
const opt = document.createElement('option');
opt.value = node.uid;
opt.textContent = `${node.name} (${node.host}:${node.port || '?'})`;
if (node.uid === current) opt.selected = true;
select.appendChild(opt);
});
} catch (err) {
select.disabled = true;
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTokens();
loadTunnelStatus();
loadExitNodes();
tunnelStatusTimer = setInterval(loadTunnelStatus, 10000);
});
</script>
{% endblock %}