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__ не коммитятся
This commit is contained in:
Niko Marmeladkov 2026-08-26 09:44:07 +03:00
commit d4d00aa0e1
Signed by: Niko
GPG key ID: E3B955F9442D44E3
9 changed files with 2379 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
*.pyc
bin/

103
README.md Normal file
View file

@ -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
не включены. Итог: ~814 MB gz вместо ~1723 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
```

349
fetch.py Normal file
View file

@ -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 <stdlib.h>\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()

881
install.sh Executable file
View file

@ -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 <<EOF
{
"type": "hysteria2",
"tag": "main",
"server": "$(q "$HOST")",
"server_port": $PORT,
$passwordj,
"tls": { "enabled": true, "server_name": "$(q "$sni")"$([ "$ins" = "1" ] && echo ', "insecure": true') }
}
EOF
}
out_tuic() {
local cc urm sni
get_param congestion_control; cc="$P"; [ -z "$cc" ] && cc="bbr"
get_param udp_relay_mode; urm="$P"; [ -z "$urm" ] && urm="native"
get_param sni; sni="$P"; [ -z "$sni" ] && sni="$HOST"
cat <<EOF
{
"type": "tuic",
"tag": "main",
"server": "$(q "$HOST")",
"server_port": $PORT,
"uuid": "$(q "$USER")",
"password": "$(q "$PASS")",
"congestion_control": "$(q "$cc")",
"udp_relay_mode": "$(q "$urm")",
"tls": { "enabled": true, "server_name": "$(q "$sni")" }
}
EOF
}
out_naive() {
local quic
case "$LINK" in naive+quic:*) quic=true ;; *) quic=false ;; esac
cat <<EOF
{
"type": "naive",
"tag": "main",
"server": "$(q "$HOST")",
"server_port": $PORT,
"username": "$(q "$USER")",
"password": "$(q "$PASS")",
"quic": $quic,
"tls": { "enabled": true, "server_name": "$(q "$HOST")" }
}
EOF
}
out_vless() {
local sec sni flow fp pbk sid uuidj realityj
get_param security; sec="$P"
get_param sni; sni="$P"; [ -z "$sni" ] && sni="$HOST"
get_param flow; flow="$P"
get_param fp; fp="$P"; [ -z "$fp" ] && fp="chrome"
get_param pbk; pbk="$P"
get_param sid; sid="$P"
uuidj="\"uuid\": \"$(q "$USER")\""
[ -n "$flow" ] && uuidj="$uuidj,
\"flow\": \"$(q "$flow")\""
realityj=""
if [ "$sec" = "reality" ]; then
realityj=",
\"reality\": { \"enabled\": true, \"public_key\": \"$(q "$pbk")\", \"short_id\": \"$(q "$sid")\" }"
fi
cat <<EOF
{
"type": "vless",
"tag": "main",
"server": "$(q "$HOST")",
"server_port": $PORT,
$uuidj,
"tls": {
"enabled": true,
"server_name": "$(q "$sni")",
"utls": { "enabled": true, "fingerprint": "$(q "$fp")" }$realityj
}
}
EOF
}
out_ss() {
local dec method sspass plugin plugin_opts pluginj
have base64 || return 1
dec=$(printf '%s' "$UI" | base64 -d 2>/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 <<EOF
{
"type": "shadowsocks",
"tag": "main",
"server": "$(q "$HOST")",
"server_port": $PORT,
"method": "$(q "$method")",
"password": "$(q "$sspass")"$pluginj
}
EOF
}
gen_config() {
local main inbound
case "$PROTOCOL" in
hysteria2) main=$(out_hysteria2) ;;
tuic) main=$(out_tuic) ;;
naive) main=$(out_naive) ;;
vless) main=$(out_vless) ;;
shadowsocks) main=$(out_ss) || die "не удалось разобрать shadowsocks-ссылку" ;;
*) die "неизвестный протокол $PROTOCOL" ;;
esac
if [ "$TUNNEL" = "singbox" ]; then
inbound=$(cat <<EOF
{
"type": "tproxy",
"tag": "tproxy-in",
"listen": "0.0.0.0",
"listen_port": $TP_PORT
}
EOF
)
else
inbound=$(cat <<EOF
{
"type": "mixed",
"tag": "mixed-in",
"listen": "127.0.0.1",
"listen_port": $SOCKS_PORT
}
EOF
)
fi
cat > "$WORK_DIR/config.json" <<EOF
{
"log": { "level": "info", "timestamp": true },
"inbounds": [
$inbound
],
"outbounds": [
$main,
{ "type": "direct", "tag": "direct" }
],
"route": {
"final": "main",
"rules": [
{ "ip_cidr": ["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"], "outbound": "direct" }
]
}
}
EOF
"$SB_PATH" check -c "$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" <<EOF
SB_VER='$SB_VER'
IPT_VER='$IPT_VER'
ARCH='$SB_ARCH'
SB_ARCH='$SB_ARCH'
IPT_ARCH='$IPT_ARCH'
NAIVE_OK='$NAIVE_OK'
PROTOCOL='$PROTOCOL'
LINK='$LINK'
HOST='$HOST'
PORT='$PORT'
SNI='$SNI'
MODE='$MODE'
FW='$FW'
TUNNEL='$TUNNEL'
SOCKS_PORT='$SOCKS_PORT'
TP_PORT='$TP_PORT'
EOF
}
write_fw() {
cat > "$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" <<EOF2
table inet nikovpn {
set bypass4 { type ipv4_addr; flags interval;
elements = { $NFT_PRIVATE } }
set vpn_ports { type inet_service; elements = { $VPN_PORT } }
EOF2
if [ "$TUNNEL" = "singbox" ]; then
cat >> "$NFT_FILE" <<EOF2
chain prerouting_tcp {
type filter hook prerouting priority -1; policy accept;
ip daddr @bypass4 return
tcp dport 53 return
meta l4proto tcp tproxy to :$TP_PORT meta mark set 1
}
chain prerouting_udp {
type filter hook prerouting priority -1; policy accept;
ip daddr @bypass4 return
udp dport 53 return
meta l4proto udp tproxy to :$TP_PORT meta mark set 1
}
}
EOF2
else
cat >> "$NFT_FILE" <<EOF2
chain prerouting_tcp {
type nat hook prerouting priority 0; policy accept;
ip daddr @bypass4 return
tcp dport 53 return
meta l4proto tcp redirect to :$TP_PORT
}
chain output_tcp {
type nat hook output priority 0; policy accept;
ip daddr @bypass4 return
fib daddr type local return
tcp dport 53 return
tcp dport @vpn_ports return
meta l4proto tcp redirect to :$TP_PORT
}
EOF2
if [ "$MODE" = "full" ]; then
cat >> "$NFT_FILE" <<EOF2
chain prerouting_udp {
type filter hook prerouting priority -1; policy accept;
ip daddr @bypass4 return
udp dport 53 return
meta l4proto udp tproxy to :$TP_PORT meta mark set 1
}
}
EOF2
else
echo '}' >> "$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

993
nikovpn.py Normal file
View file

@ -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" <<EOF2
table inet nikovpn {
set bypass4 { type ipv4_addr; flags interval;
elements = { $NFT_PRIVATE } }
set vpn_ports { type inet_service; elements = { $VPN_PORT } }
EOF2
if [ "$TUNNEL" = "singbox" ]; then
cat >> "$NFT_FILE" <<EOF2
chain prerouting_tcp {
type filter hook prerouting priority -1; policy accept;
ip daddr @bypass4 return
tcp dport 53 return
meta l4proto tcp tproxy to :$TP_PORT meta mark set 1
}
chain prerouting_udp {
type filter hook prerouting priority -1; policy accept;
ip daddr @bypass4 return
udp dport 53 return
meta l4proto udp tproxy to :$TP_PORT meta mark set 1
}
}
EOF2
else
cat >> "$NFT_FILE" <<EOF2
chain prerouting_tcp {
type nat hook prerouting priority 0; policy accept;
ip daddr @bypass4 return
tcp dport 53 return
meta l4proto tcp redirect to :$TP_PORT
}
chain output_tcp {
type nat hook output priority 0; policy accept;
ip daddr @bypass4 return
fib daddr type local return
tcp dport 53 return
tcp dport @vpn_ports return
meta l4proto tcp redirect to :$TP_PORT
}
EOF2
if [ "$MODE" = "full" ]; then
cat >> "$NFT_FILE" <<EOF2
chain prerouting_udp {
type filter hook prerouting priority -1; policy accept;
ip daddr @bypass4 return
udp dport 53 return
meta l4proto udp tproxy to :$TP_PORT meta mark set 1
}
}
EOF2
else
echo '}' >> "$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()

16
status.sh Executable file
View file

@ -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

16
uninstall.sh Executable file
View file

@ -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

16
update.sh Executable file
View file

@ -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

2
versions Normal file
View file

@ -0,0 +1,2 @@
sing-box=1.13.19-niko.1
ipt2socks=1.1.4