From 61d80ebc22d382bad31bc9ca0fea72ad7d560f26 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:12:15 +0300 Subject: [PATCH] fix(tunnel): one-liner curl|sh; keypair generated on VPS; on-page copy fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aarch64-k3.10 Entware lacks sshpass, cronie, and openssh-keygen. Generate the ed25519 keypair on the VPS (where ssh-keygen is always present) and ship the private key to the router inside the install script. The router only sees a short curl|sh one-liner — easy to copy, immune to markdown auto-linkification of .sh paths that mangled previous attempts. - _gen_ed25519_keypair / _add_pubkey_to_authorized_keys helpers - /tunnel-cmd: generate keypair on first call, store priv+pub on router record, add pub to ~/.ssh/authorized_keys, return one-liner - /tunnel-script: returns full install script with private key embedded (text/plain), token-authenticated, single-use - removed /tunnel-register-key endpoint (router no longer generates the key) - copy button: select on-page text + execCommand fallback for non-HTTPS; if both fail, leave selected so user can Cmd+C manually Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 290 ++++++++++++++++++++++++------------------- templates/index.html | 38 +++--- 2 files changed, 176 insertions(+), 152 deletions(-) diff --git a/app/main.py b/app/main.py index 279df66..70a09f8 100644 --- a/app/main.py +++ b/app/main.py @@ -3,14 +3,16 @@ from __future__ import annotations import asyncio import logging -import re +import os import secrets import socket +import subprocess +import tempfile import time from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, Header, HTTPException, Query, Request -from fastapi.responses import HTMLResponse +from fastapi import FastAPI, Header, HTTPException, Query +from fastapi.responses import HTMLResponse, PlainTextResponse from pydantic import BaseModel, Field from . import config @@ -260,133 +262,22 @@ async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Hea return r -@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") - - 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]) - - # Переиспользовать уже назначенный порт или выдать новый - if r.get("tunnel_port"): - port = int(r["tunnel_port"]) - else: - used = {int(x.get("tunnel_port")) for x in routers if x.get("tunnel_port")} - port = config.TUNNEL_PORT_START - 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) - - vps_host = config.VPS_SSH_HOST - vps_port = config.VPS_SSH_PORT - vps_user = config.VPS_SSH_USER - 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"# 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 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"# 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"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 { - "tunnel_port": port, - "rci_url": f"http://localhost:{port}", - "cmd": cmd, - } +def _gen_ed25519_keypair(rid: str) -> tuple[str, str]: + """Сгенерировать ed25519 keypair на VPS через ssh-keygen. Возвращает (private_pem, public_openssh).""" + with tempfile.TemporaryDirectory() as tmpdir: + keyfile = os.path.join(tmpdir, "k") + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-f", keyfile, "-N", "", "-q", + "-C", f"kdns-tunnel-{rid}"], + check=True, timeout=10, + ) + priv = Path(keyfile).read_text() + pub = Path(keyfile + ".pub").read_text().strip() + return priv, pub -@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) +def _add_pubkey_to_authorized_keys(rid: str, pubkey: str) -> Path: + """Добавить pubkey в ~/.ssh/authorized_keys (де-дуп по комменту kdns-tunnel-{rid}).""" auth_dir = Path.home() / ".ssh" auth_dir.mkdir(mode=0o700, exist_ok=True) auth_path = auth_dir / "authorized_keys" @@ -401,16 +292,153 @@ async def tunnel_register_key(rid: str, token: str, request: Request): auth_dir.chmod(0o700) except OSError: pass + return auth_path - # Токен использован — удалить + +@app.get("/api/routers/{rid}/tunnel-cmd") +async def tunnel_cmd(rid: str, x_admin_password: str = Header("")): + """Назначить порт + сгенерить keypair + вернуть короткую curl|sh команду для роутера.""" + _chk(x_admin_password) + if not config.VPS_SSH_HOST: + raise HTTPException(400, "VPS_SSH_HOST не задан в .env — укажи публичный IP/домен VPS") + + 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]) + + # Порт + if r.get("tunnel_port"): + port = int(r["tunnel_port"]) + else: + used = {int(x.get("tunnel_port")) for x in routers if x.get("tunnel_port")} + port = config.TUNNEL_PORT_START + while port in used: + port += 1 + r["tunnel_port"] = port + + # Keypair (один раз на роутер; ssh-keygen на VPS — он точно есть на Linux-сервере) + if not r.get("tunnel_priv_key") or not r.get("tunnel_pub_key"): + try: + priv, pub = await asyncio.to_thread(_gen_ed25519_keypair, rid) + except FileNotFoundError as e: + raise HTTPException(500, "ssh-keygen не найден на VPS — установи openssh-client") from e + except subprocess.CalledProcessError as e: + raise HTTPException(500, f"ssh-keygen упал: {e}") from e + r["tunnel_priv_key"] = priv + r["tunnel_pub_key"] = pub + await asyncio.to_thread(_add_pubkey_to_authorized_keys, rid, pub) + + # Одноразовый токен (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) + + http_url = f"http://{config.VPS_SSH_HOST}:{config.PORT}" + one_liner = f"curl -fsS '{http_url}/api/routers/{rid}/tunnel-script?token={reg_token}' | sh" + + return { + "tunnel_port": port, + "rci_url": f"http://localhost:{port}", + "cmd": one_liner, + } + + +@app.get("/api/routers/{rid}/tunnel-script") +async def tunnel_script(rid: str, token: str): + """Установочный скрипт для роутера (с приватным ключом внутри). Auth — одноразовый токен.""" + 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") + if not saved or not secrets.compare_digest(saved, token or ""): + raise HTTPException(403, "Неверный или израсходованный токен") + if time.time() > int(r.get("tunnel_reg_token_exp") or 0): + raise HTTPException(403, "Токен истёк (10 мин). Открой модалку заново.") + + if not r.get("tunnel_priv_key") or not r.get("tunnel_port"): + raise HTTPException(500, "Тоннель не подготовлен — открой модалку заново") + + # Токен — одноразовый, расходуем сейчас 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}"} + port = int(r["tunnel_port"]) + priv_key = r["tunnel_priv_key"].strip() + vps_host = config.VPS_SSH_HOST + vps_port = config.VPS_SSH_PORT + vps_user = config.VPS_SSH_USER + + script = f"""#!/bin/sh +set -e +export PATH="/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH" + +echo '[1/4] autossh...' +opkg install autossh openssh-client 2>/dev/null || true +command -v autossh >/dev/null 2>&1 || {{ echo 'ОШИБКА: autossh не установлен. Запусти opkg update и повтори.'; exit 1; }} + +echo '[2/4] Приватный ключ...' +mkdir -p /opt/etc +cat > /opt/etc/kdns_tk <<'KEYEOF' +{priv_key} +KEYEOF +chmod 600 /opt/etc/kdns_tk + +echo '[3/4] Скрипт тоннеля + автозапуск...' +cat > /opt/bin/kdns_tun <<'RUNEOF' +#!/bin/sh +PATH="/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH" +exec autossh -M 0 \\ + -i /opt/etc/kdns_tk \\ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\ + -o ExitOnForwardFailure=yes -o IdentitiesOnly=yes \\ + -N -R {port}:localhost:81 {vps_user}@{vps_host} -p {vps_port} +RUNEOF +chmod +x /opt/bin/kdns_tun + +cat > /opt/etc/init.d/S99kdns_tun <<'INITEOF' +#!/bin/sh +case "$1" in + start) killall -0 autossh 2>/dev/null || nohup /opt/bin/kdns_tun >/dev/null 2>&1 & ;; + stop) killall autossh 2>/dev/null ;; + restart) killall autossh 2>/dev/null; sleep 1; nohup /opt/bin/kdns_tun >/dev/null 2>&1 & ;; +esac +INITEOF +chmod +x /opt/etc/init.d/S99kdns_tun + +echo '[4/4] Запуск...' +killall autossh 2>/dev/null || true +sleep 1 +nohup /opt/bin/kdns_tun >/dev/null 2>&1 & +sleep 3 +if killall -0 autossh 2>/dev/null; then + echo + echo '=== OK ===' + echo 'Тоннель: localhost:81 (роутер) -> VPS:{port}' + echo 'Возвращайся в браузер и жми "Проверить связь"' +else + echo + echo '=== ОШИБКА: autossh не запустился ===' + echo 'Тест вручную: /opt/bin/kdns_tun (Ctrl+C для выхода)' + exit 1 +fi +""" + return PlainTextResponse(script, media_type="text/plain; charset=utf-8") @app.delete("/api/routers/{rid}/tunnel") diff --git a/templates/index.html b/templates/index.html index 54e7542..cba1169 100644 --- a/templates/index.html +++ b/templates/index.html @@ -434,30 +434,26 @@ function closeTunnelModal(){ _tunnelRouterId=null; } function copyTunnelCmd(){ - const txt=document.getElementById('tunnel-cmd-text').textContent; + const el=document.getElementById('tunnel-cmd-text'); + const txt=el.textContent; 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 — нет. + const flash=(t)=>{if(btn){const o=btn.textContent;btn.textContent=t||'✓ Скопировано';setTimeout(()=>{btn.textContent=o;},1800);}}; + // 1) HTTPS: современный API if(window.isSecureContext&&navigator.clipboard){ - navigator.clipboard.writeText(txt).then(flash).catch(()=>execCopy(txt,flash,fail)); - } else { - execCopy(txt,flash,fail); + navigator.clipboard.writeText(txt).then(()=>flash()).catch(selectFallback); + return; + } + selectFallback(); + function selectFallback(){ + // 2) Выделить on-page контент и попытаться execCommand('copy') + const r=document.createRange();r.selectNodeContents(el); + const s=window.getSelection();s.removeAllRanges();s.addRange(r); + let ok=false; + try{ok=document.execCommand('copy');}catch(e){ok=false;} + if(ok){flash();setTimeout(()=>s.removeAllRanges(),1500);return;} + // 3) Не получилось — оставить выделенным, дать пользователю Cmd+C + flash('Выдели команду и нажми Cmd/Ctrl+C'); } -} -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;