Files
keenetic_ssh-web/templates/index.html
Андрей Бобырев 75cb432e13 auth: вход по логину/паролю от веб-админки роутера + сессионные токены
- auth.py: NDM-аутентификация (md5+sha256 challenge) к http://ROUTER_HOST/auth
- Сессионные токены (secrets, TTL 8ч, скользящее окно), заголовок X-Session-Token
- WEB_PASSWORD остаётся как запасной мастер-пароль
- /api/login и /api/logout, форма входа в UI с полем «Логин»
- Шапка автора в UI: GitHub, Telegram, Boosty, Поддержать (Ozon СБП)
- README: NDM-флоу, переменные ROUTER_HOST/ROUTER_LOGIN/ROUTER_TIMEOUT
- install.sh копирует auth.py
2026-04-26 00:19:09 +03:00

349 lines
18 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>Keenetic SSH Web</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0d1117;--card:#161b22;--card2:#21262d;--border:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#1f6feb;--green:#3fb950;--red:#f85149;--yellow:#d29922}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 18px 80px;max-width:1100px;margin:0 auto}
h1{font-size:1.35rem;font-weight:700;margin-bottom:6px}
.sub{color:var(--muted);font-size:.88rem;margin-bottom:22px;line-height:1.45}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin-bottom:16px}
.card h2{font-size:.82rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:12px}
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
label{font-size:.85rem;color:var(--muted)}
input.inp,textarea.ta{background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;color:var(--text);font-size:.88rem;font-family:inherit}
input.inp:focus,textarea.ta:focus{outline:none;border-color:var(--accent)}
textarea.ta{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.45;width:100%;min-height:120px;resize:vertical}
.btn{padding:9px 18px;border-radius:8px;border:none;font-size:.86rem;font-weight:600;cursor:pointer;font-family:inherit}
.btn-p{background:var(--accent);color:#fff}
.btn-p:hover{filter:brightness(1.08)}
.btn-d{background:var(--card2);color:var(--text);border:1px solid var(--border)}
.btn-g{background:var(--green);color:#0d1117}
.btn-r{background:var(--red);color:#fff}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn-big{padding:12px 24px;font-size:.95rem}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th,td{padding:10px 8px;text-align:left;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--muted);font-weight:700;font-size:.72rem;text-transform:uppercase}
.chk{width:44px;text-align:center}
.mono{font-family:ui-monospace,monospace;font-size:11px;word-break:break-all;max-width:280px}
.out-wrap{display:none;margin:8px 0 4px;padding:10px;background:#010409;border:1px solid var(--border);border-radius:8px;max-height:220px;overflow:auto}
.out-wrap.on{display:block}
.out-pre{white-space:pre-wrap;color:#7ee787;font-size:11px;line-height:1.4;margin:0}
.out-meta{font-size:10px;color:var(--muted);margin-bottom:6px}
.status-ok{color:var(--green);font-weight:600;font-size:11px}
.status-bad{color:var(--red);font-weight:600;font-size:11px}
.msg{margin-top:10px;padding:10px 12px;border-radius:8px;font-size:.85rem;font-weight:600;display:none}
.msg.on{display:block}
.msg-ok{background:rgba(63,185,80,.12);color:var(--green);border:1px solid rgba(63,185,80,.35)}
.msg-err{background:rgba(248,81,73,.12);color:var(--red);border:1px solid rgba(248,81,73,.35)}
#auth-overlay{display:none;position:fixed;inset:0;background:rgba(1,4,9,.96);z-index:200;align-items:center;justify-content:center}
.auth-box{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:32px;width:min(380px,92vw)}
.auth-box h3{margin-bottom:8px;font-size:1.1rem}
.auth-box p{color:var(--muted);font-size:.85rem;margin-bottom:16px}
.hdr{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:8px}
.logout{font-size:.8rem;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:8px;padding:6px 12px;cursor:pointer;color:var(--text)}
.author-card{background:linear-gradient(135deg,#161b22 0%,#1c2230 100%);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:18px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
.author-card .who{display:flex;align-items:center;gap:12px}
.author-card .ava{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#1f6feb,#3fb950);display:flex;align-items:center;justify-content:center;font-weight:800;color:#0d1117;font-size:1rem}
.author-card .info{display:flex;flex-direction:column;gap:2px}
.author-card .name{font-weight:700;font-size:.95rem}
.author-card .meta{color:var(--muted);font-size:.78rem}
.author-card .links{display:flex;flex-wrap:wrap;gap:6px}
.author-card a.lnk{font-size:.78rem;font-weight:600;text-decoration:none;color:var(--text);background:var(--card2);border:1px solid var(--border);padding:6px 11px;border-radius:8px;transition:filter .15s}
.author-card a.lnk:hover{filter:brightness(1.2)}
.author-card a.donate{background:linear-gradient(135deg,#d29922,#f85149);color:#fff;border:none}
</style>
</head>
<body>
<div id="auth-overlay" style="display:none">
<div class="auth-box">
<h3>Keenetic SSH Web</h3>
<p id="auth-hint">Введите логин и пароль от <b>веб-админки роутера</b>.</p>
<input id="auth-login" type="text" class="inp" style="width:100%;margin-bottom:8px" placeholder="Логин (admin)" autocomplete="username">
<input id="auth-pwd" type="password" class="inp" style="width:100%;margin-bottom:10px" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()">
<div id="auth-err" class="msg msg-err" style="margin-bottom:8px">Неверный логин или пароль</div>
<button class="btn btn-p" style="width:100%" onclick="doLogin()" id="auth-btn">Войти</button>
</div>
</div>
<div class="author-card">
<div class="who">
<div class="ava">A</div>
<div class="info">
<div class="name">Andrey · keenetic_ssh-web</div>
<div class="meta">Локально на Keenetic + Entware · порт 2001 · MIT</div>
</div>
</div>
<div class="links">
<a class="lnk" href="https://github.com/andrey271192/keenetic_ssh-web" target="_blank" rel="noopener noreferrer">GitHub</a>
<a class="lnk" id="lnk-tg" href="https://t.me/Iot_andrey" target="_blank" rel="noopener noreferrer">Telegram</a>
<a class="lnk" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
<a class="lnk donate" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer" title="Поддержать проекта (Ozon Bank, СБП)">Поддержать ❤</a>
</div>
</div>
<div class="hdr">
<div>
<h1>Команды на роутере (Entware)</h1>
<p class="sub">Локальный веб-интерфейс: расписание, ручной запуск, вывод stdout/stderr. Команды выполняются прямо в shell на самом Keenetic. Вход — по паролю от веб-админки роутера.</p>
</div>
<button class="logout" type="button" onclick="logout()">Выйти</button>
</div>
<div class="card">
<h2>Периодическое выполнение</h2>
<div class="row">
<label>Интервал (мин, 0 — выкл)</label>
<input type="number" class="inp" id="iv" min="0" max="10080" style="width:100px">
<button class="btn btn-d" onclick="saveInterval()">Сохранить</button>
</div>
<p class="sub" style="margin-top:10px;margin-bottom:0">По расписанию выполняются только строки с включёнными «ВКЛ» и «Распис.».</p>
</div>
<div class="card">
<h2>Выполнить всё</h2>
<button class="btn btn-p btn-big" id="btn-all" onclick="runAll()">Выполнить все команды</button>
<p class="sub" style="margin-top:10px;margin-bottom:0">Вручную: все строки с «ВКЛ» (независимо от «Распис.»).</p>
</div>
<div class="card">
<h2>Список команд</h2>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th class="chk">ВКЛ</th>
<th class="chk">Распис.</th>
<th>Название</th>
<th>Команда</th>
<th>Примечание</th>
<th>Последний запуск</th>
<th></th>
</tr>
</thead>
<tbody id="tb"></tbody>
</table>
</div>
<div id="glob-msg" class="msg"></div>
</div>
<div class="card">
<h2>Добавить команду</h2>
<div class="row" style="align-items:flex-end;margin-bottom:10px">
<div><label style="display:block;margin-bottom:4px">Название</label><input class="inp" id="a-name" placeholder="opkg update" style="width:160px"></div>
<div style="flex:1;min-width:200px"><label style="display:block;margin-bottom:4px">Команда (shell)</label><input class="inp" id="a-cmd" placeholder="opkg update" style="width:100%"></div>
<div style="flex:1;min-width:140px"><label style="display:block;margin-bottom:4px">Примечание</label><input class="inp" id="a-note" placeholder="по желанию" style="width:100%"></div>
<button class="btn btn-g" onclick="addItem()">Добавить</button>
</div>
</div>
<script>
const TKEY = 'kssh_token';
const LKEY = 'kssh_login';
const tok = () => sessionStorage.getItem(TKEY) || '';
const hdr = () => ({ 'Content-Type': 'application/json', 'X-Session-Token': tok() });
function showAuth() {
const o = document.getElementById('auth-overlay');
o.style.display = 'flex';
document.getElementById('auth-err').classList.remove('on');
const lg = document.getElementById('auth-login');
if (!lg.value) lg.value = sessionStorage.getItem(LKEY) || 'admin';
setTimeout(() => document.getElementById('auth-pwd').focus(), 80);
}
function hideAuth() {
document.getElementById('auth-overlay').style.display = 'none';
}
async function checkAuth() {
const t = tok();
if (!t) { showAuth(); return false; }
const r = await fetch('/api/auth', { headers: hdr() });
if (!r.ok) { sessionStorage.removeItem(TKEY); showAuth(); return false; }
const j = await r.json().catch(() => ({}));
if (!j.ok) { sessionStorage.removeItem(TKEY); showAuth(); return false; }
hideAuth();
return true;
}
async function doLogin() {
const login = (document.getElementById('auth-login').value || 'admin').trim();
const password = document.getElementById('auth-pwd').value;
const btn = document.getElementById('auth-btn');
const err = document.getElementById('auth-err');
err.classList.remove('on');
btn.disabled = true;
try {
const r = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ login, password }) });
const j = await r.json().catch(() => ({}));
if (r.ok && j.ok && j.token) {
sessionStorage.setItem(TKEY, j.token);
sessionStorage.setItem(LKEY, login);
document.getElementById('auth-pwd').value = '';
hideAuth();
loadAll();
} else {
err.textContent = j.error || 'Неверный логин или пароль';
err.classList.add('on');
}
} catch (e) {
err.textContent = 'Ошибка связи с сервером';
err.classList.add('on');
} finally {
btn.disabled = false;
}
}
async function logout() {
try { await fetch('/api/logout', { method: 'POST', headers: hdr() }); } catch (e) {}
sessionStorage.removeItem(TKEY);
showAuth();
}
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
}
let CFG = { interval_minutes: 0, items: [] };
function showMsg(ok, text) {
const el = document.getElementById('glob-msg');
el.className = 'msg on ' + (ok ? 'msg-ok' : 'msg-err');
el.textContent = text;
setTimeout(() => { el.className = 'msg'; el.textContent = ''; }, 9000);
}
async function loadAll() {
const r = await fetch('/api/config', { headers: hdr() });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, 'Ошибка загрузки'); return; }
CFG = await r.json();
document.getElementById('iv').value = CFG.interval_minutes | 0;
render();
}
function toggleOut(id) {
const w = document.getElementById('out-' + id);
if (w) w.classList.toggle('on');
}
function render() {
const tb = document.getElementById('tb');
const items = CFG.items || [];
if (!items.length) {
tb.innerHTML = '<tr><td colspan="7" style="color:var(--muted);padding:20px">Пока пусто — добавьте команду ниже.</td></tr>';
return;
}
tb.innerHTML = items.map(it => {
const ok = it.last_ok === true;
const bad = it.last_ok === false;
const st = it.last_run
? `<span class="${ok ? 'status-ok' : bad ? 'status-bad' : ''}">${ok ? 'OK' : bad ? 'Ошибка' : '—'}</span><div style="font-size:10px;color:var(--muted);margin-top:4px">${esc(it.last_run)}</div>`
: '<span style="color:var(--muted)">—</span>';
const preview = (it.last_output || '').slice(0, 120).replace(/\n/g, ' ');
return `<tr>
<td class="chk"><input type="checkbox" ${it.enabled ? 'checked' : ''} onchange="patch('${it.id}','enabled',this.checked)"></td>
<td class="chk"><input type="checkbox" ${it.schedule ? 'checked' : ''} onchange="patch('${it.id}','schedule',this.checked)"></td>
<td><input class="inp" value="${esc(it.name)}" style="width:130px" onchange="patch('${it.id}','name',this.value)"></td>
<td class="mono"><input class="inp" value="${esc(it.command)}" style="width:100%;min-width:200px" onchange="patch('${it.id}','command',this.value)"></td>
<td class="mono"><input class="inp" value="${esc(it.note)}" style="width:100%;min-width:100px" onchange="patch('${it.id}','note',this.value)"></td>
<td style="font-size:11px">${st}</td>
<td>
<button type="button" class="btn btn-p" style="padding:6px 10px;margin-bottom:4px" onclick="runOne('${it.id}')">Выполнить</button><br>
<button type="button" class="btn btn-d" style="padding:6px 10px;margin-bottom:4px" onclick="toggleOut('${it.id}')">Вывод</button><br>
<button type="button" class="btn btn-r" style="padding:6px 10px" onclick="delItem('${it.id}')">Удалить</button>
</td>
</tr>
<tr class="out-row"><td colspan="7" style="border-bottom:1px solid var(--border);padding:0 8px 12px;background:#0d1117">
<div id="out-${it.id}" class="out-wrap">
<div class="out-meta">Полный вывод (последний запуск)</div>
<pre class="out-pre">${esc(it.last_output || '(пусто)')}</pre>
</div>
${preview ? `<div style="font-size:10px;color:var(--muted);margin-top:4px;max-width:600px;word-break:break-all">${esc(preview)}${(it.last_output||'').length>120?'…':''}</div>` : ''}
</td></tr>`;
}).join('');
}
async function patch(id, field, val) {
const body = {}; body[field] = val;
const r = await fetch('/api/items/' + encodeURIComponent(id), { method: 'PATCH', headers: hdr(), body: JSON.stringify(body) });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, await r.text()); loadAll(); return; }
const j = await r.json();
const ix = (CFG.items || []).findIndex(x => x.id === id);
if (ix >= 0) CFG.items[ix] = j.item;
render();
}
async function saveInterval() {
const minutes = parseInt(document.getElementById('iv').value, 10) || 0;
const r = await fetch('/api/interval', { method: 'POST', headers: hdr(), body: JSON.stringify({ minutes }) });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, 'Не сохранено'); return; }
CFG.interval_minutes = minutes;
showMsg(true, 'Интервал сохранён');
}
async function addItem() {
const name = document.getElementById('a-name').value.trim();
const command = document.getElementById('a-cmd').value.trim();
const note = document.getElementById('a-note').value.trim();
if (!name || !command) { showMsg(false, 'Название и команда обязательны'); return; }
const r = await fetch('/api/items', { method: 'POST', headers: hdr(), body: JSON.stringify({ name, command, note, enabled: true, schedule: false }) });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, await r.text()); return; }
document.getElementById('a-name').value = '';
document.getElementById('a-cmd').value = '';
document.getElementById('a-note').value = '';
await loadAll();
showMsg(true, 'Добавлено');
}
async function delItem(id) {
if (!confirm('Удалить команду?')) return;
const r = await fetch('/api/items/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr() });
if (r.status === 401 || r.status === 403) { showAuth(); return; }
await loadAll();
}
async function runAll() {
const b = document.getElementById('btn-all');
b.disabled = true;
const t = b.textContent;
b.textContent = '…';
try {
const r = await fetch('/api/run-all', { method: 'POST', headers: hdr() });
const j = await r.json().catch(() => ({}));
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, j.error || r.statusText); return; }
await loadAll();
const ok = (j.results || []).filter(x => x.ok).length, tot = (j.results || []).length;
showMsg(true, 'Готово: ' + ok + ' / ' + tot + ' успешно');
} finally { b.disabled = false; b.textContent = t; }
}
async function runOne(id) {
const r = await fetch('/api/run/' + encodeURIComponent(id), { method: 'POST', headers: hdr() });
const j = await r.json().catch(() => ({}));
if (r.status === 401 || r.status === 403) { showAuth(); return; }
if (!r.ok) { showMsg(false, j.error || r.statusText); return; }
await loadAll();
const o = document.getElementById('out-' + id);
if (o) o.classList.add('on');
showMsg(!!j.result?.ok, (j.result?.output || j.result?.msg || '').slice(0, 400) || (j.result?.ok ? 'OK' : 'Ошибка'));
}
(async () => {
if (await checkAuth()) await loadAll();
else showAuth();
})();
</script>
</body>
</html>