commit d4d00aa0e17064b7aa72b94e27ea5242b94d69d3 Author: Niko Marmeladkov Date: Wed Aug 26 09:44:07 2026 +0300 on-router install: git clone + ./install.sh - 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__ не коммитятся 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