Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443. Co-authored-by: Cursor <cursoragent@cursor.com>
1818 lines
93 KiB
HTML
1818 lines
93 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title_extra %} — {{ server.name }}{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<style>
|
||
.client-actions {
|
||
display: flex !important;
|
||
flex-direction: row !important;
|
||
flex-wrap: nowrap !important;
|
||
gap: var(--space-xs) !important;
|
||
align-items: center;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<a href="/" class="back-link">{{ _('back_to_servers') }}</a>
|
||
|
||
<!-- Server Info Header -->
|
||
<div class="card" style="margin-bottom: var(--space-xl);">
|
||
<div class="flex items-center justify-between" style="flex-wrap: wrap; gap: var(--space-md);">
|
||
<div class="flex items-center gap-md">
|
||
<div class="server-icon" style="width:56px; height:56px; font-size:1.6rem;">🖥</div>
|
||
<div>
|
||
<h1 style="font-size:1.4rem; font-weight:700;">{{ server.name }}</h1>
|
||
<div class="font-mono text-muted text-sm">{{ server.host }}:{{ server.ssh_port }} • {{ server.username
|
||
}}</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-sm items-center">
|
||
<div class="flex gap-sm items-center" id="serverStats" style="display:none;"></div>
|
||
<button class="btn btn-secondary btn-sm" onclick="checkServer()" id="checkBtn">
|
||
<span id="checkBtnText">{{ _('server_check') }}</span>
|
||
<div class="spinner hidden" id="checkSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="serverStatus" class="mt-md hidden">
|
||
<div class="divider"></div>
|
||
<div class="flex gap-md" style="flex-wrap:wrap" id="statusBadges"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Protocol Installation Section -->
|
||
<div class="flex items-center gap-md"
|
||
style="margin-bottom: var(--space-xl); border-bottom: 1px solid var(--border-color); padding-bottom: 2px;">
|
||
<button class="nav-tab active" id="tab-protocols" onclick="switchCategory('protocols')"
|
||
style="background: none; border: none; padding: 10px 16px; font-size: 1rem; color: var(--text-primary); cursor: pointer; border-bottom: 2px solid var(--accent); font-weight: 600;">
|
||
<span class="icon">🔐</span> {{ _('protocols') }} <span id="protocols-count"
|
||
style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">0/5</span>
|
||
</button>
|
||
<button class="nav-tab" id="tab-services" onclick="switchCategory('services')"
|
||
style="background: none; border: none; padding: 10px 16px; font-size: 1rem; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent;">
|
||
<span class="icon">🛠</span> {{ _('services') }} <span id="services-count"
|
||
style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">0/3</span>
|
||
</button>
|
||
<button class="nav-tab" id="tab-management" onclick="switchCategory('management')"
|
||
style="background: none; border: none; padding: 10px 16px; font-size: 1rem; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent;">
|
||
<span class="icon">⚙️</span> {{ _('management') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="protocol-cards" id="protocols-grid">
|
||
<!-- AWG2 Card (first - new version) -->
|
||
<div class="card card-hover protocol-card protocol-awg" id="proto-awg2">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">✨</div>
|
||
<div class="flex gap-sm" id="awg2-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">AmneziaWG 2.0 <span
|
||
style="font-size:0.65rem; background:var(--accent); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">NEW</span>
|
||
</div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg2-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg2-info" class="hidden">
|
||
<div class="protocol-info" id="awg2-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg2-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg2')" id="awg2-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AWG Card -->
|
||
<div class="card card-hover protocol-card protocol-awg" id="proto-awg">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">🔮</div>
|
||
<div class="flex gap-sm" id="awg-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">AmneziaWG</div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg-info" class="hidden">
|
||
<div class="protocol-info" id="awg-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg')" id="awg-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AWG Legacy Card -->
|
||
<div class="card card-hover protocol-card protocol-legacy" id="proto-awg-legacy">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">📡</div>
|
||
<div class="flex gap-sm" id="awg-legacy-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">AmneziaWG Legacy</div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg_legacy_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg-legacy-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg-legacy-info" class="hidden">
|
||
<div class="protocol-info" id="awg-legacy-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg-legacy-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg_legacy')" id="awg-legacy-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Xray Card -->
|
||
<div class="card card-hover protocol-card protocol-xray" id="proto-xray">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">⚡</div>
|
||
<div class="flex gap-sm" id="xray-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">Xray (VLESS-Reality)</div>
|
||
<div class="protocol-desc">
|
||
{{ _('xray_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="xray-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="xray-info" class="hidden">
|
||
<div class="protocol-info" id="xray-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="xray-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('xray')" id="xray-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Telemt Card -->
|
||
<div class="card card-hover protocol-card protocol-telemt" id="proto-telemt">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">✈</div>
|
||
<div class="flex gap-sm" id="telemt-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">Telemt (Telegram Proxy)</div>
|
||
<div class="protocol-desc">
|
||
{{ _('telemt_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="telemt-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="telemt-info" class="hidden">
|
||
<div class="protocol-info" id="telemt-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="telemt-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('telemt')" id="telemt-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WireGuard Card -->
|
||
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">🔒</div>
|
||
<div class="flex gap-sm" id="wireguard-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">WireGuard</div>
|
||
<div class="protocol-desc">
|
||
{{ _('wireguard_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="wireguard-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="wireguard-info" class="hidden">
|
||
<div class="protocol-info" id="wireguard-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="wireguard-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('wireguard')" id="wireguard-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== AIVPN teaser (locked, full grid width) ===== -->
|
||
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
|
||
<div class="promo-orbs" aria-hidden="true">
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
</div>
|
||
<span class="promo-lock-badge">🔒 {{ _('coming_soon') }}</span>
|
||
<div class="promo-content">
|
||
<div class="promo-icon" aria-hidden="true">🧠</div>
|
||
<div class="promo-text">
|
||
<div class="promo-title">AIVPN</div>
|
||
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
|
||
</div>
|
||
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
|
||
⭐ <span>{{ _('promo_star_cta') }}</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="protocol-cards hidden" id="services-grid">
|
||
<!-- DNS Card -->
|
||
<div class="card card-hover protocol-card protocol-dns" id="proto-dns">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">🔍</div>
|
||
<div class="flex gap-sm" id="dns-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">AmneziaDNS</div>
|
||
<div class="protocol-desc">
|
||
{{ _('dns_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="dns-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="dns-info" class="hidden">
|
||
<div class="protocol-info" id="dns-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="dns-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('dns')" id="dns-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AdGuard Home Card -->
|
||
<div class="card card-hover protocol-card protocol-adguard" id="proto-adguard">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">🛡️</div>
|
||
<div class="flex gap-sm" id="adguard-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">AdGuard Home</div>
|
||
<div class="protocol-desc">
|
||
{{ _('adguard_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="adguard-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="adguard-info" class="hidden">
|
||
<div class="protocol-info" id="adguard-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="adguard-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('adguard')" id="adguard-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SOCKS5 Card -->
|
||
<div class="card card-hover protocol-card protocol-socks5" id="proto-socks5">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">🧦</div>
|
||
<div class="flex gap-sm" id="socks5-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">SOCKS5 Proxy</div>
|
||
<div class="protocol-desc">
|
||
{{ _('socks5_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="socks5-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="socks5-info" class="hidden">
|
||
<div class="protocol-info" id="socks5-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="socks5-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('socks5')" id="socks5-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reverse Proxy Card -->
|
||
<div class="card card-hover protocol-card protocol-revproxy" id="proto-revproxy">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
|
||
<div class="protocol-icon">🛡️</div>
|
||
<div class="flex gap-sm" id="revproxy-ctrl" style="display:none!important;"></div>
|
||
</div>
|
||
<div class="protocol-name">{{ _('revproxy_title') }}</div>
|
||
<div class="protocol-desc">
|
||
{{ _('revproxy_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="revproxy-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="revproxy-info" class="hidden">
|
||
<div class="protocol-info" id="revproxy-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="revproxy-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('revproxy')" id="revproxy-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="protocol-cards hidden" id="management-grid" style="margin-bottom: var(--space-xl);">
|
||
<div class="card" style="margin-bottom: var(--space-md); width:100%; max-width:600px;">
|
||
<div class="card-title">{{ _('server_management') }}</div>
|
||
<div class="card-subtitle" style="margin-bottom: var(--space-md);">
|
||
{{ _('server_management_desc') }}
|
||
</div>
|
||
<div class="flex" style="flex-direction: column; gap: var(--space-sm);">
|
||
<button class="btn btn-secondary" onclick="checkServer()" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">🔄</span> {{ _('check_server_services') }}
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="rebootServer()" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">♻️</span> {{ _('reboot_server') }}
|
||
</button>
|
||
<button class="btn btn-danger" onclick="clearServer()" style="justify-content: flex-start; padding: 12px 20px; background: rgba(239, 68, 68, 0.05);">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">🗑️</span> {{ _('clear_server') }}
|
||
</button>
|
||
<button class="btn btn-danger" onclick="removeServer()" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">❌</span> {{ _('remove_server') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Connections Section (renamed from Clients) -->
|
||
<div class="clients-section" id="connectionsSection" style="display:none;">
|
||
<div class="clients-header">
|
||
<h2 class="section-title" style="margin-bottom:0;">
|
||
<span class="icon">🔗</span>
|
||
{{ _('connections') }}
|
||
</h2>
|
||
<div class="flex gap-sm items-center">
|
||
<select class="form-select" id="connProtoSelect" onchange="loadConnections()"
|
||
style="width:auto; min-width:160px;">
|
||
<option value="awg">AmneziaWG</option>
|
||
<option value="awg2">AmneziaWG 2.0</option>
|
||
<option value="awg_legacy">AWG Legacy</option>
|
||
<option value="xray">Xray</option>
|
||
<option value="telemt">Telemt</option>
|
||
<option value="wireguard">WireGuard</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
|
||
<span>+</span> {{ _('add') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="connectionsList">
|
||
<div class="loading-overlay" id="connectionsLoading" style="display:none;">
|
||
<div class="loading-spinner"></div>
|
||
<span>{{ _('loading_connections') }}</span>
|
||
</div>
|
||
<div id="connectionsEmpty" class="empty-state hidden" style="padding: var(--space-xl) 0;">
|
||
<div class="empty-icon">🔗</div>
|
||
<div class="empty-title">{{ _('no_connections') }}</div>
|
||
<div class="empty-desc">{{ _('no_connections_desc') }}</div>
|
||
</div>
|
||
<div class="clients-list" id="connectionsListInner"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Install Protocol Modal ===== -->
|
||
<div class="modal-backdrop" id="installModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="installModalTitle">{{ _('install_protocol') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('installModal')">×</button>
|
||
</div>
|
||
|
||
<div id="installForm">
|
||
<div class="form-group">
|
||
<label class="form-label" id="installPortLabel">{{ _('port') }}</label>
|
||
<input class="form-input" type="number" id="installPort" value="55424" min="1" max="65535">
|
||
<div class="form-hint" id="installPortHint">{{ _('port_default_hint') }}</div>
|
||
</div>
|
||
|
||
<div id="telemtOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex; justify-content:space-between; align-items:center;">
|
||
{{ _('tls_emulation') }}
|
||
<label class="switch">
|
||
<input type="checkbox" id="installTelemtTls" checked
|
||
onchange="document.getElementById('telemtDomainGroup').style.display = this.checked ? 'block' : 'none'">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</label>
|
||
</div>
|
||
<div class="form-group" id="telemtDomainGroup">
|
||
<label class="form-label">{{ _('tls_domain') }}</label>
|
||
<input class="form-input" type="text" id="installTelemtDomain" value="petrovich.ru">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('max_connections_limit') }}</label>
|
||
<input class="form-input" type="number" id="installTelemtMaxConns" value="0" min="0">
|
||
<div class="form-hint">{{ _('max_connections_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="socks5Options"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_username') }}</label>
|
||
<input class="form-input" type="text" id="installSocks5User" value="proxy_user">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_password') }}</label>
|
||
<input class="form-input" type="text" id="installSocks5Pass" placeholder="{{ _('socks5_password_placeholder') }}">
|
||
<div class="form-hint">{{ _('socks5_password_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="adguardOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_mode') }}</label>
|
||
<div style="display:flex; flex-direction:column; gap:var(--space-xs);">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="radio" name="adguardMode" value="sidebyside" checked>
|
||
<span><strong>{{ _('adguard_mode_sidebyside') }}</strong> — {{ _('adguard_mode_sidebyside_hint') }}</span>
|
||
</label>
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="radio" name="adguardMode" value="replace">
|
||
<span><strong>{{ _('adguard_mode_replace') }}</strong> — {{ _('adguard_mode_replace_hint') }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_web_port') }}</label>
|
||
<input class="form-input" type="number" id="installAdguardWebPort" value="3000" min="1" max="65535">
|
||
</div>
|
||
<div class="form-group" style="display:flex; align-items:flex-end; padding-bottom:8px;">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="checkbox" id="installAdguardExposeWeb">
|
||
<span>{{ _('adguard_expose_web') }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_dot_port') }}</label>
|
||
<input class="form-input" type="number" id="installAdguardDotPort" value="853" min="1" max="65535">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer; margin-top:4px;">
|
||
<input type="checkbox" id="installAdguardExposeDot">
|
||
<span>{{ _('adguard_expose') }}</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_doh_port') }}</label>
|
||
<input class="form-input" type="number" id="installAdguardDohPort" value="443" min="1" max="65535">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer; margin-top:4px;">
|
||
<input type="checkbox" id="installAdguardExposeDoh">
|
||
<span>{{ _('adguard_expose') }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-hint">{{ _('adguard_install_hint') }}</div>
|
||
</div>
|
||
|
||
<div id="revproxyOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_domain') }}</label>
|
||
<input class="form-input" type="text" id="installRevproxyDomain" placeholder="vpn.example.com">
|
||
<div class="form-hint">{{ _('revproxy_domain_hint') }}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_site_title') }}</label>
|
||
<input class="form-input" type="text" id="installRevproxyTitle" value="CloudEdge">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_tls_email') }}</label>
|
||
<input class="form-input" type="email" id="installRevproxyEmail" placeholder="admin@example.com">
|
||
<div class="form-hint">{{ _('revproxy_tls_email_hint') }}</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_backend') }}</label>
|
||
<select class="form-select" id="installRevproxyBackend" onchange="toggleRevproxyBackendOpts()">
|
||
<option value="none">{{ _('revproxy_backend_none') }}</option>
|
||
<option value="xray">Xray</option>
|
||
<option value="telemt">Telemt</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="revproxyBackendPortGroup">
|
||
<label class="form-label">{{ _('revproxy_backend_port') }}</label>
|
||
<input class="form-input" type="number" id="installRevproxyBackendPort" value="8443" min="1" max="65535">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="revproxyVpnPathGroup">
|
||
<label class="form-label">{{ _('revproxy_vpn_path') }}</label>
|
||
<input class="form-input" type="text" id="installRevproxyVpnPath" value="/cdn-cgi/challenge">
|
||
<div class="form-hint">{{ _('revproxy_vpn_path_hint') }}</div>
|
||
</div>
|
||
<div class="form-group" id="revproxyTelemtMaskGroup" style="display:none;">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="checkbox" id="installRevproxyTelemtMask" checked>
|
||
<span>{{ _('revproxy_telemt_mask') }}</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-hint">{{ _('revproxy_install_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
|
||
<span id="installBtnText">{{ _('install') }}</span>
|
||
<div class="spinner hidden" id="installSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="installProgress" class="hidden">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" id="installProgressFill" style="width: 0%"></div>
|
||
</div>
|
||
<div class="log-output" id="installLog"></div>
|
||
<div class="mt-md" id="installDone" style="display:none;">
|
||
<button class="btn btn-primary" onclick="closeModal('installModal'); window.location.reload();"
|
||
style="width:100%">
|
||
{{ _('done') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== SOCKS5 Settings Modal ===== -->
|
||
<div class="modal-backdrop" id="socks5SettingsModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('socks5_settings_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('socks5SettingsModal')">×</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('port') }}</label>
|
||
<input class="form-input" type="number" id="socks5SetPort" min="1" max="65535">
|
||
<div class="form-hint">{{ _('socks5_port_change_hint') }}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_username') }}</label>
|
||
<input class="form-input" type="text" id="socks5SetUser">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_password') }}</label>
|
||
<input class="form-input" type="text" id="socks5SetPass">
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('socks5SettingsModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="saveSocks5Settings()" id="socks5SaveBtn">
|
||
<span id="socks5SaveBtnText">{{ _('save') }}</span>
|
||
<div class="spinner hidden" id="socks5SaveSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Reverse Proxy Settings Modal ===== -->
|
||
<div class="modal-backdrop" id="revproxySettingsModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('revproxy_settings_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('revproxySettingsModal')">×</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_domain') }}</label>
|
||
<input class="form-input" type="text" id="revproxySetDomain" placeholder="vpn.example.com">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_site_title') }}</label>
|
||
<input class="form-input" type="text" id="revproxySetTitle">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_tls_email') }}</label>
|
||
<input class="form-input" type="email" id="revproxySetEmail">
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('revproxy_backend') }}</label>
|
||
<select class="form-select" id="revproxySetBackend" onchange="toggleRevproxySetBackendOpts()">
|
||
<option value="none">{{ _('revproxy_backend_none') }}</option>
|
||
<option value="xray">Xray</option>
|
||
<option value="telemt">Telemt</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="revproxySetBackendPortGroup">
|
||
<label class="form-label">{{ _('revproxy_backend_port') }}</label>
|
||
<input class="form-input" type="number" id="revproxySetBackendPort" min="1" max="65535">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="revproxySetVpnPathGroup">
|
||
<label class="form-label">{{ _('revproxy_vpn_path') }}</label>
|
||
<input class="form-input" type="text" id="revproxySetVpnPath">
|
||
</div>
|
||
<div class="form-group" id="revproxySetTelemtMaskGroup" style="display:none;">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="checkbox" id="revproxySetTelemtMask">
|
||
<span>{{ _('revproxy_telemt_mask') }}</span>
|
||
</label>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('revproxySettingsModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="saveRevproxySettings()" id="revproxySaveBtn">
|
||
<span id="revproxySaveBtnText">{{ _('save') }}</span>
|
||
<div class="spinner hidden" id="revproxySaveSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Add Connection Modal ===== -->
|
||
<div class="modal-backdrop" id="addConnectionModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="addConnectionModalTitle">{{ _('add_connection') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('addConnectionModal')">×</button>
|
||
</div>
|
||
|
||
<div class="form-group" id="connectionNameGroup">
|
||
<label class="form-label">{{ _('connection_name') }}</label>
|
||
<input class="form-input" type="text" id="connectionName"
|
||
placeholder="{{ _('connection_name_placeholder') }}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('assign_to_user') }}</label>
|
||
<select class="form-select" id="connectionUserId">
|
||
<option value="">{{ _('no_assign') }}</option>
|
||
{% for u in users %}
|
||
<option value="{{ u.id }}">{{ u.username }} ({{ u.role }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div id="telemtConnOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">Secret (Hex 32 chars)</label>
|
||
<input class="form-input" type="text" id="telemtSecret" placeholder="Optional (auto-generated if empty)">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">AD Tag (Hex 32 chars)</label>
|
||
<input class="form-input" type="text" id="telemtAdTag" placeholder="Optional">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max TCP Conns</label>
|
||
<input class="form-input" type="number" id="telemtMaxConns" placeholder="Optional (e.g. 1000)" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Quota (Bytes)</label>
|
||
<input class="form-input" type="number" id="telemtQuota" placeholder="Optional (e.g. 1073741824)"
|
||
min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max Unique IPs</label>
|
||
<input class="form-input" type="number" id="telemtMaxIps" placeholder="Optional (e.g. 16)" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Expiration Date (ISO 8601)</label>
|
||
<input class="form-input" type="text" id="telemtExpiry" placeholder="e.g. 2026-12-31T23:59:59Z">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('addConnectionModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="addConnection()" id="addConnBtn">
|
||
<span id="addConnBtnText">{{ _('create') }}</span>
|
||
<div class="spinner hidden" id="addConnSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Connection Config Modal (with Tabs) ===== -->
|
||
<div class="modal-backdrop" id="configModal">
|
||
<div class="modal" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="config-tabs">
|
||
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
|
||
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
||
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
||
</div>
|
||
|
||
<!-- Config Panel -->
|
||
<div class="config-panel active" id="panel-conf">
|
||
<div class="config-display">
|
||
<div class="config-text" id="configText"></div>
|
||
<div class="config-actions">
|
||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
|
||
{{ _('copy') }}
|
||
</button>
|
||
<button class="btn btn-primary btn-sm"
|
||
onclick="downloadFile(currentConfig, currentConfigName + '.conf')" style="flex:1">
|
||
{{ _('download_conf') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- VPN Key Panel -->
|
||
<div class="config-panel" id="panel-vpn">
|
||
<div class="vpn-link-box" id="vpnLinkText"></div>
|
||
<div class="config-actions" style="margin-top: var(--space-sm);">
|
||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentVpnLink)" style="flex:1">
|
||
{{ _('copy_key') }}
|
||
</button>
|
||
</div>
|
||
<div class="form-hint" style="margin-top: var(--space-sm);">
|
||
{{ _('vpn_link_hint') }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- QR Code Panel -->
|
||
<div class="config-panel" id="panel-qr">
|
||
<div class="qr-container">
|
||
<div id="qrCode"></div>
|
||
<div class="qr-hint">{{ _('qr_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ===== Server Config Modal ===== -->
|
||
<div class="modal-backdrop" id="serverConfigModal">
|
||
<div class="modal" style="max-width:680px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="serverConfigModalTitle">{{ _('server_config') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('serverConfigModal')">×</button>
|
||
</div>
|
||
<div class="config-display" style="margin-top:var(--space-md);">
|
||
<!-- Hidden input to store current proto -->
|
||
<input type="hidden" id="serverConfigProto" value="" />
|
||
<textarea class="form-input" id="serverConfigText"
|
||
style="width:100%; height:40vh; resize:vertical; font-family:monospace; white-space:pre; padding:var(--space-sm);"></textarea>
|
||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||
<button class="btn btn-secondary btn-sm"
|
||
onclick="copyToClipboard(document.getElementById('serverConfigText').value)" style="flex:1">
|
||
{{ _('copy') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm"
|
||
onclick="downloadFile(document.getElementById('serverConfigText').value, serverConfigFilename)"
|
||
style="flex:1">
|
||
{{ _('download') }}
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" onclick="saveServerConfig()" style="flex:1"
|
||
id="saveServerConfigBtn">
|
||
{{ _('save') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
const SERVER_ID = {{ server_id }};
|
||
const SERVER_HOST = "{{ server.host }}";
|
||
let currentInstallProto = 'awg';
|
||
let currentConfig = '';
|
||
let currentVpnLink = '';
|
||
let currentConfigName = 'connection';
|
||
let installedProtocols = {};
|
||
|
||
function switchCategory(cat) {
|
||
const grids = ['protocols-grid', 'services-grid', 'management-grid'];
|
||
const tabs = ['tab-protocols', 'tab-services', 'tab-management'];
|
||
|
||
grids.forEach(g => {
|
||
if (document.getElementById(g)) document.getElementById(g).classList.add('hidden');
|
||
});
|
||
tabs.forEach(t => {
|
||
const el = document.getElementById(t);
|
||
if (el) {
|
||
el.classList.remove('active');
|
||
el.style.borderBottomColor = 'transparent';
|
||
el.style.color = 'var(--text-muted)';
|
||
el.style.fontWeight = '400';
|
||
}
|
||
});
|
||
|
||
const activeGrid = document.getElementById(cat + '-grid');
|
||
const activeTab = document.getElementById('tab-' + cat);
|
||
|
||
if (activeGrid) activeGrid.classList.remove('hidden');
|
||
if (activeTab) {
|
||
activeTab.classList.add('active');
|
||
activeTab.style.borderBottomColor = 'var(--accent)';
|
||
activeTab.style.color = 'var(--text-primary)';
|
||
activeTab.style.fontWeight = '600';
|
||
}
|
||
}
|
||
|
||
function getProtoTitle(proto) {
|
||
switch (proto) {
|
||
case 'awg': return 'AmneziaWG';
|
||
case 'awg2': return 'AmneziaWG 2.0';
|
||
case 'awg_legacy': return 'AWG Legacy';
|
||
case 'xray': return 'Xray';
|
||
case 'telemt': return 'Telemt';
|
||
case 'wireguard': return 'WireGuard';
|
||
case 'dns': return 'AmneziaDNS';
|
||
case 'socks5': return 'SOCKS5 Proxy';
|
||
case 'adguard': return 'AdGuard Home';
|
||
case 'revproxy': return _('revproxy_title');
|
||
default: return proto;
|
||
}
|
||
}
|
||
|
||
// ========== Server Stats ==========
|
||
async function loadServerStats() {
|
||
try {
|
||
const stats = await apiCall(`/api/servers/${SERVER_ID}/stats`, 'POST');
|
||
const el = document.getElementById('serverStats');
|
||
el.style.display = '';
|
||
el.innerHTML = `
|
||
<span class="stat-pill" title="CPU">⚡ ${stats.cpu || 0}%</span>
|
||
<span class="stat-pill" title="RAM: ${formatBytes(stats.ram_used)} / ${formatBytes(stats.ram_total)}">🧠 ${stats.ram_percent || 0}%</span>
|
||
<span class="stat-pill" title="Disk: ${formatBytes(stats.disk_used)} / ${formatBytes(stats.disk_total)}">💾 ${stats.disk_percent || 0}%</span>
|
||
<span class="stat-pill" title="Network">🌐 ↓${formatBytes(stats.net_rx)} ↑${formatBytes(stats.net_tx)}</span>
|
||
`;
|
||
} catch (err) {
|
||
console.warn('Stats load failed:', err);
|
||
}
|
||
}
|
||
|
||
// ========== Server Check ==========
|
||
async function checkServer() {
|
||
const btn = document.getElementById('checkBtn');
|
||
const text = document.getElementById('checkBtnText');
|
||
const spinner = document.getElementById('checkSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('server_checking');
|
||
spinner.classList.remove('remove');
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/check`, 'POST');
|
||
const statusDiv = document.getElementById('serverStatus');
|
||
const badges = document.getElementById('statusBadges');
|
||
statusDiv.classList.remove('hidden');
|
||
let html = '';
|
||
if (data.connection === 'ok') {
|
||
html += `<span class="badge badge-success"><span class="badge-dot"></span> ${_('ssh_ok')}</span>`;
|
||
}
|
||
if (data.docker_installed) {
|
||
html += `<span class="badge badge-success"><span class="badge-dot"></span> ${_('docker_ok')}</span>`;
|
||
} else {
|
||
html += `<span class="badge badge-warn"><span class="badge-dot"></span> ${_('docker_not_installed')}</span>`;
|
||
}
|
||
badges.innerHTML = html;
|
||
if (data.protocols) {
|
||
const protoSelect = document.getElementById('connProtoSelect');
|
||
const currentVal = protoSelect.value;
|
||
protoSelect.innerHTML = '';
|
||
let firstVal = null;
|
||
|
||
for (const [proto, info] of Object.entries(data.protocols)) {
|
||
updateProtocolCard(proto, info);
|
||
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'].includes(proto);
|
||
if (info.container_running && isVPN) {
|
||
const opt = document.createElement('option');
|
||
opt.value = proto;
|
||
opt.textContent = getProtoTitle(proto);
|
||
protoSelect.appendChild(opt);
|
||
if (!firstVal) firstVal = proto;
|
||
}
|
||
}
|
||
|
||
if (protoSelect.innerHTML === '') {
|
||
const opt = document.createElement('option');
|
||
opt.textContent = 'Нет протоколов';
|
||
opt.disabled = true;
|
||
protoSelect.appendChild(opt);
|
||
hideConnectionsSection();
|
||
} else {
|
||
if (data.protocols[currentVal] && data.protocols[currentVal].container_running) {
|
||
protoSelect.value = currentVal;
|
||
} else if (firstVal) {
|
||
protoSelect.value = firstVal;
|
||
}
|
||
loadConnections();
|
||
}
|
||
}
|
||
updateStatusCounts(data.protocols || {});
|
||
showToast('Проверка завершена', 'success');
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
const statusDiv = document.getElementById('serverStatus');
|
||
const badges = document.getElementById('statusBadges');
|
||
statusDiv.classList.remove('hidden');
|
||
badges.innerHTML = `<span class="badge badge-danger"><span class="badge-dot"></span> Ошибка подключения</span>`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('server_check');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function updateStatusCounts(protocols) {
|
||
const protos = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'];
|
||
const servs = ['dns', 'adguard', 'socks5', 'revproxy'];
|
||
|
||
let pRunning = 0, pTotal = protos.length;
|
||
let sRunning = 0, sTotal = servs.length;
|
||
|
||
protos.forEach(p => {
|
||
if (protocols[p] && protocols[p].container_running) pRunning++;
|
||
});
|
||
servs.forEach(s => {
|
||
if (protocols[s] && protocols[s].container_running) sRunning++;
|
||
});
|
||
|
||
document.getElementById('protocols-count').textContent = `${pRunning}/${pTotal}`;
|
||
document.getElementById('services-count').textContent = `${sRunning}/${sTotal}`;
|
||
}
|
||
|
||
let serverConfigFilename = 'server.conf';
|
||
|
||
// Services that don't have per-user "connections" — they expose a single
|
||
// shared endpoint (DNS/proxy/admin UI) rather than per-client configs.
|
||
const SERVICE_PROTOS = ['dns', 'socks5', 'adguard', 'revproxy'];
|
||
|
||
function buildServiceInfoGrid(proto, info) {
|
||
// Custom info-grid markup for non-VPN services. Returns HTML for the grid body.
|
||
if (proto === 'dns') {
|
||
return `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">172.29.172.254:53 (Internal)</span></div>`;
|
||
}
|
||
if (proto === 'socks5') {
|
||
const user = info.username ? info.username : '—';
|
||
return `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/TCP</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('socks5_username')}</span><span class="protocol-info-value">${user}</span></div>
|
||
`;
|
||
}
|
||
if (proto === 'adguard') {
|
||
const mode = info.mode === 'replace' ? _('adguard_mode_replace') : _('adguard_mode_sidebyside');
|
||
const ip = info.internal_ip || '—';
|
||
const webPort = info.web_port || 3000;
|
||
const url = info.web_exposed
|
||
? `http://${SERVER_HOST}:${webPort}`
|
||
: `http://${ip}:${webPort}`;
|
||
return `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_mode')}</span><span class="protocol-info-value">${mode}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_internal_ip')}</span><span class="protocol-info-value">${ip}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_web_ui')}</span><span class="protocol-info-value"><a href="${url}" target="_blank" rel="noopener">${url}</a></span></div>
|
||
`;
|
||
}
|
||
if (proto === 'revproxy') {
|
||
const siteUrl = info.site_url || (info.domain ? `https://${info.domain}` : `https://${SERVER_HOST}`);
|
||
const backend = info.backend && info.backend !== 'none' ? info.backend.toUpperCase() : _('revproxy_backend_none');
|
||
let grid = `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">443/TCP</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('revproxy_site')}</span><span class="protocol-info-value"><a href="${siteUrl}" target="_blank" rel="noopener">${siteUrl}</a></span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('revproxy_backend')}</span><span class="protocol-info-value">${backend}</span></div>
|
||
`;
|
||
if (info.backend && info.backend !== 'none') {
|
||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('revproxy_vpn_path')}</span><span class="protocol-info-value">${info.vpn_path || '—'}</span></div>`;
|
||
}
|
||
return grid;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function updateProtocolCard(proto, info) {
|
||
const protoKey = proto.replace('_', '-');
|
||
const statusEl = document.getElementById(`${protoKey}-status`);
|
||
const infoEl = document.getElementById(`${protoKey}-info`);
|
||
const infoGrid = document.getElementById(`${protoKey}-info-grid`);
|
||
const actionsEl = document.getElementById(`${protoKey}-actions`);
|
||
const ctrlEl = document.getElementById(`${protoKey}-ctrl`);
|
||
|
||
// Some service cards may not exist in the template (e.g. on older builds);
|
||
// skip quietly rather than throwing so a partial UI still renders.
|
||
if (!statusEl || !actionsEl) return;
|
||
|
||
const isService = SERVICE_PROTOS.includes(proto);
|
||
|
||
if (info.container_running) {
|
||
installedProtocols[proto] = true;
|
||
statusEl.innerHTML = `<span class="badge badge-success"><span class="badge-dot"></span> ${_('run')}</span>`;
|
||
if (info.port || info.clients_count !== undefined || isService) {
|
||
infoEl.classList.remove('hidden');
|
||
let grid = '';
|
||
if (isService) {
|
||
grid = buildServiceInfoGrid(proto, info);
|
||
} else if (info.port) {
|
||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(proto === 'xray' || proto === 'telemt') ? 'TCP' : 'UDP'}</span></div>`;
|
||
}
|
||
if (!isService && info.clients_count !== undefined) {
|
||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
|
||
}
|
||
infoGrid.innerHTML = grid;
|
||
}
|
||
if (proto === 'socks5') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="openSocks5Settings()" style="flex:1">${_('socks5_change_settings')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (proto === 'adguard') {
|
||
const webPort = info.web_port || 3000;
|
||
const ip = info.internal_ip || '172.29.172.253';
|
||
const url = info.web_exposed
|
||
? `http://${SERVER_HOST}:${webPort}`
|
||
: `http://${ip}:${webPort}`;
|
||
actionsEl.innerHTML = `
|
||
<a class="btn btn-secondary btn-sm" href="${url}" target="_blank" rel="noopener" style="flex:1">${_('adguard_open_ui')}</a>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (proto === 'revproxy') {
|
||
const siteUrl = info.site_url || (info.domain ? `https://${info.domain}` : `https://${SERVER_HOST}`);
|
||
actionsEl.innerHTML = `
|
||
<a class="btn btn-secondary btn-sm" href="${siteUrl}" target="_blank" rel="noopener" style="flex:1">${_('revproxy_open_site')}</a>
|
||
<button class="btn btn-secondary btn-sm" onclick="openRevproxySettings()">${_('revproxy_change_settings')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (proto === 'dns') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')" style="flex:1">${_('uninstall')}</button>
|
||
`;
|
||
} else {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
showConnectionsSection();
|
||
}
|
||
// Show control buttons (stop + optional config). Services use the
|
||
// container's own config UI (AdGuard web wizard / our SOCKS5 modal /
|
||
// unbound's static config), so we don't expose a raw editor for them.
|
||
if (ctrlEl) {
|
||
ctrlEl.style.cssText = '';
|
||
if (isService) {
|
||
const cfgBtn = proto === 'revproxy'
|
||
? `<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>`
|
||
: '';
|
||
ctrlEl.innerHTML = `
|
||
${cfgBtn}
|
||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||
`;
|
||
} else {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||
`;
|
||
}
|
||
}
|
||
} else if (info.container_exists) {
|
||
installedProtocols[proto] = false;
|
||
statusEl.innerHTML = `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
|
||
if (isService) {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
}
|
||
if (ctrlEl) {
|
||
ctrlEl.style.cssText = '';
|
||
if (isService) {
|
||
ctrlEl.innerHTML = `
|
||
${proto === 'revproxy' ? `<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>` : ''}
|
||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||
`;
|
||
} else {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||
`;
|
||
}
|
||
}
|
||
} else {
|
||
installedProtocols[proto] = false;
|
||
statusEl.innerHTML = `<span class="badge badge-warn"><span class="badge-dot"></span> ${_('not_installed')}</span>`;
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('install')}</button>
|
||
`;
|
||
if (ctrlEl) {
|
||
ctrlEl.style.cssText = 'display:none!important;';
|
||
ctrlEl.innerHTML = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Toggle Docker container start/stop
|
||
async function toggleContainer(proto, isRunning) {
|
||
const name = getProtoTitle(proto);
|
||
const action = isRunning ? _('stopping') : _('starting');
|
||
if (!confirm(`${isRunning ? _('stop_container_confirm') : _('start_container_confirm')}`.replace('{}', name))) return;
|
||
try {
|
||
showToast(`${action} ${name}...`, 'info');
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
||
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
|
||
// Recheck after a short delay
|
||
setTimeout(() => checkServer(), 1500);
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// View server-side WireGuard/Xray config
|
||
async function showServerConfig(proto) {
|
||
const name = getProtoTitle(proto);
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/server_config`, 'POST', { protocol: proto });
|
||
document.getElementById('serverConfigModalTitle').textContent = `${_('server_config')} — ${name}`;
|
||
document.getElementById('serverConfigProto').value = proto;
|
||
document.getElementById('serverConfigText').value = res.config || '';
|
||
serverConfigFilename = `server_${proto}.conf`;
|
||
openModal('serverConfigModal');
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Save server config changes
|
||
async function saveServerConfig() {
|
||
const proto = document.getElementById('serverConfigProto').value;
|
||
const newConfig = document.getElementById('serverConfigText').value;
|
||
const btn = document.getElementById('saveServerConfigBtn');
|
||
const oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = _('saving') || 'Saving...';
|
||
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/server_config/save`, 'POST', {
|
||
protocol: proto,
|
||
config: newConfig
|
||
});
|
||
showToast(_('success') || 'Saved successfully', 'success');
|
||
closeModal('serverConfigModal');
|
||
} catch (err) {
|
||
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
|
||
function showConnectionsSection() {
|
||
const section = document.getElementById('connectionsSection');
|
||
if (section) section.style.display = 'block';
|
||
}
|
||
|
||
function hideConnectionsSection() {
|
||
const section = document.getElementById('connectionsSection');
|
||
if (section) section.style.display = 'none';
|
||
const select = document.getElementById('connProtoSelect');
|
||
if (select) {
|
||
select.innerHTML = '';
|
||
const opt = document.createElement('option');
|
||
opt.textContent = _('no_protocols');
|
||
opt.disabled = true;
|
||
select.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
// ========== Protocol Installation ==========
|
||
function openInstallModal(proto) {
|
||
currentInstallProto = proto;
|
||
const name = getProtoTitle(proto);
|
||
document.getElementById('installModalTitle').textContent = `${_('install')} ${name}`;
|
||
|
||
const portInput = document.getElementById('installPort');
|
||
const portLabel = document.getElementById('installPortLabel');
|
||
const portHint = document.getElementById('installPortHint');
|
||
const telemtOpts = document.getElementById('telemtOptions');
|
||
const socks5Opts = document.getElementById('socks5Options');
|
||
const adguardOpts = document.getElementById('adguardOptions');
|
||
const revproxyOpts = document.getElementById('revproxyOptions');
|
||
|
||
telemtOpts.style.display = 'none';
|
||
socks5Opts.style.display = 'none';
|
||
adguardOpts.style.display = 'none';
|
||
revproxyOpts.style.display = 'none';
|
||
|
||
if (proto === 'dns') {
|
||
portLabel.textContent = _('port') + ' (Internal)';
|
||
portInput.value = '53';
|
||
portInput.disabled = true;
|
||
portHint.textContent = _('dns_internal_hint');
|
||
} else if (proto === 'xray') {
|
||
portLabel.textContent = _('port') + ' (TCP)';
|
||
portInput.disabled = false;
|
||
portInput.value = '443';
|
||
portHint.textContent = _('port_xray_hint');
|
||
} else if (proto === 'telemt') {
|
||
portLabel.textContent = _('port') + ' (TCP)';
|
||
portInput.value = '443';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('telemt_port_hint');
|
||
telemtOpts.style.display = 'block';
|
||
} else if (proto === 'socks5') {
|
||
portLabel.textContent = _('port') + ' (TCP)';
|
||
portInput.value = '38080';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('socks5_port_hint');
|
||
socks5Opts.style.display = 'block';
|
||
} else if (proto === 'adguard') {
|
||
portLabel.textContent = _('adguard_dns_port');
|
||
portInput.value = '53';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('adguard_dns_port_hint');
|
||
adguardOpts.style.display = 'block';
|
||
} else if (proto === 'revproxy') {
|
||
portLabel.textContent = _('port') + ' (HTTPS)';
|
||
portInput.value = '443';
|
||
portInput.disabled = true;
|
||
portHint.textContent = _('revproxy_port_hint');
|
||
revproxyOpts.style.display = 'block';
|
||
toggleRevproxyBackendOpts();
|
||
} else {
|
||
portLabel.textContent = _('port') + ' (UDP)';
|
||
portInput.value = '55424';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('port_default_hint');
|
||
}
|
||
|
||
document.getElementById('installForm').classList.remove('hidden');
|
||
document.getElementById('installProgress').classList.add('hidden');
|
||
document.getElementById('installDone').style.display = 'none';
|
||
openModal('installModal');
|
||
}
|
||
|
||
async function installProtocol() {
|
||
const port = document.getElementById('installPort').value;
|
||
const btn = document.getElementById('installBtn');
|
||
const text = document.getElementById('installBtnText');
|
||
const spinner = document.getElementById('installSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('installing');
|
||
spinner.classList.remove('hidden');
|
||
document.getElementById('installForm').classList.add('hidden');
|
||
const progressDiv = document.getElementById('installProgress');
|
||
progressDiv.classList.remove('hidden');
|
||
const logEl = document.getElementById('installLog');
|
||
const fillEl = document.getElementById('installProgressFill');
|
||
logEl.innerHTML = '<div class="log-line info">Начинаем установку...</div>';
|
||
fillEl.style.width = '10%';
|
||
fillEl.style.background = '';
|
||
try {
|
||
const progressSteps = [
|
||
{ pct: 20, msg: _('check_docker') },
|
||
{ pct: 40, msg: _('prepare_host') },
|
||
{ pct: 50, msg: _('build_container') },
|
||
];
|
||
let stepIdx = 0;
|
||
const progressInterval = setInterval(() => {
|
||
if (stepIdx < progressSteps.length) {
|
||
fillEl.style.width = progressSteps[stepIdx].pct + '%';
|
||
logEl.innerHTML += `<div class="log-line info">${progressSteps[stepIdx].msg}</div>`;
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
stepIdx++;
|
||
}
|
||
}, 2000);
|
||
const params = {
|
||
protocol: currentInstallProto, port: port,
|
||
};
|
||
if (currentInstallProto === 'telemt') {
|
||
params.tls_emulation = document.getElementById('installTelemtTls').checked;
|
||
params.tls_domain = document.getElementById('installTelemtDomain').value;
|
||
params.max_connections = parseInt(document.getElementById('installTelemtMaxConns').value) || 0;
|
||
} else if (currentInstallProto === 'socks5') {
|
||
params.socks5_username = document.getElementById('installSocks5User').value || 'proxy_user';
|
||
params.socks5_password = document.getElementById('installSocks5Pass').value || '';
|
||
} else if (currentInstallProto === 'adguard') {
|
||
const modeRadio = document.querySelector('input[name="adguardMode"]:checked');
|
||
params.adguard_mode = modeRadio ? modeRadio.value : 'sidebyside';
|
||
params.adguard_web_port = parseInt(document.getElementById('installAdguardWebPort').value) || 3000;
|
||
params.adguard_expose_web = document.getElementById('installAdguardExposeWeb').checked;
|
||
params.adguard_dot_port = parseInt(document.getElementById('installAdguardDotPort').value) || 853;
|
||
params.adguard_doh_port = parseInt(document.getElementById('installAdguardDohPort').value) || 443;
|
||
params.adguard_expose_dot = document.getElementById('installAdguardExposeDot').checked;
|
||
params.adguard_expose_doh = document.getElementById('installAdguardExposeDoh').checked;
|
||
} else if (currentInstallProto === 'revproxy') {
|
||
params.revproxy_domain = document.getElementById('installRevproxyDomain').value.trim();
|
||
params.revproxy_site_title = document.getElementById('installRevproxyTitle').value.trim() || 'CloudEdge';
|
||
params.revproxy_tls_email = document.getElementById('installRevproxyEmail').value.trim();
|
||
params.revproxy_backend = document.getElementById('installRevproxyBackend').value;
|
||
params.revproxy_backend_port = parseInt(document.getElementById('installRevproxyBackendPort').value) || 8443;
|
||
params.revproxy_vpn_path = document.getElementById('installRevproxyVpnPath').value.trim();
|
||
params.revproxy_telemt_mask = document.getElementById('installRevproxyTelemtMask').checked;
|
||
}
|
||
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
|
||
clearInterval(progressInterval);
|
||
fillEl.style.width = '100%';
|
||
if (result.log) {
|
||
result.log.forEach(line => {
|
||
logEl.innerHTML += `<div class="log-line success">✓ ${line}</div>`;
|
||
});
|
||
}
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
showToast(_('install_success'), 'success');
|
||
document.getElementById('installDone').style.display = '';
|
||
} catch (err) {
|
||
fillEl.style.width = '100%';
|
||
fillEl.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)';
|
||
logEl.innerHTML += `<div class="log-line error">✕ ${_('error')}: ${err.message}</div>`;
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
showToast(_('install_error') + err.message, 'error');
|
||
document.getElementById('installDone').style.display = '';
|
||
}
|
||
}
|
||
|
||
async function uninstallProtocol(proto) {
|
||
const name = getProtoTitle(proto);
|
||
if (!confirm(_('uninstall_confirm').replace('{}', name))) return;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/uninstall`, 'POST', { protocol: proto });
|
||
showToast(`${name} ${_('stopped')}`, 'success');
|
||
setTimeout(() => window.location.reload(), 800);
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Reverse Proxy Settings ==========
|
||
function toggleRevproxyBackendOpts() {
|
||
const backend = document.getElementById('installRevproxyBackend').value;
|
||
const show = backend !== 'none';
|
||
document.getElementById('revproxyBackendPortGroup').style.display = show ? '' : 'none';
|
||
document.getElementById('revproxyVpnPathGroup').style.display = show ? '' : 'none';
|
||
document.getElementById('revproxyTelemtMaskGroup').style.display = backend === 'telemt' ? '' : 'none';
|
||
}
|
||
|
||
function toggleRevproxySetBackendOpts() {
|
||
const backend = document.getElementById('revproxySetBackend').value;
|
||
const show = backend !== 'none';
|
||
document.getElementById('revproxySetBackendPortGroup').style.display = show ? '' : 'none';
|
||
document.getElementById('revproxySetVpnPathGroup').style.display = show ? '' : 'none';
|
||
document.getElementById('revproxySetTelemtMaskGroup').style.display = backend === 'telemt' ? '' : 'none';
|
||
}
|
||
|
||
async function openRevproxySettings() {
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/revproxy/settings`);
|
||
document.getElementById('revproxySetDomain').value = data.domain || '';
|
||
document.getElementById('revproxySetTitle').value = data.site_title || 'CloudEdge';
|
||
document.getElementById('revproxySetEmail').value = data.tls_email || '';
|
||
document.getElementById('revproxySetBackend').value = data.backend || 'none';
|
||
document.getElementById('revproxySetBackendPort').value = data.backend_port || 8443;
|
||
document.getElementById('revproxySetVpnPath').value = data.vpn_path || '/cdn-cgi/challenge';
|
||
document.getElementById('revproxySetTelemtMask').checked = false;
|
||
toggleRevproxySetBackendOpts();
|
||
openModal('revproxySettingsModal');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveRevproxySettings() {
|
||
const btn = document.getElementById('revproxySaveBtn');
|
||
const text = document.getElementById('revproxySaveBtnText');
|
||
const spinner = document.getElementById('revproxySaveSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('saving');
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/revproxy/settings`, 'POST', {
|
||
domain: document.getElementById('revproxySetDomain').value.trim(),
|
||
site_title: document.getElementById('revproxySetTitle').value.trim(),
|
||
tls_email: document.getElementById('revproxySetEmail').value.trim(),
|
||
backend: document.getElementById('revproxySetBackend').value,
|
||
backend_port: parseInt(document.getElementById('revproxySetBackendPort').value) || 8443,
|
||
vpn_path: document.getElementById('revproxySetVpnPath').value.trim(),
|
||
telemt_mask: document.getElementById('revproxySetTelemtMask').checked,
|
||
});
|
||
showToast(_('success'), 'success');
|
||
closeModal('revproxySettingsModal');
|
||
setTimeout(() => checkServer(), 500);
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('save');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ========== SOCKS5 Settings ==========
|
||
async function openSocks5Settings() {
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/socks5/credentials`);
|
||
document.getElementById('socks5SetPort').value = data.port || 38080;
|
||
document.getElementById('socks5SetUser').value = data.username || 'proxy_user';
|
||
document.getElementById('socks5SetPass').value = data.password || '';
|
||
openModal('socks5SettingsModal');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveSocks5Settings() {
|
||
const btn = document.getElementById('socks5SaveBtn');
|
||
const text = document.getElementById('socks5SaveBtnText');
|
||
const spinner = document.getElementById('socks5SaveSpinner');
|
||
const port = parseInt(document.getElementById('socks5SetPort').value) || 38080;
|
||
const username = document.getElementById('socks5SetUser').value.trim();
|
||
const password = document.getElementById('socks5SetPass').value.trim();
|
||
btn.disabled = true;
|
||
text.textContent = _('saving') || 'Saving...';
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/socks5/credentials`, 'POST', {
|
||
port, username, password
|
||
});
|
||
if (res.status === 'success') {
|
||
showToast(_('socks5_settings_saved'), 'success');
|
||
closeModal('socks5SettingsModal');
|
||
setTimeout(() => checkServer(), 500);
|
||
} else {
|
||
throw new Error(res.message || res.error || 'Unknown error');
|
||
}
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('save');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ========== Connection Management ==========
|
||
function selectProtocolForConns(proto) {
|
||
document.getElementById('connectionsSection').style.display = '';
|
||
document.getElementById('connProtoSelect').value = proto;
|
||
loadConnections();
|
||
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
async function loadConnections() {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const loading = document.getElementById('connectionsLoading');
|
||
const emptyEl = document.getElementById('connectionsEmpty');
|
||
const listEl = document.getElementById('connectionsListInner');
|
||
|
||
if (!proto) {
|
||
loading.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
loading.style.display = '';
|
||
emptyEl.classList.add('hidden');
|
||
listEl.innerHTML = '';
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
|
||
loading.style.display = 'none';
|
||
if (!data.clients || data.clients.length === 0) {
|
||
emptyEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
window.connectionsStore = {};
|
||
data.clients.forEach((client, idx) => {
|
||
const userData = client.userData || {};
|
||
window.connectionsStore[client.clientId] = userData;
|
||
const name = userData.clientName || `Connection ${idx}`;
|
||
const created = userData.creationDate || '';
|
||
const handshake = userData.latestHandshake || '';
|
||
const received = userData.dataReceived || '';
|
||
const sent = userData.dataSent || '';
|
||
const initial = name.charAt(0).toUpperCase();
|
||
const enabled = (client.enabled !== undefined) ? client.enabled : (userData.enabled !== false);
|
||
const hasPrivKey = !!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt';
|
||
const assignedUser = client.assigned_user || '';
|
||
|
||
let metaHtml = '';
|
||
if (assignedUser) metaHtml += `<span class="badge badge-info" style="font-size:0.65rem;">👤 ${escapeHtml(assignedUser)}</span>`;
|
||
if (created) metaHtml += `<span>📅 ${formatDate(created)}</span>`;
|
||
|
||
if (proto === 'telemt') {
|
||
if (userData.total_octets) metaHtml += `<span class="traffic-badge">📊 ${formatBytes(userData.total_octets)}</span>`;
|
||
if (userData.current_connections !== undefined) metaHtml += `<span>🔌 ${userData.current_connections} conns</span>`;
|
||
if (userData.active_ips !== undefined) metaHtml += `<span>📍 ${userData.active_ips} IPs</span>`;
|
||
if (userData.quota) metaHtml += `<span class="badge" style="font-size:0.65rem;">Quota: ${formatBytes(userData.quota)}</span>`;
|
||
if (userData.expiry) metaHtml += `<span class="badge" style="font-size:0.65rem;">⌛ ${formatDate(userData.expiry)}</span>`;
|
||
} else {
|
||
if (handshake) metaHtml += `<span>🤝 ${handshake}</span>`;
|
||
if (received) metaHtml += `<span class="traffic-badge traffic-down">↓ ${received}</span>`;
|
||
if (sent) metaHtml += `<span class="traffic-badge traffic-up">↑ ${sent}</span>`;
|
||
}
|
||
|
||
if (!enabled) metaHtml += `<span class="badge badge-danger" style="font-size:0.65rem">${_('stop')}</span>`;
|
||
|
||
const disabledStyle = enabled ? '' : 'opacity:0.5;';
|
||
const toggleIcon = enabled ? '🔵' : '⚫';
|
||
const toggleTitle = enabled ? _('stop') : _('starting');
|
||
// Show config button for all WireGuard-based clients.
|
||
// If clientPrivateKey is missing (created via native app), we show a warning instead.
|
||
const canShowConfig = proto === 'xray' || true; // always show 📄 button
|
||
|
||
listEl.innerHTML += `
|
||
<div class="client-item" style="${disabledStyle}">
|
||
<div class="client-info">
|
||
<div class="client-avatar">${initial}</div>
|
||
<div>
|
||
<div class="client-name">${escapeHtml(name)}</div>
|
||
<div class="client-meta">${metaHtml || `<span class="text-muted">${_('no_data')}</span>`}</div>
|
||
</div>
|
||
</div>
|
||
<div class="client-actions">
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt'})" title="${_('config')}">📄</button>
|
||
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">✏️</button>` : ''}
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled})" title="${toggleTitle}">${toggleIcon}</button>
|
||
<button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">🗑</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
} catch (err) {
|
||
loading.style.display = 'none';
|
||
showToast('Ошибка загрузки подключений: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function addConnection() {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const connName = document.getElementById('connectionName').value.trim() || 'Connection';
|
||
const userId = document.getElementById('connectionUserId').value || null;
|
||
const btn = document.getElementById('addConnBtn');
|
||
const text = document.getElementById('addConnBtnText');
|
||
const spinner = document.getElementById('addConnSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('creating');
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const params = {
|
||
protocol: proto, name: connName, user_id: userId,
|
||
};
|
||
if (proto === 'telemt') {
|
||
params.telemt_quota = document.getElementById('telemtQuota').value || null;
|
||
params.telemt_max_ips = parseInt(document.getElementById('telemtMaxIps').value) || null;
|
||
params.telemt_expiry = document.getElementById('telemtExpiry').value || null;
|
||
params.telemt_secret = document.getElementById('telemtSecret').value || null;
|
||
params.telemt_ad_tag = document.getElementById('telemtAdTag').value || null;
|
||
params.telemt_max_conns = parseInt(document.getElementById('telemtMaxConns').value) || null;
|
||
if (isEditingConn) params.client_id = editingClientId;
|
||
}
|
||
const result = await apiCall(isEditingConn ? `/api/servers/${SERVER_ID}/connections/edit` : `/api/servers/${SERVER_ID}/connections/add`, 'POST', params);
|
||
if (isEditingConn) {
|
||
showToast('Подключение обновлено', 'success');
|
||
closeModal('addConnectionModal');
|
||
} else {
|
||
showToast(_('connection_created').replace('{}', connName), 'success');
|
||
closeModal('addConnectionModal');
|
||
document.getElementById('connectionName').value = '';
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
document.getElementById('configModalTitle').textContent = `Конфигурация — ${connName}`;
|
||
document.getElementById('configText').textContent = result.config;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
|
||
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
generateQR(result.config);
|
||
}
|
||
}
|
||
loadConnections();
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = 'Создать';
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
let isEditingConn = false;
|
||
let editingClientId = null;
|
||
|
||
function openAddConnectionModal(isEdit = false, clientData = null) {
|
||
isEditingConn = isEdit;
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const telemtOpts = document.getElementById('telemtConnOptions');
|
||
|
||
document.getElementById('addConnectionModalTitle').textContent = isEdit ? 'Редактировать подключение' : 'Добавить подключение';
|
||
document.getElementById('addConnBtnText').textContent = isEdit ? 'Сохранить' : 'Создать';
|
||
document.getElementById('connectionNameGroup').style.display = isEdit ? 'none' : 'block';
|
||
|
||
if (proto === 'telemt') {
|
||
telemtOpts.style.display = 'block';
|
||
if (isEdit && clientData) {
|
||
editingClientId = clientData.clientName;
|
||
document.getElementById('telemtQuota').value = clientData.quota || '';
|
||
document.getElementById('telemtMaxIps').value = '';
|
||
document.getElementById('telemtExpiry').value = clientData.expiry || '';
|
||
document.getElementById('telemtSecret').value = clientData.token || '';
|
||
document.getElementById('telemtAdTag').value = clientData.user_ad_tag || '';
|
||
document.getElementById('telemtMaxConns').value = clientData.max_tcp_conns || '';
|
||
} else {
|
||
document.getElementById('telemtQuota').value = '';
|
||
document.getElementById('telemtMaxIps').value = '';
|
||
document.getElementById('telemtExpiry').value = '';
|
||
document.getElementById('telemtSecret').value = '';
|
||
document.getElementById('telemtAdTag').value = '';
|
||
document.getElementById('telemtMaxConns').value = '';
|
||
}
|
||
} else {
|
||
telemtOpts.style.display = 'none';
|
||
}
|
||
openModal('addConnectionModal');
|
||
}
|
||
|
||
function editConnection(clientId) {
|
||
const userData = window.connectionsStore[clientId];
|
||
if (userData) {
|
||
userData.clientName = clientId;
|
||
openAddConnectionModal(true, userData);
|
||
}
|
||
}
|
||
|
||
async function removeConnection(clientId) {
|
||
if (!confirm(_('delete_connection_confirm'))) return;
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/connections/remove`, 'POST', {
|
||
protocol: proto, client_id: clientId,
|
||
});
|
||
showToast(_('connection_deleted'), 'success');
|
||
loadConnections();
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function showConnectionConfig(clientId, connName, hasKey) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
|
||
// Restore tabs visibility first
|
||
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
|
||
document.getElementById('panel-qr').style.display = '';
|
||
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
|
||
document.querySelectorAll('.config-tab')[2].style.display = '';
|
||
|
||
// Client was created via native Amnezia app — private key is not stored server-side
|
||
if (!hasKey) {
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||
currentConfig = '';
|
||
currentVpnLink = '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
document.getElementById('configText').innerHTML = `<div style="padding:var(--space-md);background:var(--bg-primary);border-radius:var(--radius-md);border:1px solid var(--border-color);"><div style="font-size:1.4rem;margin-bottom:var(--space-sm);">⚠️</div><div style="font-weight:600;margin-bottom:var(--space-sm);">${_('config_unavailable')}</div><div style="font-size:0.85rem;color:var(--text-muted);line-height:1.6;">${_('config_unavailable_desc').replace(/\\n/g, '<br>')}<br><br><strong>${_('client_public_key')}</strong><br><code style="word-break:break-all;font-size:0.78rem;">${escapeHtml(clientId)}</code></div></div>`;
|
||
document.getElementById('vpnLinkText').textContent = '';
|
||
document.getElementById('panel-vpn').style.display = 'none';
|
||
document.getElementById('panel-qr').style.display = 'none';
|
||
document.querySelectorAll('.config-tab')[1].style.display = 'none';
|
||
document.querySelectorAll('.config-tab')[2].style.display = 'none';
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const result = await apiCall(`/api/servers/${SERVER_ID}/connections/config`, 'POST', {
|
||
protocol: proto, client_id: clientId,
|
||
});
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||
document.getElementById('configText').textContent = result.config;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
generateQR(result.config);
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function toggleConnection(clientId, enable) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const action = enable ? 'Включить' : 'Отключить';
|
||
try {
|
||
showToast(`${action} подключение...`, 'info');
|
||
await apiCall(`/api/servers/${SERVER_ID}/connections/toggle`, 'POST', {
|
||
protocol: proto, client_id: clientId, enable: enable,
|
||
});
|
||
showToast(`Подключение ${enable ? 'включено' : 'отключено'}`, 'success');
|
||
loadConnections();
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Config Tabs ==========
|
||
function switchConfigTab(tab) {
|
||
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
|
||
const tabs = document.querySelectorAll('.config-tab');
|
||
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
|
||
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
|
||
tabs[tabIdx[tab]].classList.add('active');
|
||
document.getElementById(panels[tab]).classList.add('active');
|
||
}
|
||
|
||
function generateQR(text) {
|
||
const container = document.getElementById('qrCode');
|
||
container.innerHTML = '';
|
||
try {
|
||
new QRCode(container, {
|
||
text: text, width: 280, height: 280,
|
||
colorDark: '#000000', colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.L
|
||
});
|
||
} catch (e) {
|
||
console.error('QR lib error:', e);
|
||
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${_('qr_error')}</div>`;
|
||
}
|
||
}
|
||
|
||
// ========== Utilities ==========
|
||
function formatDate(dateStr) {
|
||
try {
|
||
const d = new Date(dateStr);
|
||
if (isNaN(d)) return dateStr;
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
} catch { return dateStr; }
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes || bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function escapeJs(str) {
|
||
return str.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||
}
|
||
|
||
// ========== Management ==========
|
||
async function rebootServer() {
|
||
if (!confirm('Are you sure you want to reboot the server? Disconnections will occur.')) return;
|
||
try {
|
||
showToast('Rebooting server...', 'info');
|
||
await apiCall(`/api/servers/${SERVER_ID}/reboot`, 'POST');
|
||
showToast('Server is rebooting', 'success');
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function clearServer() {
|
||
if (!confirm("⚠️ WARNING: This will completely delete all Amnezia configurations, containers, and data from /opt/amnezia on the server! Are you absolutely sure?")) return;
|
||
try {
|
||
showToast('Clearing server from Amnezia software...', 'info');
|
||
await apiCall(`/api/servers/${SERVER_ID}/clear`, 'POST');
|
||
showToast('Server cleared successfully!', 'success');
|
||
setTimeout(() => location.reload(), 1500);
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function removeServer() {
|
||
if (!confirm("Are you sure you want to remove this server from the panel? (The server itself will not be erased).")) return;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/delete`, 'POST');
|
||
window.location.href = '/';
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Init ==========
|
||
checkServer();
|
||
loadServerStats();
|
||
</script>
|
||
{% endblock %} |