mirror of
https://github.com/andrey271192/keenetic-dns-routes.git
synced 2026-09-20 14:42:01 +00:00
feat: scan interfaces via RCI; show Keenetic login + password status (no secret in UI)
Made-with: Cursor
This commit is contained in:
@@ -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**.
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|
||||||
|
|||||||
34
app/main.py
34
app/main.py
@@ -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
|
||||||
|
|||||||
36
app/rci.py
36
app/rci.py
@@ -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():
|
||||||
|
|||||||
@@ -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> </label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="tx-US" placeholder="youtube.com 1.2.3.0/24"></textarea>
|
<textarea id="tx-US" placeholder="youtube.com 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> </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> </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> '+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,'"')+'"><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']){
|
||||||
|
|||||||
Reference in New Issue
Block a user