mirror of
https://github.com/andrey271192/kaskad.git
synced 2026-09-21 13:51:57 +00:00
- 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>
379 lines
17 KiB
HTML
379 lines
17 KiB
HTML
<!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 ozon.ru 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 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>
|