feat: reverse SSH tunnel для роутеров без белого IP + README переработан

- server/config.py: VPS_SSH_HOST, VPS_SSH_PORT, VPS_SSH_USER, TUNNEL_PORT_START
- server/.env.example: секция тоннеля
- server/main.py: _gen_ed25519_keypair, _add_pubkey_to_authorized_keys;
  _ssh_on_router и _push_one_router поддерживают tunnel_port;
  эндпоинты tunnel-cmd, tunnel-script (одноразовый токен 10 мин),
  tunnel-status, DELETE tunnel
- index.html: кнопка ⇄ у каждого роутера + подсветка тоннельного порта,
  кнопка ⇄ Тоннель в форме добавления, модалка с curl-командой,
  копирование (clipboard + execCommand fallback), проверка связи
- README: полная переработка — туннель, логины/пароли, ошибки, советы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Андрей Бобырев
2026-04-28 09:57:48 +03:00
parent 61205df0cb
commit 708b4b04dc
4 changed files with 346 additions and 21 deletions

View File

@@ -7,3 +7,10 @@ ADMIN_PASSWORD=ВАШ_НАДЁЖНЫЙ_ПАРОЛЬ_ВЕБ
SSH_USER=root
SSH_PASS=ПАРОЛЬ_SSH_РОУТЕРА
# Reverse SSH tunnel — для роутеров без белого IP
# Если VPS_SSH_HOST задан — в UI появляется кнопка «⇄ Тоннель» у каждого роутера
VPS_SSH_HOST=
VPS_SSH_PORT=22
VPS_SSH_USER=root
TUNNEL_PORT_START=20100

View File

@@ -14,6 +14,12 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
SSH_USER = os.getenv("SSH_USER", "root")
SSH_PASS = os.getenv("SSH_PASS", "keenetic")
# Reverse SSH tunnel — для роутеров без белого IP
VPS_SSH_HOST = (os.getenv("VPS_SSH_HOST") or "").strip()
VPS_SSH_PORT = int(os.getenv("VPS_SSH_PORT") or "22")
VPS_SSH_USER = (os.getenv("VPS_SSH_USER") or "root").strip()
TUNNEL_PORT_START = int(os.getenv("TUNNEL_PORT_START") or "20100")
HYDRA_FILE = DATA_DIR / "hydra_config.json"
ROUTERS_FILE = DATA_DIR / "routers.json"

View File

