- 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__ не коммитятся
349 lines
14 KiB
Python
349 lines
14 KiB
Python
#!/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()
|