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:
commit
d4d00aa0e1
9 changed files with 2379 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
bin/
|
||||
103
README.md
Normal file
103
README.md
Normal 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
|
||||
не включены. Итог: ~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
|
||||
```
|
||||
349
fetch.py
Normal file
349
fetch.py
Normal 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
881
install.sh
Executable 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
993
nikovpn.py
Normal 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
16
status.sh
Executable 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
16
uninstall.sh
Executable 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
16
update.sh
Executable 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
2
versions
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
sing-box=1.13.19-niko.1
|
||||
ipt2socks=1.1.4
|
||||
Loading…
Add table
Reference in a new issue