Files
domen_hydra/server/templates/index.html
Андрей Бобырев e63138eca0 feat: WireGuard VPN генератор
- Новая вкладка «🔒 WireGuard VPN» в панели
- Авто-установка WireGuard сервера на VPS (apt + systemd wg-quick@wg0)
- Генерация ключевых пар (сервер + клиент на каждый роутер)
- Выдача IP из подсети 10.8.0.0/24
- Деплой на роутер по SSH: opkg install wireguard-tools + wg-quick + init.d
- Попытка нативной интеграции через Keenetic CLI (ndmc) — роутер появится
  в «Приоритетах подключений»; fallback на wg-quick если ndmc недоступен
- Просмотр и копирование конфигов (wg0.conf для VPS и для каждого роутера)
- Добавление/удаление пиров с авто-обновлением wg0.conf на VPS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:30:03 +03:00

696 lines
50 KiB
HTML
Raw 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.

<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HydraRoute Manager</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#000;--card:#1c1c1e;--card2:#2c2c2e;--card3:#3a3a3c;--border:#38383a;--text:#f5f5f7;--muted:#86868b;--accent:#0a84ff;--green:#30d158;--red:#ff453a;--yellow:#ffd60a;--orange:#ff9f0a}
body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',sans-serif;background:var(--bg);color:var(--text);padding:20px 24px 56px;max-width:1200px;margin:0 auto}
h1{font-size:22px;font-weight:700;margin-bottom:4px}
.sub{color:var(--muted);font-size:13px;margin-bottom:20px}
.topbar{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:20px;margin-top:12px}
.topbar-btns{display:flex;gap:8px;flex-wrap:wrap}
.tabs{display:flex;gap:8px;margin-bottom:20px}
.tab{padding:8px 18px;border-radius:10px;border:none;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;background:var(--card);color:var(--muted)}
.tab.active{background:var(--accent);color:#fff}
.section{background:var(--card);border-radius:16px;padding:18px 20px;margin-bottom:16px}
.section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.section-head h2{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}
.groups-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px}
.group{background:var(--card2);border-radius:12px;padding:0;overflow:hidden;transition:box-shadow .2s}
.group.editing{box-shadow:0 0 0 2px var(--accent)}
.group-head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px 8px}
.group-name{font-weight:700;font-size:13px}
.group-actions{display:flex;gap:4px}
.icon-btn{background:none;border:none;cursor:pointer;font-size:14px;padding:2px 6px;border-radius:6px;color:var(--muted)}
.icon-btn:hover{background:var(--card3);color:var(--text)}
.badge{font-size:11px;padding:2px 8px;border-radius:6px;font-weight:600;white-space:nowrap}
.p-HydraRoute,.p-amnezia{background:rgba(10,132,255,.15);color:var(--accent)}
.p-RU{background:rgba(255,214,10,.15);color:var(--yellow)}
.p-Google{background:rgba(48,209,88,.15);color:var(--green)}
.p-default{background:rgba(255,159,10,.15);color:var(--orange)}
.group-view{padding:0 14px 12px}
.entries-list{font-size:11px;color:var(--muted);line-height:1.7;max-height:90px;overflow-y:auto;font-family:monospace}
.group-meta{font-size:11px;color:var(--border);margin-top:6px}
.group-edit{padding:10px 14px 14px;display:none;border-top:1px solid var(--border)}
.group-edit.open{display:block}
.edit-label{font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:600}
textarea.entries-ta{width:100%;min-height:120px;background:var(--card3);border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-size:11px;font-family:monospace;outline:none;resize:vertical}
textarea.entries-ta:focus{border-color:var(--accent)}
.add-quick{display:flex;gap:6px;margin-top:8px}
input.quick-input{background:var(--card3);border:1px solid var(--border);border-radius:8px;padding:7px 10px;color:var(--text);font-size:12px;font-family:monospace;outline:none;flex:1}
input.quick-input:focus{border-color:var(--accent)}
input.quick-input::placeholder{color:var(--muted)}
.edit-footer{display:flex;justify-content:space-between;align-items:center;margin-top:10px;gap:8px}
.edit-footer-left{display:flex;gap:6px}
.btn{padding:8px 16px;border-radius:8px;border:none;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap}
.btn-b{background:var(--accent);color:#fff}.btn-r{background:var(--red);color:#fff}.btn-g{background:var(--green);color:#000}.btn-o{background:var(--orange);color:#000}
.btn-ghost{background:var(--card2);color:var(--muted)}.btn-ghost:hover{background:var(--card3)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.new-group-form{background:var(--card2);border-radius:12px;padding:14px;border:1px dashed var(--border);display:none}
.new-group-form.open{display:block}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
input.f-inp,select.f-sel{background:var(--card3);border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--text);font-size:12px;font-family:inherit;outline:none}
input.f-inp:focus,select.f-sel:focus{border-color:var(--accent)}
.msg{padding:7px 12px;border-radius:8px;font-size:12px;font-weight:600;margin-top:6px;display:none}
.msg.ok{display:block;background:rgba(48,209,88,.1);color:var(--green)}
.msg.err{display:block;background:rgba(255,69,58,.1);color:var(--red)}
.push-log{background:#111;border-radius:10px;padding:12px;font-size:11px;font-family:monospace;color:var(--muted);max-height:120px;overflow-y:auto;margin-top:10px;display:none}
.pending-banner{background:rgba(255,159,10,.12);border:1px solid rgba(255,159,10,.4);border-radius:12px;padding:12px 16px;display:none;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}
.pending-banner span{font-size:13px;color:var(--orange);font-weight:600}
.hdr{padding:0 24px;height:54px;background:rgba(28,28,30,.95);backdrop-filter:blur(20px);border-bottom:1px solid rgba(255,255,255,.08);position:sticky;top:0;z-index:100;display:flex;align-items:center;justify-content:space-between;gap:16px;margin:-20px -24px 20px}
.hdr-logo{font-size:17px;font-weight:800;white-space:nowrap}.hdr-logo em{font-style:normal;color:var(--accent)}
.logout-btn{background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:20px;padding:5px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;flex-shrink:0}
.imp-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:600px){.imp-grid{grid-template-columns:1fr}.groups-grid{grid-template-columns:1fr}}
textarea.imp-ta{width:100%;min-height:200px;background:var(--card2);border:1px solid var(--border);border-radius:10px;padding:12px;color:var(--text);font-size:11px;font-family:monospace;outline:none;resize:vertical}
textarea.imp-ta:focus{border-color:var(--accent)}
.spinner{display:inline-block;width:12px;height:12px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:5px}
@keyframes spin{to{transform:rotate(360deg)}}
.version{font-size:11px;color:var(--muted);font-family:monospace}
.toggle{width:34px;height:20px;background:var(--border);border-radius:10px;position:relative;cursor:pointer;border:none;transition:.2s}
.toggle.on{background:var(--green)}.toggle::after{content:'';position:absolute;width:16px;height:16px;background:#fff;border-radius:50%;top:2px;left:2px;transition:.2s}
.toggle.on::after{left:16px}
input[type=text],input[type=password]{background:var(--card2);border:1px solid var(--border);border-radius:10px;padding:9px 14px;color:var(--text);font-size:13px;font-family:inherit;outline:none}
input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
.ri{padding:10px 0;border-bottom:1px solid rgba(255,255,255,.05);font-size:13px;display:flex;justify-content:space-between;align-items:center;font-weight:500}
.ri:last-child{border:none}.ri .n{font-weight:700;color:var(--accent)}
</style></head><body>
<!-- AUTH OVERLAY -->
<div id="auth-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.97);z-index:9999;align-items:center;justify-content:center">
<div style="background:#1c1c1e;border-radius:24px;padding:40px 36px;width:min(360px,90vw);text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.8)">
<div style="font-size:44px;margin-bottom:14px">🌐</div>
<h2 style="font-size:20px;font-weight:700;color:#f5f5f7;margin-bottom:6px">HydraRoute Manager</h2>
<p style="font-size:13px;color:#86868b;margin-bottom:22px">Введите пароль для доступа</p>
<input id="auth-pwd" type="password" placeholder="Пароль" autocomplete="current-password"
style="width:100%;background:#2c2c2e;border:1px solid #38383a;border-radius:12px;padding:12px 16px;color:#f5f5f7;font-size:15px;outline:none;font-family:inherit;margin-bottom:8px;box-sizing:border-box"
onkeydown="if(event.key==='Enter')_doLogin()">
<div id="auth-err" style="color:#ff453a;font-size:12px;font-weight:600;margin-bottom:8px;display:none">❌ Неверный пароль</div>
<button onclick="_doLogin()" id="auth-btn" style="width:100%;background:#0a84ff;color:#fff;border:none;border-radius:12px;padding:13px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit">Войти</button>
</div>
</div>
<!-- NAV -->
<div class="hdr">
<span class="hdr-logo">🌐 <em>HydraRoute</em> Manager</span>
<button class="logout-btn" onclick="logout()">Выйти</button>
</div>
<div class="topbar">
<div>
<h1>🌐 Управление доменами</h1>
<div class="sub">HydraRoute Neo · <span id="ver" class="version">...</span></div>
</div>
<div class="topbar-btns">
<button class="btn btn-b" onclick="pushAll()" id="push-btn">📡 Обновить все роутеры</button>
<a href="/hydra/domain.conf" target="_blank" class="btn btn-ghost" style="text-decoration:none">↓ domain.conf</a>
<a href="/hydra/ip.list" target="_blank" class="btn btn-ghost" style="text-decoration:none">↓ ip.list</a>
</div>
</div>
<div id="pending-banner" class="pending-banner">
<span>⚠️ Есть несохранённые изменения — роутеры ещё не обновлены</span>
<button class="btn btn-o" onclick="pushAll()">📡 Отправить сейчас</button>
</div>
<div id="push-log" class="push-log"></div>
<div class="tabs">
<button class="tab active" onclick="showTab('view')">📋 Конфигурация</button>
<button class="tab" onclick="showTab('import')">⬆ Импорт файлов</button>
<button class="tab" onclick="showTab('routers')">🔧 Роутеры</button>
<button class="tab" onclick="showTab('vpn')">🔒 WireGuard VPN</button>
<button class="tab" onclick="showTab('settings')">⚙ Настройки</button>
</div>
<!-- CONFIGURATION TAB -->
<div id="tab-view">
<div class="section">
<div class="section-head">
<h2>📄 Группы доменов <span id="dg-count" class="badge p-HydraRoute">0</span></h2>
<button class="btn btn-b" onclick="toggleNewGroup('domain')" style="font-size:12px">+ Новая группа</button>
</div>
<div id="new-domain-form" class="new-group-form" style="margin-bottom:12px">
<div class="form-row">
<input class="f-inp" id="nd-name" placeholder="Имя группы" style="width:140px">
<select class="f-sel" id="nd-policy"><option>HydraRoute</option><option>RU</option><option>Google</option><option>amnezia</option></select>
<select class="f-sel" id="nd-type"><option value="domain">domain</option><option value="geosite">geosite</option></select>
</div>
<textarea class="entries-ta" id="nd-entries" placeholder="Одна запись на строку:&#10;youtube.com&#10;netflix.com" style="min-height:80px"></textarea>
<div style="margin-top:8px;display:flex;gap:6px">
<button class="btn btn-g" onclick="saveNewGroup('domain')">✅ Создать</button>
<button class="btn btn-ghost" onclick="toggleNewGroup('domain')">Отмена</button>
</div>
</div>
<div id="domain-groups" class="groups-grid"></div>
</div>
<div class="section">
<div class="section-head">
<h2>🔢 Группы IP <span id="ig-count" class="badge p-RU">0</span></h2>
<button class="btn btn-b" onclick="toggleNewGroup('ip')" style="font-size:12px">+ Новая группа</button>
</div>
<div id="new-ip-form" class="new-group-form" style="margin-bottom:12px">
<div class="form-row">
<input class="f-inp" id="ni-name" placeholder="Имя группы" style="width:140px">
<select class="f-sel" id="ni-policy"><option>HydraRoute</option><option>RU</option><option>Google</option><option>amnezia</option></select>
<select class="f-sel" id="ni-type"><option value="ip">ip</option><option value="geoip">geoip</option></select>
</div>
<textarea class="entries-ta" id="ni-entries" placeholder="1.2.3.0/24&#10;geoip:ru" style="min-height:80px"></textarea>
<div style="margin-top:8px;display:flex;gap:6px">
<button class="btn btn-g" onclick="saveNewGroup('ip')">✅ Создать</button>
<button class="btn btn-ghost" onclick="toggleNewGroup('ip')">Отмена</button>
</div>
</div>
<div id="ip-groups" class="groups-grid"></div>
</div>
<div id="view-msg" class="msg"></div>
</div>
<!-- IMPORT TAB -->
<div id="tab-import" style="display:none">
<div class="section">
<div class="section-head"><h2>⬆ Импорт domain.conf + ip.list</h2></div>
<p style="font-size:12px;color:var(--muted);margin-bottom:12px">Вставь содержимое файлов с роутера — формат сохраняется 1:1</p>
<div class="imp-grid">
<div>
<div style="font-size:12px;font-weight:700;margin-bottom:6px;color:var(--accent)">domain.conf</div>
<textarea class="imp-ta" id="dc-text" placeholder="##groupname&#10;domain1.com,domain2.com/PolicyName"></textarea>
</div>
<div>
<div style="font-size:12px;font-weight:700;margin-bottom:6px;color:var(--green)">ip.list</div>
<textarea class="imp-ta" id="il-text" placeholder="##groupname&#10;/PolicyName&#10;1.2.3.0/24"></textarea>
</div>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-b" onclick="importFiles()">💾 Сохранить на сервер</button>
<button class="btn btn-g" onclick="importAndPush()">💾📡 Сохранить + обновить все роутеры</button>
</div>
<div id="im" class="msg"></div>
</div>
<div class="section">
<div class="section-head"><h2>📤 Текущая конфигурация (для копирования)</h2></div>
<div class="imp-grid">
<div>
<div style="font-size:12px;font-weight:700;margin-bottom:6px;color:var(--accent)">domain.conf</div>
<textarea class="imp-ta" id="dc-export" readonly></textarea>
</div>
<div>
<div style="font-size:12px;font-weight:700;margin-bottom:6px;color:var(--green)">ip.list</div>
<textarea class="imp-ta" id="il-export" readonly></textarea>
</div>
</div>
</div>
</div>
<!-- ROUTERS TAB -->
<div id="tab-routers" style="display:none">
<div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px"> Добавить роутер</h2>
<p style="font-size:12px;color:var(--muted);margin-bottom:10px">Поле <b>IP</b> — адрес для <b>SSH</b> (LAN, белый IP или DDNS), не веб-ссылка KeenDNS. После сохранения можно <b>Тест SSH</b> и <b>Снять с роутера</b>.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="text" id="r-name" placeholder="Имя (andrey)" style="width:130px">
<input type="text" id="r-ip" placeholder="IP или hostname SSH" style="min-width:160px;flex:1;max-width:240px">
<input type="text" id="r-user" value="root" style="width:80px">
<input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px">
<button class="btn btn-b" onclick="addRouter()">+ Добавить</button>
<button class="btn" style="background:var(--orange);color:#000" onclick="addAndTunnel()" title="Добавить роутер без IP и сразу открыть настройку тоннеля">⇄ Тоннель</button>
</div>
<div id="rm" class="msg"></div>
</div>
<div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px">📋 Роутеры</h2>
<div id="routers-list"><span style="color:var(--muted)">Загрузка...</span></div>
<div id="router-ops-msg" class="push-log" style="display:none;margin-top:12px;max-height:200px"></div>
</div>
</div>
<!-- TUNNEL MODAL -->
<div id="tun-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center">
<div style="background:var(--card);border-radius:18px;padding:24px;max-width:600px;width:94%;position:relative">
<button onclick="closeTunnel()" style="position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer"></button>
<h2 style="font-size:16px;font-weight:700;margin-bottom:4px">⇄ Обратный тоннель</h2>
<div style="font-size:12px;color:var(--muted);margin-bottom:16px">Роутер: <b id="tun-rname"></b> · Порт VPS: <span id="tun-port" style="color:var(--green)"></span></div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Скопируй команду и вставь в SSH-терминал <b>роутера</b> (там где <code style="background:var(--card2);padding:1px 4px;border-radius:4px">~ #</code>):</div>
<div style="position:relative;margin-bottom:12px">
<code id="tun-cmd" style="display:block;background:var(--card2);border-radius:10px;padding:12px;font-size:11px;word-break:break-all;min-height:40px;color:var(--text)">Генерирую...</code>
<button id="tun-copy-btn" onclick="copyTunnelCmd()" style="position:absolute;top:8px;right:8px;background:var(--accent);color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer">📋 Копировать</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-b" onclick="checkTunnelStatus()" style="font-size:12px">⟳ Проверить связь</button>
<button class="btn" onclick="closeTunnel()" style="font-size:12px;background:var(--green);color:#fff">✓ Готово</button>
<span id="tun-status" style="font-size:12px;color:var(--muted)"></span>
</div>
<div style="margin-top:14px;font-size:11px;color:var(--muted)">
Если автозапуск не сработал — на роутере: <code style="background:var(--card2);padding:1px 4px;border-radius:4px">/opt/bin/hydra_tun</code> (вручную) · лог: <code style="background:var(--card2);padding:1px 4px;border-radius:4px">cat /tmp/hydra_tun.log</code>
</div>
</div>
</div>
<!-- VPN TAB -->
<div id="tab-vpn" style="display:none">
<div class="section">
<div class="section-head">
<h2>🖥 WireGuard сервер (VPS)</h2>
<button class="btn btn-ghost" onclick="loadWg()" style="font-size:11px">↺ Обновить</button>
</div>
<div id="wg-server-card"><span style="color:var(--muted)">Загрузка...</span></div>
</div>
<div class="section" id="wg-routers-section" style="display:none">
<div class="section-head">
<h2>📡 Роутеры в VPN</h2>
</div>
<p style="font-size:12px;color:var(--muted);margin-bottom:12px">Добавь роутер — сгенерируются ключи и IP. Потом нажми «Установить на роутер» — скрипт сам настроит WireGuard через SSH.</p>
<div id="wg-routers-list"></div>
<div id="wg-ops-log" style="display:none;background:var(--card2);border-radius:10px;padding:12px;font-size:11px;font-family:monospace;white-space:pre-wrap;max-height:220px;overflow-y:auto;margin-top:12px;color:var(--text)"></div>
</div>
</div>
<!-- WG CONFIG MODAL -->
<div id="wg-cfg-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center">
<div style="background:var(--card);border-radius:18px;padding:24px;max-width:620px;width:94%;position:relative">
<button onclick="document.getElementById('wg-cfg-modal').style.display='none'" style="position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer"></button>
<h2 style="font-size:15px;font-weight:700;margin-bottom:12px" id="wg-cfg-title">WireGuard конфиг</h2>
<textarea id="wg-cfg-text" readonly style="width:100%;min-height:260px;background:var(--card2);border:1px solid var(--border);border-radius:10px;padding:12px;font-size:11px;font-family:monospace;color:var(--text);resize:vertical;outline:none"></textarea>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-b" onclick="copyWgCfg()">📋 Копировать</button>
<button class="btn btn-ghost" onclick="document.getElementById('wg-cfg-modal').style.display='none'">✓ Закрыть</button>
</div>
<div id="wg-cfg-hint" style="margin-top:10px;font-size:11px;color:var(--muted)"></div>
</div>
</div>
<!-- SETTINGS TAB -->
<div id="tab-settings" style="display:none">
<div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px">🔑 Смена пароля</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="password" id="new-pwd" placeholder="Новый пароль" style="width:200px">
<input type="password" id="new-pwd2" placeholder="Повторить" style="width:200px">
<button class="btn btn-b" onclick="changePwd()">💾 Сохранить</button>
</div>
<div id="pwd-msg" class="msg"></div>
</div>
</div>
<script>
// ── AUTH ──────────────────────────────────────────────────────────────────
function getPass(){ return sessionStorage.getItem('hm_pass')||''; }
function authHdr(){ return {'Content-Type':'application/json','X-Admin-Password':getPass()}; }
function _showLogin(){ const o=document.getElementById('auth-overlay'); o.style.display='flex'; setTimeout(()=>document.getElementById('auth-pwd').focus(),100); }
async function _checkAuth(){
const p=sessionStorage.getItem('hm_pass'); if(!p){ _showLogin(); return false; }
const r=await fetch('/api/auth',{headers:{'X-Admin-Password':p}});
if(!r.ok){ sessionStorage.removeItem('hm_pass'); _showLogin(); return false; }
return true;
}
async function _doLogin(){
const p=document.getElementById('auth-pwd').value; if(!p) return;
const err=document.getElementById('auth-err'),btn=document.getElementById('auth-btn');
err.style.display='none'; btn.textContent='Проверка...'; btn.disabled=true;
const r=await fetch('/api/auth',{headers:{'X-Admin-Password':p}});
if(r.ok){ sessionStorage.setItem('hm_pass',p); document.getElementById('auth-overlay').style.display='none'; _afterLogin(); }
else { err.style.display='block'; }
btn.textContent='Войти'; btn.disabled=false;
}
function logout(){ sessionStorage.removeItem('hm_pass'); _showLogin(); }
function _afterLogin(){ loadConfig(); }
// ── TABS ──────────────────────────────────────────────────────────────────
const TABS = ['view','import','routers','vpn','settings'];
function showTab(t){
TABS.forEach(id=>{ document.getElementById('tab-'+id).style.display=id===t?'':'none'; });
document.querySelectorAll('.tab').forEach((el,i)=>el.classList.toggle('active',i===TABS.indexOf(t)));
if(t==='import') loadExport();
if(t==='routers') loadRouters();
if(t==='vpn') loadWg();
}
// ── CONFIG ────────────────────────────────────────────────────────────────
let CFG={domain_groups:[],ip_groups:[]};
let pendingChanges=false;
function markPending(){ pendingChanges=true; document.getElementById('pending-banner').style.display='flex'; }
function clearPending(){ pendingChanges=false; document.getElementById('pending-banner').style.display='none'; }
function pc(p){ const m={'HydraRoute':'p-HydraRoute','RU':'p-RU','Google':'p-Google','amnezia':'p-HydraRoute'}; return m[p]||'p-default'; }
async function loadConfig(){
CFG=await(await fetch('/hydra/config')).json();
const ver=await(await fetch('/hydra/version')).text();
document.getElementById('ver').textContent='v: '+ver.trim();
renderGroups();
}
function renderGroups(){
document.getElementById('dg-count').textContent=CFG.domain_groups.length;
document.getElementById('ig-count').textContent=CFG.ip_groups.length;
document.getElementById('domain-groups').innerHTML=CFG.domain_groups.map((g,i)=>groupCard(g,i,'domain')).join('');
document.getElementById('ip-groups').innerHTML=CFG.ip_groups.map((g,i)=>groupCard(g,i,'ip')).join('');
}
function groupCard(g,idx,type){
const key=`${type}-${idx}`;
const entries=g.entries.join('\n');
const preview=g.entries.slice(0,6).join('<br>')+(g.entries.length>6?`<br><span style="color:var(--border)">+${g.entries.length-6} ещё...</span>`:'');
return `<div class="group" id="card-${key}">
<div class="group-head">
<div style="display:flex;align-items:center;gap:8px">
<button class="toggle ${g.enabled?'on':''}" onclick="toggleGroup('${type}',${idx})"></button>
<span class="group-name">${g.name}</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span class="badge ${pc(g.policy)}">${g.policy}</span>
<div class="group-actions">
<button class="icon-btn" onclick="openEdit('${type}',${idx})">✏️</button>
<button class="icon-btn" onclick="deleteGroup('${type}','${g.name}')">🗑</button>
</div>
</div>
</div>
<div class="group-view" id="view-${key}">
<div class="entries-list">${preview}</div>
<div class="group-meta">${g.entries.length} записей · ${g.entry_type}</div>
</div>
<div class="group-edit" id="edit-${key}">
<div class="edit-label">Записи (по одной на строку):</div>
<textarea class="entries-ta" id="ta-${key}">${entries}</textarea>
<div class="add-quick">
<input class="quick-input" id="qi-${key}" placeholder="Быстро добавить домен или IP" onkeydown="if(event.key==='Enter')quickAdd('${type}',${idx})">
<button class="btn btn-b" onclick="quickAdd('${type}',${idx})">+</button>
</div>
<div class="edit-footer">
<div class="edit-footer-left">
<button class="btn btn-g" onclick="saveGroup('${type}',${idx})">💾 Сохранить</button>
<button class="btn btn-ghost" onclick="closeEdit('${type}',${idx})">Отмена</button>
</div>
<span style="font-size:11px;color:var(--muted)" id="cnt-${key}">${g.entries.length} записей</span>
</div>
</div>
</div>`;
}
function openEdit(type,idx){ const key=`${type}-${idx}`; document.getElementById(`edit-${key}`).classList.add('open'); document.getElementById(`card-${key}`).classList.add('editing'); const ta=document.getElementById(`ta-${key}`); ta.addEventListener('input',()=>{ document.getElementById(`cnt-${key}`).textContent=ta.value.split('\n').filter(l=>l.trim()).length+' записей'; }); }
function closeEdit(type,idx){ const key=`${type}-${idx}`; document.getElementById(`edit-${key}`).classList.remove('open'); document.getElementById(`card-${key}`).classList.remove('editing'); }
function quickAdd(type,idx){ const key=`${type}-${idx}`; const qi=document.getElementById(`qi-${key}`); const val=qi.value.trim(); if(!val)return; const ta=document.getElementById(`ta-${key}`); ta.value=ta.value.trimEnd()+(ta.value.trim()?'\n':'')+val; qi.value=''; document.getElementById(`cnt-${key}`).textContent=ta.value.split('\n').filter(l=>l.trim()).length+' записей'; }
async function saveGroup(type,idx){ const key=`${type}-${idx}`; const g=type==='domain'?CFG.domain_groups[idx]:CFG.ip_groups[idx]; const entries=document.getElementById(`ta-${key}`).value.split('\n').map(l=>l.trim()).filter(l=>l); const url=type==='domain'?'/api/domain-group':'/api/ip-group'; try{ await fetch(url,{method:'POST',headers:authHdr(),body:JSON.stringify({...g,entries})}); sm('view-msg','ok',`${g.name} сохранён`); markPending(); await loadConfig(); }catch(e){sm('view-msg','err','❌ '+e)} }
async function deleteGroup(type,name){ if(!confirm(`Удалить "${name}"?`))return; const url=type==='domain'?`/api/domain-group/${name}`:`/api/ip-group/${name}`; await fetch(url,{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); sm('view-msg','ok',`🗑 ${name} удалён`); markPending(); await loadConfig(); }
async function toggleGroup(type,idx){ const g=type==='domain'?CFG.domain_groups[idx]:CFG.ip_groups[idx]; const url=type==='domain'?'/api/domain-group':'/api/ip-group'; await fetch(url,{method:'POST',headers:authHdr(),body:JSON.stringify({...g,enabled:!g.enabled})}); markPending(); await loadConfig(); }
function toggleNewGroup(type){ document.getElementById(type==='domain'?'new-domain-form':'new-ip-form').classList.toggle('open'); }
async function saveNewGroup(type){ const pfx=type==='domain'?'nd':'ni'; const name=document.getElementById(`${pfx}-name`).value.trim(); const policy=document.getElementById(`${pfx}-policy`).value; const entry_type=document.getElementById(`${pfx}-type`).value; const raw=document.getElementById(`${pfx}-entries`).value; if(!name){alert('Введи имя группы');return;} const entries=raw.split(/[\n,]/).map(l=>l.trim()).filter(l=>l); const url=type==='domain'?'/api/domain-group':'/api/ip-group'; await fetch(url,{method:'POST',headers:authHdr(),body:JSON.stringify({name,entries,policy,entry_type,enabled:true})}); sm('view-msg','ok',`✅ Группа "${name}" создана`); document.getElementById(`${pfx}-name`).value=''; document.getElementById(`${pfx}-entries`).value=''; toggleNewGroup(type); markPending(); await loadConfig(); }
// ── PUSH ──────────────────────────────────────────────────────────────────
async function pushAll(){
const btn=document.getElementById('push-btn'); const log=document.getElementById('push-log');
btn.disabled=true; btn.innerHTML='<span class="spinner"></span>Обновляю...'; log.style.display='block'; log.textContent='Запуск...\n';
try{
const j=await(await fetch('/api/push_all',{method:'POST',headers:{'X-Admin-Password':getPass()}})).json();
log.textContent='';
for(const r of j.results) log.textContent+=`${r.ok?'✅':'❌'} ${r.router}: ${r.msg}\n`;
log.textContent+=`\nИтого: ${j.ok} успешно, ${j.failed} ошибок`;
if(j.failed===0) clearPending();
}catch(e){log.textContent='❌ '+e}
btn.disabled=false; btn.innerHTML='📡 Обновить все роутеры';
}
// ── IMPORT ────────────────────────────────────────────────────────────────
async function importFiles(){ const dc=document.getElementById('dc-text').value.trim(); const il=document.getElementById('il-text').value.trim(); if(!dc&&!il){sm('im','err','❌ Поля пусты');return;} const b={}; if(dc)b.domain_conf=dc; if(il)b.ip_list=il; try{ const j=await(await fetch('/api/import',{method:'POST',headers:authHdr(),body:JSON.stringify(b)})).json(); sm('im','ok',`${j.domains} доменных групп, ${j.ips} IP групп`); markPending(); await loadConfig(); }catch(e){sm('im','err','❌ '+e)} }
async function importAndPush(){ await importFiles(); setTimeout(pushAll,300); }
async function loadExport(){ document.getElementById('dc-export').value=await(await fetch('/hydra/domain.conf')).text(); document.getElementById('il-export').value=await(await fetch('/hydra/ip.list')).text(); }
// ── ROUTERS ───────────────────────────────────────────────────────────────
function _escHtml(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
(function _bindRouterList(){
const box=document.getElementById('routers-list');
if(!box||box.dataset.actBind==='1') return;
box.dataset.actBind='1';
box.addEventListener('click',async (e)=>{
const b=e.target.closest('button[data-r-act]');
if(!b) return;
const n=decodeURIComponent(b.getAttribute('data-router')||'');
const act=b.getAttribute('data-r-act');
if(act==='test') await testRouter(n);
else if(act==='pull') await pullRouter(n);
else if(act==='push') await pushRouter(n);
else if(act==='tunnel') openTunnel(n);
else if(act==='del') await delRouter(n);
});
})();
async function loadRouters(){
const R=await(await fetch('/api/routers')).json();
const el=document.getElementById('routers-list');
if(!Object.keys(R).length){ el.innerHTML='<span style="color:var(--muted)">Роутеры не добавлены</span>'; return; }
el.innerHTML=Object.entries(R).map(([n,c])=>{
const enc=encodeURIComponent(n);
return `<div class="ri" style="align-items:flex-start;gap:10px">
<div style="flex:1;min-width:0"><span class="n">${_escHtml(n)}</span> <span style="font-size:12px">${c.tunnel_port?`<span style="color:var(--green)">⇄ тоннель :${c.tunnel_port}</span>`:`<span style="color:var(--muted)">— ${_escHtml(c.ip||'—')}</span>`}</span></div>
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end">
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:5px 10px" data-r-act="test" data-router="${enc}">🔌 Тест</button>
<button type="button" class="btn btn-b" style="font-size:11px;padding:5px 10px" data-r-act="pull" data-router="${enc}">⬇ С роутера</button>
<button type="button" class="btn btn-g" style="font-size:11px;padding:5px 10px" data-r-act="push" data-router="${enc}">📡 На роутер</button>
<button type="button" class="btn" style="background:var(--orange);color:#000;font-size:11px;padding:5px 10px" data-r-act="tunnel" data-router="${enc}">⇄</button>
<button type="button" class="btn btn-r" style="font-size:11px;padding:5px 10px" data-r-act="del" data-router="${enc}">✗</button>
</div></div>`;
}).join('');
}
async function addRouter(){ const n=document.getElementById('r-name').value.trim(); if(!n)return; const b={ip:document.getElementById('r-ip').value.trim(),user:document.getElementById('r-user').value||'root',password:document.getElementById('r-pass').value}; try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); sm('rm','ok','✅ '+n); loadRouters(); }catch(e){sm('rm','err','❌ '+e)} }
async function addAndTunnel(){
const n=document.getElementById('r-name').value.trim();
if(!n){sm('rm','err','❌ Введи имя роутера');return;}
const pwd=document.getElementById('r-pass').value;
if(!pwd){sm('rm','err','❌ Введи SSH пароль роутера');return;}
const b={ip:'',user:document.getElementById('r-user').value||'root',password:pwd};
try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); await loadRouters(); openTunnel(n); }catch(e){sm('rm','err','❌ '+e)}
}
async function delRouter(n){ if(!confirm('Удалить '+n+'?'))return; await fetch('/api/routers/'+encodeURIComponent(n),{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); loadRouters(); }
async function testRouter(n){
const log=document.getElementById('router-ops-msg');
log.style.display='block'; log.textContent='Проверка «'+n+'»…\n';
try{
const r=await fetch('/api/routers/'+encodeURIComponent(n)+'/test',{method:'POST',headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){ log.textContent='❌ '+(j.detail||r.status); return; }
log.textContent=(j.ok?'✅':'⚠️')+' «'+n+'»\n'+j.detail;
}catch(e){ log.textContent='❌ '+e; }
}
async function pullRouter(n){
if(!confirm('Считать domain.conf и ip.list с «'+n+'» и подставить во вкладку «Импорт»? На сервер не сохранится, пока не нажмёшь «Сохранить на сервер».')) return;
try{
const r=await fetch('/api/routers/'+encodeURIComponent(n)+'/fetch',{headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){ sm('rm','err','❌ '+(j.detail||r.status)); return; }
if(!j.domain_ok && !j.ip_ok){ sm('rm','err','❌ На роутере не найдены файлы в /opt/etc/HydraRoute или /opt/etc/hydra'); return; }
document.getElementById('dc-text').value=j.domain_ok?(j.domain_conf||''):'';
document.getElementById('il-text').value=j.ip_ok?(j.ip_list||''):'';
showTab('import');
const parts=[]; if(j.domain_ok) parts.push('domain.conf'); if(j.ip_ok) parts.push('ip.list');
sm('im','ok','✅ Подставлено: '+parts.join(', ')+(j.domain_ok&&j.ip_ok?'':' (часть файлов не найдена — пустое поле)'));
}catch(e){ sm('rm','err','❌ '+e); }
}
async function pushRouter(n){
const log=document.getElementById('router-ops-msg');
log.style.display='block'; log.textContent='Отправка на «'+n+'»…\n';
try{
const r=await fetch('/api/routers/'+encodeURIComponent(n)+'/push',{method:'POST',headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){ log.textContent='❌ '+(Array.isArray(j.detail)?j.detail.map(x=>x.msg||x).join(' '):(j.detail||r.status)); return; }
log.textContent=(j.ok?'✅':'❌')+' «'+n+'»: '+(j.msg||'');
}catch(e){ log.textContent='❌ '+e; }
}
// ── TUNNEL ────────────────────────────────────────────────────────────────
let _tunName='';
function openTunnel(name){
_tunName=name;
document.getElementById('tun-rname').textContent=name;
document.getElementById('tun-port').textContent='…';
document.getElementById('tun-cmd').textContent='Генерирую команду...';
document.getElementById('tun-status').textContent='';
document.getElementById('tun-copy-btn').textContent='📋 Копировать';
document.getElementById('tun-modal').style.display='flex';
getTunnelCmd(name);
}
function closeTunnel(){ document.getElementById('tun-modal').style.display='none'; }
async function getTunnelCmd(name){
const el=document.getElementById('tun-cmd');
try{
const r=await fetch('/api/routers/'+encodeURIComponent(name)+'/tunnel-cmd',{headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){el.textContent='❌ '+(j.detail||'Ошибка'); return;}
el.textContent=j.cmd;
document.getElementById('tun-port').textContent=j.tunnel_port;
}catch(e){el.textContent='❌ '+e;}
}
function copyTunnelCmd(){
const el=document.getElementById('tun-cmd');
const btn=document.getElementById('tun-copy-btn');
const text=el.textContent;
const done=()=>{btn.textContent='✓ Скопировано'; setTimeout(()=>btn.textContent='📋 Копировать',2000);};
const fail=()=>{
const range=document.createRange(); range.selectNodeContents(el);
const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
try{document.execCommand('copy');done();}catch{btn.textContent='Выдели текст → Cmd/Ctrl+C';}
};
if(navigator.clipboard){navigator.clipboard.writeText(text).then(done).catch(fail);}else{fail();}
}
async function checkTunnelStatus(){
const el=document.getElementById('tun-status');
el.textContent='Проверяю...'; el.style.color='var(--muted)';
try{
const r=await fetch('/api/routers/'+encodeURIComponent(_tunName)+'/tunnel-status',{headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
el.textContent=j.active?'✅ Активен (порт '+j.tunnel_port+')':'❌ Не активен';
el.style.color=j.active?'var(--green)':'var(--red)';
}catch(e){el.textContent='❌ '+e; el.style.color='var(--red)';}
}
// ── WIREGUARD VPN ─────────────────────────────────────────────────────────
let WG = null;
async function loadWg(){
try {
const r = await fetch('/api/wireguard', {headers: authHdr()});
WG = await r.json();
renderWgServer();
await renderWgRouters();
} catch(e) {
document.getElementById('wg-server-card').innerHTML = '<span style="color:var(--red)">Ошибка загрузки: '+e+'</span>';
}
}
function renderWgServer(){
const el = document.getElementById('wg-server-card');
if(!WG.initialized){
el.innerHTML = `
<p style="font-size:13px;color:var(--muted);margin-bottom:14px">WireGuard не установлен на VPS. Нажми кнопку — скрипт установит <code>wireguard</code>, сгенерирует ключи и запустит сервер.</p>
<button class="btn btn-b" onclick="wgInitServer()" id="wg-init-btn">⚡ Установить WireGuard на VPS</button>
<div id="wg-init-msg" class="msg"></div>`;
} else {
const statusColor = WG.running ? 'var(--green)' : 'var(--red)';
const statusText = WG.running ? '✅ Работает (wg0)' : '❌ Остановлен';
el.innerHTML = `
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start">
<div style="min-width:120px">
<div style="font-size:11px;color:var(--muted);margin-bottom:2px">Статус</div>
<div style="font-weight:700;color:${statusColor}">${statusText}</div>
<div style="font-size:11px;color:var(--muted);margin-top:6px">Порт</div>
<div style="font-weight:600">${WG.port}</div>
</div>
<div style="flex:1;min-width:200px">
<div style="font-size:11px;color:var(--muted);margin-bottom:2px">Публичный ключ VPS (для клиентов)</div>
<div style="font-family:monospace;font-size:11px;word-break:break-all;background:var(--card2);padding:8px 10px;border-radius:8px">${WG.public_key}</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
${!WG.running ? `<button class="btn btn-b" onclick="wgInitServer()">⚡ Запустить</button>` : ''}
<button class="btn btn-ghost" onclick="wgShowServerConfig()" style="font-size:11px">📄 wg0.conf</button>
</div>
</div>`;
document.getElementById('wg-routers-section').style.display = '';
}
}
async function renderWgRouters(){
if(!WG || !WG.initialized) return;
let routers = {};
try { routers = await (await fetch('/api/routers', {headers: authHdr()})).json(); } catch(e){}
const el = document.getElementById('wg-routers-list');
const entries = Object.entries(routers);
if(!entries.length){ el.innerHTML = '<p style="color:var(--muted);font-size:12px">Нет роутеров. Добавь их во вкладке «Роутеры».</p>'; return; }
el.innerHTML = entries.map(([name, r]) => {
const peer = WG.peers[name];
const hasPeer = !!peer;
return `<div style="background:var(--card2);border-radius:12px;padding:12px 14px;margin-bottom:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<div style="font-weight:700;font-size:13px;min-width:110px">${name}</div>
<div style="font-size:12px;flex:1">
${hasPeer
? `<span style="color:var(--green)">● VPN&nbsp;IP:&nbsp;<b>${peer.ip}</b></span>`
: `<span style="color:var(--muted)">— не добавлен</span>`}
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${!hasPeer
? `<button class="btn btn-b" style="font-size:11px" onclick="wgAddPeer('${name}')">+ В VPN</button>`
: `<button class="btn btn-g" style="font-size:11px" onclick="wgDeploy('${name}')">📡 Установить на роутер</button>
<button class="btn btn-ghost" style="font-size:11px" onclick="wgShowRouterConfig('${name}')">📄 Конфиг</button>
<button class="btn btn-r" style="font-size:11px" onclick="wgRemovePeer('${name}')">✕ Убрать</button>`}
</div>
</div>`;
}).join('');
}
async function wgInitServer(){
const btn = document.getElementById('wg-init-btn');
if(btn){ btn.disabled=true; btn.textContent='Устанавливаю (~1 мин)...'; }
try {
const r = await fetch('/api/wireguard/init', {method:'POST', headers: authHdr()});
const d = await r.json();
if(d.ok){ await loadWg(); }
else { alert('Ошибка: '+(d.detail||JSON.stringify(d))); if(btn){btn.disabled=false;btn.textContent='⚡ Установить WireGuard на VPS';} }
} catch(e){ alert('Ошибка: '+e); if(btn){btn.disabled=false;btn.textContent='⚡ Установить WireGuard на VPS';} }
}
async function wgAddPeer(name){
try {
const r = await fetch(`/api/routers/${encodeURIComponent(name)}/wireguard`, {method:'POST', headers: authHdr()});
const d = await r.json();
if(d.ok){ await loadWg(); }
else alert('Ошибка: '+(d.detail||JSON.stringify(d)));
} catch(e){ alert('Ошибка: '+e); }
}
async function wgRemovePeer(name){
if(!confirm(`Удалить ${name} из WireGuard VPN?`)) return;
try {
await fetch(`/api/routers/${encodeURIComponent(name)}/wireguard`, {method:'DELETE', headers: authHdr()});
await loadWg();
} catch(e){ alert('Ошибка: '+e); }
}
async function wgDeploy(name){
const log = document.getElementById('wg-ops-log');
log.style.display = 'block';
log.textContent = `Устанавливаю WireGuard на роутер «${name}»...\n`;
try {
const r = await fetch(`/api/routers/${encodeURIComponent(name)}/wireguard/deploy`, {method:'POST', headers: authHdr()});
const d = await r.json();
log.textContent += d.output || '';
if(!d.ok) log.textContent += '\n❌ Завершилось с ошибкой';
else log.textContent += '\n✅ Готово';
log.scrollTop = log.scrollHeight;
} catch(e){ log.textContent += '\nОшибка: '+e; }
}
async function wgShowServerConfig(){
try {
const r = await fetch('/api/wireguard/server-config', {headers: authHdr()});
const text = await r.text();
document.getElementById('wg-cfg-title').textContent = 'wg0.conf — конфиг VPS сервера';
document.getElementById('wg-cfg-text').value = text;
document.getElementById('wg-cfg-hint').textContent = 'Этот файл лежит на VPS в /etc/wireguard/wg0.conf';
document.getElementById('wg-cfg-modal').style.display = 'flex';
} catch(e){ alert('Ошибка: '+e); }
}
async function wgShowRouterConfig(name){
try {
const r = await fetch(`/api/routers/${encodeURIComponent(name)}/wireguard-config`, {headers: authHdr()});
const text = await r.text();
document.getElementById('wg-cfg-title').textContent = `wg0.conf — конфиг роутера «${name}»`;
document.getElementById('wg-cfg-text').value = text;
document.getElementById('wg-cfg-hint').textContent = 'Можно вручную добавить в Keenetic: Интернет → WireGuard → Добавить подключение → Вставить из буфера';
document.getElementById('wg-cfg-modal').style.display = 'flex';
} catch(e){ alert('Ошибка: '+e); }
}
function copyWgCfg(){
const t = document.getElementById('wg-cfg-text').value;
if(navigator.clipboard){ navigator.clipboard.writeText(t); }
else { document.getElementById('wg-cfg-text').select(); document.execCommand('copy'); }
}
// ── SETTINGS ──────────────────────────────────────────────────────────────
async function changePwd(){ const p1=document.getElementById('new-pwd').value; const p2=document.getElementById('new-pwd2').value; if(p1!==p2){sm('pwd-msg','err','❌ Пароли не совпадают');return;} if(p1.length<4){sm('pwd-msg','err','❌ Минимум 4 символа');return;} try{ const r=await fetch('/api/set_password',{method:'POST',headers:authHdr(),body:JSON.stringify({password:p1})}); if(r.ok){ sessionStorage.setItem('hm_pass',p1); sm('pwd-msg','ok','✅ Пароль сохранён'); document.getElementById('new-pwd').value=''; document.getElementById('new-pwd2').value=''; } }catch(e){sm('pwd-msg','err','❌ '+e)} }
function sm(id,c,t){const e=document.getElementById(id);e.className='msg '+c;e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',5000)}
_checkAuth().then(ok=>{ if(ok) _afterLogin(); });
</script>
<div id="hm-foot" lang="ru" style="position:fixed;bottom:10px;left:12px;z-index:90;max-width:min(96vw,720px);font-size:11px;font-weight:600;letter-spacing:.02em;color:#86868b;opacity:.92;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;line-height:1.3">
<span style="color:#6e6e73;font-weight:500;margin-right:2px">поддержка:</span>
<a href="https://github.com/andrey271192/domen_hydra" target="_blank" rel="noopener noreferrer" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">GitHub</a>
<span style="color:#86868b;user-select:none">·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">Boosty&nbsp;(донат)</a>
<span style="color:#86868b;user-select:none">·</span>
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer" title="Поддержка (Ozon Bank, СБП)" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">Ozon&nbsp;СБП</a>
<span style="color:#86868b;user-select:none">·</span>
<a href="https://t.me/Iot_andrey" target="_blank" rel="noopener noreferrer" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">Telegram&nbsp;@Iot_andrey</a>
</div>
</body></html>