feat: scan interfaces via RCI; show Keenetic login + password status (no secret in UI)

Made-with: Cursor
This commit is contained in:
Андрей Бобырев
2026-04-25 00:45:55 +03:00
parent e1d6e6bb81
commit ef946e37f2
4 changed files with 149 additions and 5 deletions

View File

@@ -39,6 +39,8 @@ sudo systemctl restart keenetic-dns-routes
- `PUT /api/data` — полное или частичное обновление (`groups` и/или `routers`). - `PUT /api/data` — полное или частичное обновление (`groups` и/или `routers`).
- `POST /api/groups/{US|RU}/lines` — тело `{"add":["a.com"],"remove":["b.com"]}`: правка списка **на сервере** без пересылки всего textarea (порядок: сначала удаления, затем добавления в конец). - `POST /api/groups/{US|RU}/lines` — тело `{"add":["a.com"],"remove":["b.com"]}`: правка списка **на сервере** без пересылки всего textarea (порядок: сначала удаления, затем добавления в конец).
- `POST /api/apply``{"mode":"all"|"selected","router_ids":["id1"]}`. - `POST /api/apply``{"mode":"all"|"selected","router_ids":["id1"]}`.
- `GET /api/keenetic-env` — логин Keenetic и флаг «пароль задан» (сам пароль не отдаётся).
- `GET /api/routers/{id}/interfaces` — список интерфейсов с роутера (`GET /rci/show/interface`), для подбора **Interface ID**.
## Ограничения ## Ограничения

View File

@@ -55,6 +55,40 @@ async def get_data(x_admin_password: str = Header("")):
return load_store() return load_store()
@app.get("/api/keenetic-env")
async def keenetic_env(x_admin_password: str = Header("")):
"""Логин и факт наличия пароля (сам пароль в ответ не кладём — только из .env на сервере)."""
_chk(x_admin_password)
return {
"login": config.KEENETIC_LOGIN,
"password_configured": bool(config.KEENETIC_PASSWORD),
"hint": "Пароль смотри только в server/.env (KEENETIC_PASSWORD); в браузер не передаётся.",
}
@app.get("/api/routers/{rid}/interfaces")
async def router_interfaces(rid: str, x_admin_password: str = Header("")):
_chk(x_admin_password)
if not config.KEENETIC_PASSWORD:
raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env")
cur = load_store()
r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None)
if not r:
raise HTTPException(404, "Роутер не найден")
def _run():
k = KeeneticRCI(
r["rci_base_url"], config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD
)
return k.list_interfaces()
try:
items = await asyncio.to_thread(_run)
except KeeneticRCIError as e:
raise HTTPException(502, str(e)) from e
return {"interfaces": items}
class PutDataBody(BaseModel): class PutDataBody(BaseModel):
groups: dict[str, dict] | None = None groups: dict[str, dict] | None = None
routers: list[dict] | None = None routers: list[dict] | None = None

View File

@@ -90,6 +90,42 @@ class KeeneticRCI:
if r2.status_code not in (200, 201, 202): if r2.status_code not in (200, 201, 202):
raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}") raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}")
def list_interfaces(self) -> list[dict[str, Any]]:
"""GET /rci/show/interface — id, type, description, state (как gokeenapi)."""
with self._client_ctx() as client:
self._auth(client)
r = client.get("/rci/show/interface")
if r.status_code != 200:
raise KeeneticRCIError(f"show/interface HTTP {r.status_code}")
data = r.json()
if not isinstance(data, dict):
raise KeeneticRCIError("show/interface: ожидался объект JSON")
rows: list[dict[str, Any]] = []
for key, body in data.items():
if not isinstance(body, dict) or str(key).startswith("_"):
continue
iid = str(body.get("id") or body.get("Id") or key)
typ = str(body.get("type") or body.get("Type") or "")
desc = str(body.get("description") or body.get("Description") or "")
state = str(body.get("state") or body.get("State") or "")
link = str(body.get("link") or body.get("Link") or "")
conn = str(body.get("connected") or body.get("Connected") or "")
addr = str(body.get("address") or body.get("Address") or "")
rows.append(
{
"id": iid,
"type": typ,
"description": desc,
"state": state,
"link": link,
"connected": conn,
"address": addr,
"label": f"{iid}{desc or typ or 'интерфейс'}",
}
)
rows.sort(key=lambda x: x["id"].lower())
return rows
def _parse_fqdn_response(self, data: dict[str, Any]) -> dict[str, list[str]]: def _parse_fqdn_response(self, data: dict[str, Any]) -> dict[str, list[str]]:
out: dict[str, list[str]] = {} out: dict[str, list[str]] = {}
for name, body in data.items(): for name, body in data.items():

