wswg/scripts/bundle.py

143 lines
8.2 KiB
Python

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