@@ -1,11 +1,17 @@
"""HydraRoute Domain Manager — standalone web server."""
import asyncio
import logging
import os
import secrets
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from urllib.parse import urlparse
from fastapi import FastAPI, Request, Header, HTTPException
from fastapi.responses import HTMLResponse, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from pydantic import BaseModel
from . import config
from .database import load_json, save_json
@@ -46,10 +52,17 @@ def _normalize_router_ip(value: str) -> str:
async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tuple[int, str, str]:
"""Выполнить команду на роутере по SSH. Возвращает (код, stdout, stderr)."""
"""Выполнить команду на роутере по SSH (прямо или через тоннель). Возвращает (код, stdout, stderr)."""
tunnel_port = rcfg.get("tunnel_port")
if tunnel_port:
ssh_host = "127.0.0.1"
extra_args = ["-p", str(int(tunnel_port))]
else:
ip = (rcfg.get("ip") or "").strip()
if not ip:
return 1, "", "нет IP (нужен SSH: IP или hostname без https://)"
return 1, "", "нет IP и нет тоннеля (добавь IP или настрой тоннель)"
ssh_host = ip
extra_args = []
user = rcfg.get("user") or config.SSH_USER
pwd = rcfg.get("password") or config.SSH_PASS
try:
@@ -58,7 +71,8 @@ async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tupl
[
"sshpass", "-p", pwd,
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=12",
f"{user}@{ip}", remote_cmd,
*extra_args,
f"{user}@{ssh_host}", remote_cmd,
],
capture_output=True,
text=True,
@@ -70,10 +84,11 @@ async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tupl
async def _push_one_router(server_url: str, router_key: str, rcfg: dict) -> dict:
"""Скачать с manager domain.conf + ip.list на роутер по SSH и neo restart."""
"""Скачать с manager domain.conf + ip.list на роутер по SSH (прямо или через тоннель) и neo restart."""
ip = rcfg.get("ip", "")
if not ip:
return {"router": router_key, "ok": False, "msg": "нет IP"}
tunnel_port = rcfg.get("tunnel_port")
if not ip and not tunnel_port:
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 = (
@@ -81,18 +96,16 @@ async def _push_one_router(server_url: str, router_key: str, rcfg: dict) -> dict
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",
if tunnel_port:
ssh_cmd = ["sshpass", "-p", pwd, "ssh",
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
f"{user}@{ip}", cmd,
],
capture_output=True,
text=True,
timeout=60,
)
"-p", str(int(tunnel_port)), f"{user}@127.0.0.1", cmd]
else:
ssh_cmd = ["sshpass", "-p", pwd, "ssh",
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
f"{user}@{ip}", cmd]
try:
r = await asyncio.to_thread(subprocess.run, ssh_cmd, capture_output=True, text=True, timeout=60)
tail = ((r.stdout or "") + (r.stderr or ""))[:400]
return {
"router": router_key,
@@ -283,3 +296,224 @@ async def delete_router(name: str, x_admin_password: str = Header("")):
R.pop(_router_key(name), None)
save_json(config.ROUTERS_FILE, R)
return {"ok": True}
# ── Tunnel helpers ────────────────────────────────────────────────────────────
def _gen_ed25519_keypair(name: 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"hydra-tunnel-{name}"],
check=True, timeout=10,
)
priv = Path(keyfile).read_text()
pub = Path(keyfile + ".pub").read_text().strip()
return priv, pub
def _add_pubkey_to_authorized_keys(name: str, pubkey: str) -> None:
"""Добавить pubkey в ~/.ssh/authorized_keys (де-дуп по комменту hydra-tunnel-{name})."""
auth_dir = Path.home() / ".ssh"
auth_dir.mkdir(mode=0o700, exist_ok=True)
auth_path = auth_dir / "authorized_keys"
comment = f"hydra-tunnel-{name}"
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
# ── Tunnel endpoints ──────────────────────────────────────────────────────────
@app.get("/api/routers/{name}/tunnel-cmd")
async def tunnel_cmd(name: 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 не задан в server/.env — укажи публичный IP/домен VPS")
key = _router_key(name)
R = load_json(config.ROUTERS_FILE, {})
if key not in R:
raise HTTPException(404, "Роутер не найден")
rcfg = dict(R[key])
# Порт
if rcfg.get("tunnel_port"):
port = int(rcfg["tunnel_port"])
else:
used = {int(v.get("tunnel_port")) for v in R.values() if v.get("tunnel_port")}
port = config.TUNNEL_PORT_START
while port in used:
port += 1
rcfg["tunnel_port"] = port
# Keypair (один раз на роутер)
if not rcfg.get("tunnel_priv_key") or not rcfg.get("tunnel_pub_key"):
try:
priv, pub = await asyncio.to_thread(_gen_ed25519_keypair, name)
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
rcfg["tunnel_priv_key"] = priv
rcfg["tunnel_pub_key"] = pub
await asyncio.to_thread(_add_pubkey_to_authorized_keys, name, pub)
# Одноразовый токен (10 мин)
reg_token = secrets.token_urlsafe(32)
rcfg["tunnel_reg_token"] = reg_token
rcfg["tunnel_reg_token_exp"] = int(time.time()) + 600
R[key] = rcfg
save_json(config.ROUTERS_FILE, R)
http_url = f"http://{config.VPS_SSH_HOST}:{config.PORT}"
one_liner = f"curl -fsS '{http_url}/api/routers/{name}/tunnel-script?token={reg_token}' | sh"
return {"tunnel_port": port, "cmd": one_liner}
@app.get("/api/routers/{name}/tunnel-script")
async def tunnel_script(name: str, token: str):
"""Установочный скрипт для роутера (с приватным ключом внутри). Auth — одноразовый токен."""
key = _router_key(name)
R = load_json(config.ROUTERS_FILE, {})
if key not in R:
raise HTTPException(404, "Роутер не найден")
rcfg = dict(R[key])
saved = rcfg.get("tunnel_reg_token")
if not saved or not secrets.compare_digest(saved, token or ""):
raise HTTPException(403, "Неверный или израсходованный токен")
if time.time() > int(rcfg.get("tunnel_reg_token_exp") or 0):
raise HTTPException(403, "Токен истёк (10 мин). Открой модалку заново.")
if not rcfg.get("tunnel_priv_key") or not rcfg.get("tunnel_port"):
raise HTTPException(500, "Тоннель не подготовлен — открой модалку заново")
# Токен одноразовый — расходуем
rcfg.pop("tunnel_reg_token", None)
rcfg.pop("tunnel_reg_token_exp", None)
R[key] = rcfg
save_json(config.ROUTERS_FILE, R)
port = int(rcfg["tunnel_port"])
priv_key = rcfg["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/hydra_tk <<'KEYEOF'
{priv_key}
KEYEOF
chmod 600 /opt/etc/hydra_tk
echo '[3/4] Скрипт тоннеля + автозапуск...'
cat > /opt/bin/hydra_tun <<'RUNEOF'
#!/bin/sh
PATH="/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH"
export AUTOSSH_GATETIME=0
export AUTOSSH_LOGFILE=/tmp/hydra_tun.log
exec autossh -M 0 \\
-i /opt/etc/hydra_tk \\
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\
-o ExitOnForwardFailure=yes -o IdentitiesOnly=yes \\
-N -R {port}:localhost:22 {vps_user}@{vps_host} -p {vps_port}
RUNEOF
chmod +x /opt/bin/hydra_tun
cat > /opt/etc/init.d/S99hydra_tun <<'INITEOF'
#!/bin/sh
case "$1" in
start) killall -0 autossh 2>/dev/null || ( nohup /opt/bin/hydra_tun </dev/null >/dev/null 2>&1 & ) ;;
stop) killall autossh 2>/dev/null ;;
restart) killall autossh 2>/dev/null; sleep 1; ( nohup /opt/bin/hydra_tun </dev/null >/dev/null 2>&1 & ) ;;
esac
INITEOF
chmod +x /opt/etc/init.d/S99hydra_tun
echo '[4/4] Запуск...'
killall autossh 2>/dev/null || true
sleep 1
rm -f /tmp/hydra_tun.log
if command -v setsid >/dev/null 2>&1; then
setsid /opt/bin/hydra_tun </dev/null >/dev/null 2>&1 &
else
( nohup /opt/bin/hydra_tun </dev/null >/dev/null 2>&1 & )
fi
sleep 5
if killall -0 autossh 2>/dev/null; then
echo
echo '=== OK ==='
echo 'Тоннель: localhost:22 (роутер) -> VPS:{port}'
echo 'Лог: /tmp/hydra_tun.log'
echo 'Возвращайся в браузер и жми "Проверить связь"'
else
echo
echo '=== ОШИБКА: autossh не запустился в фоне ==='
echo '--- /tmp/hydra_tun.log ---'
cat /tmp/hydra_tun.log 2>/dev/null || echo '(лог пустой)'
echo '-------------------------'
echo 'Тест вручную: /opt/bin/hydra_tun (Ctrl+C для выхода)'
exit 1
fi
"""
return PlainTextResponse(script, media_type="text/plain; charset=utf-8")
@app.get("/api/routers/{name}/tunnel-status")
async def tunnel_status(name: str, x_admin_password: str = Header("")):
"""Проверить: слушает ли тоннельный порт на localhost VPS."""
_chk(x_admin_password)
key = _router_key(name)
R = load_json(config.ROUTERS_FILE, {})
if key not in R:
raise HTTPException(404, "Роутер не найден")
port = R[key].get("tunnel_port")
if not port:
return {"active": False, "reason": "tunnel_port не назначен"}
def _check() -> bool:
try:
with socket.create_connection(("127.0.0.1", int(port)), timeout=2):
return True
except OSError:
return False
active = await asyncio.to_thread(_check)
return {"active": active, "tunnel_port": port}
@app.delete("/api/routers/{name}/tunnel")
async def tunnel_remove(name: str, x_admin_password: str = Header("")):
"""Снять тоннельный порт с роутера."""
_chk(x_admin_password)
key = _router_key(name)
R = load_json(config.ROUTERS_FILE, {})
if key not in R:
raise HTTPException(404, "Роутер не найден")
rcfg = dict(R[key])
rcfg.pop("tunnel_port", None)
R[key] = rcfg
save_json(config.ROUTERS_FILE, R)
return {"ok": True}

View File

@@ -208,6 +208,7 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
<input type="text" id="r-user" value="root" style="width:80px">
<input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px">
<button class="btn btn-b" onclick="addRouter()">+ Добавить</button>
<button class="btn" style="background:var(--orange);color:#000" onclick="addAndTunnel()" title="Добавить роутер без IP и сразу открыть настройку тоннеля">⇄ Тоннель</button>
</div>
<div id="rm" class="msg"></div>
</div>
@@ -218,6 +219,27 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
</div>
</div>
<!-- TUNNEL MODAL -->
<div id="tun-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center">
<div style="background:var(--card);border-radius:18px;padding:24px;max-width:600px;width:94%;position:relative">
<button onclick="closeTunnel()" style="position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer"></button>
<h2 style="font-size:16px;font-weight:700;margin-bottom:4px">⇄ Обратный тоннель</h2>
<div style="font-size:12px;color:var(--muted);margin-bottom:16px">Роутер: <b id="tun-rname"></b> · Порт VPS: <span id="tun-port" style="color:var(--green)"></span></div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Скопируй команду и вставь в SSH-терминал <b>роутера</b> (там где <code style="background:var(--card2);padding:1px 4px;border-radius:4px">~ #</code>):</div>
<div style="position:relative;margin-bottom:12px">
<code id="tun-cmd" style="display:block;background:var(--card2);border-radius:10px;padding:12px;font-size:11px;word-break:break-all;min-height:40px;color:var(--text)">Генерирую...</code>
<button id="tun-copy-btn" onclick="copyTunnelCmd()" style="position:absolute;top:8px;right:8px;background:var(--accent);color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer">📋 Копировать</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-b" onclick="checkTunnelStatus()" style="font-size:12px">⟳ Проверить связь</button>
<span id="tun-status" style="font-size:12px;color:var(--muted)"></span>
</div>
<div style="margin-top:14px;font-size:11px;color:var(--muted)">
Если автозапуск не сработал — на роутере: <code style="background:var(--card2);padding:1px 4px;border-radius:4px">/opt/bin/hydra_tun</code> (вручную) · лог: <code style="background:var(--card2);padding:1px 4px;border-radius:4px">cat /tmp/hydra_tun.log</code>
</div>
</div>
</div>
<!-- SETTINGS TAB -->
<div id="tab-settings" style="display:none">
<div class="section">
@@ -363,6 +385,7 @@ function _escHtml(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(
if(act==='test') await testRouter(n);
else if(act==='pull') await pullRouter(n);
else if(act==='push') await pushRouter(n);
else if(act==='tunnel') openTunnel(n);
else if(act==='del') await delRouter(n);
});
})();
@@ -373,16 +396,25 @@ async function loadRouters(){
el.innerHTML=Object.entries(R).map(([n,c])=>{
const enc=encodeURIComponent(n);
return `<div class="ri" style="align-items:flex-start;gap:10px">
<div style="flex:1;min-width:0"><span class="n">${_escHtml(n)}</span> <span style="color:var(--muted);font-size:12px">— ${_escHtml(c.ip||'—')}</span></div>
<div style="flex:1;min-width:0"><span class="n">${_escHtml(n)}</span> <span style="font-size:12px">${c.tunnel_port?`<span style="color:var(--green)">⇄ тоннель :${c.tunnel_port}</span>`:`<span style="color:var(--muted)">— ${_escHtml(c.ip||'—')}</span>`}</span></div>
<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-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" style="background:var(--orange);color:#000;font-size:11px;padding:5px 10px" data-r-act="tunnel" 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>`;
}).join('');
}
async function addRouter(){ const n=document.getElementById('r-name').value.trim(); if(!n)return; const b={ip:document.getElementById('r-ip').value.trim(),user:document.getElementById('r-user').value||'root',password:document.getElementById('r-pass').value}; try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); sm('rm','ok','✅ '+n); loadRouters(); }catch(e){sm('rm','err','❌ '+e)} }
async function addAndTunnel(){
const n=document.getElementById('r-name').value.trim();
if(!n){sm('rm','err','❌ Введи имя роутера');return;}
const pwd=document.getElementById('r-pass').value;
if(!pwd){sm('rm','err','❌ Введи SSH пароль роутера');return;}
const b={ip:'',user:document.getElementById('r-user').value||'root',password:pwd};
try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); await loadRouters(); openTunnel(n); }catch(e){sm('rm','err','❌ '+e)}
}
async function delRouter(n){ if(!confirm('Удалить '+n+'?'))return; await fetch('/api/routers/'+encodeURIComponent(n),{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); loadRouters(); }
async function testRouter(n){
const log=document.getElementById('router-ops-msg');
@@ -419,6 +451,52 @@ async function pushRouter(n){
}catch(e){ log.textContent='❌ '+e; }
}
// ── TUNNEL ────────────────────────────────────────────────────────────────
let _tunName='';
function openTunnel(name){
_tunName=name;
document.getElementById('tun-rname').textContent=name;
document.getElementById('tun-port').textContent='…';
document.getElementById('tun-cmd').textContent='Генерирую команду...';
document.getElementById('tun-status').textContent='';
document.getElementById('tun-copy-btn').textContent='📋 Копировать';
document.getElementById('tun-modal').style.display='flex';
getTunnelCmd(name);
}
function closeTunnel(){ document.getElementById('tun-modal').style.display='none'; }
async function getTunnelCmd(name){
const el=document.getElementById('tun-cmd');
try{
const r=await fetch('/api/routers/'+encodeURIComponent(name)+'/tunnel-cmd',{headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){el.textContent='❌ '+(j.detail||'Ошибка'); return;}
el.textContent=j.cmd;
document.getElementById('tun-port').textContent=j.tunnel_port;
}catch(e){el.textContent='❌ '+e;}
}
function copyTunnelCmd(){
const el=document.getElementById('tun-cmd');
const btn=document.getElementById('tun-copy-btn');
const text=el.textContent;
const done=()=>{btn.textContent='✓ Скопировано'; setTimeout(()=>btn.textContent='📋 Копировать',2000);};
const fail=()=>{
const range=document.createRange(); range.selectNodeContents(el);
const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
try{document.execCommand('copy');done();}catch{btn.textContent='Выдели текст → Cmd/Ctrl+C';}
};
if(navigator.clipboard){navigator.clipboard.writeText(text).then(done).catch(fail);}else{fail();}
}
async function checkTunnelStatus(){
const el=document.getElementById('tun-status');
el.textContent='Проверяю...'; el.style.color='var(--muted)';
try{
const r=await fetch('/api/routers/'+encodeURIComponent(_tunName)+'/tunnel-status',{headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
el.textContent=j.active?'✅ Активен (порт '+j.tunnel_port+')':'❌ Не активен';
el.style.color=j.active?'var(--green)':'var(--red)';
}catch(e){el.textContent='❌ '+e; el.style.color='var(--red)';}
}
// ── 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)} }