mirror of
https://github.com/andrey271192/domen_hydra.git
synced 2026-09-20 14:42:00 +00:00
feat: footer support links; POST /api/routers/{name}/push for single-router update
Made-with: Cursor
This commit is contained in:
@@ -22,8 +22,11 @@
|
|||||||
|
|
||||||
- **Тест** — проверка SSH, каталога `/opt/etc/HydraRoute` (или `/opt/etc/hydra`) и наличия `domain.conf` / `ip.list` на роутере.
|
- **Тест** — проверка SSH, каталога `/opt/etc/HydraRoute` (или `/opt/etc/hydra`) и наличия `domain.conf` / `ip.list` на роутере.
|
||||||
- **С роутера** — скачать эти файлы по SSH и подставить во вкладку **«Импорт файлов»** (сохранение на сервер — отдельной кнопкой).
|
- **С роутера** — скачать эти файлы по SSH и подставить во вкладку **«Импорт файлов»** (сохранение на сервер — отдельной кнопкой).
|
||||||
|
- **На роутер** — отправить текущие `domain.conf` и `ip.list` с сервера **только на этот** роутер (аналог одной строки из «Обновить все роутеры»).
|
||||||
|
|
||||||
API: `POST /api/routers/{имя}/test`, `GET /api/routers/{имя}/fetch` (заголовок `X-Admin-Password`).
|
Внизу экрана — плашка **поддержки**: GitHub, Boosty (донат), Ozon СБП, Telegram @Iot_andrey.
|
||||||
|
|
||||||
|
API: `POST /api/routers/{имя}/test`, `GET /api/routers/{имя}/fetch`, `POST /api/routers/{имя}/push` (заголовок `X-Admin-Password`).
|
||||||
|
|
||||||
### Вкладка «Настройки»
|
### Вкладка «Настройки»
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,41 @@ async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tupl
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return 1, "", str(e)
|
return 1, "", str(e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_one_router(server_url: str, router_key: str, rcfg: dict) -> dict:
|
||||||
|
"""Скачать с manager domain.conf + ip.list на роутер по SSH и neo restart."""
|
||||||
|
ip = rcfg.get("ip", "")
|
||||||
|
if not ip:
|
||||||
|
return {"router": router_key, "ok": False, "msg": "нет IP"}
|
||||||
|
user = rcfg.get("user") or config.SSH_USER
|
||||||
|
pwd = rcfg.get("password") or config.SSH_PASS
|
||||||
|
cmd = (
|
||||||
|
f"curl -sf '{server_url}/hydra/domain.conf' -o /opt/etc/HydraRoute/domain.conf && "
|
||||||
|
f"curl -sf '{server_url}/hydra/ip.list' -o /opt/etc/HydraRoute/ip.list && "
|
||||||
|
f"neo restart"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
[
|
||||||
|
"sshpass", "-p", pwd, "ssh",
|
||||||
|
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||||||
|
f"{user}@{ip}", cmd,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
tail = ((r.stdout or "") + (r.stderr or ""))[:400]
|
||||||
|
return {
|
||||||
|
"router": router_key,
|
||||||
|
"ok": r.returncode == 0,
|
||||||
|
"msg": tail or ("ok" if r.returncode == 0 else "exit " + str(r.returncode)),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"router": router_key, "ok": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
# ── Pages ────────────────────────────────────────────────────────────────────
|
# ── Pages ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
@@ -171,33 +206,7 @@ async def push_all(request: Request, x_admin_password: str = Header("")):
|
|||||||
server_url = str(request.base_url).rstrip("/")
|
server_url = str(request.base_url).rstrip("/")
|
||||||
results = []
|
results = []
|
||||||
for name, rcfg in routers.items():
|
for name, rcfg in routers.items():
|
||||||
ip = rcfg.get("ip", "")
|
results.append(await _push_one_router(server_url, name, rcfg))
|
||||||
if not ip:
|
|
||||||
results.append({"router": name, "ok": False, "msg": "нет IP"})
|
|
||||||
continue
|
|
||||||
user = rcfg.get("user") or config.SSH_USER
|
|
||||||
pwd = rcfg.get("password") or config.SSH_PASS
|
|
||||||
cmd = (
|
|
||||||
f"curl -sf '{server_url}/hydra/domain.conf' -o /opt/etc/HydraRoute/domain.conf && "
|
|
||||||
f"curl -sf '{server_url}/hydra/ip.list' -o /opt/etc/HydraRoute/ip.list && "
|
|
||||||
f"neo restart"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
r = await asyncio.to_thread(
|
|
||||||
subprocess.run,
|
|
||||||
[
|
|
||||||
"sshpass", "-p", pwd, "ssh",
|
|
||||||
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
|
||||||
f"{user}@{ip}", cmd,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
tail = ((r.stdout or "") + (r.stderr or ""))[:400]
|
|
||||||
results.append({"router": name, "ok": r.returncode == 0, "msg": tail or ("ok" if r.returncode == 0 else "exit " + str(r.returncode))})
|
|
||||||
except Exception as e:
|
|
||||||
results.append({"router": name, "ok": False, "msg": str(e)})
|
|
||||||
return {"results": results, "ok": sum(1 for r in results if r["ok"]), "failed": sum(1 for r in results if not r["ok"])}
|
return {"results": results, "ok": sum(1 for r in results if r["ok"]), "failed": sum(1 for r in results if not r["ok"])}
|
||||||
|
|
||||||
# ── Routers CRUD ──────────────────────────────────────────────────────────────
|
# ── Routers CRUD ──────────────────────────────────────────────────────────────
|
||||||
@@ -256,6 +265,17 @@ async def fetch_from_router(name: str, x_admin_password: str = Header("")):
|
|||||||
"errors": {"domain": err_d if code_d else "", "ip": err_i if code_i else ""},
|
"errors": {"domain": err_d if code_d else "", "ip": err_i if code_i else ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/routers/{name}/push")
|
||||||
|
async def push_one_router(name: str, request: Request, x_admin_password: str = Header("")):
|
||||||
|
"""Отправить текущий domain.conf + ip.list только на один роутер."""
|
||||||
|
_chk(x_admin_password)
|
||||||
|
key = _router_key(name)
|
||||||
|
rcfg = _get_router_cfg(name)
|
||||||
|
server_url = str(request.base_url).rstrip("/")
|
||||||
|
return await _push_one_router(server_url, key, rcfg)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/routers/{name}")
|
@app.delete("/api/routers/{name}")
|
||||||
async def delete_router(name: str, x_admin_password: str = Header("")):
|
async def delete_router(name: str, x_admin_password: str = Header("")):
|
||||||
_chk(x_admin_password)
|
_chk(x_admin_password)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<style>
|
<style>
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
:root{--bg:#000;--card:#1c1c1e;--card2:#2c2c2e;--card3:#3a3a3c;--border:#38383a;--text:#f5f5f7;--muted:#86868b;--accent:#0a84ff;--green:#30d158;--red:#ff453a;--yellow:#ffd60a;--orange:#ff9f0a}
|
:root{--bg:#000;--card:#1c1c1e;--card2:#2c2c2e;--card3:#3a3a3c;--border:#38383a;--text:#f5f5f7;--muted:#86868b;--accent:#0a84ff;--green:#30d158;--red:#ff453a;--yellow:#ffd60a;--orange:#ff9f0a}
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',sans-serif;background:var(--bg);color:var(--text);padding:20px 24px;max-width:1200px;margin:0 auto}
|
body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',sans-serif;background:var(--bg);color:var(--text);padding:20px 24px 56px;max-width:1200px;margin:0 auto}
|
||||||
h1{font-size:22px;font-weight:700;margin-bottom:4px}
|
h1{font-size:22px;font-weight:700;margin-bottom:4px}
|
||||||
.sub{color:var(--muted);font-size:13px;margin-bottom:20px}
|
.sub{color:var(--muted);font-size:13px;margin-bottom:20px}
|
||||||
.topbar{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:20px;margin-top:12px}
|
.topbar{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:20px;margin-top:12px}
|
||||||
@@ -362,6 +362,7 @@ function _escHtml(s){ return String(s==null?'':s).replace(/&/g,'&').replace(
|
|||||||
const act=b.getAttribute('data-r-act');
|
const act=b.getAttribute('data-r-act');
|
||||||
if(act==='test') await testRouter(n);
|
if(act==='test') await testRouter(n);
|
||||||
else if(act==='pull') await pullRouter(n);
|
else if(act==='pull') await pullRouter(n);
|
||||||
|
else if(act==='push') await pushRouter(n);
|
||||||
else if(act==='del') await delRouter(n);
|
else if(act==='del') await delRouter(n);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -376,6 +377,7 @@ async function loadRouters(){
|
|||||||
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end">
|
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end">
|
||||||
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:5px 10px" data-r-act="test" data-router="${enc}">🔌 Тест</button>
|
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:5px 10px" data-r-act="test" data-router="${enc}">🔌 Тест</button>
|
||||||
<button type="button" class="btn btn-b" style="font-size:11px;padding:5px 10px" data-r-act="pull" data-router="${enc}">⬇ С роутера</button>
|
<button type="button" class="btn btn-b" style="font-size:11px;padding:5px 10px" data-r-act="pull" data-router="${enc}">⬇ С роутера</button>
|
||||||
|
<button type="button" class="btn btn-g" style="font-size:11px;padding:5px 10px" data-r-act="push" data-router="${enc}">📡 На роутер</button>
|
||||||
<button type="button" class="btn btn-r" style="font-size:11px;padding:5px 10px" data-r-act="del" data-router="${enc}">✗</button>
|
<button type="button" class="btn btn-r" style="font-size:11px;padding:5px 10px" data-r-act="del" data-router="${enc}">✗</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -406,10 +408,31 @@ async function pullRouter(n){
|
|||||||
sm('im','ok','✅ Подставлено: '+parts.join(', ')+(j.domain_ok&&j.ip_ok?'':' (часть файлов не найдена — пустое поле)'));
|
sm('im','ok','✅ Подставлено: '+parts.join(', ')+(j.domain_ok&&j.ip_ok?'':' (часть файлов не найдена — пустое поле)'));
|
||||||
}catch(e){ sm('rm','err','❌ '+e); }
|
}catch(e){ sm('rm','err','❌ '+e); }
|
||||||
}
|
}
|
||||||
|
async function pushRouter(n){
|
||||||
|
const log=document.getElementById('router-ops-msg');
|
||||||
|
log.style.display='block'; log.textContent='Отправка на «'+n+'»…\n';
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/routers/'+encodeURIComponent(n)+'/push',{method:'POST',headers:{'X-Admin-Password':getPass()}});
|
||||||
|
const j=await r.json();
|
||||||
|
if(!r.ok){ log.textContent='❌ '+(Array.isArray(j.detail)?j.detail.map(x=>x.msg||x).join(' '):(j.detail||r.status)); return; }
|
||||||
|
log.textContent=(j.ok?'✅':'❌')+' «'+n+'»: '+(j.msg||'');
|
||||||
|
}catch(e){ log.textContent='❌ '+e; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── SETTINGS ──────────────────────────────────────────────────────────────
|
// ── SETTINGS ──────────────────────────────────────────────────────────────
|
||||||
async function changePwd(){ const p1=document.getElementById('new-pwd').value; const p2=document.getElementById('new-pwd2').value; if(p1!==p2){sm('pwd-msg','err','❌ Пароли не совпадают');return;} if(p1.length<4){sm('pwd-msg','err','❌ Минимум 4 символа');return;} try{ const r=await fetch('/api/set_password',{method:'POST',headers:authHdr(),body:JSON.stringify({password:p1})}); if(r.ok){ sessionStorage.setItem('hm_pass',p1); sm('pwd-msg','ok','✅ Пароль сохранён'); document.getElementById('new-pwd').value=''; document.getElementById('new-pwd2').value=''; } }catch(e){sm('pwd-msg','err','❌ '+e)} }
|
async function changePwd(){ const p1=document.getElementById('new-pwd').value; const p2=document.getElementById('new-pwd2').value; if(p1!==p2){sm('pwd-msg','err','❌ Пароли не совпадают');return;} if(p1.length<4){sm('pwd-msg','err','❌ Минимум 4 символа');return;} try{ const r=await fetch('/api/set_password',{method:'POST',headers:authHdr(),body:JSON.stringify({password:p1})}); if(r.ok){ sessionStorage.setItem('hm_pass',p1); sm('pwd-msg','ok','✅ Пароль сохранён'); document.getElementById('new-pwd').value=''; document.getElementById('new-pwd2').value=''; } }catch(e){sm('pwd-msg','err','❌ '+e)} }
|
||||||
|
|
||||||
function sm(id,c,t){const e=document.getElementById(id);e.className='msg '+c;e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',5000)}
|
function sm(id,c,t){const e=document.getElementById(id);e.className='msg '+c;e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',5000)}
|
||||||
_checkAuth().then(ok=>{ if(ok) _afterLogin(); });
|
_checkAuth().then(ok=>{ if(ok) _afterLogin(); });
|
||||||
</script></body></html>
|
</script>
|
||||||
|
<div id="hm-foot" lang="ru" style="position:fixed;bottom:10px;left:12px;z-index:90;max-width:min(96vw,720px);font-size:11px;font-weight:600;letter-spacing:.02em;color:#86868b;opacity:.92;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;line-height:1.3">
|
||||||
|
<span style="color:#6e6e73;font-weight:500;margin-right:2px">поддержка:</span>
|
||||||
|
<a href="https://github.com/andrey271192/domen_hydra" target="_blank" rel="noopener noreferrer" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">GitHub</a>
|
||||||
|
<span style="color:#86868b;user-select:none">·</span>
|
||||||
|
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">Boosty (донат)</a>
|
||||||
|
<span style="color:#86868b;user-select:none">·</span>
|
||||||
|
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer" title="Поддержка (Ozon Bank, СБП)" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">Ozon СБП</a>
|
||||||
|
<span style="color:#86868b;user-select:none">·</span>
|
||||||
|
<a href="https://t.me/Iot_andrey" target="_blank" rel="noopener noreferrer" style="color:#a1a1a6;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.12)">Telegram @Iot_andrey</a>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
|||||||
Reference in New Issue
Block a user