Files
keenetic-dns-routes/templates/index.html
Андрей Бобырев 3f2d621d02 fix(tunnel): close stdin so autossh survives backgrounding; add quick "+ Тоннель" button
Background autossh died immediately because nohup left stdin attached to the
closed curl pipe — autossh saw EOF and bailed. Add </dev/null and wrap in
subshell so the process is fully detached from the install script's session.

Also add an "+ Тоннель" button next to "+ Добавить": creates a router with a
placeholder base URL and opens the tunnel modal in one click — for routers
without a public IP that have nothing to put in the base URL field yet.

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

553 lines
34 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 DNS Routes</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0b;--card:#161618;--bd:#2a2a2e;--tx:#f4f4f5;--mu:#71717a;--ac:#3b82f6;--ok:#22c55e;--er:#ef4444}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--tx);min-height:100vh;padding-bottom:46px}
.hdr{padding:14px 20px;border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.hdr h1{font-size:17px;font-weight:800}
.hdr small{color:var(--mu);font-weight:500}
.wrap{max-width:1100px;margin:0 auto;padding:20px}
.tabs{display:flex;gap:6px;margin-bottom:16px}
.tab{padding:8px 18px;border-radius:10px;border:1px solid var(--bd);background:var(--card);color:var(--mu);font-weight:600;cursor:pointer;font-size:13px}
.tab.on{background:var(--ac);color:#fff;border-color:var(--ac)}
.card{background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:18px;margin-bottom:16px}
.card h2{font-size:14px;margin-bottom:10px}
label{display:block;font-size:11px;color:var(--mu);margin-bottom:4px;font-weight:600}
input[type=text],input[type=url],input[type=password],textarea{width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px 12px;color:var(--tx);font-size:13px;font-family:inherit}
textarea{min-height:220px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.45;resize:vertical}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.row > div{flex:1;min-width:200px}
.btn{padding:9px 18px;border-radius:10px;border:none;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit}
.btn-b{background:var(--ac);color:#fff}
.btn-g{background:var(--ok);color:#0a0a0b}
.btn-d{background:#3f3f46;color:#fff}
.btn-o{background:#ea580c;color:#fff}
.tbl{width:100%;border-collapse:collapse;font-size:13px}
.tbl th,.tbl td{padding:8px 6px;border-bottom:1px solid var(--bd);text-align:left}
.tbl th{color:var(--mu);font-size:11px;text-transform:uppercase}
.log{margin-top:10px;padding:12px;background:#111;border-radius:10px;font-size:12px;white-space:pre-wrap;max-height:240px;overflow-y:auto;display:none}
.log.show{display:block}
.pill{display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;font-weight:700}
.pill.ok{background:rgba(34,197,94,.15);color:var(--ok)}
.pill.bad{background:rgba(239,68,68,.15);color:var(--er)}
#auth-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:100;align-items:center;justify-content:center}
#auth-bg.on{display:flex}
#auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)}
#ifscan-modal,#edit-router-modal,#tunnel-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px}
#ifscan-modal.on,#edit-router-modal.on,#tunnel-modal.on{display:flex}
#tunnel-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:680px;width:100%;padding:22px}
.tunnel-cmd{background:#111;border:1px solid var(--bd);border-radius:10px;padding:14px;font-family:ui-monospace,monospace;font-size:11px;line-height:1.6;color:#a3e635;white-space:pre;overflow-x:auto;max-height:260px;overflow-y:auto;margin:10px 0}
.tunnel-url{background:#111;border:1px solid #22c55e44;border-radius:8px;padding:10px 14px;font-family:ui-monospace,monospace;font-size:13px;color:var(--ok);margin:8px 0}
.btn-tunnel{background:#7c3aed;color:#fff}
#ifscan-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:900px;width:100%;max-height:88vh;overflow:auto;padding:18px}
#edit-router-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:520px;width:100%;padding:18px}
.if-row{cursor:pointer}
.if-row:hover{background:rgba(59,130,246,.12)}
.kdns-foot{position:fixed;bottom:0;left:0;right:0;z-index:50;font-size:11px;font-weight:600;color:var(--mu);
padding:10px 20px;border-top:1px solid var(--bd);background:rgba(59,130,246,.1);
display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;justify-content:center;backdrop-filter:blur(10px)}
.kdns-foot a{color:var(--ac);text-decoration:none;border-bottom:1px solid rgba(59,130,246,.45);font-weight:700}
.kdns-foot a:hover{color:#93c5fd;border-bottom-color:#93c5fd}
.kdns-foot .kdns-am{color:var(--tx);font-weight:700;opacity:.95}
</style>
</head>
<body>
<div id="auth-bg" class="on"><div id="auth-box">
<h2 style="margin-bottom:12px;font-size:18px">Keenetic DNS Routes</h2>
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Сюда только <b>ADMIN_PASSWORD</b> из <code>.env</code> (вход на эту страницу). Для API роутеров: поля ниже или <code>KEENETIC_*</code> в <code>.env</code>, или <code>логин:пароль@</code> в URL.</p>
<label>Пароль веб-интерфейса (ADMIN_PASSWORD)</label>
<input type="password" id="apw" autocomplete="current-password" style="margin-bottom:12px" onkeydown="if(event.key==='Enter')doLogin()"/>
<div id="aerr" style="color:var(--er);font-size:12px;margin-bottom:8px;display:none"></div>
<button class="btn btn-b" style="width:100%" onclick="doLogin()">Войти</button>
</div></div>
<div class="hdr">
<div><h1>Keenetic DNS Routes</h1><small>Списки DNS-маршрутизации (без Neo) · порт 8001</small></div>
<button class="btn btn-d" onclick="logout()">Выйти</button>
</div>
<div class="wrap">
<div class="card" id="cred-card" style="display:none">
<h2>Учётные данные Keenetic (API)</h2>
<p style="font-size:11px;color:var(--mu);margin-bottom:8px;line-height:1.5">Два варианта по умолчанию: <b>KEENETIC_LOGIN / KEENETIC_PASSWORD</b> в <code>.env</code> на сервере <b>или</b> свои логин/пароль (или <code>http(s)://логин:пароль@хост:порт</code>) у каждого роутера. Пароль входа на эту страницу — только <code>ADMIN_PASSWORD</code>.</p>
<p id="cred-line" style="font-size:14px;margin-bottom:6px"></p>
<p id="cred-hint" style="font-size:11px;color:var(--mu);line-height:1.5"></p>
<p style="font-size:11px;color:var(--mu);margin-top:8px">В URL можно указать <code>логин:пароль@</code> перед хостом — при сохранении они переносятся в поля, в адресе остаётся только <code>http(s)://хост:порт</code>. Пути вида <code>/rci/...</code> в base URL не указывай — их добавляет сервис.</p>
</div>
<div class="tabs">
<button class="tab on" data-t="US" onclick="setTab('US')">Список US</button>
<button class="tab" data-t="RU" onclick="setTab('RU')">Список RU</button>
</div>
<div class="card" id="panel-US">
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
<div class="row" style="align-items:flex-end">
<div style="flex:1"><label>Interface ID (как в RCI: Wireguard0, PPPoE0…)</label>
<input type="text" id="if-US" placeholder="Wireguard0" oninput="ST.groups=readForm()"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
</div>
<textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24" oninput="ST.groups=readForm()"></textarea>
</div>
<div class="card" id="panel-RU" style="display:none">
<h2>RU — домены и IP/CIDR</h2>
<div class="row" style="align-items:flex-end">
<div style="flex:1"><label>Interface ID</label>
<input type="text" id="if-RU" placeholder="GigabitEthernet0" oninput="ST.groups=readForm()"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('RU')">Сканировать…</button></div>
</div>
<textarea id="tx-RU" placeholder="yandex.ru" oninput="ST.groups=readForm()"></textarea>
</div>
<div class="row" style="align-items:center;margin-top:4px">
<input type="text" id="one-line" placeholder="Добавить одну строку в активную вкладку (US/RU)…" style="flex:1"/>
<button class="btn btn-d" type="button" onclick="appendCurrentTab()">+ В текст</button>
</div>
<div class="row">
<button class="btn btn-g" onclick="saveServer()">Сохранить на сервер</button>
<button class="btn btn-o" onclick="applyAll()">Применить на всех legacy</button>
<button class="btn btn-b" onclick="applySel()">Только на выбранных</button>
</div>
<div id="sv" style="font-size:12px;color:var(--mu);margin-bottom:8px"></div>
<div class="log" id="log"></div>
<div class="card" style="margin-top:24px">
<h2>Роутеры (KeenDNS → base URL прокси)</h2>
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">У каждого роутера: <b>base URL</b> + при необходимости <b>логин/пароль</b>, либо сразу URL <code>http(s)://логин:пароль@хост:порт</code>. Если поля логина/пароля пустые — берутся <code>KEENETIC_*</code> из <code>.env</code> на сервере.</p>
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Укажи тот же <b>хост и порт</b>, что в KeenDNS для HTTP Proxy (часто не стандартный порт). Для <code>http</code> без порта запрос уйдёт на <b>80</b> и может быть <code>/auth 404</code>. <code>/auth 403</code> — проверь разрешённые IP для API на роутере.</p>
<div class="row">
<div style="min-width:120px"><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
<div style="flex:2;min-width:220px"><label>Base URL прокси</label><input type="url" id="ru" placeholder="https://homesmart.netcraze.pro:81"/></div>
<div style="min-width:100px"><label>Логин (если не .env)</label><input type="text" id="rl" placeholder="пусто = из .env"/></div>
<div style="min-width:120px"><label>Пароль (если не .env)</label><input type="password" id="rp" placeholder="пусто = из .env" autocomplete="new-password"/></div>
<div><label>&nbsp;</label><button class="btn btn-b" onclick="addR()">+ Добавить</button></div>
<div><label>&nbsp;</label><button class="btn" style="background:#7c3aed;color:#fff" onclick="addAndTunnel()" title="Создать роутер с base URL=http://localhost:NNNN и сразу открыть модалку тоннеля. Удобно для роутеров без белого IP.">⇄ Тоннель</button></div>
</div>
<div style="overflow-x:auto">
<table class="tbl" id="rtbl"><thead><tr>
<th style="width:36px" title="Выбор для кнопки «Только на выбранных»"></th><th>Имя</th><th>Base URL</th><th>Логин</th><th>Пароль</th><th title="Включить/выключить роутер для «Применить на всех»">Вкл</th><th></th>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
<div id="edit-router-modal"><div id="edit-router-box">
<h3 style="margin-bottom:12px;font-size:16px">Роутер</h3>
<input type="hidden" id="er-id"/>
<div class="row"><div style="flex:1"><label>Имя</label><input type="text" id="er-name"/></div></div>
<div class="row"><div style="flex:1"><label>Base URL прокси</label><input type="url" id="er-url" placeholder="https://хост:порт"/></div></div>
<div class="row"><div style="flex:1"><label>Keenetic логин</label><input type="text" id="er-login" placeholder="логин RCI"/></div></div>
<div class="row"><div style="flex:1"><label>Keenetic пароль</label><input type="password" id="er-pass" placeholder="пусто = не менять" autocomplete="new-password"/></div></div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:12px">
<input type="checkbox" id="er-clear-pass"/> Очистить пароль (потом задай заново или в URL user:pass@)
</label>
<div class="row" style="margin-bottom:0">
<button type="button" class="btn btn-b" onclick="saveEditRouter()">Сохранить</button>
<button type="button" class="btn btn-d" onclick="closeEditRouter()">Отмена</button>
</div>
</div></div>
<div id="tunnel-modal"><div id="tunnel-box">
<h3 style="margin-bottom:6px;font-size:16px">Обратный SSH-тоннель</h3>
<p style="font-size:12px;color:var(--mu);margin-bottom:14px">Для роутеров без белого IP. Скопируй команду и выполни в SSH на роутере — он сам подключится к VPS и откроет порт.</p>
<div style="font-size:12px;color:var(--mu);margin-bottom:4px">Команда для SSH роутера:</div>
<div class="tunnel-cmd" id="tunnel-cmd-text"></div>
<button class="btn btn-d" style="font-size:11px;padding:6px 14px;margin-bottom:14px" onclick="copyTunnelCmd()">📋 Скопировать команду</button>
<div style="font-size:12px;color:var(--mu);margin-bottom:4px">После запуска тоннеля — новый RCI URL для этого роутера:</div>
<div class="tunnel-url" id="tunnel-url-text"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px">
<button class="btn btn-b" onclick="applyTunnelUrl()">✓ Применить URL автоматически</button>
<button class="btn btn-d" onclick="closeTunnelModal()">Закрыть</button>
<button class="btn" style="background:#0f2;color:#000;font-size:11px;padding:6px 12px" onclick="checkTunnelStatus()">⟳ Проверить связь</button>
<button class="btn" style="background:#7c3aed22;color:#a78bfa;border:1px solid #7c3aed44;margin-left:auto;font-size:11px;padding:6px 12px" onclick="removeTunnel()">✕ Сбросить тоннель</button>
</div>
<div id="tunnel-msg" style="margin-top:10px;font-size:12px;display:none"></div>
</div></div>
<div id="ifscan-modal"><div id="ifscan-box">
<h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3>
<p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p>
<div class="row" style="align-items:flex-end;margin-bottom:10px">
<div style="flex:1"><label>Роутер</label>
<select id="ifscan-router" style="width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px;color:var(--tx);font-size:13px"></select></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-b" onclick="runIfScan()">Загрузить</button></div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:10px">
<input type="checkbox" id="ifscan-wg-only"/> Только WireGuard (удобно выбрать туннель US/RU)
</label>
<div id="ifscan-list" style="font-size:12px"></div>
<button type="button" class="btn btn-d" style="margin-top:14px" onclick="closeIfScan()">Закрыть</button>
</div></div>
<script>
let ST={groups:{US:{interface_id:'',lines:[]},RU:{interface_id:'',lines:[]}},routers:[]};
function hdr(){return{'Content-Type':'application/json','X-Admin-Password':sessionStorage.getItem('kdns_pw')||''};}
async function doLogin(){
const p=document.getElementById('apw').value;
const e=document.getElementById('aerr');
e.style.display='none';
const r=await fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
if(r.ok){sessionStorage.setItem('kdns_pw',p);document.getElementById('auth-bg').classList.remove('on');await load();}
else{e.textContent='Неверный пароль';e.style.display='block';}
}
function logout(){sessionStorage.removeItem('kdns_pw');document.getElementById('auth-bg').classList.add('on');}
async function loadCred(){
try{
const r=await fetch('/api/keenetic-env',{headers:hdr()});
if(!r.ok)return;
const j=await r.json();
document.getElementById('cred-card').style.display='block';
const pw=j.password_configured?'<span class="pill ok">KEENETIC_PASSWORD в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD в .env пустой</span>';
document.getElementById('cred-line').innerHTML='Дефолт из .env: логин <b>'+esc(j.login||'')+'</b> &nbsp; '+pw;
document.getElementById('cred-hint').textContent=j.hint||'';
}catch(_){}
}
let _ifTab='US';
function openIfScan(tab){
_ifTab=tab;
const s=document.getElementById('ifscan-router');
s.innerHTML='<option value="">— выбери роутер —</option>'+(ST.routers||[]).map(r=>'<option value="'+esc(r.id)+'">'+esc(r.name)+'</option>').join('');
document.getElementById('ifscan-list').innerHTML='';
document.getElementById('ifscan-modal').classList.add('on');
}
function closeIfScan(){document.getElementById('ifscan-modal').classList.remove('on');}
function pickIf(id){
document.getElementById('if-'+_ifTab).value=id;
closeIfScan();
}
async function runIfScan(){
const id=document.getElementById('ifscan-router').value;
const L=document.getElementById('ifscan-list');
if(!id){L.innerHTML='<span style="color:var(--er)">Выбери роутера</span>';return;}
L.innerHTML='Загрузка…';
const wg=document.getElementById('ifscan-wg-only').checked;
const q=wg?'?wireguard_only=1':'';
const r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces'+q,{headers:hdr()});
if(r.status===401){logout();return;}
if(!r.ok){L.innerHTML='<span style="color:var(--er)">'+(await r.text())+'</span>';return;}
const j=await r.json();
const rows=(j.interfaces||[]).map(it=>{
const iid=String(it.id||'');
return '<tr class="if-row" data-ifid="'+esc(iid).replace(/"/g,'&quot;')+'"><td><code>'+esc(iid)+'</code></td><td>'+esc(it.type)+'</td><td>'+esc(it.description)+'</td><td style="font-size:11px;color:var(--mu)">'+esc([it.state,it.link,it.connected].filter(Boolean).join(' · '))+'</td><td><button type="button" class="btn btn-b" style="padding:4px 10px;font-size:11px">Вставить</button></td></tr>';
}).join('');
L.innerHTML='<table class="tbl"><thead><tr><th>ID</th><th>Тип</th><th>Описание</th><th>Состояние</th><th></th></tr></thead><tbody>'+rows+'</tbody></table>';
L.querySelector('tbody').onclick=function(ev){
const tr=ev.target.closest('tr[data-ifid]');
if(!tr)return;
pickIf(tr.getAttribute('data-ifid'));
};
}
async function load(){
const r=await fetch('/api/data',{headers:hdr()});
if(r.status===401){logout();return;}
ST=await r.json();
paint();
await loadCred();
}
function paint(){
for(const k of['US','RU']){
const g=ST.groups[k]||{};
document.getElementById('if-'+k).value=g.interface_id||'';
document.getElementById('tx-'+k).value=(g.lines||[]).join('\n');
}
const tb=document.querySelector('#rtbl tbody');
tb.innerHTML=(ST.routers||[]).map(ro=>{
const lo=String(ro.keenetic_login||'').trim();
const hasPw=String(ro.keenetic_password||'').length>0;
const fromEnv=!lo&&!hasPw;
const iid=String(ro.id||'');
return `<tr>
<td><input type="checkbox" class="kdns-sel" data-id="${esc(iid)}" title="Выбор для «Только на выбранных»"/></td>
<td>${esc(ro.name)}</td>
<td style="font-size:11px;word-break:break-all">${esc(ro.rci_base_url)}</td>
<td style="font-size:11px">${fromEnv?'<span style="color:var(--mu)">из .env</span>':esc(lo)}</td>
<td style="font-size:11px">${fromEnv?'<span class="pill ok">из .env</span>':(hasPw?'<span class="pill ok">есть</span>':'<span class="pill bad">нет</span>')}</td>
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange='toggleEn(${JSON.stringify(iid)},this.checked)'/></td>
<td style="white-space:nowrap"><button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='openEditRouter(${JSON.stringify(iid)})'>Изм.</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='testR(${JSON.stringify(iid)})'>Тест</button>
<button type="button" class="btn" style="padding:4px 10px;font-size:11px;background:${ro.tunnel_port?'#7c3aed':'#3f3f46'};color:#fff" onclick='tunnelR(${JSON.stringify(iid)})' title="${ro.tunnel_port?'Тоннель активен — порт '+ro.tunnel_port:'Настроить обратный SSH-тоннель'}">⇄${ro.tunnel_port?' :'+ro.tunnel_port:' Тоннель'}</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='delR(${JSON.stringify(iid)})'>✕</button></td>
</tr>`;
}).join('')||'<tr><td colspan="7" style="color:var(--mu)">Нет роутеров</td></tr>';
}
function openEditRouter(id){
const ro=(ST.routers||[]).find(x=>x.id===id);if(!ro)return;
document.getElementById('er-id').value=ro.id;
document.getElementById('er-name').value=ro.name||'';
document.getElementById('er-url').value=ro.rci_base_url||'';
document.getElementById('er-login').value=ro.keenetic_login||'';
document.getElementById('er-pass').value='';
document.getElementById('er-clear-pass').checked=false;
document.getElementById('edit-router-modal').classList.add('on');
}
function closeEditRouter(){document.getElementById('edit-router-modal').classList.remove('on');}
async function saveEditRouter(){
const id=document.getElementById('er-id').value;
const body={name:document.getElementById('er-name').value.trim(),rci_base_url:document.getElementById('er-url').value.trim(),keenetic_login:document.getElementById('er-login').value.trim()};
if(document.getElementById('er-clear-pass').checked)body.keenetic_password='';
else{const pw=document.getElementById('er-pass').value;if(pw)body.keenetic_password=pw;}
const r=await fetch('/api/routers/'+encodeURIComponent(id),{method:'PATCH',headers:hdr(),body:JSON.stringify(body)});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
closeEditRouter();
await load();
}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');}
function setTab(t){
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.t===t));
document.getElementById('panel-US').style.display=t==='US'?'block':'none';
document.getElementById('panel-RU').style.display=t==='RU'?'block':'none';
}
function activeGroup(){return(document.querySelector('.tab.on')||{}).dataset?.t||'US';}
function appendCurrentTab(){
const k=activeGroup();
const line=document.getElementById('one-line').value.trim();
if(!line)return;
const ta=document.getElementById('tx-'+k);
const v=ta.value.replace(/\s+$/,'');
ta.value=(v?v+'\n':'')+line;
document.getElementById('one-line').value='';
ST.groups=readForm();
}
function readForm(){
const groups={};
for(const k of['US','RU']){
const raw=document.getElementById('tx-'+k).value||'';
const lines=raw.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
groups[k]={interface_id:document.getElementById('if-'+k).value.trim(),lines};
}
return groups;
}
/**
* @param {{skipLoad?:boolean}} opts — skipLoad: не перезагружать страницу (нужно перед «Только на выбранных»,
* иначе load() сбрасывает чекбоксы выбора; или перед добавлением роутера, чтобы сначала сохранить US/RU).
*/
async function saveServer(opts){
const skipLoad=opts&&opts.skipLoad;
const groups=readForm();
const r=await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
ST.groups=groups;
document.getElementById('sv').textContent='Сохранено '+new Date().toLocaleTimeString('ru');
if(!skipLoad) await load();
}
async function addR(){
const name=document.getElementById('rn').value.trim();
const rci_base_url=document.getElementById('ru').value.trim();
const keenetic_login=document.getElementById('rl').value.trim();
const keenetic_password=document.getElementById('rp').value;
if(!name||!rci_base_url){alert('Имя и URL');return;}
// Сначала сохраняем US/RU с сервера в store, иначе после load() пропадут несохранённые домены
await saveServer({skipLoad:true});
const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({name,rci_base_url,keenetic_login,keenetic_password})});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
document.getElementById('rn').value='';document.getElementById('ru').value='';
document.getElementById('rl').value='';document.getElementById('rp').value='';
await load();
}
async function addAndTunnel(){
// Создать роутер с placeholder base URL (заменится после "Применить URL автоматически")
// и сразу открыть модалку тоннеля.
let name=document.getElementById('rn').value.trim();
let keenetic_login=document.getElementById('rl').value.trim();
let keenetic_password=document.getElementById('rp').value;
if(!name){
name=prompt('Имя роутера (для тоннеля):','Роутер без IP');
if(!name)return;
}
if(!keenetic_login){keenetic_login='admin';}
if(!keenetic_password){
const p=prompt('Пароль Keenetic для RCI (поле «Пароль» пустое):');
if(p===null)return;
keenetic_password=p;
}
await saveServer({skipLoad:true});
// base URL — временный, реальный придёт из tunnel-cmd (http://localhost:NNNN)
const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({
name, rci_base_url:'http://localhost:1', keenetic_login, keenetic_password
})});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
const j=await r.json();
document.getElementById('rn').value='';document.getElementById('ru').value='';
document.getElementById('rl').value='';document.getElementById('rp').value='';
await load();
// Открыть модалку тоннеля для нового роутера
await tunnelR(j.id);
}
async function delR(id){
if(!confirm('Удалить?'))return;
const r=await fetch('/api/routers/'+encodeURIComponent(id),{method:'DELETE',headers:hdr()});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
await load();
}
async function toggleEn(id,en){
ST.routers=(ST.routers||[]).map(x=>x.id===id?{...x,enabled:en}:x);
const groups=readForm();
ST.groups=groups;
await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
}
async function testR(id){
const r=await fetch('/api/test-router/'+encodeURIComponent(id),{method:'POST',headers:hdr()});
if(r.status===401){logout();return;}
const raw=await r.text();
let j=null; try{j=JSON.parse(raw);}catch(_){}
if(!r.ok){
const d=j&&j.detail;
alert('Ошибка: '+(typeof d==='string'?d:Array.isArray(d)?d.map(x=>x.msg||x).join('; '):raw||r.status));
return;
}
if(!j){alert('Ошибка: '+raw);return;}
alert(j.ok?'OK: '+j.message:'Ошибка: '+j.message);
}
function showLog(t){const L=document.getElementById('log');L.textContent=t;L.classList.add('show');}
async function applyAll(){
await saveServer({skipLoad:true});
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'all'})});
if(r.status===401){logout();return;}
const j=await r.json();
showLog((j.results||[]).map(x=>x.ok?`${x.router}\n${(x.log||[]).join('\n')}`:`${x.router}: ${x.error}`).join('\n\n'));
await load();
}
async function applySel(){
const ids=[...document.querySelectorAll('#rtbl input.kdns-sel:checked')].map(c=>c.getAttribute('data-id')).filter(Boolean);
if(!ids.length){
alert('Отметьте чекбоксы в первой колонке таблицы «Роутеры» (слева), не переключатель «Вкл». Обнови страницу (Ctrl+F5), если не помогло.');
return;
}
await saveServer({skipLoad:true});
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'selected',router_ids:ids})});
if(r.status===401){logout();return;}
const j=await r.json();
showLog((j.results||[]).map(x=>x.ok?`${x.router}\n${(x.log||[]).join('\n')}`:`${x.router}: ${x.error}`).join('\n\n'));
await load();
}
let _tunnelRouterId=null;
async function tunnelR(id){
_tunnelRouterId=id;
const msg=document.getElementById('tunnel-msg');
msg.style.display='none';
document.getElementById('tunnel-cmd-text').textContent='Загрузка…';
document.getElementById('tunnel-url-text').textContent='';
document.getElementById('tunnel-modal').classList.add('on');
const r=await fetch(`/api/routers/${encodeURIComponent(id)}/tunnel-cmd`,{headers:hdr()});
if(r.status===401){logout();return;}
if(!r.ok){
const j=await r.json().catch(()=>({}));
document.getElementById('tunnel-cmd-text').textContent='Ошибка: '+(j.detail||r.statusText);
return;
}
const j=await r.json();
document.getElementById('tunnel-cmd-text').textContent=j.cmd||'';
document.getElementById('tunnel-url-text').textContent=j.rci_url||'';
}
function closeTunnelModal(){
document.getElementById('tunnel-modal').classList.remove('on');
_tunnelRouterId=null;
}
function copyTunnelCmd(){
const el=document.getElementById('tunnel-cmd-text');
const txt=el.textContent;
const btn=(typeof event!=='undefined'&&event&&event.target)?event.target:document.querySelector('button[onclick="copyTunnelCmd()"]');
const flash=(t)=>{if(btn){const o=btn.textContent;btn.textContent=t||'✓ Скопировано';setTimeout(()=>{btn.textContent=o;},1800);}};
// 1) HTTPS: современный API
if(window.isSecureContext&&navigator.clipboard){
navigator.clipboard.writeText(txt).then(()=>flash()).catch(selectFallback);
return;
}
selectFallback();
function selectFallback(){
// 2) Выделить on-page контент и попытаться execCommand('copy')
const r=document.createRange();r.selectNodeContents(el);
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
let ok=false;
try{ok=document.execCommand('copy');}catch(e){ok=false;}
if(ok){flash();setTimeout(()=>s.removeAllRanges(),1500);return;}
// 3) Не получилось — оставить выделенным, дать пользователю Cmd+C
flash('Выдели команду и нажми Cmd/Ctrl+C');
}
}
async function applyTunnelUrl(){
if(!_tunnelRouterId)return;
const url=document.getElementById('tunnel-url-text').textContent.trim();
if(!url)return;
const msg=document.getElementById('tunnel-msg');
const r=await fetch(`/api/routers/${encodeURIComponent(_tunnelRouterId)}`,{method:'PATCH',headers:{...hdr(),'Content-Type':'application/json'},body:JSON.stringify({rci_base_url:url})});
if(r.status===401){logout();return;}
if(r.ok){
msg.style.color='var(--ok)';msg.textContent='✓ URL обновлён';msg.style.display='block';
await load();
} else {
const j=await r.json().catch(()=>({}));
msg.style.color='var(--er)';msg.textContent='Ошибка: '+(j.detail||r.statusText);msg.style.display='block';
}
}
async function checkTunnelStatus(){
if(!_tunnelRouterId)return;
const msg=document.getElementById('tunnel-msg');
msg.style.color='var(--mu)';msg.textContent='Проверяю порт…';msg.style.display='block';
const r=await fetch(`/api/routers/${encodeURIComponent(_tunnelRouterId)}/tunnel-status`,{headers:hdr()});
if(r.status===401){logout();return;}
const j=await r.json().catch(()=>({}));
if(j.active){
msg.style.color='var(--ok)';msg.textContent=`✓ Тоннель активен — порт ${j.tunnel_port} слушает`;
} else {
msg.style.color='var(--er)';msg.textContent=`✗ Порт ${j.tunnel_port||'?'} не отвечает — запусти команду на роутере`;
}
}
async function removeTunnel(){
if(!_tunnelRouterId)return;
const msg=document.getElementById('tunnel-msg');
const r=await fetch(`/api/routers/${encodeURIComponent(_tunnelRouterId)}/tunnel`,{method:'DELETE',headers:hdr()});
if(r.status===401){logout();return;}
if(r.ok){
msg.style.color='var(--ok)';msg.textContent='✓ Тоннель сброшен';msg.style.display='block';
await load();
setTimeout(closeTunnelModal,1200);
} else {
const j=await r.json().catch(()=>({}));
msg.style.color='var(--er)';msg.textContent='Ошибка: '+(j.detail||r.statusText);msg.style.display='block';
}
}
(async()=>{
const p=sessionStorage.getItem('kdns_pw');
if(p){
const r=await fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
if(r.ok){document.getElementById('auth-bg').classList.remove('on');await load();}
else{sessionStorage.removeItem('kdns_pw');}
}
})();
</script>
<div class="kdns-foot" lang="ru" id="kdns-foot-strip" aria-label="Автор и ссылки">
<span class="kdns-am">автор ·</span>
<a href="https://github.com/andrey271192" target="_blank" rel="noopener noreferrer" title="Репозитории на GitHub">GitHub andrey271192</a>
<span>·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
<span>·</span>
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer" title="Поддержка проекта (Ozon Bank, СБП)">Поддержка</a>
<span>·</span>
<a href="https://t.me/Iot_andrey" target="_blank" rel="noopener noreferrer">Telegram @Iot_andrey</a>
</div>
</body>
</html>