mirror of
https://github.com/andrey271192/domen_hydra.git
synced 2026-09-20 14:42:00 +00:00
POST /api/routers/{name}/wireguard/uninstall:
- killall autossh
- rm init.d S50hydravpn, S99hydra_tun
- rm hydra_tun, hydra_tk, wg0.conf, wg0.key
- ip link delete nwg50-70
Confirm-диалог с описанием что будет удалено.
874 lines
35 KiB
Python
874 lines
35 KiB
Python
"""HydraRoute Domain Manager — standalone web server."""
|
||
import asyncio
|
||
import json as _json
|
||
import logging
|
||
import os
|
||
import re
|
||
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, PlainTextResponse, Response
|
||
from pydantic import BaseModel
|
||
from . import config
|
||
from .database import load_json, save_json
|
||
from .hydra_manager import (load_hydra_config, save_hydra_config,
|
||
generate_domain_conf, generate_ip_list, get_config_version,
|
||
parse_domain_conf, parse_ip_list)
|
||
from .models import DomainGroup, IpGroup, HydraConfig
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
app = FastAPI(title="HydraRoute Domain Manager")
|
||
|
||
def _chk(pwd: str):
|
||
if config.ADMIN_PASSWORD and pwd != config.ADMIN_PASSWORD:
|
||
raise HTTPException(401, "Неверный пароль")
|
||
|
||
|
||
def _router_key(name: str) -> str:
|
||
return name.strip().lower()
|
||
|
||
|
||
def _get_router_cfg(name: str) -> dict:
|
||
routers = load_json(config.ROUTERS_FILE, {})
|
||
key = _router_key(name)
|
||
if key not in routers:
|
||
raise HTTPException(404, "роутер не найден")
|
||
return routers[key]
|
||
|
||
|
||
def _normalize_router_ip(value: str) -> str:
|
||
"""Убрать https:// из поля IP: для SSH нужен хост или IP, не веб-URL."""
|
||
s = (value or "").strip()
|
||
if not s:
|
||
return ""
|
||
if "://" in s:
|
||
p = urlparse(s if s.startswith(("http://", "https://")) else "https://" + s)
|
||
return (p.hostname or "").strip() or s.split("/")[0].split("@")[-1].strip()
|
||
return s.split("/")[0].strip()
|
||
|
||
|
||
async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tuple[int, str, str]:
|
||
"""Выполнить команду на роутере по 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 и нет тоннеля (добавь IP или настрой тоннель)"
|
||
ssh_host = ip
|
||
extra_args = []
|
||
user = rcfg.get("user") or config.SSH_USER
|
||
pwd = rcfg.get("password") or config.SSH_PASS
|
||
try:
|
||
r = await asyncio.to_thread(
|
||
subprocess.run,
|
||
[
|
||
"sshpass", "-p", pwd,
|
||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=12",
|
||
*extra_args,
|
||
f"{user}@{ssh_host}", remote_cmd,
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=timeout,
|
||
)
|
||
return r.returncode, (r.stdout or ""), (r.stderr or "")
|
||
except Exception as e:
|
||
return 1, "", str(e)
|
||
|
||
|
||
async def _push_one_router(server_url: str, router_key: str, rcfg: dict) -> dict:
|
||
"""Скачать с manager domain.conf + ip.list на роутер по SSH (прямо или через тоннель) и neo restart."""
|
||
ip = rcfg.get("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 = (
|
||
f"curl -sf '{server_url}/hydra/domain.conf' -o /opt/etc/HydraRoute/domain.conf && "
|
||
f"curl -sf '{server_url}/hydra/ip.list' -o /opt/etc/HydraRoute/ip.list && "
|
||
f"neo restart"
|
||
)
|
||
if tunnel_port:
|
||
ssh_cmd = ["sshpass", "-p", pwd, "ssh",
|
||
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||
"-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,
|
||
"ok": r.returncode == 0,
|
||
"msg": tail or ("ok" if r.returncode == 0 else "exit " + str(r.returncode)),
|
||
}
|
||
except Exception as e:
|
||
return {"router": router_key, "ok": False, "msg": str(e)}
|
||
|
||
|
||
# ── Pages ────────────────────────────────────────────────────────────────────
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def index():
|
||
with open(config.BASE_DIR / "templates" / "index.html", encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||
|
||
@app.get("/api/auth")
|
||
async def auth(x_admin_password: str = Header("")):
|
||
_chk(x_admin_password); return {"ok": True}
|
||
|
||
@app.post("/api/set_password")
|
||
async def set_password(body: dict, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
new_pwd = (body.get("password") or "").strip()
|
||
if len(new_pwd) < 4: raise HTTPException(400, "Минимум 4 символа")
|
||
import re
|
||
env_path = config.BASE_DIR / ".env"
|
||
if env_path.exists():
|
||
txt = env_path.read_text()
|
||
txt = re.sub(r"ADMIN_PASSWORD=.*", f"ADMIN_PASSWORD={new_pwd}", txt) if "ADMIN_PASSWORD" in txt else txt + f"\nADMIN_PASSWORD={new_pwd}\n"
|
||
env_path.write_text(txt)
|
||
config.ADMIN_PASSWORD = new_pwd
|
||
return {"ok": True}
|
||
|
||
# ── HydraRoute files (served to routers) ─────────────────────────────────────
|
||
|
||
@app.get("/hydra/domain.conf")
|
||
async def domain_conf():
|
||
return Response(content=generate_domain_conf(load_hydra_config()), media_type="text/plain")
|
||
|
||
@app.get("/hydra/ip.list")
|
||
async def ip_list():
|
||
return Response(content=generate_ip_list(load_hydra_config()), media_type="text/plain")
|
||
|
||
@app.get("/hydra/version")
|
||
async def version():
|
||
return Response(content=get_config_version(load_hydra_config()), media_type="text/plain")
|
||
|
||
@app.get("/hydra/config")
|
||
async def hydra_config():
|
||
return load_hydra_config().model_dump()
|
||
|
||
# ── Domain groups ─────────────────────────────────────────────────────────────
|
||
|
||
@app.post("/api/domain-group")
|
||
async def upsert_domain_group(g: DomainGroup, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
cfg = load_hydra_config()
|
||
cfg.domain_groups = [x for x in cfg.domain_groups if x.name != g.name]
|
||
cfg.domain_groups.append(g)
|
||
save_hydra_config(cfg); return {"ok": True}
|
||
|
||
@app.delete("/api/domain-group/{name}")
|
||
async def delete_domain_group(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
cfg = load_hydra_config()
|
||
cfg.domain_groups = [x for x in cfg.domain_groups if x.name != name]
|
||
save_hydra_config(cfg); return {"ok": True}
|
||
|
||
# ── IP groups ─────────────────────────────────────────────────────────────────
|
||
|
||
@app.post("/api/ip-group")
|
||
async def upsert_ip_group(g: IpGroup, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
cfg = load_hydra_config()
|
||
cfg.ip_groups = [x for x in cfg.ip_groups if x.name != g.name]
|
||
cfg.ip_groups.append(g)
|
||
save_hydra_config(cfg); return {"ok": True}
|
||
|
||
@app.delete("/api/ip-group/{name}")
|
||
async def delete_ip_group(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
cfg = load_hydra_config()
|
||
cfg.ip_groups = [x for x in cfg.ip_groups if x.name != name]
|
||
save_hydra_config(cfg); return {"ok": True}
|
||
|
||
# ── Import ────────────────────────────────────────────────────────────────────
|
||
|
||
class ImportBody(BaseModel):
|
||
domain_conf: str = ""; ip_list: str = ""
|
||
|
||
@app.post("/api/import")
|
||
async def import_config(body: ImportBody, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
cfg = load_hydra_config()
|
||
if body.domain_conf.strip():
|
||
cfg.domain_groups = parse_domain_conf(body.domain_conf)
|
||
if body.ip_list.strip():
|
||
cfg.ip_groups = parse_ip_list(body.ip_list)
|
||
save_hydra_config(cfg); return {"ok": True, "domains": len(cfg.domain_groups), "ips": len(cfg.ip_groups)}
|
||
|
||
# ── Push to all routers via SSH ───────────────────────────────────────────────
|
||
|
||
@app.post("/api/push_all")
|
||
async def push_all(request: Request, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
routers = load_json(config.ROUTERS_FILE, {})
|
||
server_url = str(request.base_url).rstrip("/")
|
||
results = []
|
||
for name, rcfg in routers.items():
|
||
results.append(await _push_one_router(server_url, name, rcfg))
|
||
return {"results": results, "ok": sum(1 for r in results if r["ok"]), "failed": sum(1 for r in results if not r["ok"])}
|
||
|
||
# ── Routers CRUD ──────────────────────────────────────────────────────────────
|
||
|
||
@app.get("/api/routers")
|
||
async def get_routers():
|
||
return load_json(config.ROUTERS_FILE, {})
|
||
|
||
@app.post("/api/routers/{name}")
|
||
async def upsert_router(name: str, body: dict, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
body = dict(body)
|
||
if isinstance(body.get("ip"), str):
|
||
body["ip"] = _normalize_router_ip(body["ip"])
|
||
R = load_json(config.ROUTERS_FILE, {})
|
||
R[_router_key(name)] = body
|
||
save_json(config.ROUTERS_FILE, R); return {"ok": True}
|
||
|
||
|
||
@app.post("/api/routers/{name}/test")
|
||
async def test_router(name: str, x_admin_password: str = Header("")):
|
||
"""Проверка SSH: echo + каталог HydraRoute + наличие файлов."""
|
||
_chk(x_admin_password)
|
||
rcfg = _get_router_cfg(name)
|
||
script = (
|
||
"echo HM_SSH_OK; (uname -n 2>/dev/null || hostname 2>/dev/null || echo unknown); "
|
||
"found=0; for d in /opt/etc/HydraRoute /opt/etc/hydra; do "
|
||
"if test -d \"$d\"; then found=1; echo HM_HR_DIR:$d; "
|
||
"if test -f \"$d/domain.conf\"; then echo HM_HAS_DOMAIN; else echo HM_NO_DOMAIN; fi; "
|
||
"if test -f \"$d/ip.list\"; then echo HM_HAS_IP; else echo HM_NO_IP; fi; "
|
||
"break; fi; done; "
|
||
"if test \"$found\" = 0; then echo HM_NO_HR_DIR; fi"
|
||
)
|
||
code, out, err = await _ssh_on_router(rcfg, script)
|
||
text = (out + (("\n" + err) if err.strip() else "")).strip()
|
||
ok = code == 0 and "HM_SSH_OK" in out
|
||
return {"ok": ok, "exit_code": code, "detail": text or (err or "пустой вывод")}
|
||
|
||
|
||
@app.get("/api/routers/{name}/fetch")
|
||
async def fetch_from_router(name: str, x_admin_password: str = Header("")):
|
||
"""Считать domain.conf и ip.list с роутера (HydraRoute или hydra)."""
|
||
_chk(x_admin_password)
|
||
rcfg = _get_router_cfg(name)
|
||
read_one = (
|
||
"sh -c 'for d in /opt/etc/HydraRoute /opt/etc/hydra; do "
|
||
"if test -f \"$d/{file}\"; then cat \"$d/{file}\"; exit 0; fi; done; exit 1'"
|
||
)
|
||
code_d, domain_conf, err_d = await _ssh_on_router(rcfg, read_one.format(file="domain.conf"))
|
||
code_i, ip_list, err_i = await _ssh_on_router(rcfg, read_one.format(file="ip.list"))
|
||
return {
|
||
"domain_conf": domain_conf,
|
||
"ip_list": ip_list,
|
||
"domain_ok": code_d == 0,
|
||
"ip_ok": code_i == 0,
|
||
"errors": {"domain": err_d if code_d else "", "ip": err_i if code_i else ""},
|
||
}
|
||
|
||
|
||
@app.post("/api/routers/{name}/push")
|
||
async def push_one_router(name: str, request: Request, x_admin_password: str = Header("")):
|
||
"""Отправить текущий domain.conf + ip.list только на один роутер."""
|
||
_chk(x_admin_password)
|
||
key = _router_key(name)
|
||
rcfg = _get_router_cfg(name)
|
||
server_url = str(request.base_url).rstrip("/")
|
||
return await _push_one_router(server_url, key, rcfg)
|
||
|
||
|
||
@app.delete("/api/routers/{name}")
|
||
async def delete_router(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
R = load_json(config.ROUTERS_FILE, {})
|
||
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 && exit 0
|
||
/opt/sbin/start-stop-daemon -S -b -x /opt/bin/hydra_tun 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
|
||
/opt/sbin/start-stop-daemon -S -b -x /opt/bin/hydra_tun 2>/dev/null || \
|
||
( 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 /opt/sbin/start-stop-daemon -S -b -x /opt/bin/hydra_tun 2>/dev/null; then
|
||
:
|
||
elif 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}
|
||
|
||
|
||
# ── WireGuard VPN ─────────────────────────────────────────────────────────────
|
||
|
||
_WG_DATA = config.DATA_DIR / "wireguard.json"
|
||
_WG_SUBNET = "10.8.0"
|
||
|
||
|
||
def _load_wg() -> dict:
|
||
return load_json(_WG_DATA, {"server": {}, "peers": {}})
|
||
|
||
|
||
def _save_wg(d: dict):
|
||
save_json(_WG_DATA, d)
|
||
|
||
|
||
def _wg_genkey() -> tuple[str, str]:
|
||
r = subprocess.run(["wg", "genkey"], capture_output=True, text=True, timeout=5)
|
||
if r.returncode != 0:
|
||
raise RuntimeError("wg не найден. На VPS: apt install wireguard-tools")
|
||
priv = r.stdout.strip()
|
||
pub = subprocess.run(["wg", "pubkey"], input=priv, capture_output=True, text=True, timeout=5).stdout.strip()
|
||
return priv, pub
|
||
|
||
|
||
def _wg_next_ip(peers: dict) -> str:
|
||
used = {int(v["ip"].split(".")[-1]) for v in peers.values() if v.get("ip")}
|
||
for i in range(2, 255):
|
||
if i not in used:
|
||
return f"{_WG_SUBNET}.{i}"
|
||
raise RuntimeError("Нет свободных IP в WG-подсети")
|
||
|
||
|
||
def _wg_server_conf(data: dict) -> str:
|
||
srv = data["server"]
|
||
try:
|
||
r = subprocess.run(["ip", "route", "get", "8.8.8.8"], capture_output=True, text=True, timeout=5)
|
||
m = re.search(r"dev (\S+)", r.stdout)
|
||
iface = m.group(1) if m else "eth0"
|
||
except Exception:
|
||
iface = "eth0"
|
||
lines = [
|
||
"[Interface]",
|
||
f"PrivateKey = {srv['private_key']}",
|
||
f"Address = {_WG_SUBNET}.1/24",
|
||
f"ListenPort = {srv.get('port', 51820)}",
|
||
f"PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o {iface} -j MASQUERADE",
|
||
f"PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o {iface} -j MASQUERADE",
|
||
"",
|
||
]
|
||
for rname, peer in data.get("peers", {}).items():
|
||
lines += [f"# {rname}", "[Peer]", f"PublicKey = {peer['public_key']}",
|
||
f"AllowedIPs = {peer['ip']}/32", ""]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _wg_client_conf(data: dict, rkey: str, vps_host: str) -> str:
|
||
srv = data["server"]
|
||
peer = data["peers"][rkey]
|
||
return (
|
||
"[Interface]\n"
|
||
f"PrivateKey = {peer['private_key']}\n"
|
||
f"Address = {peer['ip']}/32\n"
|
||
"DNS = 8.8.8.8\n\n"
|
||
"[Peer]\n"
|
||
f"PublicKey = {srv['public_key']}\n"
|
||
f"Endpoint = {vps_host}:{srv.get('port', 51820)}\n"
|
||
"AllowedIPs = 0.0.0.0/0\n"
|
||
"PersistentKeepalive = 25\n"
|
||
)
|
||
|
||
|
||
def _wg_reload(conf: str):
|
||
p = Path("/etc/wireguard/wg0.conf")
|
||
p.write_text(conf)
|
||
p.chmod(0o600)
|
||
subprocess.run(
|
||
["bash", "-c", "wg syncconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf) 2>/dev/null || true"],
|
||
timeout=10,
|
||
)
|
||
|
||
|
||
@app.get("/api/wireguard")
|
||
async def wg_get(x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
srv = data.get("server", {})
|
||
running = False
|
||
if srv.get("private_key"):
|
||
try:
|
||
running = subprocess.run(["wg", "show", "wg0"], capture_output=True, timeout=5).returncode == 0
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"initialized": bool(srv.get("private_key")),
|
||
"running": running,
|
||
"public_key": srv.get("public_key", ""),
|
||
"port": srv.get("port", 51820),
|
||
"peers": {k: {"ip": v.get("ip"), "public_key": v.get("public_key")}
|
||
for k, v in data.get("peers", {}).items()},
|
||
}
|
||
|
||
|
||
@app.post("/api/wireguard/init")
|
||
async def wg_init_server(x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
if not data["server"].get("private_key"):
|
||
priv, pub = await asyncio.to_thread(_wg_genkey)
|
||
data["server"] = {"private_key": priv, "public_key": pub, "port": 51820}
|
||
_save_wg(data)
|
||
conf = _wg_server_conf(data)
|
||
|
||
def _setup():
|
||
subprocess.run(["apt-get", "install", "-y", "--no-install-recommends", "wireguard"],
|
||
capture_output=True, timeout=120)
|
||
subprocess.run(["sysctl", "-w", "net.ipv4.ip_forward=1"], capture_output=True, timeout=5)
|
||
Path("/etc/sysctl.d/99-wg.conf").write_text("net.ipv4.ip_forward=1\n")
|
||
Path("/etc/wireguard").mkdir(parents=True, exist_ok=True)
|
||
_wg_reload(conf)
|
||
subprocess.run(["systemctl", "enable", "wg-quick@wg0"], capture_output=True, timeout=10)
|
||
subprocess.run(["systemctl", "restart", "wg-quick@wg0"], capture_output=True, timeout=30)
|
||
|
||
await asyncio.to_thread(_setup)
|
||
running = subprocess.run(["wg", "show", "wg0"], capture_output=True, timeout=5).returncode == 0
|
||
return {"ok": True, "running": running, "public_key": data["server"]["public_key"]}
|
||
|
||
|
||
@app.get("/api/wireguard/server-config", response_class=PlainTextResponse)
|
||
async def wg_server_config(x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
if not data["server"].get("private_key"):
|
||
raise HTTPException(400, "WireGuard не инициализирован")
|
||
return _wg_server_conf(data)
|
||
|
||
|
||
@app.post("/api/routers/{name}/wireguard")
|
||
async def wg_add_peer(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
if not data["server"].get("private_key"):
|
||
raise HTTPException(400, "Сначала инициализируй WireGuard сервер")
|
||
key = _router_key(name)
|
||
peers = data.setdefault("peers", {})
|
||
if key not in peers:
|
||
priv, pub = await asyncio.to_thread(_wg_genkey)
|
||
peers[key] = {"private_key": priv, "public_key": pub, "ip": _wg_next_ip(peers)}
|
||
_save_wg(data)
|
||
await asyncio.to_thread(_wg_reload, _wg_server_conf(data))
|
||
return {"ok": True, "ip": peers[key]["ip"], "public_key": peers[key]["public_key"]}
|
||
|
||
|
||
@app.get("/api/routers/{name}/wireguard-config", response_class=PlainTextResponse)
|
||
async def wg_router_config(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
key = _router_key(name)
|
||
if key not in data.get("peers", {}):
|
||
raise HTTPException(404, "Пир не найден")
|
||
return _wg_client_conf(data, key, config.VPS_SSH_HOST or "VPS_IP")
|
||
|
||
|
||
@app.post("/api/routers/{name}/wireguard/deploy")
|
||
async def wg_deploy(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
key = _router_key(name)
|
||
if key not in data.get("peers", {}):
|
||
raise HTTPException(400, "Сначала добавь роутер в WireGuard")
|
||
vps_host = config.VPS_SSH_HOST or ""
|
||
if not vps_host:
|
||
raise HTTPException(400, "VPS_SSH_HOST не задан в .env")
|
||
rcfg = _get_router_cfg(name)
|
||
peer = data["peers"][key]
|
||
srv = data["server"]
|
||
wg_conf = _wg_client_conf(data, key, vps_host)
|
||
priv_key = peer["private_key"]
|
||
pub_key = srv["public_key"]
|
||
port = srv.get("port", 51820)
|
||
client_ip = peer["ip"]
|
||
|
||
router_pass = (rcfg.get("password") or config.SSH_PASS).replace("'", r"'\''")
|
||
# Экранируем для встраивания в heredoc shell-скрипта (одинарные кавычки уже обработаны выше)
|
||
priv_key_sh = priv_key.replace("\\", "\\\\")
|
||
pub_key_sh = pub_key.replace("\\", "\\\\")
|
||
|
||
script = f"""\
|
||
echo '[1/3] Установка wireguard-tools...'
|
||
opkg update 2>/dev/null || true
|
||
opkg install wireguard-tools 2>/dev/null || true
|
||
command -v wg >/dev/null 2>&1 || {{ echo 'ОШИБКА: wg не установлен'; exit 1; }}
|
||
|
||
echo '[2/3] Запись конфига...'
|
||
mkdir -p /opt/etc/wireguard
|
||
printf '%s\\n' '{priv_key_sh}' > /opt/etc/wireguard/wg0.key
|
||
chmod 600 /opt/etc/wireguard/wg0.key
|
||
|
||
echo '[3/3] Настройка WireGuard через Keenetic RCI...'
|
||
DONE=0
|
||
|
||
# === Keenetic RCI API (нативная интеграция, интерфейс виден в UI) ===
|
||
AUTH=$(curl -s http://localhost/auth 2>/dev/null)
|
||
echo "Auth response: $AUTH"
|
||
REALM=$(printf '%s' "$AUTH" | grep -o '"realm":"[^"]*"' | cut -d'"' -f4)
|
||
CHAL=$(printf '%s' "$AUTH" | grep -o '"challenge":"[^"]*"' | cut -d'"' -f4)
|
||
echo "Realm=[$REALM] Challenge=[$CHAL]"
|
||
|
||
if [ -n "$REALM" ] && [ -n "$CHAL" ]; then
|
||
HASH=$(printf 'admin:%s:{router_pass}' "$REALM" | md5sum | cut -c1-32)
|
||
RESP=$(printf '%s%s' "$HASH" "$CHAL" | md5sum | cut -c1-32)
|
||
LOGIN=$(curl -s -c /tmp/rci.jar http://localhost/auth \\
|
||
-H 'Content-Type: application/json' \\
|
||
-d '{{"login":"admin","password":"'"$RESP"'"}}')
|
||
echo "Login: $LOGIN"
|
||
|
||
echo "=== Существующий Wireguard0 (для проверки формата) ==="
|
||
curl -s -b /tmp/rci.jar 'http://localhost/rci/interface/Wireguard0' 2>/dev/null | head -c 800
|
||
echo ""
|
||
|
||
# Найти следующий свободный последовательный индекс WireGuard
|
||
MAX_WG=-1
|
||
for i in $(seq 0 20); do
|
||
wg show nwg$i >/dev/null 2>&1 && MAX_WG=$i
|
||
done
|
||
WG_IDX=$((MAX_WG + 1))
|
||
echo "Создаём Wireguard${{WG_IDX}} (nwg${{WG_IDX}})"
|
||
|
||
# JSON с правильными именами полей Keenetic: allowed-address, keepalive
|
||
cat > /tmp/wg_rci.json << RCIEOF
|
||
{{"interface":{{"Wireguard${{WG_IDX}}":{{"description":"HydraVPN","up":true,"address":[{{"ip":"{client_ip}","mask":"255.255.255.255"}}],"wireguard":{{"private-key":"{priv_key_sh}","peer":[{{"public-key":"{pub_key_sh}","endpoint":{{"address":"{vps_host}","port":{port}}},"allowed-address":[{{"ip":"0.0.0.0","mask":"0.0.0.0"}}],"keepalive":25}}]}}}}}}}}
|
||
RCIEOF
|
||
echo "RCI JSON: $(cat /tmp/wg_rci.json)"
|
||
|
||
CFG=$(curl -s -b /tmp/rci.jar -X POST http://localhost/rci/ \\
|
||
-H 'Content-Type: application/json' \\
|
||
--data-binary @/tmp/wg_rci.json)
|
||
echo "RCI response: $CFG"
|
||
|
||
curl -s -b /tmp/rci.jar -X POST http://localhost/rci/ \\
|
||
-H 'Content-Type: application/json' \\
|
||
-d '{{"system":{{"configuration":{{"save":true}}}}}}' >/dev/null
|
||
|
||
sleep 2
|
||
if wg show nwg${{WG_IDX}} >/dev/null 2>&1; then
|
||
DONE=1
|
||
echo "OK: Wireguard${{WG_IDX}} (nwg${{WG_IDX}}) создан — виден в UI"
|
||
else
|
||
echo "RCI не создал интерфейс. Смотри response выше."
|
||
fi
|
||
fi
|
||
rm -f /tmp/rci.jar /tmp/wg_rci.json 2>/dev/null
|
||
|
||
# === Fallback: wg + ip (nwg50+, без UI) ===
|
||
if [ "$DONE" = "0" ]; then
|
||
echo 'RCI не сработал → fallback wg + ip (nwg50+)...'
|
||
WG_IDX=50
|
||
while wg show nwg${{WG_IDX}} >/dev/null 2>&1; do
|
||
WG_IDX=$((WG_IDX+1))
|
||
done
|
||
echo "Fallback: создаём nwg${{WG_IDX}}"
|
||
ip link add nwg${{WG_IDX}} type wireguard 2>/dev/null || true
|
||
wg set nwg${{WG_IDX}} private-key /opt/etc/wireguard/wg0.key \\
|
||
peer '{pub_key_sh}' allowed-ips 0.0.0.0/0 \\
|
||
endpoint '{vps_host}:{port}' persistent-keepalive 25 && DONE=1 || true
|
||
ip addr add '{client_ip}/32' dev nwg${{WG_IDX}} 2>/dev/null || true
|
||
ip link set up dev nwg${{WG_IDX}} 2>/dev/null || true
|
||
|
||
cat > /opt/etc/init.d/S50hydravpn << INITEOF
|
||
#!/bin/sh
|
||
case "\\$1" in
|
||
start)
|
||
ip link show nwg${{WG_IDX}} >/dev/null 2>&1 || ip link add nwg${{WG_IDX}} type wireguard 2>/dev/null || exit 0
|
||
wg set nwg${{WG_IDX}} private-key /opt/etc/wireguard/wg0.key \\
|
||
peer '{pub_key_sh}' allowed-ips 0.0.0.0/0 \\
|
||
endpoint '{vps_host}:{port}' persistent-keepalive 25
|
||
ip addr add '{client_ip}/32' dev nwg${{WG_IDX}} 2>/dev/null || true
|
||
ip link set up dev nwg${{WG_IDX}}
|
||
;;
|
||
stop) ip link set down dev nwg${{WG_IDX}} 2>/dev/null; ip link delete nwg${{WG_IDX}} 2>/dev/null ;;
|
||
restart) \\$0 stop; sleep 1; \\$0 start ;;
|
||
esac
|
||
INITEOF
|
||
chmod +x /opt/etc/init.d/S50hydravpn
|
||
[ "$DONE" = "1" ] && echo "OK (fallback): nwg${{WG_IDX}} активен. Добавь в Keenetic вручную через UI."
|
||
fi
|
||
|
||
[ "$DONE" = "0" ] && {{ echo 'ОШИБКА: VPN не удалось настроить'; exit 1; }}
|
||
echo '=== OK ==='
|
||
echo "VPN IP: {client_ip}"
|
||
wg show 2>/dev/null || true
|
||
"""
|
||
|
||
rc, out, err = await _ssh_on_router(rcfg, script, timeout=120)
|
||
return {"ok": rc == 0, "output": (out + err)[:1000]}
|
||
|
||
|
||
@app.delete("/api/routers/{name}/wireguard")
|
||
async def wg_remove_peer(name: str, x_admin_password: str = Header("")):
|
||
_chk(x_admin_password)
|
||
data = _load_wg()
|
||
key = _router_key(name)
|
||
data.get("peers", {}).pop(key, None)
|
||
_save_wg(data)
|
||
await asyncio.to_thread(_wg_reload, _wg_server_conf(data))
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/routers/{name}/wireguard/uninstall")
|
||
async def wg_uninstall(name: str, x_admin_password: str = Header("")):
|
||
"""Удалить всё установленное сервером с роутера через SSH."""
|
||
_chk(x_admin_password)
|
||
rcfg = _get_router_cfg(name)
|
||
script = """\
|
||
echo '=== Удаление HydraVPN с роутера ==='
|
||
|
||
killall autossh 2>/dev/null && echo 'autossh остановлен' || true
|
||
|
||
if [ -f /opt/etc/init.d/S50hydravpn ]; then
|
||
/opt/etc/init.d/S50hydravpn stop 2>/dev/null || true
|
||
rm -f /opt/etc/init.d/S50hydravpn && echo 'S50hydravpn удалён'
|
||
fi
|
||
|
||
if [ -f /opt/etc/init.d/S99hydra_tun ]; then
|
||
/opt/etc/init.d/S99hydra_tun stop 2>/dev/null || true
|
||
rm -f /opt/etc/init.d/S99hydra_tun && echo 'S99hydra_tun удалён'
|
||
fi
|
||
|
||
rm -f /opt/bin/hydra_tun && echo 'hydra_tun удалён' || true
|
||
rm -f /opt/etc/hydra_tk && echo 'SSH-ключ туннеля удалён' || true
|
||
rm -f /opt/etc/wireguard/wg0.conf /opt/etc/wireguard/wg0.key 2>/dev/null && echo 'WireGuard конфиг удалён' || true
|
||
rm -f /tmp/hydra_tun.log /tmp/rci.jar /tmp/wg_rci.json 2>/dev/null || true
|
||
|
||
for i in $(seq 50 70); do
|
||
ip link show nwg$i >/dev/null 2>&1 && ip link delete nwg$i && echo "nwg$i удалён"
|
||
done
|
||
|
||
echo '=== OK: всё удалено ==='
|
||
echo 'Нативные WireGuard-интерфейсы (peer-1, HydraVPN...) удали вручную:'
|
||
echo ' Интернет → Другие подключения → WireGuard → Удалить подключение'
|
||
"""
|
||
rc, out, err = await _ssh_on_router(rcfg, script, timeout=60)
|
||
return {"ok": rc == 0, "output": (out + err)[:1500]}
|