#!/usr/bin/env python3 """Fetch pinned companions, verify hashes and build static Linux wg tools.""" import concurrent.futures import hashlib import json import os from pathlib import Path import shutil import subprocess import tarfile import urllib.request ROOT=Path(__file__).resolve().parent.parent CACHE=ROOT/'cache'; CACHE.mkdir(exist_ok=True) (CACHE/'go.mod').write_text('module wswg-build-cache\n\ngo 1.24\n') LOCK=ROOT/'scripts/dependencies.lock.json' known=json.loads(LOCK.read_text()) if LOCK.exists() else {} update=os.environ.get('UPDATE_LOCK')=='1' WST='11.1.0'; WG='1.0.20250521'; WIN='1.1.1'; MUSL='1.2.3' def fetch(url): entry=known.get(url) download_url=entry.get('url',url) if isinstance(entry,dict) else url path=CACHE/url.rsplit('/',1)[-1] if not path.exists(): print('Download',path.name,flush=True) tmp=path.with_suffix(path.suffix+'.part') with urllib.request.urlopen(download_url,timeout=90) as r,tmp.open('wb') as f: shutil.copyfileobj(r,f) tmp.replace(path) digest=hashlib.file_digest(path.open('rb'),'sha256').hexdigest() if url not in known: if not update: raise RuntimeError(f'Unpinned dependency: {url}. Use UPDATE_LOCK=1 only when reviewing a dependency update.') known[url]=digest if (known[url].get('sha256') if isinstance(known[url],dict) else known[url])!=digest: raise RuntimeError(f'Checksum mismatch: {path}') return path def extract(archive,dest): dest.mkdir(parents=True,exist_ok=True) with tarfile.open(archive) as t: root=dest.resolve() for m in t.getmembers(): target=(root/m.name).resolve() if not target.is_relative_to(root) or not (m.isfile() or m.isdir() or m.issym() or m.islnk()): raise RuntimeError(f'Unsafe archive member: {m.name}') if m.issym() or m.islnk(): link=((target.parent if m.issym() else root)/m.linkname).resolve() if not link.is_relative_to(root): raise RuntimeError(f'Unsafe archive link: {m.name}') t.extractall(dest) def run(args,**kw): logfile=CACHE/'build.log' with logfile.open('a') as log: r=subprocess.run(args,stdout=log,stderr=subprocess.STDOUT,**kw) if r.returncode: raise RuntimeError(f'{args} failed. See {logfile}') def check_static(path): for args,marker in [(['readelf','-l',str(path)],'INTERP'),(['readelf','-d',str(path)],'(NEEDED)')]: if marker in subprocess.check_output(args,text=True): raise RuntimeError(f'Dynamic dependency in {path}') def copy(src,dst): dst.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(src,dst) def static_wg(arch,archive,musl): result=CACHE/f'wg-static-{arch}' if result.exists(): return result compilers={'amd64':('gcc','x86_64',[]),'386':('gcc','i386',['-m32']), 'arm':('arm-linux-gnueabi-gcc','arm',['-march=armv6','-mfloat-abi=soft']), 'armv7':('arm-linux-gnueabihf-gcc','arm',['-march=armv7-a','-mfpu=vfpv3-d16','-mfloat-abi=hard']), 'arm64':('aarch64-linux-gnu-gcc','aarch64',[])} cc,host,flags=compilers[arch];work=CACHE/f'compile-{arch}';extract(musl,work) source=work/f'musl-{MUSL}';prefix=work/'musl-install' prefixcc=cc.removesuffix('gcc') env=dict(os.environ,CC=cc,REALGCC=cc,AR=prefixcc+'ar',RANLIB=prefixcc+'ranlib',CFLAGS='-Os '+' '.join(flags)) if flags: env['CC']=cc+' '+' '.join(flags) run(['./configure',f'--prefix={prefix}',f'--target={host}','--disable-shared'],cwd=source,env=env) run(['make','-j4'],cwd=source,env=env);run(['make','install'],cwd=source,env=env) extract(archive,work);wg=work/f'wireguard-tools-{WG}'/'src' # Add only kernel UAPI headers, never the host glibc headers. headers=work/'kernel-headers';headers.mkdir(exist_ok=True) kernelbase=Path('/usr/include') if cc=='gcc' else Path('/usr')/cc.removesuffix('-gcc')/'include' for name in ['linux','asm-generic']: shutil.copytree(kernelbase/name,headers/name,dirs_exist_ok=True) asm=Path('/usr/include/x86_64-linux-gnu/asm') if cc=='gcc' else kernelbase/'asm' shutil.copytree(asm,headers/'asm',dirs_exist_ok=True) (headers/'linux/wireguard.h').unlink(missing_ok=True) run(['make','-j4',f'CPPFLAGS=-I{headers}', f'CC={prefix}/bin/musl-gcc {" ".join(flags)}','LDFLAGS=-static'+(' -Wl,-m,elf_i386' if arch=='386' else ''),'wg'],cwd=wg,env=env) copy(wg/'wg',result);result.chmod(0o755) return result def main(): wg=fetch(f'https://git.zx2c4.com/wireguard-tools/snapshot/wireguard-tools-{WG}.tar.xz') musl=fetch(f'https://deb.debian.org/debian/pool/main/m/musl/musl_{MUSL}.orig.tar.gz') extract(wg,CACHE/'wg-source');wgsrc=CACHE/'wg-source'/f'wireguard-tools-{WG}' base=f'https://github.com/erebe/wstunnel/releases/download/v{WST}/' sums=fetch(base+'checksums.txt');expected={} for line in sums.read_text().splitlines(): parts=line.split() if len(parts)==2: expected[parts[1].lstrip('*')]=parts[0] targets=sorted(p for p in (ROOT/'dist').glob('wswg-*') if p.is_dir()) urls=[] for folder in targets: platform,arch=folder.name.removeprefix('wswg-').split('-',1) osname='linux' if platform=='termux' else platform wa='armv6' if arch=='arm' else arch urls.append(base+f'wstunnel_{WST}_{osname}_{wa}.tar.gz') if platform=='windows': winarch={'386':'x86','amd64':'amd64','arm64':'arm64'}[arch] urls.extend([f'https://download.wireguard.com/windows-client/wireguard-{winarch}-{WIN}.msi',f'https://aka.ms/vs/17/release/vc_redist.{"x64" if arch=="amd64" else winarch}.exe']) # Independent downloads only; all archive extraction and builds stay sequential. with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: files=dict(zip(sorted(set(urls)),pool.map(fetch,sorted(set(urls))))) for folder in targets: platform,arch=folder.name.removeprefix('wswg-').split('-',1);osname='linux' if platform=='termux' else platform;wa='armv6' if arch=='arm' else arch archive=files[base+f'wstunnel_{WST}_{osname}_{wa}.tar.gz'] actual=hashlib.file_digest(archive.open('rb'),'sha256').hexdigest() if expected.get(archive.name)!=actual: raise RuntimeError(f'Upstream wstunnel checksum mismatch: {archive.name}') unpack=CACHE/(archive.name+'.unpacked');extract(archive,unpack) name='wstunnel.exe' if platform=='windows' else 'wstunnel' binary=next(unpack.rglob(name));copy(binary,folder/'bin'/name);(folder/'bin'/name).chmod(0o755) licenses=folder/'licenses';licenses.mkdir(exist_ok=True) copy(wgsrc/'COPYING',licenses/'wireguard-tools.txt') wlicense=fetch(f'https://raw.githubusercontent.com/erebe/wstunnel/v{WST}/LICENSE') copy(wlicense,licenses/'wstunnel.txt') if platform=='windows': winarch={'386':'x86','amd64':'amd64','arm64':'arm64'}[arch] msi=files[f'https://download.wireguard.com/windows-client/wireguard-{winarch}-{WIN}.msi'] unpack=CACHE/(msi.name+'.unpacked');unpack.mkdir(exist_ok=True) run(['msiextract','-C',str(unpack),str(msi)]) for name in ['wireguard.exe','wg.exe']: copy(next(unpack.rglob(name)),folder/'bin'/name) redarch='x64' if arch=='amd64' else winarch redist=files[f'https://aka.ms/vs/17/release/vc_redist.{redarch}.exe'];copy(redist,folder/'redist'/redist.name) wlicense=fetch('https://raw.githubusercontent.com/WireGuard/wireguard-windows/v1.1.1/COPYING');copy(wlicense,licenses/'wireguard-windows.txt') else: check_static(binary) static=static_wg(arch,wg,musl);check_static(static) copy(static,folder/'bin'/'wg') quick=wgsrc/'src/wg-quick/linux.bash';copy(quick,folder/'bin'/'wg-quick');(folder/'bin'/'wg-quick').chmod(0o755) if platform=='termux': # A static Linux ELF runs under Android's kernel. No Bionic shared libraries. # Use Termux's bash: Android does not have /bin/bash. q=folder/'bin'/'wg-quick';q.write_text(q.read_text().replace('#!/bin/bash','#!/data/data/com.termux/files/usr/bin/bash',1)) copy(CACHE/f'compile-{arch}'/f'musl-{MUSL}'/'COPYRIGHT',licenses/'musl.txt') if update: LOCK.write_text(json.dumps(known,indent=2,sort_keys=True)+'\n') print('Companions bundled.',flush=True) if __name__=='__main__': main()