mirror of
https://github.com/andrey271192/keenetic-dns-routes.git
synced 2026-09-20 14:42:01 +00:00
fix(tunnel): one-liner curl|sh; keypair generated on VPS; on-page copy fallback
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 <noreply@anthropic.com>
This commit is contained in:
288
app/main.py
288
app/main.py
@@ -3,14 +3,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, Header, HTTPException, Query, Request
|
from fastapi import FastAPI, Header, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
@@ -260,133 +262,22 @@ async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Hea
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/routers/{rid}/tunnel-cmd")
|
def _gen_ed25519_keypair(rid: str) -> tuple[str, str]:
|
||||||
async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
|
"""Сгенерировать ed25519 keypair на VPS через ssh-keygen. Возвращает (private_pem, public_openssh)."""
|
||||||
"""Назначить порт тоннеля и вернуть команду установки для роутера (SSH-ключ, без sshpass)."""
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
_chk(x_admin_password)
|
keyfile = os.path.join(tmpdir, "k")
|
||||||
if not config.VPS_SSH_HOST:
|
subprocess.run(
|
||||||
raise HTTPException(400, "VPS_SSH_HOST не задан в .env — укажи публичный IP/домен VPS")
|
["ssh-keygen", "-t", "ed25519", "-f", keyfile, "-N", "", "-q",
|
||||||
|
"-C", f"kdns-tunnel-{rid}"],
|
||||||
cur = load_store()
|
check=True, timeout=10,
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
priv = Path(keyfile).read_text()
|
||||||
return {
|
pub = Path(keyfile + ".pub").read_text().strip()
|
||||||
"tunnel_port": port,
|
return priv, pub
|
||||||
"rci_url": f"http://localhost:{port}",
|
|
||||||
"cmd": cmd,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/routers/{rid}/tunnel-register-key")
|
def _add_pubkey_to_authorized_keys(rid: str, pubkey: str) -> Path:
|
||||||
async def tunnel_register_key(rid: str, token: str, request: Request):
|
"""Добавить pubkey в ~/.ssh/authorized_keys (де-дуп по комменту kdns-tunnel-{rid})."""
|
||||||
"""Принять 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 = Path.home() / ".ssh"
|
||||||
auth_dir.mkdir(mode=0o700, exist_ok=True)
|
auth_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
auth_path = auth_dir / "authorized_keys"
|
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)
|
auth_dir.chmod(0o700)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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", None)
|
||||||
r.pop("tunnel_reg_token_exp", None)
|
r.pop("tunnel_reg_token_exp", None)
|
||||||
routers[idx] = r
|
routers[idx] = r
|
||||||
cur["routers"] = routers
|
cur["routers"] = routers
|
||||||
save_store(cur)
|
save_store(cur)
|
||||||
|
|
||||||
logger.info("tunnel-register-key rid=%s host=%s", rid, request.client.host if request.client else "?")
|
port = int(r["tunnel_port"])
|
||||||
return {"ok": True, "message": f"Ключ {comment} добавлен в {auth_path}"}
|
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")
|
@app.delete("/api/routers/{rid}/tunnel")
|
||||||
|
|||||||
@@ -434,30 +434,26 @@ function closeTunnelModal(){
|
|||||||
_tunnelRouterId=null;
|
_tunnelRouterId=null;
|
||||||
}
|
}
|
||||||
function copyTunnelCmd(){
|
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 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 flash=(t)=>{if(btn){const o=btn.textContent;btn.textContent=t||'✓ Скопировано';setTimeout(()=>{btn.textContent=o;},1800);}};
|
||||||
const fail=()=>{
|
// 1) HTTPS: современный API
|
||||||
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){
|
if(window.isSecureContext&&navigator.clipboard){
|
||||||
navigator.clipboard.writeText(txt).then(flash).catch(()=>execCopy(txt,flash,fail));
|
navigator.clipboard.writeText(txt).then(()=>flash()).catch(selectFallback);
|
||||||
} else {
|
return;
|
||||||
execCopy(txt,flash,fail);
|
}
|
||||||
|
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(){
|
async function applyTunnelUrl(){
|
||||||
if(!_tunnelRouterId)return;
|
if(!_tunnelRouterId)return;
|
||||||
|
|||||||
Reference in New Issue
Block a user