From b140cdac43a066e658a6f05ecdd5622f4695f755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Mon, 27 Apr 2026 22:03:40 +0300 Subject: [PATCH] fix(tunnel): SSH key auth instead of sshpass; init.d boot persistence; copy fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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 --- app/main.py | 127 ++++++++++++++++++++++++++++++++++++------- templates/index.html | 26 ++++++++- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/app/main.py b/app/main.py index e948278..279df66 100644 --- a/app/main.py +++ b/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,38 +284,68 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")): while port in used: port += 1 r["tunnel_port"] = port - routers[idx] = r - cur["routers"] = routers - save_store(cur) + + # Одноразовый токен регистрации 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) 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("")): """Снять назначение тоннельного порта с роутера.""" diff --git a/templates/index.html b/templates/index.html index bc9d2d3..54e7542 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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;