Files
kaskad/webui/templates/index.html
andrey271192 035534e4f2 feat(webui): смена пароля и кнопка выхода
- POST /api/auth/password — смена пароля админа с проверкой текущего,
  атомарно переписывает /etc/kaskad/webui.env и hot-reload WEB_PASS
  в памяти (рестарт не нужен)
- GET  /api/auth/whoami   — текущий логин (показываем в шапке)
- GET  /api/logout        — 401 с уникальным realm → браузер сбрасывает
  кеш Basic Auth
- UI: кнопки 🔑 Пароль и ↪ Выход в шапке; модалка смены пароля
  с двойным вводом и автологаутом после успеха

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 06:37:52 +03:00

379 lines
17 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.0">
<title>Kaskad</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script defer src="https://unpkg.com/alpinejs@3.13.0/dist/cdn.min.js"></script>
</head>
<body x-data="kaskad()" x-init="load()">
<header>
<h1>Каскад</h1>
<button @click="load()" :disabled="loading">⟳ Обновить</button>
<span class="status" x-show="loading">загружаю…</span>
<span class="status err" x-show="error" x-text="error"></span>
<div class="spacer"></div>
<span class="muted" x-show="user" x-text="'👤 ' + user"></span>
<button @click="openSettings()" title="сменить пароль">🔑 Пароль</button>
<button class="danger" @click="logout()" title="выйти из WebUI">↪ Выход</button>
</header>
<!-- Settings modal -->
<div class="modal" x-show="settingsOpen" x-cloak @click.self="settingsOpen=false">
<div class="modal-body">
<h2>Сменить пароль администратора</h2>
<form @submit.prevent="changePassword()">
<label>Текущий пароль
<input type="password" x-model="forms.pwd.current" required autocomplete="current-password">
</label>
<label>Новый пароль (≥ 8 символов)
<input type="password" x-model="forms.pwd.new" required minlength="8" autocomplete="new-password">
</label>
<label>Повторите новый пароль
<input type="password" x-model="forms.pwd.confirm" required minlength="8" autocomplete="new-password">
</label>
<div class="modal-actions">
<button type="button" @click="settingsOpen=false">отмена</button>
<button type="submit" class="danger solid" :disabled="loading">сохранить</button>
</div>
</form>
<p class="muted" style="margin-top:12px">
После смены пароля браузер автоматически попросит ввести новый — это нормально.
</p>
</div>
</div>
<!-- Status: ам. серверы и куда ходят -->
<section>
<h2>Состояние туннелей</h2>
<table>
<thead><tr><th>Ам. сервер</th><th>Tunnel IP</th><th>Через RU</th><th>Handshake</th><th>State</th></tr></thead>
<tbody>
<template x-for="s in state.status" :key="s.id">
<tr :class="s.state !== 'ok' ? 'bad' : ''">
<td><strong x-text="s.id"></strong> <span class="muted" x-text="s.host"></span></td>
<td x-text="s.tunnel_ip"></td>
<td x-text="s.label || '-'"></td>
<td x-text="s.handshake_age != null ? fmtAge(s.handshake_age) : '—'"></td>
<td x-text="s.state"></td>
</tr>
</template>
</tbody>
</table>
<div class="actions">
Force переключить все на:
<template x-for="ru in state.ru_servers" :key="ru.id">
<button @click="useServer(ru.id)" x-text="ru.id"></button>
</template>
</div>
</section>
<!-- RU-серверы -->
<section>
<h2>RU-серверы (по приоритету)</h2>
<table>
<thead><tr><th>Prio</th><th>ID</th><th>Label</th><th>Endpoint</th><th>Probe</th><th>SSH</th><th></th></tr></thead>
<tbody>
<template x-for="ru in state.ru_servers" :key="ru.id">
<tr>
<td x-text="ru.priority"></td>
<td><strong x-text="ru.id"></strong></td>
<td x-text="ru.label"></td>
<td x-text="ru.endpoint"></td>
<td x-text="'TCP ' + ru.probe_port"></td>
<td class="muted" x-text="(ru.ssh_user||'?') + '@' + ru.host + ':' + (ru.ssh_port||'?')"></td>
<td><button class="danger" @click="removeServer(ru.id)">удалить</button></td>
</tr>
</template>
</tbody>
</table>
<details>
<summary>+ добавить RU-сервер</summary>
<form @submit.prevent="addServer()">
<label>host <input x-model="forms.ru.host" placeholder="1.2.3.4 или domain.ru" required></label>
<label>id <input x-model="forms.ru.id" placeholder="newru" required></label>
<label>SSH user <input x-model="forms.ru.user" value="root"></label>
<label>SSH port <input type="number" x-model.number="forms.ru.ssh_port" value="22"></label>
<label>label <input x-model="forms.ru.label" placeholder="my-ru.example"></label>
<label>priority <input type="number" x-model.number="forms.ru.priority" value="3"></label>
<label>listen_port <input type="number" x-model.number="forms.ru.listen_port" value="1939"></label>
<label>probe_port <input type="number" x-model.number="forms.ru.probe_port" placeholder="по умолч. ssh_port"></label>
<label>SSH password <input type="password" x-model="forms.ru.password" placeholder="оставь пустым если ключ бота уже там"></label>
<button type="submit" :disabled="loading">добавить</button>
</form>
<p class="muted">Если оставишь пароль пустым — на новом RU должен лежать SSH-ключ бота: <code>cat /root/.ssh/id_ed25519.pub</code></p>
</details>
</section>
<!-- Ам. серверы -->
<section>
<h2>Ам. серверы (зарубежные)</h2>
<table>
<thead><tr><th>ID</th><th>Host</th><th>SSH</th><th>Tunnel</th><th>Pubkey</th><th></th></tr></thead>
<tbody>
<template x-for="a in state.ams_servers" :key="a.id">
<tr>
<td><strong x-text="a.id"></strong> <span class="muted" x-show="a.is_local">(этот сервер)</span></td>
<td x-text="a.host"></td>
<td x-text="a.ssh_port"></td>
<td x-text="a.tunnel_ip"></td>
<td class="muted mono" x-text="a.pubkey.slice(0, 20) + '...'"></td>
<td>
<template x-if="!a.is_local">
<button class="danger solid" @click="removeAms(a.id)">удалить</button>
</template>
<template x-if="a.is_local">
<span class="muted" title="нельзя удалить сервер, на котором запущен WebUI/бот">⛔ нельзя</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
<details>
<summary>+ добавить ам. сервер</summary>
<form @submit.prevent="addAms()">
<label>host <input x-model="forms.ams.host" required></label>
<label>id <input x-model="forms.ams.id" required></label>
<label>SSH user <input x-model="forms.ams.user" value="root"></label>
<label>SSH port <input type="number" x-model.number="forms.ams.ssh_port" value="22"></label>
<label>tunnel_ip <input x-model="forms.ams.tunnel_ip" placeholder="auto = следующий свободный"></label>
<label>xray_iface <input x-model="forms.ams.xray_iface" value="amn0"></label>
<label>SSH password <input type="password" x-model="forms.ams.password"></label>
<button type="submit" :disabled="loading">добавить</button>
</form>
</details>
</section>
<!-- Домены -->
<section>
<h2>Домены (<span x-text="Object.keys(state.domains||{}).length"></span>)</h2>
<div class="actions">
<button @click="refreshDomains()" :disabled="loading">🔄 Refresh DNS</button>
<input type="text" x-model="domainFilter" placeholder="фильтр…" style="flex:1">
</div>
<table>
<thead><tr><th>Домен</th><th>IP</th><th></th></tr></thead>
<tbody>
<template x-for="(cnt, dom) in filteredDomains()" :key="dom">
<tr>
<td x-text="dom"></td>
<td x-text="cnt + ' IP'"></td>
<td><button class="danger" @click="removeDomain(dom)">удалить</button></td>
</tr>
</template>
</tbody>
</table>
<details>
<summary>+ добавить домены</summary>
<form @submit.prevent="addDomains()">
<textarea x-model="forms.domains" rows="6" placeholder="vk.com&#10;ozon.ru&#10;gosuslugi.ru" required></textarea>
<button type="submit" :disabled="loading">добавить</button>
</form>
<p class="muted">Бот резолвит каждый домен и кладёт IP в маршруты на всех ам. серверах. Cron каждые 6ч обновляет резолвы.</p>
</details>
</section>
<!-- IP / CIDR -->
<section>
<h2>Доп. IP/CIDR (<span x-text="(state.extra_ips||[]).length"></span>)</h2>
<div class="actions">
<input type="text" x-model="ipFilter" placeholder="фильтр…" style="flex:1">
<button class="danger" @click="clearIps()" :disabled="loading">🧹 Очистить все</button>
</div>
<ul class="ip-list">
<template x-for="ip in filteredIps()" :key="ip">
<li>
<span x-text="ip"></span>
<button class="danger sm" @click="removeIp(ip)">×</button>
</li>
</template>
</ul>
<details>
<summary>+ добавить IP/CIDR</summary>
<form @submit.prevent="addIps()">
<textarea x-model="forms.ips" rows="5" placeholder="5.45.192.1/32&#10;1.2.3.0/24" required></textarea>
<button type="submit" :disabled="loading">добавить</button>
</form>
</details>
</section>
<!-- Базовые подсети -->
<section>
<h2>Базовые подсети (read-only)</h2>
<p class="muted">Эти подсети живут в <code>/etc/wireguard/ru-base.aips</code>, прописаны при первоначальной установке. Дополнительные адреса/домены идут отдельным списком.</p>
<ul class="ip-list compact">
<template x-for="ip in state.base_ips" :key="ip"><li x-text="ip"></li></template>
</ul>
</section>
<footer>
<div class="foot-row">
<strong>Kaskad WebUI</strong>
·
<a href="https://github.com/andrey271192/kaskad" target="_blank">⭐ GitHub</a>
·
<a href="https://boosty.to/andrey27/donate" target="_blank">💖 Boosty</a>
·
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank">💳 Ozon СБП</a>
·
<a href="https://t.me/Iot_andrey" target="_blank">✉️ Telegram @Iot_andrey</a>
</div>
<div class="foot-row muted">
Поддержать проект — поставь звезду на GitHub или донат. Связаться с автором — Telegram.
</div>
</footer>
<div class="toast" x-show="toast" x-text="toast"></div>
<script>
function kaskad() {
return {
state: {ru_servers:[], ams_servers:[], status:[], extra_ips:[], base_ips:[], domains:{}},
loading: false, error: '', toast: '',
user: '',
settingsOpen: false,
domainFilter: '', ipFilter: '',
forms: {
ru: {user:'root', ssh_port:22, listen_port:1939, priority:3},
ams: {user:'root', ssh_port:22, xray_iface:'amn0'},
domains: '', ips: '',
pwd: {current:'', new:'', confirm:''},
},
async req(method, url, body) {
this.loading = true; this.error = '';
try {
const r = await fetch(url, {
method, headers: {'Content-Type':'application/json'},
body: body ? JSON.stringify(body) : undefined
});
const j = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
} catch (e) { this.error = e.message; throw e; }
finally { this.loading = false; }
},
flash(msg) { this.toast = msg; setTimeout(()=>this.toast='', 3500); },
async load() {
try { this.state = await this.req('GET','/api/state'); } catch{}
try { const w = await this.req('GET','/api/auth/whoami'); this.user = w.user || ''; } catch{}
},
openSettings() {
this.forms.pwd = {current:'', new:'', confirm:''};
this.settingsOpen = true;
},
async changePassword() {
if (this.forms.pwd.new !== this.forms.pwd.confirm) {
this.error = 'новый пароль и подтверждение не совпадают';
return;
}
try {
await this.req('POST','/api/auth/password', {
current: this.forms.pwd.current,
new: this.forms.pwd.new,
});
} catch { return; }
this.settingsOpen = false;
this.flash('Пароль изменён. Сейчас браузер попросит новый.');
setTimeout(() => this.logout(), 1200);
},
async logout() {
// 1) бьём бэк бракованными credentials — он отвечает 401 с новым realm,
// браузер сбрасывает кешированный Basic Auth
try {
await fetch('/api/logout', {
headers: { 'Authorization': 'Basic ' + btoa('logout:' + Math.random()) },
cache: 'no-store',
credentials: 'omit',
});
} catch {}
// 2) редиректим на корень с уникальным query — браузер заново спросит логин/пароль
window.location.href = '/?_=' + Date.now();
},
fmtAge(s) {
if (s == null) return '—';
if (s >= 86400) return Math.floor(s/86400)+'д';
if (s >= 3600) return Math.floor(s/3600)+'ч';
if (s >= 60) return Math.floor(s/60)+'м';
return s+'с';
},
filteredDomains() {
const f = this.domainFilter.toLowerCase();
const o = {};
for (const k of Object.keys(this.state.domains||{}).sort())
if (!f || k.includes(f)) o[k] = this.state.domains[k];
return o;
},
filteredIps() {
const f = this.ipFilter.toLowerCase();
return (this.state.extra_ips||[]).filter(ip => !f || ip.includes(f));
},
async useServer(id) {
if (!confirm(`Переключить все ам. на ${id}?`)) return;
await this.req('POST','/api/use',{id}); this.flash('переключено'); this.load();
},
async addServer() {
await this.req('POST','/api/server', this.forms.ru);
this.flash('RU добавлен'); this.forms.ru = {user:'root', ssh_port:22, listen_port:1939, priority:3};
this.load();
},
async removeServer(id) {
if (!confirm(`Удалить RU '${id}'?`)) return;
await this.req('DELETE',`/api/server/${id}`); this.flash('удалено'); this.load();
},
async addAms() {
await this.req('POST','/api/ams', this.forms.ams);
this.flash('Ам. добавлен'); this.forms.ams = {user:'root', ssh_port:22, xray_iface:'amn0'};
this.load();
},
async removeAms(id) {
if (!confirm(`Удалить зарубежный сервер '${id}'?\n\nЧто произойдёт:\n • peer уберётся со всех RU-серверов\n • запись удалится из конфига\n • сам сервер ${id} останется жив, но больше не будет частью каскада\n\nПродолжить?`)) return;
try {
const r = await this.req('DELETE',`/api/ams/${id}`);
const peers = (r.peers||[]).map(p => `${p.ru}: ${p.ok?'✓':'✗'}`).join(', ');
this.flash(`удалён ${id}${peers ? ' ('+peers+')' : ''}`);
} catch (e) {
if (confirm(`Ошибка: ${e.message}\n\nУдалить запись принудительно (force, даже если RU-серверы недоступны)?`)) {
await this.req('DELETE',`/api/ams/${id}?force=1`);
this.flash(`удалён ${id} (force)`);
}
}
this.load();
},
async addDomains() {
const doms = this.forms.domains.split(/[\s,]+/).map(s=>s.trim()).filter(Boolean);
await this.req('POST','/api/domains',{domains:doms});
this.flash(`добавлено ${doms.length}`); this.forms.domains=''; this.load();
},
async removeDomain(dom) {
if (!confirm(`Убрать ${dom}?`)) return;
await this.req('DELETE','/api/domains',{domains:[dom]}); this.flash('убран'); this.load();
},
async refreshDomains() {
await this.req('POST','/api/domains/refresh',{}); this.flash('refresh запущен'); this.load();
},
async addIps() {
await this.req('POST','/api/ips',{ips: this.forms.ips});
this.flash('добавлено'); this.forms.ips=''; this.load();
},
async removeIp(ip) {
await this.req('DELETE','/api/ips',{ips:[ip]}); this.flash('убран'); this.load();
},
async clearIps() {
if (!confirm('Очистить ВСЕ доп. IP? (домены тоже потеряют свои IP до следующего refresh)')) return;
await this.req('POST','/api/ips/clear',{}); this.flash('очищено'); this.load();
},
};
}
</script>
</body>
</html>