#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.9" # dependencies = [] # /// # ============================================================================= # fetch.py — сборка лёгких бинарников NikoVPN (замена fetch.sh) # # uv run fetch.py [--jobs N] [--only имя,...] # # sing-box v1.13.19 собирается из исходников slim-патчем: # остаются только outbounds: vless (+utls, без reality), hysteria, # hysteria2, tuic, naive; inbounds: tproxy/mixed/socks/http/direct; # выпилены: vmess, trojan, shadowsocks, tor, ssh, shadowtls, anytls, # tun, ssmapi; теги без gvisor/wireguard/clash_api/acme/dhcp/tailscale. # Результат: gz ~14MB вместо ~23MB (с naive), ~9MB (без). # # naive (cronet) собирается через прекомпилированные libcronet.a # (github.com/sagernet/cronet-go/lib/*_musl) + system clang/lld + # musl.cc sysroot. Архитектуры без cronet-библиотек собираются # CGO_ENABLED=0 без naive (установщик сам пропустит этот протокол). # # ipt2socks не пересобирается — готовые .gz в bin/ сохраняются. # ============================================================================= import argparse import concurrent.futures import gzip import os import shutil import subprocess import sys import tarfile import urllib.request from pathlib import Path SB_VER = "1.13.19" SB_TAG = "1.13.19-niko.1" IPT_VER = "1.1.4" SB_SRC_URL = f"https://github.com/SagerNet/sing-box/archive/refs/tags/v{SB_VER}.tar.gz" GO_VER = "1.25.0" CRONET_LIBS = "github.com/sagernet/cronet-go/lib" BASE_TAGS = ["with_quic", "with_utls", "badlinkname", "tfogo_checklinkname0"] NAIVE_TAGS = ["with_naive_outbound", "with_musl"] LDF_VERSION = [ "-s", "-w", "-buildid=", "-checklinkname=0", f"-X github.com/sagernet/sing-box/constant.Version={SB_TAG}", ] # --- архитектуры с naive (cronet-musl библиотеки существуют) ----------------- NAIVE_ARCHS = { "amd64": dict(goarch="amd64", tc="x86_64-linux-musl-cross", triple="x86_64-linux-musl", lib="linux_amd64_musl"), "386": dict(goarch="386", tc="i686-linux-musl-cross", triple="i686-linux-musl", lib="linux_386_musl", goenv={"GO386": "sse2"}), "arm64": dict(goarch="arm64", tc="aarch64-linux-musl-cross", triple="aarch64-linux-musl", lib="linux_arm64_musl"), "armv7": dict(goarch="arm", tc="armv7l-linux-musleabihf-cross", triple="arm-linux-musleabihf", lib="linux_arm_musl", goenv={"GOARM": "7"}, cc=["-march=armv7-a"]), "mipsle": dict(goarch="mipsle", tc="mipsel-linux-muslsf-cross", triple="mipsel-linux-musl", lib="linux_mipsle_musl", goenv={"GOMIPS": "softfloat"}, cc=["-mfloat-abi=soft"], nopie=True), "riscv64": dict(goarch="riscv64", tc="riscv64-linux-musl-cross", triple="riscv64-linux-musl", lib="linux_riscv64_musl"), } # loong64: нет в musl.cc — пробуем zig cc, иначе уйдёт в PLAIN LOONG64_ZIG = dict(goarch="loong64", zig="loongarch64-linux-musl", lib="linux_loong64_musl", nopie=True) # --- архитектуры без naive (чистый go) --------------------------------------- PLAIN_ARCHS = { "armv6": dict(goarch="arm", goenv={"GOARM": "6"}), "armv5": dict(goarch="arm", goenv={"GOARM": "5"}), "mips": dict(goarch="mips", goenv={"GOMIPS": "softfloat"}), "mips64": dict(goarch="mips64"), "mips64le": dict(goarch="mips64le"), "ppc64le": dict(goarch="ppc64le"), "s390x": dict(goarch="s390x"), } MUSLCC_URL = "https://musl.cc/{tc}.tgz" STUB_LIBS = ["resolv", "rt", "pthread", "dl", "atomic"] log = lambda m: print(f"[*] {m}", flush=True) warn = lambda m: print(f"[!] {m}", flush=True) def sh(cmd, cwd=None, env=None, capture=False): e = dict(os.environ) if env: e.update(env) r = subprocess.run(cmd, cwd=cwd, env=e, shell=isinstance(cmd, str), text=True, capture_output=capture) if r.returncode != 0: raise RuntimeError(f"команда failed: {cmd}\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}") return r.stdout if capture else None def dl(url, dest: Path): if dest.exists() and dest.stat().st_size > 0: return log(f"скачаю {url}") dest.parent.mkdir(parents=True, exist_ok=True) urllib.request.urlretrieve(url, dest) def ensure_go(cache: Path) -> Path: for cand in [Path("/tmp/opencode/go-sdk/bin/go"), Path.home() / f".cache/nikovpn-build/go-sdk-{GO_VER}/bin/go"]: if cand.exists(): return cand sdk = cache / f"go-sdk-{GO_VER}" dl(f"https://go.dev/dl/go{GO_VER}.linux-amd64.tar.gz", cache / "go.tgz") log("распакую go") with tarfile.open(cache / "go.tgz") as t: t.extractall(cache) os.rename(cache / "go", sdk) return sdk / "bin/go" def patch_registry(src: Path): """выпиливаем ненужные протоколы/сервисы из реестра sing-box""" p = src / "include" / "registry.go" s = p.read_text() imports = ["protocol/anytls", "protocol/shadowsocks", "protocol/shadowtls", "protocol/ssh", "protocol/tor", "protocol/trojan", "protocol/tun", "protocol/vmess", "service/ssmapi"] regs = ["tun.RegisterInbound(registry)", "shadowsocks.RegisterInbound(registry)", "vmess.RegisterInbound(registry)", "trojan.RegisterInbound(registry)", "shadowtls.RegisterInbound(registry)", "vless.RegisterInbound(registry)", "anytls.RegisterInbound(registry)", "shadowsocks.RegisterOutbound(registry)", "vmess.RegisterOutbound(registry)", "trojan.RegisterOutbound(registry)", "tor.RegisterOutbound(registry)", "ssh.RegisterOutbound(registry)", "shadowtls.RegisterOutbound(registry)", "anytls.RegisterOutbound(registry)", "ssmapi.RegisterService(registry)"] for i in imports: s = s.replace(f'\t"github.com/sagernet/sing-box/{i}"\n', "") for r in regs: s = s.replace(f"\t{r}\n", "") p.write_text(s) def prepare_source(cache: Path, go: Path) -> Path: src_tgz = cache / f"sing-box-{SB_VER}.tar.gz" dl(SB_SRC_URL, src_tgz) master = cache / f"sing-box-{SB_VER}-niko" if master.exists(): shutil.rmtree(master) master.mkdir(parents=True) with tarfile.open(src_tgz) as t: t.extractall(master, filter="data") inner = next(master.iterdir()) root = master / "src" os.rename(inner, root) patch_registry(root) log("прогреваю модульный кэш go ...") sh([str(go), "mod", "download"], cwd=root) return master def vendor_cronet(root: Path, go: Path, lib: str) -> Path: mod = f"{CRONET_LIBS}/{lib}" sh([str(go), "mod", "download", mod], cwd=root) d = (sh([str(go), "list", "-m", "-f", "{{.Dir}}", mod], cwd=root, capture=True) or "").strip() dst = root.parent / "cronet-libs" / lib if dst.exists(): return dst dst.mkdir(parents=True) shutil.copy(Path(d) / "go.mod", dst / "go.mod") shutil.copy(Path(d) / "libcronet.a", dst / "libcronet.a") cgo = (Path(d) / "libcronet_cgo.go").read_text() cgo = cgo.replace("#cgo LDFLAGS: -L${SRCDIR} -l:libcronet.a", "#cgo LDFLAGS: ${SRCDIR}/libcronet.a") (dst / "libcronet_cgo.go").write_text(cgo) return dst def ensure_toolchain(cache: Path, tc: str) -> Path: tdir = cache / "muslcc" / tc if tdir.exists(): return tdir dl(MUSLCC_URL.format(tc=tc), cache / "muslcc" / f"{tc}.tgz") log(f"распакую {tc}") with tarfile.open(cache / "muslcc" / f"{tc}.tgz") as t: t.extractall(cache / "muslcc", filter="tar") return tdir def make_linkdir(work: Path, gccdir: Path) -> Path: ld = work / "linkdir" ld.mkdir(exist_ok=True) for name in ["libgcc.a", "crtbeginT.o", "crtend.o"]: f = gccdir / name if f.exists(): shutil.copy(f, ld / name, follow_symlinks=True) (ld / "libgcc_eh.a").unlink(missing_ok=True) os.symlink(ld / "libgcc.a", ld / "libgcc_eh.a") return ld def make_stubs(work: Path, cc_base: list) -> Path: st = work / "stubs" st.mkdir(exist_ok=True) c = work / "empty.c" c.write_text("void __niko_stub(void){}\n") o = st / "empty.o" sh(cc_base + ["-c", str(c), "-o", str(o)]) for l in STUB_LIBS: sh(["ar", "rcs", str(st / f"lib{l}.a"), str(o)]) return st def build_one(name: str, spec: dict, master: Path, go: Path, cache: Path, bin_out: Path) -> dict: work = master.parent / "build" / name if work.exists(): shutil.rmtree(work) work.mkdir(parents=True) root = work / "src" shutil.copytree(master / "src", root) naive = spec.get("lib") is not None cc, linkdir, stubs = None, None, None tags = list(BASE_TAGS) + (NAIVE_TAGS if naive else []) goenv = {"GOOS": "linux", "GOARCH": spec["goarch"], **spec.get("goenv", {})} if naive: lib = vendor_cronet(root, go, spec["lib"]) sh([str(go), "mod", "edit", "-replace", f"{CRONET_LIBS}/{spec['lib']}={lib}"], cwd=root) if spec.get("zig"): cc = ["zig", "cc", "-target", spec["zig"]] sysroot = None else: tdir = ensure_toolchain(cache, spec["tc"]) sysroot = tdir / spec["tc"].removesuffix("-cross") gccdirs = list((tdir / "lib" / "gcc").glob("*/*")) or [tdir / "lib" / "gcc" / "x" / "x"] linkdir = make_linkdir(work, gccdirs[0]) cc = ["clang", f"--target={spec['triple']}", f"--sysroot={sysroot}"] cc += spec.get("cc", []) + [f"-B{linkdir}"] stubs = make_stubs(work, cc) env = {"CGO_ENABLED": "1" if naive else "0"} if naive: env["CC"] = " ".join(str(x) for x in (cc or [])) ldflags = ["-fuse-ld=lld"] if spec.get("nopie"): ldflags.append("-Wl,-no-pie") if linkdir: ldflags += [f"-L{linkdir}"] if stubs: ldflags += [f"-L{stubs}"] env["CGO_LDFLAGS"] = " ".join(ldflags) env.update(goenv) out = work / "sing-box" sh([str(go), "build", "-tags", ",".join(tags), "-trimpath", "-ldflags", " ".join(LDF_VERSION), "-o", str(out), "./cmd/sing-box"], cwd=root, env=env) data = out.read_bytes() if data[:4] != b"\x7fELF": raise RuntimeError(f"{name}: не ELF") gz_path = bin_out / f"sing-box-{name}.gz" with open(gz_path, "wb") as f: with gzip.GzipFile("", "wb", compresslevel=9, fileobj=f, mtime=0) as g: g.write(data) raw_mb = len(data) / 1e6 gz_mb = gz_path.stat().st_size / 1e6 return dict(name=name, naive=naive, raw=raw_mb, gz=gz_mb) def main(): ap = argparse.ArgumentParser() ap.add_argument("--jobs", type=int, default=3) ap.add_argument("--only", help="список имён через запятую") args = ap.parse_args() repo = Path(__file__).resolve().parent bin_out = repo / "bin" bin_out.mkdir(exist_ok=True) cache = Path(os.environ.get("NIKOVPN_BUILD_CACHE", Path.home() / ".cache/nikovpn-build")) cache.mkdir(parents=True, exist_ok=True) specs = {} for n, s in NAIVE_ARCHS.items(): specs[n] = {**s, "lib": s["lib"]} # loong64: zig или вылетает в plain zt = LOONG64_ZIG["zig"] probe = cache / "build" / "_probe" probe.mkdir(parents=True, exist_ok=True) (probe / "e.c").write_text("#include \nint main(){return 0;}\n") try: sh(["zig", "cc", "-target", zt, str(probe / "e.c"), "-o", str(probe / "e.out")]) specs["loong64"] = {**LOONG64_ZIG, "lib": LOONG64_ZIG["lib"]} log("loong64: zig cc работает — соберу с naive") except Exception: warn("loong64: zig cc не смог — будет сборка без naive") specs["loong64"] = dict(goarch="loong64") for n, s in PLAIN_ARCHS.items(): specs.setdefault(n, s) if args.only: keep = [x.strip() for x in args.only.split(",")] specs = {k: v for k, v in specs.items() if k in keep} go = ensure_go(cache) log(f"go: {go}") master = prepare_source(cache, go) todo = sorted(specs) log(f"собираю {len(todo)} архитектур: {', '.join(todo)}") results, failed = [], [] with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex: futs = {ex.submit(build_one, n, specs[n], master, go, cache, bin_out): n for n in todo} for fut in concurrent.futures.as_completed(futs): n = futs[fut] try: r = fut.result() results.append(r) print(f" [{len(results)}/{len(todo)}] {n}: " f"{r['raw']:.1f}MB raw -> {r['gz']:.1f}MB gz " f"(naive={'да' if r['naive'] else 'нет'})", flush=True) except Exception as e: failed.append(n) warn(f"{n}: {e}") # ipt2socks не трогаем; версии (repo / "versions").write_text(f"sing-box={SB_TAG}\nipt2socks={IPT_VER}\n") print("\n=== итог ===") for r in sorted(results, key=lambda x: x["name"]): print(f" {r['name']:<9} naive:{'+' if r['naive'] else '-'} " f"bin/sing-box-{r['name']}..gz ({r['gz']:.1f}MB)") if failed: warn(f"ПРОВАЛЕНО: {', '.join(failed)}") sys.exit(1) print("OK. Залейте bin/ + versions на f.n1ko.dev/nikovpn_openwrt/") if __name__ == "__main__": main()