Files
kaskad/webui/templates/index.html
Андрей Бобырев a2182d0b91 fix(webui): cookie session login/logout (replace Basic Auth)
Browsers cannot clear HTTP Basic credentials; logout never worked reliably.
Use Flask signed session + /login form + GET /logout + POST /api/logout.
Optional KASKAD_SECRET_KEY or auto /etc/kaskad/.session_secret.
Fetch API uses credentials: same-origin; 401 redirects to /login.

Docs + README updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:04:03 +03:00

378 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 type="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 type="button" @click="openSettings()" title="сменить пароль">🔑 Пароль</button>
<button type="button" class="danger" @click.prevent="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,
credentials: 'same-origin',
headers: {'Content-Type':'application/json'},
body: body ? JSON.stringify(body) : undefined
});
if (r.status === 401) {
window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname || '/');
throw new Error('требуется вход');
}
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('Пароль изменён');
this.forms.pwd = {current:'', new:'', confirm:''};
},
async logout() {
try {
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
} catch {}
window.location.href = '/login';
},
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>