From d4d00aa0e17064b7aa72b94e27ea5242b94d69d3 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Wed, 26 Aug 2026 09:44:07 +0300 Subject: [PATCH] on-router install: git clone + ./install.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh работает из клона на роутере: креды из /root/creds или env NIKOVPN_CREDS, бинарники качаются с f.n1ko.dev с фолбэком на локальный bin/ и versions из репозитория - README: быстрый старт git.n1ko.dev:Niko/nikovpn_openwrt.git, nikovpn.py (SSH-управление с ПК) — как опциональный способ - .gitignore: bin/ (~150MB) и __pycache__ не коммитятся --- .gitignore | 3 + README.md | 103 ++++++ fetch.py | 349 ++++++++++++++++++ install.sh | 881 +++++++++++++++++++++++++++++++++++++++++++++ nikovpn.py | 993 +++++++++++++++++++++++++++++++++++++++++++++++++++ status.sh | 16 + uninstall.sh | 16 + update.sh | 16 + versions | 2 + 9 files changed, 2379 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 fetch.py create mode 100755 install.sh create mode 100644 nikovpn.py create mode 100755 status.sh create mode 100755 uninstall.sh create mode 100755 update.sh create mode 100644 versions diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb0cbf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2405bcb --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# NikoVPN OpenWRT + +Установщик VPN для роутеров OpenWRT. Всё выполняется прямо на роутере: +нужны только busybox + wget/curl + nft/iptables (и git один раз — для клонирования). + +- Принцип: **sing-box → ipt2socks** (прозрачный прокси для всего LAN). +- sing-box собирается из исходников slim-патчем: только vless (+uTLS, без + reality), hysteria, hysteria2, tuic, naive. vmess/trojan/shadowsocks/ssh/ + tor/shadowtls/anytls/tun выпилены, gvisor/wireguard/clash-api/acme/dhcp + не включены. Итог: ~8–14 MB gz вместо ~17–23 MB. +- Креды VPN (`login:password`): `/root/creds` на роутере или env + `NIKOVPN_CREDS`. + +## Установка + +Прямо на роутере: + +```sh +git clone git.n1ko.dev:Niko/nikovpn_openwrt.git +cd nikovpn_openwrt +./install.sh +``` + +Перед запуском положите креды в `/root/creds`: + +```sh +echo 'login:password' > /root/creds +``` + +Бинарники при установке качаются с f.n1ko.dev (каталог `bin/` ~150 MB +в репозиторий не входит; если он есть рядом с install.sh — берётся из него). + +## Команды + +| Команда | Что делает | +|---|---| +| `./install.sh` | установка / переустановка | +| `./install.sh update` | обновление бинарников и пересбор сервера | +| `./install.sh status` | статус VPN и трафик | +| `./install.sh uninstall` | удаление | + +То же самое через обёртки `status.sh`, `update.sh`, `uninstall.sh`. + +## Управление с ПК (опционально) + +nikovpn.py делает то же самое по SSH, без git на роутере: + +```sh +uv run nikovpn.py --host 192.168.1.1 --creds 'login:password' install +uv run nikovpn.py update | status | uninstall +``` + +Параметры подключения: `--host` (по умолчанию 192.168.1.1), `--user`, +`--port`, `--password` (нужен sshpass), `--identity`. + +## Как это работает + +1. Определяется архитектура (`DISTRIB_ARCH` → `apk` → `opkg` → `uname`); + nikovpn.py делает это по SSH. +2. Бинарники качаются с f.n1ko.dev (или заливаются из локального `bin/`, + если он есть). +3. Через API `vpnd.n1ko.dev` получаются ссылки на серверы. +4. Кандидаты перебираются: конфиг поднимается на роутере, туннель + проверяется; файрвол включается только после подтверждения, что + туннель живой (в nikovpn.py проверка сквозная — curl с ПК через + SSH-форвард). +5. Приоритет протоколов: `hysteria2 > tuic > naive > vless > shadowsocks`. +6. Поднимается прозрачный прокси (nft, при отсутствии — iptables) и + init-скрипт `/etc/init.d/nikovpn` (procd). + +Типичные параметры — в `/etc/nikovpn/state`, предпочтения можно задать в +`/etc/nikovpn/config` на роутере: + +```sh +PREFER_PROTOCOL="tuic" # принудительный протокол +PREFER_SERVER="host.n1ko.dev" +``` + +DNS (порт 53) всегда идёт в обход прокси; трафик в приватные сети и к самому +роутеру не проксируется. + +## Пересборка бинарников + +```sh +uv run fetch.py --jobs 4 # все архитектуры +uv run fetch.py --only mipsle # одну +``` + +Требует go (скачает сам), clang/lld + musl.cc sysroot для naive-архитектур, +zig для loong64. Готовое `bin/` + `versions` заливаются на +f.n1ko.dev/nikovpn_openwrt/. + +> **xhttp не поддерживается**: это транспорт Xray-core, в sing-box его нет +> (только v2ray-транспорты tcp/ws/grpc/httpupgrade/quic). VLESS работает +> поверх tcp+tls. + +## Отладка + +```sh +ssh root@192.168.1.1 sh /etc/nikovpn/install.sh status +ssh root@192.168.1.1 cat /var/log/nikovpn.log +ssh root@192.168.1.1 nft list table inet nikovpn +``` diff --git a/fetch.py b/fetch.py new file mode 100644 index 0000000..3ed47bc --- /dev/null +++ b/fetch.py @@ -0,0 +1,349 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.9" +# dependencies = [] +# /// +# ============================================================================= +# fetch.py — сборка лёгких бинарников NikoVPN (замена fetch.sh) +# +# uv run fetch.py [--jobs N] [--only имя,...] +# +# sing-box v1.13.19 собирается из исходников slim-патчем: +# остаются только outbounds: vless (+utls, без reality), hysteria, +# hysteria2, tuic, naive; inbounds: tproxy/mixed/socks/http/direct; +# выпилены: vmess, trojan, shadowsocks, tor, ssh, shadowtls, anytls, +# tun, ssmapi; теги без gvisor/wireguard/clash_api/acme/dhcp/tailscale. +# Результат: gz ~14MB вместо ~23MB (с naive), ~9MB (без). +# +# naive (cronet) собирается через прекомпилированные libcronet.a +# (github.com/sagernet/cronet-go/lib/*_musl) + system clang/lld + +# musl.cc sysroot. Архитектуры без cronet-библиотек собираются +# CGO_ENABLED=0 без naive (установщик сам пропустит этот протокол). +# +# ipt2socks не пересобирается — готовые .gz в bin/ сохраняются. +# ============================================================================= + +import argparse +import concurrent.futures +import gzip +import os +import shutil +import subprocess +import sys +import tarfile +import urllib.request +from pathlib import Path + +SB_VER = "1.13.19" +SB_TAG = "1.13.19-niko.1" +IPT_VER = "1.1.4" +SB_SRC_URL = f"https://github.com/SagerNet/sing-box/archive/refs/tags/v{SB_VER}.tar.gz" +GO_VER = "1.25.0" + +CRONET_LIBS = "github.com/sagernet/cronet-go/lib" + +BASE_TAGS = ["with_quic", "with_utls", "badlinkname", "tfogo_checklinkname0"] +NAIVE_TAGS = ["with_naive_outbound", "with_musl"] + +LDF_VERSION = [ + "-s", "-w", "-buildid=", "-checklinkname=0", + f"-X github.com/sagernet/sing-box/constant.Version={SB_TAG}", +] + +# --- архитектуры с naive (cronet-musl библиотеки существуют) ----------------- +NAIVE_ARCHS = { + "amd64": dict(goarch="amd64", tc="x86_64-linux-musl-cross", + triple="x86_64-linux-musl", lib="linux_amd64_musl"), + "386": dict(goarch="386", tc="i686-linux-musl-cross", + triple="i686-linux-musl", lib="linux_386_musl", + goenv={"GO386": "sse2"}), + "arm64": dict(goarch="arm64", tc="aarch64-linux-musl-cross", + triple="aarch64-linux-musl", lib="linux_arm64_musl"), + "armv7": dict(goarch="arm", tc="armv7l-linux-musleabihf-cross", + triple="arm-linux-musleabihf", lib="linux_arm_musl", + goenv={"GOARM": "7"}, cc=["-march=armv7-a"]), + "mipsle": dict(goarch="mipsle", tc="mipsel-linux-muslsf-cross", + triple="mipsel-linux-musl", lib="linux_mipsle_musl", + goenv={"GOMIPS": "softfloat"}, + cc=["-mfloat-abi=soft"], nopie=True), + "riscv64": dict(goarch="riscv64", tc="riscv64-linux-musl-cross", + triple="riscv64-linux-musl", lib="linux_riscv64_musl"), +} + +# loong64: нет в musl.cc — пробуем zig cc, иначе уйдёт в PLAIN +LOONG64_ZIG = dict(goarch="loong64", zig="loongarch64-linux-musl", + lib="linux_loong64_musl", nopie=True) + +# --- архитектуры без naive (чистый go) --------------------------------------- +PLAIN_ARCHS = { + "armv6": dict(goarch="arm", goenv={"GOARM": "6"}), + "armv5": dict(goarch="arm", goenv={"GOARM": "5"}), + "mips": dict(goarch="mips", goenv={"GOMIPS": "softfloat"}), + "mips64": dict(goarch="mips64"), + "mips64le": dict(goarch="mips64le"), + "ppc64le": dict(goarch="ppc64le"), + "s390x": dict(goarch="s390x"), +} + +MUSLCC_URL = "https://musl.cc/{tc}.tgz" +STUB_LIBS = ["resolv", "rt", "pthread", "dl", "atomic"] + +log = lambda m: print(f"[*] {m}", flush=True) +warn = lambda m: print(f"[!] {m}", flush=True) + + +def sh(cmd, cwd=None, env=None, capture=False): + e = dict(os.environ) + if env: + e.update(env) + r = subprocess.run(cmd, cwd=cwd, env=e, shell=isinstance(cmd, str), + text=True, capture_output=capture) + if r.returncode != 0: + raise RuntimeError(f"команда failed: {cmd}\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}") + return r.stdout if capture else None + + +def dl(url, dest: Path): + if dest.exists() and dest.stat().st_size > 0: + return + log(f"скачаю {url}") + dest.parent.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(url, dest) + + +def ensure_go(cache: Path) -> Path: + for cand in [Path("/tmp/opencode/go-sdk/bin/go"), Path.home() / f".cache/nikovpn-build/go-sdk-{GO_VER}/bin/go"]: + if cand.exists(): + return cand + sdk = cache / f"go-sdk-{GO_VER}" + dl(f"https://go.dev/dl/go{GO_VER}.linux-amd64.tar.gz", cache / "go.tgz") + log("распакую go") + with tarfile.open(cache / "go.tgz") as t: + t.extractall(cache) + os.rename(cache / "go", sdk) + return sdk / "bin/go" + + +def patch_registry(src: Path): + """выпиливаем ненужные протоколы/сервисы из реестра sing-box""" + p = src / "include" / "registry.go" + s = p.read_text() + imports = ["protocol/anytls", "protocol/shadowsocks", "protocol/shadowtls", + "protocol/ssh", "protocol/tor", "protocol/trojan", "protocol/tun", + "protocol/vmess", "service/ssmapi"] + regs = ["tun.RegisterInbound(registry)", "shadowsocks.RegisterInbound(registry)", + "vmess.RegisterInbound(registry)", "trojan.RegisterInbound(registry)", + "shadowtls.RegisterInbound(registry)", "vless.RegisterInbound(registry)", + "anytls.RegisterInbound(registry)", "shadowsocks.RegisterOutbound(registry)", + "vmess.RegisterOutbound(registry)", "trojan.RegisterOutbound(registry)", + "tor.RegisterOutbound(registry)", "ssh.RegisterOutbound(registry)", + "shadowtls.RegisterOutbound(registry)", "anytls.RegisterOutbound(registry)", + "ssmapi.RegisterService(registry)"] + for i in imports: + s = s.replace(f'\t"github.com/sagernet/sing-box/{i}"\n', "") + for r in regs: + s = s.replace(f"\t{r}\n", "") + p.write_text(s) + + +def prepare_source(cache: Path, go: Path) -> Path: + src_tgz = cache / f"sing-box-{SB_VER}.tar.gz" + dl(SB_SRC_URL, src_tgz) + master = cache / f"sing-box-{SB_VER}-niko" + if master.exists(): + shutil.rmtree(master) + master.mkdir(parents=True) + with tarfile.open(src_tgz) as t: + t.extractall(master, filter="data") + inner = next(master.iterdir()) + root = master / "src" + os.rename(inner, root) + patch_registry(root) + log("прогреваю модульный кэш go ...") + sh([str(go), "mod", "download"], cwd=root) + return master + + +def vendor_cronet(root: Path, go: Path, lib: str) -> Path: + mod = f"{CRONET_LIBS}/{lib}" + sh([str(go), "mod", "download", mod], cwd=root) + d = (sh([str(go), "list", "-m", "-f", "{{.Dir}}", mod], cwd=root, capture=True) or "").strip() + dst = root.parent / "cronet-libs" / lib + if dst.exists(): + return dst + dst.mkdir(parents=True) + shutil.copy(Path(d) / "go.mod", dst / "go.mod") + shutil.copy(Path(d) / "libcronet.a", dst / "libcronet.a") + cgo = (Path(d) / "libcronet_cgo.go").read_text() + cgo = cgo.replace("#cgo LDFLAGS: -L${SRCDIR} -l:libcronet.a", + "#cgo LDFLAGS: ${SRCDIR}/libcronet.a") + (dst / "libcronet_cgo.go").write_text(cgo) + return dst + + +def ensure_toolchain(cache: Path, tc: str) -> Path: + tdir = cache / "muslcc" / tc + if tdir.exists(): + return tdir + dl(MUSLCC_URL.format(tc=tc), cache / "muslcc" / f"{tc}.tgz") + log(f"распакую {tc}") + with tarfile.open(cache / "muslcc" / f"{tc}.tgz") as t: + t.extractall(cache / "muslcc", filter="tar") + return tdir + + +def make_linkdir(work: Path, gccdir: Path) -> Path: + ld = work / "linkdir" + ld.mkdir(exist_ok=True) + for name in ["libgcc.a", "crtbeginT.o", "crtend.o"]: + f = gccdir / name + if f.exists(): + shutil.copy(f, ld / name, follow_symlinks=True) + (ld / "libgcc_eh.a").unlink(missing_ok=True) + os.symlink(ld / "libgcc.a", ld / "libgcc_eh.a") + return ld + + +def make_stubs(work: Path, cc_base: list) -> Path: + st = work / "stubs" + st.mkdir(exist_ok=True) + c = work / "empty.c" + c.write_text("void __niko_stub(void){}\n") + o = st / "empty.o" + sh(cc_base + ["-c", str(c), "-o", str(o)]) + for l in STUB_LIBS: + sh(["ar", "rcs", str(st / f"lib{l}.a"), str(o)]) + return st + + +def build_one(name: str, spec: dict, master: Path, go: Path, cache: Path, + bin_out: Path) -> dict: + work = master.parent / "build" / name + if work.exists(): + shutil.rmtree(work) + work.mkdir(parents=True) + root = work / "src" + shutil.copytree(master / "src", root) + + naive = spec.get("lib") is not None + cc, linkdir, stubs = None, None, None + tags = list(BASE_TAGS) + (NAIVE_TAGS if naive else []) + goenv = {"GOOS": "linux", "GOARCH": spec["goarch"], **spec.get("goenv", {})} + + if naive: + lib = vendor_cronet(root, go, spec["lib"]) + sh([str(go), "mod", "edit", "-replace", f"{CRONET_LIBS}/{spec['lib']}={lib}"], cwd=root) + if spec.get("zig"): + cc = ["zig", "cc", "-target", spec["zig"]] + sysroot = None + else: + tdir = ensure_toolchain(cache, spec["tc"]) + sysroot = tdir / spec["tc"].removesuffix("-cross") + gccdirs = list((tdir / "lib" / "gcc").glob("*/*")) or [tdir / "lib" / "gcc" / "x" / "x"] + linkdir = make_linkdir(work, gccdirs[0]) + cc = ["clang", f"--target={spec['triple']}", f"--sysroot={sysroot}"] + cc += spec.get("cc", []) + [f"-B{linkdir}"] + stubs = make_stubs(work, cc) + + env = {"CGO_ENABLED": "1" if naive else "0"} + if naive: + env["CC"] = " ".join(str(x) for x in (cc or [])) + ldflags = ["-fuse-ld=lld"] + if spec.get("nopie"): + ldflags.append("-Wl,-no-pie") + if linkdir: + ldflags += [f"-L{linkdir}"] + if stubs: + ldflags += [f"-L{stubs}"] + env["CGO_LDFLAGS"] = " ".join(ldflags) + env.update(goenv) + + out = work / "sing-box" + sh([str(go), "build", "-tags", ",".join(tags), "-trimpath", + "-ldflags", " ".join(LDF_VERSION), "-o", str(out), "./cmd/sing-box"], + cwd=root, env=env) + + data = out.read_bytes() + if data[:4] != b"\x7fELF": + raise RuntimeError(f"{name}: не ELF") + gz_path = bin_out / f"sing-box-{name}.gz" + with open(gz_path, "wb") as f: + with gzip.GzipFile("", "wb", compresslevel=9, fileobj=f, mtime=0) as g: + g.write(data) + raw_mb = len(data) / 1e6 + gz_mb = gz_path.stat().st_size / 1e6 + return dict(name=name, naive=naive, raw=raw_mb, gz=gz_mb) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--jobs", type=int, default=3) + ap.add_argument("--only", help="список имён через запятую") + args = ap.parse_args() + + repo = Path(__file__).resolve().parent + bin_out = repo / "bin" + bin_out.mkdir(exist_ok=True) + cache = Path(os.environ.get("NIKOVPN_BUILD_CACHE", + Path.home() / ".cache/nikovpn-build")) + cache.mkdir(parents=True, exist_ok=True) + + specs = {} + for n, s in NAIVE_ARCHS.items(): + specs[n] = {**s, "lib": s["lib"]} + # loong64: zig или вылетает в plain + zt = LOONG64_ZIG["zig"] + probe = cache / "build" / "_probe" + probe.mkdir(parents=True, exist_ok=True) + (probe / "e.c").write_text("#include \nint main(){return 0;}\n") + try: + sh(["zig", "cc", "-target", zt, str(probe / "e.c"), "-o", str(probe / "e.out")]) + specs["loong64"] = {**LOONG64_ZIG, "lib": LOONG64_ZIG["lib"]} + log("loong64: zig cc работает — соберу с naive") + except Exception: + warn("loong64: zig cc не смог — будет сборка без naive") + specs["loong64"] = dict(goarch="loong64") + for n, s in PLAIN_ARCHS.items(): + specs.setdefault(n, s) + + if args.only: + keep = [x.strip() for x in args.only.split(",")] + specs = {k: v for k, v in specs.items() if k in keep} + + go = ensure_go(cache) + log(f"go: {go}") + master = prepare_source(cache, go) + + todo = sorted(specs) + log(f"собираю {len(todo)} архитектур: {', '.join(todo)}") + results, failed = [], [] + with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex: + futs = {ex.submit(build_one, n, specs[n], master, go, cache, bin_out): n + for n in todo} + for fut in concurrent.futures.as_completed(futs): + n = futs[fut] + try: + r = fut.result() + results.append(r) + print(f" [{len(results)}/{len(todo)}] {n}: " + f"{r['raw']:.1f}MB raw -> {r['gz']:.1f}MB gz " + f"(naive={'да' if r['naive'] else 'нет'})", flush=True) + except Exception as e: + failed.append(n) + warn(f"{n}: {e}") + + # ipt2socks не трогаем; версии + (repo / "versions").write_text(f"sing-box={SB_TAG}\nipt2socks={IPT_VER}\n") + + print("\n=== итог ===") + for r in sorted(results, key=lambda x: x["name"]): + print(f" {r['name']:<9} naive:{'+' if r['naive'] else '-'} " + f"bin/sing-box-{r['name']}..gz ({r['gz']:.1f}MB)") + if failed: + warn(f"ПРОВАЛЕНО: {', '.join(failed)}") + sys.exit(1) + print("OK. Залейте bin/ + versions на f.n1ko.dev/nikovpn_openwrt/") + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cf0b67e --- /dev/null +++ b/install.sh @@ -0,0 +1,881 @@ +#!/bin/sh +# ============================================================================ +# NikoVPN — универсальный установщик VPN для OpenWRT +# +# Принцип: sing-box -> ipt2socks -> nftables/iptables (прозрачный прокси) +# +# Быстрая установка (прямо на роутере, нужен git): +# git clone git.n1ko.dev:Niko/nikovpn_openwrt.git +# cd nikovpn_openwrt +# ./install.sh +# +# Команды: +# sh install.sh установить / переустановить +# sh install.sh update обновить (пересканировать протокол/сервер) +# sh install.sh status показать статус +# sh install.sh uninstall удалить +# +# Требования: +# - креды "login:password" (без кавычек): файл /root/creds +# на роутере или переменная окружения NIKOVPN_CREDS +# - доступ в интернет (скрипт тянет всё только с f.n1ko.dev и vpnd.n1ko.dev; +# если рядом с install.sh есть каталог bin/ — бинарники берутся из него) +# +# Скрипт сам определяет архитектуру (openwrt_release/apk/opkg/uname), +# выбирает лучший протокол и сервер (Hysteria2 > TUIC > Naive > VLESS > SS) +# и настраивает прозрачный прокси. +# ============================================================================ + +BASE_URL="https://f.n1ko.dev/nikovpn_openwrt" +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" +REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) + +CMD="${1:-install}" +TMP="/tmp/nikovpn.$$" +trap 'rm -rf "$TMP"' EXIT INT TERM + +# ------------------------------- helpers ----------------------------------- +have() { command -v "$1" >/dev/null 2>&1; } +log() { echo "[*] $*"; } +warn() { echo "[!] $*"; } +die() { echo "[ERROR] $*" >&2; exit 1; } + +need_root() { [ "$(id -u)" = "0" ] || die "нужны права root"; } + +# --- downloader --- +DL="" +for t in curl wget; do have "$t" && DL="$t" && break; done +[ -n "$DL" ] || die "нужен curl или wget" + +dl() { # $1=url $2=out ; резерв на http:// для f.n1ko.dev + local url="$1" out="$2" ok="" + if [ "$DL" = curl ]; then + curl -fsSL --max-time 120 -o "$out" "$url" && ok=1 + [ -z "$ok" ] && case "$url" in https://f.n1ko.dev/*) + curl -fsSL --max-time 120 -o "$out" "http://${url#https://}" && ok=1 ;; esac + else + wget -q -T 120 -O "$out" "$url" && ok=1 + [ -z "$ok" ] && case "$url" in https://f.n1ko.dev/*) + wget -q -T 120 -O "$out" "http://${url#https://}" && ok=1 ;; esac + fi + [ -n "$ok" ] +} + +# --- API (HTTP Basic auth) --- +API_AUTH=""; API_AUTH_V="" +api_init_auth() { + if [ "$DL" = curl ]; then + API_AUTH="-u"; API_AUTH_V="$CRED" + else + API_AUTH="--header" + API_AUTH_V="Authorization: Basic $(printf '%s' "$CRED" | base64 | tr -d '\n')" + fi +} +# api_get $1=path $2=outfile -> rc: 0=ok,401,403,1=err +api_get() { + local path="$1" out="$2" code rc + if [ "$DL" = curl ]; then + code=$(curl -s --max-time 30 -o "$out" -w '%{http_code}' "$API_AUTH" "$API_AUTH_V" "$API_URL$path") + [ -s "$out" ] || return 1 + case $code in 2*) return 0 ;; 401) return 401 ;; 403) return 403 ;; *) return 1 ;; esac + else + wget -q -T 30 -O "$out" "$API_AUTH" "$API_AUTH_V" "$API_URL$path" 2>/dev/null + rc=$? + [ -s "$out" ] || return 1 + [ "$rc" = "0" ] && return 0 + grep -q '"unauthorized"' "$out" 2>/dev/null && return 401 + grep -qE '"(account disabled|account expired|traffic limit)' "$out" 2>/dev/null && return 403 + return 1 + fi +} + +read_creds() { + if [ -n "$NIKOVPN_CREDS" ]; then + CRED=$(printf '%s' "$NIKOVPN_CREDS" | tr -d '[:space:]') + else + [ -f "$CREDS_FILE" ] || die "нет кредов: запишите 'login:password' в $CREDS_FILE или задайте env NIKOVPN_CREDS" + CRED=$(grep -v '^[[:space:]]*#' "$CREDS_FILE" 2>/dev/null | grep -v '^[[:space:]]*$' | head -1 | tr -d '[:space:]') + [ -n "$CRED" ] || die "$CREDS_FILE пуст — запишите 'login:password'" + fi + case "$CRED" in *:*) ;; *) die "в кредах нет ':' — формат login:password" ;; esac + api_init_auth +} + +# ============================================================================ +# ОПРЕДЕЛЕНИЕ АРХИТЕКТУРЫ +# ============================================================================ +SB_ARCH=""; IPT_ARCH=""; NAIVE_OK=0; UNAME_M="" + +map_uname() { + case "$1" in + x86_64|amd64) SB_ARCH=amd64; IPT_ARCH=x86_64; NAIVE_OK=1 ;; + aarch64|arm64) SB_ARCH=arm64; IPT_ARCH=aarch64; NAIVE_OK=1 ;; + armv7l|armv7) SB_ARCH=armv7; IPT_ARCH=armv7hf; NAIVE_OK=1 ;; + armv6l|armv6) SB_ARCH=armv6; IPT_ARCH=armv6; NAIVE_OK=0 ;; + armv5tel|armv5te|armv5l) SB_ARCH=armv5; IPT_ARCH=armv5; NAIVE_OK=0 ;; + arm) SB_ARCH=armv7; IPT_ARCH=armv7hf; NAIVE_OK=1 ;; + mips) SB_ARCH=mips; IPT_ARCH=mips; NAIVE_OK=0 ;; + mipsel) SB_ARCH=mipsle; IPT_ARCH=mipsel; NAIVE_OK=1 ;; + mips64) SB_ARCH=mips64; IPT_ARCH=mips64; NAIVE_OK=0 ;; + mips64el) SB_ARCH=mips64le; IPT_ARCH=mips64el;NAIVE_OK=0 ;; + i686|i586|i386|x86) SB_ARCH=386; IPT_ARCH=i686; NAIVE_OK=1 ;; + riscv64) SB_ARCH=riscv64; IPT_ARCH=""; NAIVE_OK=1 ;; + loongarch64|loong64) SB_ARCH=loong64; IPT_ARCH=""; NAIVE_OK=1 ;; + ppc64le) SB_ARCH=ppc64le; IPT_ARCH=""; NAIVE_OK=0 ;; + s390x) SB_ARCH=s390x; IPT_ARCH=""; NAIVE_OK=0 ;; + *) SB_ARCH=""; IPT_ARCH="" ;; + esac +} + +map_openwrt() { + case "$1" in + x86_64) map_uname x86_64 ;; + i386_*) map_uname i686 ;; + aarch64_*) map_uname aarch64 ;; + arm_cortex-a7*|arm_cortex-a9*|arm_cortex-a8*|arm_cortex-a5*|arm_cortex-a15*) map_uname armv7l ;; + arm_arm1176jzf-s_vfp) map_uname armv6l ;; + arm_arm926ej-s|arm_fa526|arm_xscale) map_uname armv5l ;; + arm_*) map_uname armv7l ;; + mips_*) map_uname mips ;; + mipsel_*) map_uname mipsel ;; + mips64_*) map_uname mips64 ;; + mips64el_*) map_uname mips64el ;; + riscv64_*) map_uname riscv64 ;; + loongarch64_*) map_uname loong64 ;; + powerpc_*) map_uname ppc64le ;; + *) map_uname "$UNAME_M" ;; + esac +} + +detect_arch() { + local da="" + if [ -r /etc/openwrt_release ]; then + . /etc/openwrt_release 2>/dev/null + da="$DISTRIB_ARCH" + fi + if [ -z "$da" ] && have apk; then + da=$(apk --print-arch 2>/dev/null) + fi + if [ -z "$da" ] && have opkg; then + da=$(opkg print-architecture 2>/dev/null | awk '$1=="arch" && $2!="all" && $2!="noarch" {print $2; exit}') + fi + UNAME_M=$(uname -m 2>/dev/null) + if [ -n "$da" ]; then map_openwrt "$da"; else map_uname "$UNAME_M"; fi + [ -n "$SB_ARCH" ] || die "архитектура не поддерживается (${da:-$UNAME_M}). Напишите автору скрипта." +} + +# ============================================================================ +# ВЫБОР ПРОТОКОЛА И СЕРВЕРА +# ============================================================================ +LINK=""; PROTOCOL=""; HOST=""; PORT=""; SNI=""; FRAG=""; QUERY=""; UI=""; USER=""; PASS="" +# пользовательские предпочтения (опционально /etc/nikovpn/config) +PREFER_PROTOCOL=""; PREFER_SERVER="" +[ -f "$WORK_DIR/config" ] && . "$WORK_DIR/config" + +urldecode() { + printf '%b' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/%\([0-9A-Fa-f][0-9A-Fa-f]\)/\\x\1/g')" +} +jesc() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} +q() { jesc "$(urldecode "$1")"; } + +get_param() { # $1=name -> $P (percent-encoded) + P="" + local _q="$QUERY" + while [ -n "$_q" ]; do + local _kv="${_q%%&*}" + [ "$_q" = "$_kv" ] && _q="" || _q="${_q#*&}" + case "$_kv" in + "$1="*) P="${_kv#*=}"; break ;; + esac + done +} + +parse_link() { # $1=uri -> HOST PORT USER PASS FRAG QUERY + LINK="$1" + local rest pq hp + rest="${LINK#*://}" + FRAG="" + case "$LINK" in *\#*) FRAG="${rest##*\#}" ;; esac + pq="${rest%%\#*}" + QUERY="" + case "$pq" in *\?*) QUERY="${pq#*\?}" ;; esac + hp="${pq%%\?*}" + case "$hp" in + *@*) UI="${hp%%@*}"; hp="${hp#*@}" ;; + *) UI="" ;; + esac + hp="${hp%%/*}" # срезать возможный "/" в конце (hysteria2) + case "$hp" in + *:*) PORT="${hp##*:}"; HOST="${hp%:*}" ;; + *) HOST="$hp"; PORT="" ;; + esac + USER="${UI%%:*}" + PASS="${UI#*:}"; [ "$PASS" = "$UI" ] && PASS="" + HOST=$(urldecode "$HOST") + PORT=$(urldecode "$PORT") + USER=$(urldecode "$USER") + PASS=$(urldecode "$PASS") +} + +# проверка доступности хоста; QUIC-протоколы проверяем по TCP:443 того же хоста +host_alive() { + have nc || return 0 + local h="$1" p="$2" proto="$3" + case "$proto" in hysteria2|tuic) p="443" ;; esac + nc -w 2 "$h" "$p" >/dev/null 2>&1 +} + +choose() { # $1=файл со ссылками + local links_file="$1" + local prefs="hysteria2 tuic naive vless shadowsocks" p pat line first first_ok n + for p in $prefs; do + [ "$p" = naive ] && [ "$NAIVE_OK" != 1 ] && continue + [ -n "$PREFER_PROTOCOL" ] && [ "$PREFER_PROTOCOL" != "$p" ] && continue + case "$p" in naive) pat='^naive\+' ;; *) pat="^$p://" ;; esac + grep -q "$pat" "$links_file" && { PROTOCOL="$p"; break; } + done + [ -n "$PROTOCOL" ] || die "ни один протокол недоступен (или предпочтение '$PREFER_PROTOCOL' не найдено)" + + case "$PROTOCOL" in naive) pat='^naive\+' ;; *) pat="^$PROTOCOL://" ;; esac + grep "$pat" "$links_file" > "$TMP/cand" + + if [ -n "$PREFER_SERVER" ]; then + grep -i "$PREFER_SERVER" "$TMP/cand" > "$TMP/cand2" 2>/dev/null + [ -s "$TMP/cand2" ] && mv "$TMP/cand2" "$TMP/cand" + fi + + first=""; first_ok=""; n=0 + while IFS= read -r line; do + n=$((n+1)) + [ -z "$first" ] && first="$line" + [ $n -gt 4 ] && break + parse_link "$line" + if host_alive "$HOST" "$PORT" "$PROTOCOL"; then first_ok="$line"; break; fi + done < "$TMP/cand" + + if [ -n "$first_ok" ]; then LINK="$first_ok" + elif [ -n "$first" ]; then LINK="$first" + else LINK=$(head -1 "$TMP/cand"); fi + [ -n "$LINK" ] || die "пустой список серверов от API" + parse_link "$LINK" + SNI="$HOST" +} + +# ============================================================================ +# ГЕНЕРАЦИЯ sing-box config.json +# ============================================================================ +TUNNEL="ipt2socks" # или singbox (нет ipt2socks под эту архитектуру) + +out_hysteria2() { + local sni ins obf obfpass passwordj + get_param sni; sni="$P"; [ -n "$sni" ] || sni="$HOST" + get_param insecure; ins="$P" + get_param obfs; obf="$P" + get_param obfs-password; obfpass="$P" + passwordj="\"password\": \"$(q "$USER")\"" + [ -n "$obf" ] && passwordj="$passwordj, + \"obfs\": { \"type\": \"$(q "$obf")\", \"password\": \"$(q "$obfpass")\" }" + cat </dev/null) + [ -n "$dec" ] || return 1 + method="${dec%%:*}"; sspass="${dec#*:}" + get_param plugin; plugin=$(urldecode "$P") + plugin_opts="" + case "$plugin" in *\;*) plugin_opts="${plugin#*;}"; plugin="${plugin%%;*}" ;; esac + pluginj="" + [ -n "$plugin" ] && pluginj=", + \"plugin\": \"$(q "$plugin")\", + \"plugin_opts\": \"$(q "$plugin_opts")\"" + cat < "$WORK_DIR/config.json" </dev/null 2>&1 || die "сгенерированный конфиг sing-box не проходит проверку" +} + +# ============================================================================ +# УСТАНОВКА БИНАРНИКОВ +# ============================================================================ +install_bins() { + local skip_sb=0 skip_ipt=0 cur_sb cur_ipt + mkdir -p "$(dirname "$SB_PATH")" "$(dirname "$IPT_PATH")" + cur_sb=""; cur_ipt="" + [ -f "$WORK_DIR/state" ] && . "$WORK_DIR/state" 2>/dev/null && { cur_sb="$SB_VER"; cur_ipt="$IPT_VER"; } + + if [ "$UPDATE" = 1 ] && [ -x "$SB_PATH" ] && [ "$cur_sb" = "$SB_VER" ]; then skip_sb=1; fi + if [ "$UPDATE" = 1 ] && [ -x "$IPT_PATH" ] && [ "$cur_ipt" = "$IPT_VER" ]; then skip_ipt=1; fi + + if [ "$skip_sb" != 1 ]; then + log "Получаю sing-box ($SB_ARCH) ..." + dl "$BASE_URL/bin/sing-box-$SB_ARCH.gz" "$TMP/sing-box.gz" || \ + cp "$REPO_DIR/bin/sing-box-$SB_ARCH.gz" "$TMP/sing-box.gz" 2>/dev/null + [ -s "$TMP/sing-box.gz" ] || die "не удалось скачать sing-box" + gzip -dc "$TMP/sing-box.gz" > "$TMP/sing-box" 2>/dev/null || die "повреждённый архив sing-box" + chmod +x "$TMP/sing-box" + "$TMP/sing-box" version >/dev/null 2>&1 || die "sing-box не запускается на этой системе (архитектура?)" + mv "$TMP/sing-box" "$SB_PATH" + rm -f "$TMP/sing-box.gz" + fi + + if [ "$TUNNEL" = "ipt2socks" ] && [ "$skip_ipt" != 1 ]; then + log "Получаю ipt2socks ($IPT_ARCH) ..." + dl "$BASE_URL/bin/ipt2socks-$IPT_ARCH.gz" "$TMP/ipt2socks.gz" || \ + cp "$REPO_DIR/bin/ipt2socks-$IPT_ARCH.gz" "$TMP/ipt2socks.gz" 2>/dev/null + [ -s "$TMP/ipt2socks.gz" ] || die "не удалось скачать ipt2socks" + gzip -dc "$TMP/ipt2socks.gz" > "$TMP/ipt2socks" 2>/dev/null || die "повреждённый архив ipt2socks" + chmod +x "$TMP/ipt2socks" + "$TMP/ipt2socks" -V >/dev/null 2>&1 || die "ipt2socks не запускается на этой системе (архитектура?)" + mv "$TMP/ipt2socks" "$IPT_PATH" + rm -f "$TMP/ipt2socks.gz" + fi +} + +# ============================================================================ +# СОСТОЯНИЕ / FW / INIT +# ============================================================================ +write_state() { + cat > "$WORK_DIR/state" < "$WORK_DIR/fw.sh" <<'FWE' +#!/bin/sh +# NikoVPN firewall (генерируется install.sh) +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 +FWE + chmod +x "$WORK_DIR/fw.sh" +} + +write_init() { + cat > "/etc/init.d/nikovpn" <<'INIE' +#!/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 +} +INIE + chmod +x /etc/init.d/nikovpn +} + +# ============================================================================ +# ПРОВЕРКА ВОЗМОЖНОСТИ FULL-РЕЖИМА (TCP+UDP через TPROXY) +# ============================================================================ +probe_full() { + have ip || return 1 + local ok=1 + if [ "$FW" = nft ]; then + nft add table inet nikovpn_test 2>/dev/null + nft add chain inet nikovpn_test t '{ type filter hook prerouting priority -1; policy accept; }' 2>/dev/null + nft add rule inet nikovpn_test t 'meta l4proto udp tproxy to :12345' 2>/dev/null || ok=0 + nft delete table inet nikovpn_test 2>/dev/null + else + iptables -t mangle -N NIKOVPN_TEST 2>/dev/null + iptables -t mangle -A NIKOVPN_TEST -p udp -j TPROXY --on-port 12345 --tproxy-mark 0x1/0x1 2>/dev/null || ok=0 + iptables -t mangle -F NIKOVPN_TEST 2>/dev/null + iptables -t mangle -X NIKOVPN_TEST 2>/dev/null + fi + [ $ok = 0 ] && return 1 + ip rule add fwmark 0x1 table 101 2>/dev/null || return 1 + ip rule del fwmark 0x1 table 101 2>/dev/null + ip route add local 192.0.2.1/32 dev lo table 101 2>/dev/null || return 1 + ip route del local 192.0.2.1/32 dev lo table 101 2>/dev/null + return 0 +} + +# ============================================================================ +# СТАТУС +# ============================================================================ +status() { + [ -f "$WORK_DIR/state" ] || die "NikoVPN не установлен (нет $WORK_DIR/state)" + . "$WORK_DIR/state" + echo "=== NikoVPN ===" + echo "Протокол: $PROTOCOL" + echo "Сервер: $HOST:$PORT" + echo "Архитектура: $ARCH (sing-box $SB_VER, ipt2socks $IPT_VER)" + echo "Режим: $MODE / $TUNNEL / $FW" + pgrep -x sing-box >/dev/null 2>&1 && echo "sing-box: RUNNING" || echo "sing-box: STOPPED" + if [ "$TUNNEL" = ipt2socks ]; then + pgrep -x ipt2socks >/dev/null 2>&1 && echo "ipt2socks: RUNNING" || echo "ipt2socks: STOPPED" + fi + if [ -r /var/log/nikovpn.log ]; then + echo "--- последние строки /var/log/nikovpn.log ---" + tail -n 5 /var/log/nikovpn.log 2>/dev/null + fi + read_creds 2>/dev/null || return 0 + if api_get "/me" "$TMP/me"; then + local tu tt td + tu=$(sed -n 's/.*"traffic_used": *\([0-9]*\).*/\1/p' "$TMP/me") + tt=$(sed -n 's/.*"traffic_total": *\([0-9]*\).*/\1/p' "$TMP/me") + td=$(sed -n 's/.*"traffic_day": *\([0-9]*\).*/\1/p' "$TMP/me") + echo "Трафик: использовано $tu, за сутки $td, лимит $tt байт" + fi +} + +# ============================================================================ +# УДАЛЕНИЕ +# ============================================================================ +uninstall() { + need_root + if [ -x /etc/init.d/nikovpn ]; then + /etc/init.d/nikovpn stop 2>/dev/null + /etc/init.d/nikovpn disable 2>/dev/null + fi + nft delete table inet nikovpn 2>/dev/null + rm -f /etc/nftables.d/10-nikovpn.nft + for c in NIKOVPN NIKOVPN_TP NIKOVPN_UDP; do + iptables -t nat -D PREROUTING -p tcp -j "$c" 2>/dev/null + iptables -t mangle -D PREROUTING -p udp -j "$c" 2>/dev/null + done + ip rule del fwmark 0x1 table 100 2>/dev/null + ip route del local default dev lo table 100 2>/dev/null + rm -f /etc/init.d/nikovpn /etc/rc.d/S95nikovpn /etc/rc.d/K10nikovpn + sed -i '\#nikovpn/fw.sh start#d' /etc/firewall.user 2>/dev/null + rm -rf "$WORK_DIR" + rm -f "$SB_PATH" "$IPT_PATH" + echo "NikoVPN удалён." +} + +# ============================================================================ +# УСТАНОВКА +# ============================================================================ +install() { + need_root + detect_arch + read_creds + + log "Архитектура: $SB_ARCH (sing-box), ipt2socks: ${IPT_ARCH:-нет}" + if [ -z "$IPT_ARCH" ]; then + warn "для этой архитектуры нет ipt2socks — использую встроенный tproxy-вход sing-box" + TUNNEL="singbox" + fi + + mkdir -p "$WORK_DIR" "$TMP" + + log "Проверяю обновления с f.n1ko.dev ..." + dl "$BASE_URL/versions" "$TMP/versions" || cp "$REPO_DIR/versions" "$TMP/versions" 2>/dev/null + [ -s "$TMP/versions" ] || die "не могу скачать $BASE_URL/versions (нет интернета?)" + SB_VER=""; IPT_VER="" + while IFS='=' read -r k v; do + case "$k" in sing-box) SB_VER="$v" ;; ipt2socks) IPT_VER="$v" ;; esac + done < "$TMP/versions" + [ -n "$SB_VER" ] && [ -n "$IPT_VER" ] || die "неверный файл versions на сервере" + log "Версии: sing-box $SB_VER, ipt2socks $IPT_VER" + + log "Проверяю аккаунт ..." + api_get "/me" "$TMP/me"; rc=$? + case $rc in + 0) ;; + 401) die "неверный логин/пароль в $CREDS_FILE" ;; + 403) die "аккаунт заблокирован/истёк/превышен лимит — проверьте подписку" ;; + *) die "не удалось связаться с $API_URL" ;; + esac + + log "Получаю список серверов ..." + api_get "/config?format=links" "$TMP/links"; rc=$? + [ $rc = 0 ] && [ -s "$TMP/links" ] || die "не удалось получить список серверов (код $rc)" + + choose "$TMP/links" + log "Выбран протокол: $PROTOCOL" + log "Выбран сервер: $HOST:$PORT${FRAG:+ ($FRAG)}" + + if have nft; then FW=nft + elif have iptables; then FW=ipt + else die "не найдено ни nftables, ни iptables"; fi + + MODE=tcp + if [ "$TUNNEL" = singbox ]; then + MODE=full + else + case "$PROTOCOL" in + naive) MODE=tcp ;; + *) if probe_full; then MODE=full; else warn "UDP/TPROXY недоступно — режим только TCP"; MODE=tcp; fi ;; + esac + fi + + # остановить старый сервис перед перезаписью + if [ -x /etc/init.d/nikovpn ]; then + /etc/init.d/nikovpn stop 2>/dev/null + fi + + install_bins + log "Генерирую конфиг ..." + gen_config + write_state + write_fw + write_init + + cp "$0" "$WORK_DIR/install.sh" 2>/dev/null + chmod +x "$WORK_DIR/install.sh" + + log "Запускаю сервис ..." + /etc/init.d/nikovpn enable 2>/dev/null + /etc/init.d/nikovpn start 2>/dev/null + sleep 3 + + if pgrep -x sing-box >/dev/null 2>&1; then + log "sing-box запущен. Прозрачный прокси активен." + else + warn "sing-box не запустился. Смотрите: cat /var/log/nikovpn.log" + fi + log "Готово. Проверка: sh /etc/nikovpn/install.sh status" +} + +# ============================================================================ +# MAIN +# ============================================================================ +UPDATE=0 +case "$CMD" in + update) UPDATE=1; install ;; + status) status ;; + uninstall) uninstall ;; + install|-i|reinstall) install ;; + *) echo "usage: $0 [install|update|status|uninstall]"; exit 1 ;; +esac +exit 0 \ No newline at end of file diff --git a/nikovpn.py b/nikovpn.py new file mode 100644 index 0000000..cf48b17 --- /dev/null +++ b/nikovpn.py @@ -0,0 +1,993 @@ +#!/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" +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", "-o", "BatchMode=yes"] + pre = [] + if password is not None: + if not shutil_which("sshpass"): + die("для парольного входа нужен sshpass") + pre = ["sshpass", "-p", password] + opts += ["-o", "PreferredAuthentications=password", + "-o", "PubkeyAuthentication=no"] + if key: + opts += ["-i", str(key)] + self.base = pre + ["ssh", "-p", str(port)] + opts + [f"{user}@{host}"] + + 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"] = proto + 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}) на роутере с f.n1ko.dev ...") + gz = f"/tmp/nikovpn.{os.getpid()}.sb.gz" + if not 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}) на роутере с f.n1ko.dev ...") + gz = f"/tmp/nikovpn.{os.getpid()}.ipt.gz" + if not 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") + + +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( + ["ssh", "-p", str(rt.port), "-o", "StrictHostKeyChecking=accept-new", + "-o", "BatchMode=yes", "-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 {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, 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() diff --git a/status.sh b/status.sh new file mode 100755 index 0000000..8a0b4f0 --- /dev/null +++ b/status.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# NikoVPN: статус VPN +[ -f /etc/nikovpn/install.sh ] && exec /etc/nikovpn/install.sh status +TMP=/tmp/nikovpn-status.sh +URL="https://f.n1ko.dev/nikovpn_openwrt/install.sh" +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$URL" -o "$TMP" 2>/dev/null +elif command -v wget >/dev/null 2>&1; then + wget -q "$URL" -O "$TMP" 2>/dev/null +fi +if [ -s "$TMP" ]; then + chmod +x "$TMP" + exec "$TMP" status +fi +echo "не удалось загрузить install.sh с f.n1ko.dev" >&2 +exit 1 \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..88749b0 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# NikoVPN: удаление VPN (останавливает сервисы и чистит firewall) +[ -f /etc/nikovpn/install.sh ] && exec /etc/nikovpn/install.sh uninstall +TMP=/tmp/nikovpn-uninstall.sh +URL="https://f.n1ko.dev/nikovpn_openwrt/install.sh" +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$URL" -o "$TMP" 2>/dev/null +elif command -v wget >/dev/null 2>&1; then + wget -q "$URL" -O "$TMP" 2>/dev/null +fi +if [ -s "$TMP" ]; then + chmod +x "$TMP" + exec "$TMP" uninstall +fi +echo "не удалось загрузить install.sh с f.n1ko.dev" >&2 +exit 1 \ No newline at end of file diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..d2a8c30 --- /dev/null +++ b/update.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# NikoVPN: обновление (переустановка) — скачивает свежий install.sh с f.n1ko.dev +[ -f /etc/nikovpn/install.sh ] && exec /etc/nikovpn/install.sh update +TMP=/tmp/nikovpn-update.sh +URL="https://f.n1ko.dev/nikovpn_openwrt/install.sh" +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$URL" -o "$TMP" 2>/dev/null +elif command -v wget >/dev/null 2>&1; then + wget -q "$URL" -O "$TMP" 2>/dev/null +fi +if [ -s "$TMP" ]; then + chmod +x "$TMP" + exec "$TMP" update +fi +echo "не удалось загрузить install.sh с f.n1ko.dev" >&2 +exit 1 \ No newline at end of file diff --git a/versions b/versions new file mode 100644 index 0000000..f71fffd --- /dev/null +++ b/versions @@ -0,0 +1,2 @@ +sing-box=1.13.19-niko.1 +ipt2socks=1.1.4