nikovpn_openwrt/fetch.py
Niko Marmeladkov d4d00aa0e1
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__ не коммитятся
2026-08-26 09:44:07 +03:00

349 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()