View File

@@ -38,6 +38,11 @@
#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{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-bg.on{display:flex}
#auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)} #auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)}
#ifscan-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{display:flex}
#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}
.if-row{cursor:pointer}
.if-row:hover{background:rgba(59,130,246,.12)}
</style> </style>
</head> </head>
<body> <body>
@@ -55,6 +60,13 @@
</div> </div>
<div class="wrap"> <div class="wrap">
<div class="card" id="cred-card" style="display:none">
<h2>Учётная запись Keenetic (RCI)</h2>
<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">Пароль в этот экран <b>не передаётся</b> — только в <code>/opt/keenetic-dns-routes/.env</code> как <code>KEENETIC_PASSWORD</code>. Для KeenDNS API часто нужен URL вида <code>http://rci.имя.keenetic.pro:79</code>, а не HTTPS веб-морды.</p>
</div>
<div class="tabs"> <div class="tabs">
<button class="tab on" data-t="US" onclick="setTab('US')">Список US</button> <button class="tab on" data-t="US" onclick="setTab('US')">Список US</button>
<button class="tab" data-t="RU" onclick="setTab('RU')">Список RU</button> <button class="tab" data-t="RU" onclick="setTab('RU')">Список RU</button>
@@ -62,17 +74,19 @@
<div class="card" id="panel-US"> <div class="card" id="panel-US">
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2> <h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
<div class="row"> <div class="row" style="align-items:flex-end">
<div><label>Interface ID (например Wireguard0, PPPoE0)</label> <div style="flex:1"><label>Interface ID (как в RCI: Wireguard0, PPPoE0)</label>
<input type="text" id="if-US" placeholder="Wireguard0"/></div> <input type="text" id="if-US" placeholder="Wireguard0"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
</div> </div>
<textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24"></textarea> <textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24"></textarea>
</div> </div>
<div class="card" id="panel-RU" style="display:none"> <div class="card" id="panel-RU" style="display:none">
<h2>RU — домены и IP/CIDR</h2> <h2>RU — домены и IP/CIDR</h2>
<div class="row"> <div class="row" style="align-items:flex-end">
<div><label>Interface ID (например GigabitEthernet0)</label> <div style="flex:1"><label>Interface ID</label>
<input type="text" id="if-RU" placeholder="GigabitEthernet0"/></div> <input type="text" id="if-RU" placeholder="GigabitEthernet0"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('RU')">Сканировать…</button></div>
</div> </div>
<textarea id="tx-RU" placeholder="yandex.ru"></textarea> <textarea id="tx-RU" placeholder="yandex.ru"></textarea>
</div> </div>
@@ -92,7 +106,7 @@
<div class="card" style="margin-top:24px"> <div class="card" style="margin-top:24px">
<h2>Роутеры (KeenDNS → RCI base URL)</h2> <h2>Роутеры (KeenDNS → RCI base URL)</h2>
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">Один логин/пароль Keenetic задаётся в <code>.env</code> на сервере (KEENETIC_LOGIN / KEENETIC_PASSWORD).</p> <p style="font-size:12px;color:var(--mu);margin-bottom:12px">Логин/пароль для входа в RCI — в <code>.env</code> на сервере. Сводка сверху на странице; пароль в браузер не выводится.</p>
<div class="row"> <div class="row">
<div><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div> <div><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
<div style="flex:2"><label>RCI URL</label><input type="url" id="ru" placeholder="http://rci.home.keenetic.pro:79"/></div> <div style="flex:2"><label>RCI URL</label><input type="url" id="ru" placeholder="http://rci.home.keenetic.pro:79"/></div>
@@ -106,6 +120,18 @@
</div> </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>
<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> <script>
let ST={groups:{US:{interface_id:'',lines:[]},RU:{interface_id:'',lines:[]}},routers:[]}; 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')||''};} function hdr(){return{'Content-Type':'application/json','X-Admin-Password':sessionStorage.getItem('kdns_pw')||''};}
@@ -120,11 +146,57 @@ async function doLogin(){
} }
function logout(){sessionStorage.removeItem('kdns_pw');document.getElementById('auth-bg').classList.add('on');} 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">пароль в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD пустой</span>';
document.getElementById('cred-line').innerHTML='Логин: <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 r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces',{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(){ async function load(){
const r=await fetch('/api/data',{headers:hdr()}); const r=await fetch('/api/data',{headers:hdr()});
if(r.status===401){logout();return;} if(r.status===401){logout();return;}
ST=await r.json(); ST=await r.json();
paint(); paint();
await loadCred();
} }
function paint(){ function paint(){
for(const k of['US','RU']){ for(const k of['US','RU']){