mirror of
https://github.com/andrey271192/keenetic-dns-routes.git
synced 2026-09-20 14:42:01 +00:00
fix(tunnel): SSH key auth instead of sshpass; init.d boot persistence; copy fallback
aarch64-k3.10 Entware does not have sshpass or cronie packages. Switch to ed25519
SSH key auth and persist via /opt/etc/init.d/S99kdns_tunnel instead of cron.
- /api/routers/{rid}/tunnel-cmd: drop VPS_SSH_PASS dependency, generate one-time
registration token, build install script that:
1. opkg install autossh + openssh-keygen
2. ssh-keygen ed25519 if not exists
3. curl POST pubkey to new register-key endpoint with token
4. autossh -i <key> (no sshpass)
5. /opt/etc/init.d/S99kdns_tunnel for boot autostart
- /api/routers/{rid}/tunnel-register-key: new endpoint, token-auth, appends pubkey
to ~/.ssh/authorized_keys with kdns-tunnel-{rid} comment for de-dup
- copy button: fallback to document.execCommand('copy') for non-HTTPS contexts
(navigator.clipboard requires secure context — UI runs on plain http)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
121
app/main.py
121
app/main.py
@@ -3,10 +3,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Header, HTTPException, Query
|
||||
from fastapi import FastAPI, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -259,12 +262,10 @@ async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Hea
|
||||
|
||||
@app.get("/api/routers/{rid}/tunnel-cmd")
|
||||
async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
|
||||
"""Назначить порт тоннеля и вернуть команду установки для роутера."""
|
||||
"""Назначить порт тоннеля и вернуть команду установки для роутера (SSH-ключ, без sshpass)."""
|
||||
_chk(x_admin_password)
|
||||
if not config.VPS_SSH_HOST:
|
||||
raise HTTPException(400, "VPS_SSH_HOST не задан в .env — укажи публичный IP/домен VPS")
|
||||
if not config.VPS_SSH_PASS:
|
||||
raise HTTPException(400, "VPS_SSH_PASS не задан в .env — укажи пароль SSH для VPS")
|
||||
|
||||
cur = load_store()
|
||||
routers = list(cur.get("routers") or [])
|
||||
@@ -283,6 +284,12 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
|
||||
while port in used:
|
||||
port += 1
|
||||
r["tunnel_port"] = port
|
||||
|
||||
# Одноразовый токен регистрации pubkey (10 мин)
|
||||
reg_token = secrets.token_urlsafe(32)
|
||||
r["tunnel_reg_token"] = reg_token
|
||||
r["tunnel_reg_token_exp"] = int(time.time()) + 600
|
||||
|
||||
routers[idx] = r
|
||||
cur["routers"] = routers
|
||||
save_store(cur)
|
||||
@@ -290,31 +297,55 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
|
||||
vps_host = config.VPS_SSH_HOST
|
||||
vps_port = config.VPS_SSH_PORT
|
||||
vps_user = config.VPS_SSH_USER
|
||||
vps_pass = config.VPS_SSH_PASS.replace("'", "'\\''")
|
||||
http_url = f"http://{vps_host}:{config.PORT}"
|
||||
|
||||
cmd = (
|
||||
f"export PATH=\"/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH\"\n\n"
|
||||
f"# Установить зависимости (autossh, sshpass, cronie для watchdog)\n"
|
||||
f"opkg install autossh sshpass cronie 2>/dev/null; true\n"
|
||||
f"/opt/etc/init.d/S10crond start 2>/dev/null; true\n\n"
|
||||
f"# Создать скрипт тоннеля\n"
|
||||
f"# 1. Зависимости (sshpass/cronie не нужны — авторизуемся по ключу)\n"
|
||||
f"opkg install autossh openssh-client openssh-keygen 2>/dev/null; true\n\n"
|
||||
f"# 2. Сгенерировать SSH-ключ для тоннеля (один раз)\n"
|
||||
f"mkdir -p /opt/etc\n"
|
||||
f"[ -f /opt/etc/kdns_tunnel_key ] || ssh-keygen -t ed25519 -f /opt/etc/kdns_tunnel_key -N '' -C 'kdns-tunnel-{rid}'\n\n"
|
||||
f"# 3. Зарегистрировать публичный ключ на VPS (одноразовый токен, действителен 10 мин)\n"
|
||||
f"curl -fsS -X POST '{http_url}/api/routers/{rid}/tunnel-register-key?token={reg_token}' \\\n"
|
||||
f" -H 'Content-Type: text/plain' \\\n"
|
||||
f" --data-binary @/opt/etc/kdns_tunnel_key.pub \\\n"
|
||||
f" || {{ echo 'ОШИБКА: не удалось зарегистрировать ключ — проверь VPS_SSH_HOST и доступность {http_url}'; exit 1; }}\n"
|
||||
f"echo\n\n"
|
||||
f"# 4. Скрипт тоннеля\n"
|
||||
f"cat > /opt/bin/kdns_tunnel.sh << 'ENDSCRIPT'\n"
|
||||
f"#!/bin/sh\n"
|
||||
f"PATH=\"/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH\"\n"
|
||||
f"exec sshpass -p '{vps_pass}' autossh -M 0 \\\n"
|
||||
f"exec autossh -M 0 \\\n"
|
||||
f" -i /opt/etc/kdns_tunnel_key \\\n"
|
||||
f" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\\n"
|
||||
f" -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\\n"
|
||||
f" -o ExitOnForwardFailure=yes -o IdentitiesOnly=yes \\\n"
|
||||
f" -N -R {port}:localhost:81 {vps_user}@{vps_host} -p {vps_port}\n"
|
||||
f"ENDSCRIPT\n"
|
||||
f"chmod +x /opt/bin/kdns_tunnel.sh\n\n"
|
||||
f"# Watchdog через cron: перезапуск каждые 3 мин если не работает\n"
|
||||
f"(crontab -l 2>/dev/null | grep -v kdns_tunnel; "
|
||||
f"echo '*/3 * * * * killall -0 autossh 2>/dev/null || /opt/bin/kdns_tunnel.sh &') | crontab -\n\n"
|
||||
f"# Запустить сейчас\n"
|
||||
f"# 5. Автозапуск при загрузке роутера через Entware init.d\n"
|
||||
f"cat > /opt/etc/init.d/S99kdns_tunnel << 'ENDINIT'\n"
|
||||
f"#!/bin/sh\n"
|
||||
f"case \"$1\" in\n"
|
||||
f" start) killall -0 autossh 2>/dev/null || nohup /opt/bin/kdns_tunnel.sh >/dev/null 2>&1 & ;;\n"
|
||||
f" stop) killall autossh 2>/dev/null ;;\n"
|
||||
f" restart) killall autossh 2>/dev/null; sleep 1; nohup /opt/bin/kdns_tunnel.sh >/dev/null 2>&1 & ;;\n"
|
||||
f"esac\n"
|
||||
f"ENDINIT\n"
|
||||
f"chmod +x /opt/etc/init.d/S99kdns_tunnel\n\n"
|
||||
f"# 6. Запустить тоннель\n"
|
||||
f"killall autossh 2>/dev/null; sleep 1\n"
|
||||
f"nohup /opt/bin/kdns_tunnel.sh >/dev/null 2>&1 &\n\n"
|
||||
f"echo \"Тоннель запущен: порт 81 → VPS:{port}\"\n"
|
||||
f"echo \"URL для платформы: http://localhost:{port}\""
|
||||
f"sleep 3\n"
|
||||
f"if killall -0 autossh 2>/dev/null; then\n"
|
||||
f" echo 'Тоннель запущен: localhost:81 на роутере → VPS:{port}'\n"
|
||||
f" echo 'URL для платформы: http://localhost:{port}'\n"
|
||||
f" echo 'Теперь нажми «⟳ Проверить связь» в модалке.'\n"
|
||||
f"else\n"
|
||||
f" echo 'ОШИБКА: тоннель не поднялся. Проверь: ssh -i /opt/etc/kdns_tunnel_key {vps_user}@{vps_host}'\n"
|
||||
f" exit 1\n"
|
||||
f"fi"
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -324,6 +355,64 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/routers/{rid}/tunnel-register-key")
|
||||
async def tunnel_register_key(rid: str, token: str, request: Request):
|
||||
"""Принять SSH-публичный ключ от роутера и добавить в authorized_keys VPS.
|
||||
|
||||
Авторизация — одноразовый токен из /tunnel-cmd (без admin password — роутер его не знает).
|
||||
"""
|
||||
cur = load_store()
|
||||
routers = list(cur.get("routers") or [])
|
||||
idx = next((i for i, x in enumerate(routers) if x.get("id") == rid), -1)
|
||||
if idx < 0:
|
||||
raise HTTPException(404, "Роутер не найден")
|
||||
r = dict(routers[idx])
|
||||
|
||||
saved = r.get("tunnel_reg_token")
|
||||
exp = int(r.get("tunnel_reg_token_exp") or 0)
|
||||
if not saved or not secrets.compare_digest(saved, token or ""):
|
||||
raise HTTPException(403, "Неверный или израсходованный токен регистрации")
|
||||
if time.time() > exp:
|
||||
raise HTTPException(403, "Токен истёк (10 мин). Открой модалку «Тоннель» заново.")
|
||||
|
||||
body = await request.body()
|
||||
pubkey = body.decode("utf-8", errors="replace").strip()
|
||||
if not pubkey or len(pubkey) > 4096 or "\n" in pubkey or "\r" in pubkey:
|
||||
raise HTTPException(400, "Некорректный формат SSH-ключа")
|
||||
if not re.match(
|
||||
r"^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+|sk-\S+) [A-Za-z0-9+/=]+( .*)?$",
|
||||
pubkey,
|
||||
):
|
||||
raise HTTPException(400, "Не похоже на SSH-публичный ключ")
|
||||
|
||||
# Добавить в authorized_keys пользователя, под которым работает сервис
|
||||
# (обычно root, т.к. systemctl restart требует root)
|
||||
auth_dir = Path.home() / ".ssh"
|
||||
auth_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
auth_path = auth_dir / "authorized_keys"
|
||||
comment = f"kdns-tunnel-{rid}"
|
||||
lines: list[str] = []
|
||||
if auth_path.exists():
|
||||
lines = [l for l in auth_path.read_text().splitlines() if comment not in l and l.strip()]
|
||||
lines.append(pubkey)
|
||||
auth_path.write_text("\n".join(lines) + "\n")
|
||||
auth_path.chmod(0o600)
|
||||
try:
|
||||
auth_dir.chmod(0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Токен использован — удалить
|
||||
r.pop("tunnel_reg_token", None)
|
||||
r.pop("tunnel_reg_token_exp", None)
|
||||
routers[idx] = r
|
||||
cur["routers"] = routers
|
||||
save_store(cur)
|
||||
|
||||
logger.info("tunnel-register-key rid=%s host=%s", rid, request.client.host if request.client else "?")
|
||||
return {"ok": True, "message": f"Ключ {comment} добавлен в {auth_path}"}
|
||||
|
||||
|
||||
@app.delete("/api/routers/{rid}/tunnel")
|
||||
async def tunnel_remove(rid: str, x_admin_password: str = Header("")):
|
||||
"""Снять назначение тоннельного порта с роутера."""
|
||||
|
||||
@@ -435,9 +435,29 @@ function closeTunnelModal(){
|
||||
}
|
||||
function copyTunnelCmd(){
|
||||
const txt=document.getElementById('tunnel-cmd-text').textContent;
|
||||
navigator.clipboard.writeText(txt).then(()=>{
|
||||
const btn=event.target;btn.textContent='✓ Скопировано';setTimeout(()=>{btn.textContent='📋 Скопировать команду';},2000);
|
||||
}).catch(()=>alert('Не удалось скопировать'));
|
||||
const btn=(typeof event!=='undefined'&&event&&event.target)?event.target:document.querySelector('button[onclick="copyTunnelCmd()"]');
|
||||
const flash=()=>{if(btn){const o=btn.textContent;btn.textContent='✓ Скопировано';setTimeout(()=>{btn.textContent=o;},2000);}};
|
||||
const fail=()=>{
|
||||
const ta=document.getElementById('tunnel-cmd-text');
|
||||
const r=document.createRange();r.selectNodeContents(ta);
|
||||
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
|
||||
alert('Не получилось автоматически. Команда выделена — нажми Cmd/Ctrl+C, затем вставь в SSH роутера.');
|
||||
};
|
||||
// navigator.clipboard работает только в HTTPS / localhost. На http://IP:port — нет.
|
||||
if(window.isSecureContext&&navigator.clipboard){
|
||||
navigator.clipboard.writeText(txt).then(flash).catch(()=>execCopy(txt,flash,fail));
|
||||
} else {
|
||||
execCopy(txt,flash,fail);
|
||||
}
|
||||
}
|
||||
function execCopy(txt,ok,fail){
|
||||
const ta=document.createElement('textarea');
|
||||
ta.value=txt;ta.style.position='fixed';ta.style.top='0';ta.style.left='0';ta.style.opacity='0';
|
||||
document.body.appendChild(ta);ta.focus();ta.select();
|
||||
let okv=false;
|
||||
try{okv=document.execCommand('copy');}catch(e){okv=false;}
|
||||
document.body.removeChild(ta);
|
||||
okv?ok():fail();
|
||||
}
|
||||
async function applyTunnelUrl(){
|
||||
if(!_tunnelRouterId)return;
|
||||
|
||||
Reference in New Issue
Block a user