#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.9" # dependencies = [] # /// # ============================================================================= # NikoVPN — контроллер для OpenWRT (замена install.sh, запуск с ПК) # # uv run nikovpn.py [--host 192.168.1.1] install # uv run nikovpn.py update | status | uninstall # # Управляет роутером по SSH: определяет архитектуру, заливает бинарники # (локальные bin/ или f.n1ko.dev), спрашивает API vpnd.n1ko.dev, выбирает # протокол/сервер (hysteria2 > tuic > naive > vless > shadowsocks), # генерирует конфиг sing-box и поднимает прозрачный прокси. # # Креды VPN (login:password): # --creds U:P | --creds-file F | env NIKOVPN_CREDS | /root/creds на роутере # # На роутере требуется только busybox + wget/curl + nft/iptables. # ============================================================================= import argparse import base64 import json import os import re import shlex import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path from urllib.parse import parse_qs, unquote, urlsplit BASE_URL = "https://f.n1ko.dev/nikovpn_openwrt" REL_URL = "https://git.n1ko.dev/Niko/nikovpn_openwrt/releases/download/bins" API_URL = "https://vpnd.n1ko.dev/api/v1" WORK_DIR = "/etc/nikovpn" CREDS_FILE = "/root/creds" SB_PATH = "/usr/bin/sing-box" IPT_PATH = "/usr/bin/ipt2socks" SOCKS_PORT = "1080" TP_PORT = "12345" RULE_TABLE = "100" FW_MARK = "0x1" PRIVATE_CIDRS = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4"] log = lambda m: print(f"[*] {m}", flush=True) warn = lambda m: print(f"[!] {m}", flush=True) def die(m): print(f"[ERROR] {m}", file=sys.stderr) sys.exit(1) # ============================================================================ # SSH-транспорт # ============================================================================ class Router: def __init__(self, host, user="root", port=22, password=None, key=None): self.host, self.user, self.port = host, user, port opts = ["-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10"] pre = [] if password is not None: if not shutil_which("sshpass"): die("для парольного входа нужен sshpass") pre = ["sshpass", "-p", password] opts += ["-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no"] else: opts += ["-o", "BatchMode=yes"] if key: opts += ["-i", str(key)] self.pre, self.opts = pre, opts self.base = pre + ["ssh", "-p", str(port)] + opts + [f"{user}@{host}"] def ssh_args(self, *extra): return self.pre + ["ssh", "-p", str(self.port)] + self.opts + list(extra) def run(self, cmd, timeout=180, input_bytes=None, check=False): r = subprocess.run(self.base + [cmd], capture_output=True, timeout=timeout, input=input_bytes if input_bytes is not None else b"") if check and r.returncode != 0: raise RuntimeError( f"{cmd}: rc={r.returncode} {r.stderr.decode(errors='replace')[:500]}") return r.returncode, r.stdout, r.stderr def have(self, tool): rc, out, _ = self.run(f"command -v {shlex.quote(tool)} >/dev/null 2>&1 && echo y") return rc == 0 and b"y" in out def exists(self, path): rc, out, _ = self.run(f"test -e {shlex.quote(path)} && echo y") return rc == 0 and b"y" in out def read_file(self, path): rc, out, _ = self.run(f"cat {shlex.quote(path)} 2>/dev/null") return out if rc == 0 else None def write_file(self, path, data: bytes, mode=None): tmp = f"/tmp/nikovpn.{os.getpid()}.{os.path.basename(path)}" cmd = f"cat > {shlex.quote(tmp)}" if mode: cmd += f" && chmod {mode} {shlex.quote(tmp)}" cmd += f" && mv {shlex.quote(tmp)} {shlex.quote(path)}" self.run(cmd, input_bytes=data, check=True) def mkdirs(self, *dirs): self.run("mkdir -p " + " ".join(shlex.quote(d) for d in dirs), check=True) def shutil_which(x): from shutil import which return which(x) # ============================================================================ # Архитектуры # ============================================================================ def map_uname(u): m = { "x86_64|amd64": ("amd64", "x86_64", 1), "aarch64|arm64": ("arm64", "aarch64", 1), "armv7l|armv7|arm": ("armv7", "armv7hf", 1), "armv6l|armv6": ("armv6", "armv6", 0), "armv5tel|armv5te|armv5l": ("armv5", "armv5", 0), "mips": ("mips", "mips", 0), "mipsel": ("mipsle", "mipsel", 1), "mips64": ("mips64", "mips64", 0), "mips64el": ("mips64le", "mips64el", 0), "i686|i586|i386|x86": ("386", "i686", 1), "riscv64": ("riscv64", "", 1), "loongarch64|loong64": ("loong64", "", 1), "ppc64le": ("ppc64le", "", 0), "s390x": ("s390x", "", 0), } for pat, v in m.items(): if u in pat.split("|"): return v return ("", "", 0) def map_openwrt(da, uname_m): if da.startswith("x86_64"): return map_uname("x86_64") if da.startswith("i386_"): return map_uname("i686") if da.startswith("aarch64_"): return map_uname("aarch64") if re.match(r"arm_cortex-a(5|7|8|9|15)", da) or da.startswith("arm_"): return map_uname("armv7l") if da == "arm_arm1176jzf-s_vfp": return map_uname("armv6l") if da in ("arm_arm926ej-s", "arm_fa526", "arm_xscale"): return map_uname("armv5l") for pfx, u in [("mips64el_", "mips64el"), ("mips64_", "mips64"), ("mipsel_", "mipsel"), ("mips_", "mips"), ("riscv64_", "riscv64"), ("loongarch64_", "loongarch64"), ("powerpc_", "ppc64le")]: if da.startswith(pfx): return map_uname(u) return map_uname(uname_m) def detect_arch(rt: Router): da = "" rel = rt.read_file("/etc/openwrt_release") or b"" mm = re.search(rb"DISTRIB_ARCH='([^']+)'", rel) if mm: da = mm.group(1).decode() if not da and rt.have("apk"): rc, out, _ = rt.run("apk --print-arch 2>/dev/null") da = out.decode().strip() if rc == 0 else "" if not da and rt.have("opkg"): rc, out, _ = rt.run("opkg print-architecture 2>/dev/null") if rc == 0: for line in out.decode().splitlines(): p = line.split() if len(p) >= 2 and p[0] == "arch" and p[1] not in ("all", "noarch"): da = p[1] break rc, out, _ = rt.run("uname -m") uname_m = out.decode().strip() sb, ipt, naive = map_openwrt(da, uname_m) if da else map_uname(uname_m) if not sb: die(f"архитектура не поддерживается ({da or uname_m})") return sb, ipt, naive, da or uname_m # ============================================================================ # API vpnd.n1ko.dev (запросы идут с ПК) # ============================================================================ def api_get(creds: str, path: str, timeout=30): req = urllib.request.Request( API_URL + path, headers={"Authorization": "Basic " + base64.b64encode(creds.encode()).decode()}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, r.read() except urllib.error.HTTPError as e: return e.code, e.read() except Exception as e: warn(f"API недоступен: {e}") return None, b"" def get_creds(rt: Router, args) -> str: cred = None if getattr(args, "creds", None): cred = args.creds.strip() elif getattr(args, "creds_file", None): cred = Path(args.creds_file).read_text().strip().splitlines()[0].strip() elif os.environ.get("NIKOVPN_CREDS"): cred = os.environ["NIKOVPN_CREDS"].strip() else: data = rt.read_file(CREDS_FILE) if data: for line in data.decode(errors="replace").splitlines(): line = line.strip() if line and not line.startswith("#"): cred = line break if not cred or ":" not in cred: die("нет кредов: --creds login:password | --creds-file F | " "env NIKOVPN_CREDS | /root/creds на роутере") return "".join(str(cred).split()) # ============================================================================ # Разбор ссылок и выбор сервера # ============================================================================ def parse_link(uri: str) -> dict: sp = urlsplit(uri) q = parse_qs(sp.query, keep_blank_values=True) user = unquote(sp.username or "") if sp.username else "" pw = unquote(sp.password or "") if sp.password else "" return dict(uri=uri, scheme=sp.scheme.lower(), host=unquote(sp.hostname or ""), port=sp.port or "", user=user, password=pw, query=q, frag=unquote(sp.fragment)) def qp(link, name, default=""): v = link["query"].get(name) return v[0] if v else default def host_alive(link) -> bool: """проверка с ПК (у роутера busybox-nc всегда возвращает ошибку). UDP-протоколы (hysteria2/tuic) так проверить нельзя — считаем живыми.""" import socket if link["scheme"] in ("hysteria2", "tuic"): return True host, port = link["host"], int(link["port"] or 443) try: s = socket.create_connection((host, port), timeout=4) s.close() return True except OSError: return False def choose_candidates(rt: Router, links_text: bytes, naive_ok: bool) -> list: prefs = ["hysteria2", "tuic", "naive", "vless", "shadowsocks"] prefer_proto, prefer_srv = read_prefs(rt) links = [parse_link(l) for l in links_text.decode(errors="replace").splitlines() if "://" in l] proto = None for p in prefs: if p == "naive" and not naive_ok: continue if prefer_proto and prefer_proto != p: continue if any(l["scheme"] == p or (p == "naive" and l["scheme"].startswith("naive")) for l in links): proto = p break if not proto: die("ни один протокол недоступен (или предпочтение не найдено)") cand = [l for l in links if l["scheme"] == proto or (proto == "naive" and l["scheme"].startswith("naive"))] if prefer_srv: filt = [l for l in cand if prefer_srv.lower() in l["uri"].lower()] if filt: cand = filt # плоский список: внутри протокола — живые первыми, протоколы по приоритету picked = [] for p in prefs: group = [l for l in links if l["scheme"] == p or (p == "naive" and l["scheme"].startswith("naive"))] alive = [l for l in group if host_alive(l)] rest = [l for l in group if not host_alive(l)] picked += alive + rest for l in picked: l["protocol"] = l["scheme"].split("+")[0] l["sni"] = qp(l, "sni") or l["host"] return picked def read_prefs(rt: Router): proto, srv = "", "" data = rt.read_file(f"{WORK_DIR}/config") if data: for line in data.decode(errors="replace").splitlines(): mm = re.match(r'\s*PREFER_PROTOCOL="([^"]+)"', line) if mm: proto = mm.group(1) mm = re.match(r'\s*PREFER_SERVER="([^"]+)"', line) if mm: srv = mm.group(1) return proto, srv # ============================================================================ # Генерация config.json sing-box # ============================================================================ def tls_block(link, sni=None, insecure=False, utls_fp=None, reality=False): t = {"enabled": True, "server_name": sni or link["sni"]} if insecure: t["insecure"] = True if utls_fp: t["utls"] = {"enabled": True, "fingerprint": utls_fp} if reality: t["reality"] = {"enabled": True, "public_key": qp(link, "pbk"), "short_id": qp(link, "sid")} return t def build_outbound(link) -> dict: p = link["protocol"] ob = {"tag": "main", "server": link["host"], "server_port": int(link["port"])} if p == "hysteria2": ob.update(type="hysteria2", password=link["user"], tls=tls_block(link, insecure=qp(link, "insecure") == "1")) if qp(link, "obfs"): ob["obfs"] = {"type": qp(link, "obfs"), "password": qp(link, "obfs-password")} elif p == "tuic": ob.update(type="tuic", uuid=link["user"], password=link["password"], congestion_control=qp(link, "congestion_control", "bbr"), udp_relay_mode=qp(link, "udp_relay_mode", "native"), tls=tls_block(link)) elif p.startswith("naive"): ob.update(type="naive", username=link["user"], password=link["password"], quic=(link["scheme"] == "naive+quic"), tls=tls_block(link, sni=link["host"])) elif p == "vless": ob.update(type="vless", uuid=link["user"]) if qp(link, "flow"): ob["flow"] = qp(link, "flow") sec = qp(link, "security") fp = qp(link, "fp", "chrome") ob["tls"] = tls_block(link, utls_fp=fp, reality=(sec == "reality")) elif p == "shadowsocks": try: pad = "=" * (-len(link["user"]) % 4) dec = base64.b64decode(link["user"] + pad).decode() except Exception: dec = "" if ":" not in dec: die("не удалось разобрать shadowsocks-ссылку") method, sspass = dec.split(":", 1) ob.update(type="shadowsocks", method=method, password=sspass) plugin = unquote(qp(link, "plugin")) if plugin: popts = "" if ";" in plugin: plugin, popts = plugin.split(";", 1) ob["plugin"], ob["plugin_opts"] = plugin, popts else: die(f"неизвестный протокол {p}") return ob def gen_config(rt: Router, link, tunnel: str): main_ob = build_outbound(link) if tunnel == "singbox": inbound = {"type": "tproxy", "tag": "tproxy-in", "listen": "0.0.0.0", "listen_port": int(TP_PORT)} else: inbound = {"type": "mixed", "tag": "mixed-in", "listen": "127.0.0.1", "listen_port": int(SOCKS_PORT)} cfg = { "log": {"level": "info", "timestamp": True}, "inbounds": [inbound], "outbounds": [main_ob, {"type": "direct", "tag": "direct"}], "route": {"final": "main", "rules": [{"ip_cidr": PRIVATE_CIDRS, "outbound": "direct"}]}, } data = json.dumps(cfg, indent=2).encode() rt.write_file(f"{WORK_DIR}/config.json", data) rc, _, err = rt.run(f"{SB_PATH} check -c {WORK_DIR}/config.json") if rc != 0: die("конфиг sing-box не проходит проверку: " + err.decode(errors="replace")[:300]) # ============================================================================ # fw.sh / init.d (выполняются на роутере — остаются shell) # ============================================================================ FW_SH = r"""#!/bin/sh # NikoVPN firewall (генерируется nikovpn.py) WORK_DIR=/etc/nikovpn STATE="$WORK_DIR/state" [ -f "$STATE" ] && . "$STATE" MODE="${MODE:-tcp}" FW="${FW:-nft}" TUNNEL="${TUNNEL:-ipt2socks}" TP_PORT="${TP_PORT:-12345}" VPN_PORT="${PORT:-0}" MARK="0x1" TABLE="100" NFT_FILE="/etc/nftables.d/10-nikovpn.nft" PRIVATE="0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4" NFT_PRIVATE="$(echo $PRIVATE | tr ' ' ',')" have() { command -v "$1" >/dev/null 2>&1; } ip_rules_add() { ip rule add fwmark $MARK table $TABLE 2>/dev/null ip route add local default dev lo table $TABLE 2>/dev/null true } ip_rules_del() { ip rule del fwmark $MARK table $TABLE 2>/dev/null ip route del local default dev lo table $TABLE 2>/dev/null true } nft_write() { cat > "$NFT_FILE" <> "$NFT_FILE" <> "$NFT_FILE" <> "$NFT_FILE" <> "$NFT_FILE" fi fi } fw_start() { if [ "$FW" = "nft" ]; then have nft || return 1 nft_write nft delete table inet nikovpn 2>/dev/null nft -f "$NFT_FILE" || return 1 if [ "$MODE" = full ] || [ "$TUNNEL" = singbox ]; then ip_rules_add; fi else have iptables || return 1 if [ "$TUNNEL" = singbox ]; then iptables -t mangle -N NIKOVPN_TP 2>/dev/null iptables -t mangle -F NIKOVPN_TP for n in $PRIVATE; do iptables -t mangle -A NIKOVPN_TP -d $n -j RETURN; done iptables -t mangle -A NIKOVPN_TP -m addrtype --dst-type LOCAL -j RETURN iptables -t mangle -A NIKOVPN_TP -p tcp --dport 53 -j RETURN iptables -t mangle -A NIKOVPN_TP -p tcp -j TPROXY --on-port $TP_PORT --tproxy-mark $MARK/$MARK iptables -t mangle -C PREROUTING -p tcp -j NIKOVPN_TP 2>/dev/null || iptables -t mangle -A PREROUTING -p tcp -j NIKOVPN_TP else iptables -t nat -N NIKOVPN 2>/dev/null iptables -t nat -F NIKOVPN for n in $PRIVATE; do iptables -t nat -A NIKOVPN -d $n -j RETURN; done iptables -t nat -A NIKOVPN -m addrtype --dst-type LOCAL -j RETURN iptables -t nat -A NIKOVPN -p tcp --dport 53 -j RETURN iptables -t nat -A NIKOVPN -p tcp --dport $VPN_PORT -j RETURN iptables -t nat -A NIKOVPN -p tcp -j REDIRECT --to-ports $TP_PORT iptables -t nat -C PREROUTING -p tcp -j NIKOVPN 2>/dev/null || iptables -t nat -A PREROUTING -p tcp -j NIKOVPN iptables -t nat -C OUTPUT -p tcp -m addrtype ! --dst-type LOCAL -j NIKOVPN 2>/dev/null || iptables -t nat -A OUTPUT -p tcp -m addrtype ! --dst-type LOCAL -j NIKOVPN fi if [ "$MODE" = full ] || [ "$TUNNEL" = singbox ]; then iptables -t mangle -N NIKOVPN_UDP 2>/dev/null iptables -t mangle -F NIKOVPN_UDP for n in $PRIVATE; do iptables -t mangle -A NIKOVPN_UDP -d $n -j RETURN; done iptables -t mangle -A NIKOVPN_UDP -m addrtype --dst-type LOCAL -j RETURN iptables -t mangle -A NIKOVPN_UDP -p udp --dport 53 -j RETURN iptables -t mangle -A NIKOVPN_UDP -p udp -j TPROXY --on-port $TP_PORT --tproxy-mark $MARK/$MARK iptables -t mangle -C PREROUTING -p udp -j NIKOVPN_UDP 2>/dev/null || iptables -t mangle -A PREROUTING -p udp -j NIKOVPN_UDP ip_rules_add fi grep -q 'nikovpn/fw.sh start' /etc/firewall.user 2>/dev/null || echo "sh /etc/nikovpn/fw.sh start" >> /etc/firewall.user fi } fw_stop() { if [ "$FW" = "nft" ]; then nft delete table inet nikovpn 2>/dev/null rm -f "$NFT_FILE" else iptables -t nat -D PREROUTING -p tcp -j NIKOVPN 2>/dev/null iptables -t nat -D OUTPUT -p tcp -m addrtype ! --dst-type LOCAL -j NIKOVPN 2>/dev/null iptables -t mangle -D PREROUTING -p tcp -j NIKOVPN_TP 2>/dev/null iptables -t mangle -D PREROUTING -p udp -j NIKOVPN_UDP 2>/dev/null iptables -t nat -F NIKOVPN 2>/dev/null; iptables -t nat -X NIKOVPN 2>/dev/null iptables -t mangle -F NIKOVPN_TP 2>/dev/null; iptables -t mangle -X NIKOVPN_TP 2>/dev/null iptables -t mangle -F NIKOVPN_UDP 2>/dev/null; iptables -t mangle -X NIKOVPN_UDP 2>/dev/null sed -i '\#nikovpn/fw.sh start#d' /etc/firewall.user 2>/dev/null fi ip_rules_del } case "$1" in start) fw_start ;; stop) fw_stop ;; *) echo "usage: $0 start|stop"; exit 1 ;; esac """ INIT_SH = r"""#!/bin/sh /etc/rc.common START=95 STOP=10 USE_PROCD=1 WORK_DIR=/etc/nikovpn STATE="$WORK_DIR/state" [ -f "$STATE" ] && . "$STATE" start_service() { [ -f "$WORK_DIR/config.json" ] || return 1 procd_open_instance singbox procd_set_param command /usr/bin/sing-box run -c "$WORK_DIR/config.json" procd_set_param respawn 3600 5 5 procd_set_param file /var/log/nikovpn.log procd_set_param pidfile /var/run/nikovpn-singbox.pid procd_close_instance if [ "$TUNNEL" = "ipt2socks" ]; then IPT_FLAGS="-R" [ "$MODE" = "tcp" ] && IPT_FLAGS="-R -T" procd_open_instance ipt2socks procd_set_param command /usr/bin/ipt2socks $IPT_FLAGS -l $TP_PORT -s 127.0.0.1 -p $SOCKS_PORT -b 0.0.0.0 procd_set_param respawn 3600 5 5 procd_set_param file /var/log/nikovpn-ipt2socks.log procd_set_param pidfile /var/run/nikovpn-ipt2socks.pid procd_close_instance fi } service_started() { "$WORK_DIR/fw.sh" start } stop_service() { "$WORK_DIR/fw.sh" stop } """ # ============================================================================ # Установка бинарников # ============================================================================ def router_download(rt: Router, url: str, out: str) -> bool: """скачивание на самом роутере (curl/wget + http-fallback для f.n1ko.dev)""" if rt.have("curl"): dl = f"curl -fsSL --max-time 120 -o {shlex.quote(out)} {shlex.quote(url)}" elif rt.have("wget"): dl = f"wget -q -T 120 -O {shlex.quote(out)} {shlex.quote(url)}" else: return False if rt.run(dl + " >/dev/null 2>&1")[0] == 0: return True if url.startswith("https://f.n1ko.dev/"): http_url = "http://" + url[len("https://"):] return rt.run(dl.replace(shlex.quote(url), shlex.quote(http_url)) + " >/dev/null 2>&1")[0] == 0 return False def push_gz(rt: Router, local_gz: Path, dest: str, test_cmd: str, label: str): """залить .gz с ПК и распаковать сразу в dest (без staging в tmpfs — /tmp на OpenWRT это RAM, большой бинарник туда класть нельзя)""" log(f"заливаю {label} ({local_gz.stat().st_size // 1024 // 1024}MB) по SSH ...") with open(local_gz, "rb") as f: rt.run(f"gzip -dc > {shlex.quote(dest)} && chmod 755 {shlex.quote(dest)}", input_bytes=f.read(), check=True) if rt.run(test_cmd.format(b=shlex.quote(dest)), timeout=30)[0] != 0: die(f"{label} не запускается на этой системе (архитектура?)") def install_bins(rt: Router, sb_arch: str, ipt_arch: str, tunnel: str, update: bool, state: dict, local_bin: Path): rt.mkdirs("/usr/bin") want_sb = SB_VER want_ipt = IPT_VER skip_sb = (update and state.get("SB_VER") == want_sb and rt.exists(SB_PATH)) skip_ipt = (update and state.get("IPT_VER") == want_ipt and rt.exists(IPT_PATH)) if not skip_sb: local = local_bin / f"sing-box-{sb_arch}.gz" if local.exists(): push_gz(rt, local, SB_PATH, "{b} version", "sing-box") else: log(f"скачиваю sing-box ({sb_arch}) на роутере ...") gz = f"/tmp/nikovpn.{os.getpid()}.sb.gz" if not (router_download(rt, f"{REL_URL}/sing-box-{sb_arch}.gz", gz) or router_download(rt, f"{BASE_URL}/bin/sing-box-{sb_arch}.gz", gz)): die("не удалось скачать sing-box") rt.run(f"gzip -dc {gz} > {SB_PATH} && chmod +x {SB_PATH}", check=True) if rt.run(f"{SB_PATH} version")[0] != 0: die("sing-box не запускается на этой системе") rt.run(f"rm -f {gz}") if tunnel == "ipt2socks" and not skip_ipt: local = local_bin / f"ipt2socks-{ipt_arch}.gz" if local.exists(): push_gz(rt, local, IPT_PATH, "{b} -V", "ipt2socks") else: log(f"скачиваю ipt2socks ({ipt_arch}) на роутере ...") gz = f"/tmp/nikovpn.{os.getpid()}.ipt.gz" if not (router_download(rt, f"{REL_URL}/ipt2socks-{ipt_arch}.gz", gz) or router_download(rt, f"{BASE_URL}/bin/ipt2socks-{ipt_arch}.gz", gz)): die("не удалось скачать ipt2socks") rt.run(f"gzip -dc {gz} > {IPT_PATH} && chmod +x {IPT_PATH}", check=True) if rt.run(f"{IPT_PATH} -V")[0] != 0: die("ipt2socks не запускается на этой системе") rt.run(f"rm -f {gz}") # ============================================================================ # FULL-режим (TCP+UDP через TPROXY) # ============================================================================ def probe_full(rt: Router, fw: str) -> bool: if not rt.have("ip"): return False ok = True if fw == "nft": if rt.run("nft add table inet nikovpn_test")[0] != 0: return False rt.run("nft add chain inet nikovpn_test t " "'{ type filter hook prerouting priority -1; policy accept; }'") if rt.run("nft add rule inet nikovpn_test t 'meta l4proto udp tproxy to :12345'")[0] != 0: ok = False rt.run("nft delete table inet nikovpn_test") else: rt.run("iptables -t mangle -N NIKOVPN_TEST") if rt.run("iptables -t mangle -A NIKOVPN_TEST -p udp -j TPROXY " "--on-port 12345 --tproxy-mark 0x1/0x1")[0] != 0: ok = False rt.run("iptables -t mangle -F NIKOVPN_TEST; iptables -t mangle -X NIKOVPN_TEST") if not ok: return False if rt.run("ip rule add fwmark 0x1 table 101")[0] != 0: return False rt.run("ip rule del fwmark 0x1 table 101") if rt.run("ip route add local 192.0.2.1/32 dev lo table 101")[0] != 0: return False rt.run("ip route del local 192.0.2.1/32 dev lo table 101") return True # ============================================================================ # Состояние # ============================================================================ def write_state(rt: Router, **kw): lines = "".join(f"{k}='{v}'\n" for k, v in kw.items()) rt.write_file(f"{WORK_DIR}/state", lines.encode()) def read_state(rt: Router) -> dict: data = rt.read_file(f"{WORK_DIR}/state") if data is None: return {} st = {} for k, v in re.findall(r"^([A-Z_]+)='(.*)'$", data.decode(errors="replace"), re.M): st[k] = v return st def human(n): try: n = float(n) except (TypeError, ValueError): return "?" for u in ["Б", "КБ", "МБ", "ГБ", "ТБ"]: if n < 1024 or u == "ТБ": return f"{n:.1f} {u}" n /= 1024 # ============================================================================ # Команды # ============================================================================ def stop_service(rt: Router): if rt.exists("/etc/init.d/nikovpn"): rt.run("/etc/init.d/nikovpn stop >/dev/null 2>&1") # процессы, запущенные в обход procd, держат бинарники (Text file busy) rt.run("kill $(pgrep sing-box) 2>/dev/null; " "kill $(pgrep ipt2socks) 2>/dev/null") for _ in range(5): _, out, _ = rt.run("pgrep sing-box; pgrep ipt2socks") if not out.strip(): break rt.run("kill -9 $(pgrep sing-box) 2>/dev/null; " "kill -9 $(pgrep ipt2socks) 2>/dev/null") time.sleep(1) def verify_tunnel(rt: Router, local_port: int) -> str | None: """сквозной тест: socks5 роутера -> SSH-форвард -> curl с ПК. Возвращает внешний IP или None.""" import socket as _s fwd = None try: with _s.socket() as t: t.bind(("127.0.0.1", 0)) local_port = t.getsockname()[1] fwd = subprocess.Popen( rt.ssh_args("-f", "-N", "-L", f"{local_port}:127.0.0.1:{SOCKS_PORT}", f"{rt.user}@{rt.host}"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(1.5) r = subprocess.run( ["curl", "-s", "--socks5-hostname", f"127.0.0.1:{local_port}", "--max-time", "12", "https://api.ipify.org"], capture_output=True, text=True, timeout=20) ip = r.stdout.strip() return ip if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip) else None except Exception: return None finally: if fwd: subprocess.run(["pkill", "-f", f"-L {local_port}:127.0.0.1:{SOCKS_PORT}"], capture_output=True) def restart_singbox_manual(rt: Router): # procd-respawn мешает ручным запускам: гасим надзор и дожидаемся смерти rt.run("/etc/init.d/nikovpn stop >/dev/null 2>&1; " "/etc/init.d/nikovpn disable >/dev/null 2>&1") for _ in range(10): _, out, _ = rt.run("pgrep sing-box") if not out.strip(): break rt.run("kill $(pgrep sing-box) 2>/dev/null") time.sleep(1) rt.run("kill $(pgrep ipt2socks) 2>/dev/null") time.sleep(1) rt.run(f"(/usr/bin/sing-box run -c {WORK_DIR}/config.json " f">> /var/log/nikovpn.log 2>&1 &); " f"(/usr/bin/ipt2socks -R -T -l {TP_PORT} -s 127.0.0.1 -p {SOCKS_PORT} " f"-b 0.0.0.0 >/dev/null 2>&1 &)") time.sleep(3) _, out, _ = rt.run("netstat -tln 2>/dev/null | grep ':${P}'".replace("${P}", SOCKS_PORT)) if SOCKS_PORT.encode() not in out: warn("sing-box не слушает порт — см. лог") def do_install(rt: Router, args, update=False): sb_arch, ipt_arch, naive_ok, da = detect_arch(rt) creds = get_creds(rt, args) tunnel = "ipt2socks" if ipt_arch else "singbox" if tunnel == "singbox": warn("для этой архитектуры нет ipt2socks — использую встроенный tproxy sing-box") rt.mkdirs(WORK_DIR) if args.creds: rt.write_file(CREDS_FILE, (args.creds.strip() + "\n").encode(), mode="600") # версии: локальный bin/ приоритетнее local_bin = Path(__file__).resolve().parent / "bin" global SB_VER, IPT_VER SB_VER = IPT_VER = "" src_versions = None vf = local_bin / ".." / "versions" if vf.exists(): src_versions = vf.read_text() if src_versions: for line in src_versions.splitlines(): k, _, v = line.partition("=") if k == "sing-box": SB_VER = v elif k == "ipt2socks": IPT_VER = v if not SB_VER: rc, out, _ = rt.run( f"(curl -fsSL {REL_URL}/versions 2>/dev/null || " f"wget -q -O - {REL_URL}/versions 2>/dev/null || " f"curl -fsSL {BASE_URL}/versions 2>/dev/null || " f"wget -q -O - {BASE_URL}/versions 2>/dev/null)") if rc == 0: for line in out.decode(errors="replace").splitlines(): k, _, v = line.partition("=") if k == "sing-box": SB_VER = v elif k == "ipt2socks": IPT_VER = v if not SB_VER or not IPT_VER: die("не удалось определить версии бинарников") log(f"Архитектура: {sb_arch} (sing-box {SB_VER}), ipt2socks: {ipt_arch or 'нет'}") log("Проверяю аккаунт ...") code, body = api_get(creds, "/me") if code == 401: die("неверный логин/пароль") if code == 403: die("аккаунт заблокирован/истёк/лимит — проверьте подписку") if code != 200: die(f"не удалось связаться с {API_URL}") log("Получаю список серверов ...") code, links = api_get(creds, "/config?format=links") if code != 200 or not links.strip(): code, links = api_get(creds, "/config?format=links") if code != 200 or not links.strip(): die(f"не удалось получить список серверов (код {code})") cands = choose_candidates(rt, links, bool(naive_ok)) if not cands: die("пустой список кандидатов") # остановить старый сервис перед перебором stop_service(rt) install_bins(rt, sb_arch, ipt_arch, tunnel, update, read_state(rt), local_bin) link = None for i, cand in enumerate(cands[:5]): tag = f" ({cand['frag']})" if cand["frag"] else "" log(f"Кандидат {i + 1}/{min(5, len(cands))}: " f"{cand['protocol']} {cand['host']}:{cand['port']}{tag}") gen_config(rt, cand, tunnel) restart_singbox_manual(rt) ip = verify_tunnel(rt, 0) if ip: log(f"Туннель работает, внешний IP: {ip}") link = cand break warn("не работает, пробую следующего кандидата") if link is None: die("ни один сервер не заработал — смотрите /var/log/nikovpn.log на роутере") assert link is not None fw = "" if rt.have("nft"): fw = "nft" elif rt.have("iptables"): fw = "ipt" else: die("не найдено ни nftables, ни iptables") mode = "tcp" if tunnel == "singbox": mode = "full" elif link["protocol"] == "naive": mode = "tcp" elif probe_full(rt, fw): mode = "full" else: warn("UDP/TPROXY недоступно — режим только TCP") log(f"Выбран протокол: {link['protocol']}") log(f"Выбран сервер: {link['host']}:{link['port']}" + (f" ({link['frag']})" if link["frag"] else "")) write_state(rt, SB_VER=SB_VER, IPT_VER=IPT_VER, ARCH=sb_arch, SB_ARCH=sb_arch, IPT_ARCH=ipt_arch, NAIVE_OK=int(naive_ok), PROTOCOL=link["protocol"], LINK=link["uri"], HOST=link["host"], PORT=str(link["port"]), SNI=link["sni"], MODE=mode, FW=fw, TUNNEL=tunnel, SOCKS_PORT=SOCKS_PORT, TP_PORT=TP_PORT) rt.write_file(f"{WORK_DIR}/fw.sh", FW_SH.encode(), mode="755") rt.write_file("/etc/init.d/nikovpn", INIT_SH.encode(), mode="755") log("Запускаю сервис ...") rt.run("kill $(pgrep sing-box) 2>/dev/null; " "kill $(pgrep ipt2socks) 2>/dev/null") time.sleep(1) rt.run("/etc/init.d/nikovpn enable >/dev/null 2>&1") rt.run("/etc/init.d/nikovpn start >/dev/null 2>&1") time.sleep(3) rc, out, _ = rt.run("pgrep sing-box >/dev/null && echo y") if rc == 0 and b"y" in out: log("sing-box запущен. Прозрачный прокси активен.") else: warn("sing-box не запустился. Лог: ssh root@host cat /var/log/nikovpn.log") log("Готово. Проверка: uv run nikovpn.py status") def do_status(rt: Router, args): st = read_state(rt) if not st: die(f"NikoVPN не установлен (нет {WORK_DIR}/state)") print("=== NikoVPN ===") print(f"Протокол: {st.get('PROTOCOL', '?')}") print(f"Сервер: {st.get('HOST', '?')}:{st.get('PORT', '?')}") print(f"Архитектура: {st.get('ARCH', '?')} " f"(sing-box {st.get('SB_VER', '?')}, ipt2socks {st.get('IPT_VER', '?')})") print(f"Режим: {st.get('MODE', '?')} / {st.get('TUNNEL', '?')} / {st.get('FW', '?')}") rc, out, _ = rt.run("pgrep sing-box >/dev/null && echo RUNNING || echo STOPPED") print(f"sing-box: {out.decode().strip()}") if st.get("TUNNEL") == "ipt2socks": rc, out, _ = rt.run("pgrep ipt2socks >/dev/null && echo RUNNING || echo STOPPED") print(f"ipt2socks: {out.decode().strip()}") logs = rt.read_file("/var/log/nikovpn.log") if logs: print("--- последние строки /var/log/nikovpn.log ---") for line in logs.decode(errors="replace").splitlines()[-5:]: print(line) try: creds = get_creds(rt, args) code, body = api_get(creds, "/me") if code == 200: me = json.loads(body) print(f"Трафик: использовано {human(me.get('traffic_used'))}, " f"за сутки {human(me.get('traffic_day'))}, " f"всего {human(me.get('traffic_total'))}") except SystemExit: pass def do_uninstall(rt: Router, args): rt.run("/etc/init.d/nikovpn stop >/dev/null 2>&1") rt.run("/etc/init.d/nikovpn disable >/dev/null 2>&1") rt.run("nft delete table inet nikovpn >/dev/null 2>&1") rt.run("rm -f /etc/nftables.d/10-nikovpn.nft") for c in ("NIKOVPN", "NIKOVPN_TP", "NIKOVPN_UDP"): rt.run(f"iptables -t nat -D PREROUTING -p tcp -j {c} >/dev/null 2>&1") rt.run(f"iptables -t mangle -D PREROUTING -p udp -j {c} >/dev/null 2>&1") rt.run("ip rule del fwmark 0x1 table 100 >/dev/null 2>&1") rt.run("ip route del local default dev lo table 100 >/dev/null 2>&1") rt.run("rm -f /etc/init.d/nikovpn /etc/rc.d/S95nikovpn /etc/rc.d/K10nikovpn") rt.run(r"sed -i '\#nikovpn/fw.sh start#d' /etc/firewall.user >/dev/null 2>&1") rt.run(f"rm -rf {shlex.quote(WORK_DIR)}") rt.run(f"rm -f {SB_PATH} {IPT_PATH}") print("NikoVPN удалён.") # ============================================================================ # MAIN # ============================================================================ SB_VER = "" IPT_VER = "" def main(): ap = argparse.ArgumentParser(prog="nikovpn", description="NikoVPN OpenWRT controller") ap.add_argument("--host", default=os.environ.get("NIKOVPN_HOST", "192.168.1.1")) ap.add_argument("--user", default="root") ap.add_argument("--port", type=int, default=22) ap.add_argument("--password", help="пароль SSH (нужен sshpass)") ap.add_argument("--identity", help="ключ SSH") ap.add_argument("--creds", help="креды VPN login:password") ap.add_argument("--creds-file", help="файл с кредами login:password") ap.add_argument("command", choices=["install", "update", "status", "uninstall"]) args = ap.parse_args() rt = Router(args.host, args.user, args.port, args.password, args.identity) rc, out, err = rt.run("echo ok", timeout=20) if rc != 0: die(f"нет SSH-доступа к {args.user}@{args.host}: " f"{err.decode(errors='replace').strip()[:200]}") if args.command == "install": do_install(rt, args) elif args.command == "update": do_install(rt, args, update=True) elif args.command == "status": do_status(rt, args) elif args.command == "uninstall": do_uninstall(rt, args) if __name__ == "__main__": main()