From 8d504e621880cac307f8263d93a265dfcb6ed6c3 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Fri, 11 Sep 2026 09:54:45 +0800 Subject: [PATCH] Gate static Clang builds on macOS, Windows and Linux --- .github/workflows/portability.yml | 84 +++++++++++++++++++++++++++ docker/static/Dockerfile | 33 +++++++++++ docker/static/Dockerfile.dockerignore | 8 +++ docker/static/README.md | 41 +++++++++++++ docs/modernization.md | 16 +++++ tests/check_static_runtime.py | 53 +++++++++++++++++ 6 files changed, 235 insertions(+) create mode 100644 docker/static/Dockerfile create mode 100644 docker/static/Dockerfile.dockerignore create mode 100644 docker/static/README.md create mode 100644 tests/check_static_runtime.py diff --git a/.github/workflows/portability.yml b/.github/workflows/portability.yml index 38f0e91c..04c15070 100644 --- a/.github/workflows/portability.yml +++ b/.github/workflows/portability.yml @@ -73,6 +73,90 @@ jobs: & ./consumer/consumer_shared.exe if ($LASTEXITCODE -ne 0) { throw "Shared consumer failed: $LASTEXITCODE" } + static-macos: + runs-on: macos-latest + env: + CC: clang + steps: + - uses: actions/checkout@v6 + - name: Build Clang static-only installation + run: | + cmake -S . -B build-static -DCMAKE_C_COMPILER=clang -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON -DSS_BUILD_STATIC_LIBRARY=ON -DSS_BUILD_SHARED_LIBRARY=OFF + cmake --build build-static --parallel 4 + ctest --test-dir build-static -L 'unit|vendor' --output-on-failure --no-tests=error + cmake --install build-static --prefix "$PWD/install-static" + mv install-static relocated-static + cmake -S tests/consumer -B consumer-static -DCMAKE_C_COMPILER=clang -DCMAKE_PREFIX_PATH="$PWD/relocated-static" + cmake --build consumer-static + test ! -e consumer-static/consumer_shared + ./consumer-static/consumer_static + - name: Reject third-party dynamic libraries + run: python3 tests/check_static_runtime.py relocated-static/bin/ss-* consumer-static/consumer_static + - name: Exercise installed static programs + run: | + python3 tests/interop.py --self --bin relocated-static/bin + python3 tests/interop.py --self --bin relocated-static/bin --plugin tests/sip003_fixture.py + - uses: actions/upload-artifact@v4 + with: + name: static-macos-${{ runner.arch }} + path: relocated-static/ + if-no-files-found: error + + static-windows: + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v6 + - uses: msys2/setup-msys2@v2 + with: + msystem: CLANG64 + update: true + install: >- + mingw-w64-clang-x86_64-clang + mingw-w64-clang-x86_64-llvm + mingw-w64-clang-x86_64-cmake + mingw-w64-clang-x86_64-ninja + mingw-w64-clang-x86_64-python + - name: Build Clang static-only installation + run: | + cmake -S . -B build-static -G Ninja -DCMAKE_C_COMPILER=clang -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON -DSS_BUILD_STATIC_LIBRARY=ON -DSS_BUILD_SHARED_LIBRARY=OFF + cmake --build build-static --parallel 4 + ctest --test-dir build-static -L 'unit|vendor' --output-on-failure --no-tests=error + cmake --install build-static --prefix "$PWD/install-static" + mv install-static relocated-static + cmake -S tests/consumer -B consumer-static -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_PREFIX_PATH="$PWD/relocated-static" + cmake --build consumer-static + test ! -e consumer-static/consumer_shared.exe + - name: Reject third-party DLL imports, including delay imports + run: python tests/check_static_runtime.py relocated-static/bin/ss-*.exe consumer-static/consumer_static.exe + - name: Exercise installed programs without toolchain DLLs + run: python tests/interop.py --self --bin relocated-static/bin --isolate-windows-runtime + - name: Run static consumer with only Windows system directories on PATH + shell: pwsh + run: | + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + & ./consumer-static/consumer_static.exe + if ($LASTEXITCODE -ne 0) { throw "Static consumer failed: $LASTEXITCODE" } + - uses: actions/upload-artifact@v4 + with: + name: static-windows-${{ runner.arch }} + path: relocated-static/ + if-no-files-found: error + + static-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Build and test fully static Clang/musl executables + run: docker build -f docker/static/Dockerfile --target artifacts --output type=local,dest=static-linux . + - uses: actions/upload-artifact@v4 + with: + name: static-linux-${{ runner.arch }} + path: static-linux/ + if-no-files-found: error + freebsd: runs-on: ubuntu-latest steps: diff --git a/docker/static/Dockerfile b/docker/static/Dockerfile new file mode 100644 index 00000000..76fdaf45 --- /dev/null +++ b/docker/static/Dockerfile @@ -0,0 +1,33 @@ +ARG ALPINE_IMAGE=alpine:3.24 +FROM ${ALPINE_IMAGE} AS build +ARG ALPINE_MIRROR=https://dl-cdn.alpinelinux.org/alpine +RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories \ + && apk add --no-cache clang lld make musl-dev compiler-rt cmake linux-headers python3 binutils file +ENV CC=clang +WORKDIR /src +COPY . . +ARG SS_MINIMAL=OFF +RUN --network=none cmake -S . -B /build \ + -DCMAKE_BUILD_TYPE=Release \ + -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON \ + -DSS_BUILD_STATIC_LIBRARY=OFF -DSS_BUILD_SHARED_LIBRARY=OFF \ + -DSS_MINIMAL=${SS_MINIMAL} -DCMAKE_EXE_LINKER_FLAGS="-static -fuse-ld=lld --rtlib=compiler-rt" \ + -DCMAKE_INSTALL_PREFIX=/out \ + && cmake --build /build --parallel 4 \ + && ctest --test-dir /build -L 'unit|vendor' --output-on-failure --no-tests=error \ + && python3 tests/interop.py --self --bin /build/bin \ + && cmake --install /build --strip \ + && for binary in /out/bin/ss-*; do \ + file "$binary"; \ + ! readelf -l "$binary" | grep -q INTERP || exit 1; \ + ! readelf -d "$binary" | grep -q NEEDED || exit 1; \ + done + +# Export binaries and their license files with --target artifacts --output. +FROM scratch AS artifacts +COPY --from=build /out/ / + +# No libc, dynamic loader, package manager or shell in the runtime image. +FROM scratch AS runtime +COPY --from=build /out/ /usr/local/ +ENTRYPOINT ["/usr/local/bin/ss-server"] diff --git a/docker/static/Dockerfile.dockerignore b/docker/static/Dockerfile.dockerignore new file mode 100644 index 00000000..d81d4847 --- /dev/null +++ b/docker/static/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +.git +build* +release +libcork +libipset +libbloom +**/__pycache__ +**/.DS_Store diff --git a/docker/static/README.md b/docker/static/README.md new file mode 100644 index 00000000..a946206e --- /dev/null +++ b/docker/static/README.md @@ -0,0 +1,41 @@ +# Fully static Linux builds + +The Alpine/musl builder uses Clang with LLD by default and links both third-party dependencies and libc statically. +`WITH_STATIC=ON` alone only selects static third-party libraries; this image also +passes `-DCMAKE_EXE_LINKER_FLAGS=-static` and omits shared-library outputs. +The compile, unit/vendor tests and six-method TCP/UDP relay tests run with Docker +network access disabled. The final ELF check rejects interpreters and dynamic +library dependencies. Initial image/toolchain installation requires networking. + +From the repository root, export binaries and licenses (ARM64 example): + +```sh +docker build --platform linux/arm64 -f docker/static/Dockerfile \ + --target artifacts --output type=local,dest=build-artifacts/static-linux-arm64 . +``` + +Requires Docker Buildx/BuildKit. If Docker Hub is unavailable, add +`--build-arg ALPINE_IMAGE=public.ecr.aws/docker/library/alpine:3.24`. +An alternate package mirror can be selected with `--build-arg ALPINE_MIRROR=https://mirrors.aliyun.com/alpine`; APK signature checks remain enabled. +Docker build proxy settings apply to package installation; a broken configured +proxy can be bypassed for this command with empty `HTTP_PROXY`, `HTTPS_PROXY`, +`ALL_PROXY` build arguments and their lowercase equivalents. + +Use `--platform linux/amd64` and a different destination for x86-64. Docker needs +a matching native builder or CPU emulation to compile and run the tests for a +foreign architecture. Add `--build-arg SS_MINIMAL=ON` for the minimal profile. + +Build the scratch runtime image using the same cached build stage: + +```sh +docker build --platform linux/arm64 -f docker/static/Dockerfile \ + --target runtime -t shadowsocks-libev:static-arm64 . +docker run --rm -p 8388:8388/tcp -p 8388:8388/udp \ + shadowsocks-libev:static-arm64 \ + -s 0.0.0.0 -p 8388 -k example-password -m aes-256-gcm -u +``` + +The runtime image has no shell or external plugin executables. Supply any SIP003 +plugin and its own runtime requirements separately. Configuration files can be +bind-mounted and passed with `-c`. Exported binaries run on Linux with the same +CPU architecture; they do not run directly on macOS or Windows. diff --git a/docs/modernization.md b/docs/modernization.md index 7a9166ad..4a9aaad7 100644 --- a/docs/modernization.md +++ b/docs/modernization.md @@ -147,3 +147,19 @@ submodule dependencies and reduces what is built without replacing audited cryptographic algorithms with project-specific implementations. See [measurements and tradeoffs](performance.md) for reproducible local results. + +## Static build CI + +The `static-macos` and `static-windows` jobs use Clang, bundled dependencies and +`SS_BUILD_SHARED_LIBRARY=OFF`. They run unit/vendor tests, relocate the installed +static library, build its consumer, and exercise the installed TCP/UDP programs. +The macOS job also exercises SIP003. `tests/check_static_runtime.py` rejects +non-system dylibs and DLLs (including Windows delay imports) in every installed +program and the static consumer. Windows runtime tests exclude toolchain DLL +paths. Windows uses the native [MSYS2 CLANG64 environment](https://www.msys2.org/docs/environments/). + +macOS and Windows retain their OS libraries; static builds on these platforms +mean no separately distributed project, crypto, event-loop or toolchain shared +libraries. The `static-linux` job builds the Clang/musl Dockerfile, which also +links libc statically and rejects ELF interpreters and `NEEDED` entries. All +three jobs publish the tested installations as workflow artifacts. diff --git a/tests/check_static_runtime.py b/tests/check_static_runtime.py new file mode 100644 index 00000000..3ff4d93b --- /dev/null +++ b/tests/check_static_runtime.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Reject non-system runtime dependencies in static macOS/Windows outputs.""" +import argparse +import platform +import re +import subprocess +from pathlib import Path + +WINDOWS_SYSTEM_DLLS = { + 'advapi32.dll', 'bcrypt.dll', 'crypt32.dll', 'dnsapi.dll', 'iphlpapi.dll', + 'kernel32.dll', 'msvcrt.dll', 'ntdll.dll', 'ole32.dll', 'psapi.dll', + 'rpcrt4.dll', 'secur32.dll', 'shell32.dll', 'ucrtbase.dll', 'user32.dll', + 'userenv.dll', 'winmm.dll', 'ws2_32.dll', +} + + +def dependencies(binary, system): + if system == 'Darwin': + output = subprocess.check_output(['otool', '-L', str(binary)], text=True) + return [line.strip().split(' (compatibility version', 1)[0] + for line in output.splitlines() if line.startswith('\t')] + output = subprocess.check_output( + ['llvm-readobj', '--coff-imports', str(binary)], text=True) + return re.findall(r'^\s+Name: (.+)$', output, re.MULTILINE) + + +def is_system_dependency(name, system): + if system == 'Darwin': + return name.startswith(('/usr/lib/', '/System/Library/')) + name = name.strip().lower() + return name in WINDOWS_SYSTEM_DLLS or ( + name.startswith(('api-ms-win-', 'ext-ms-win-')) and name.endswith('.dll')) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('binaries', type=Path, nargs='+') + args = parser.parse_args() + system = platform.system() + if system not in ('Darwin', 'Windows'): + parser.error('this check supports macOS and native Windows Python') + for binary in args.binaries: + names = dependencies(binary, system) + if not names: + raise SystemExit(f'{binary}: no OS imports found; check file format/tool output') + unexpected = [name for name in names if not is_system_dependency(name, system)] + if unexpected: + raise SystemExit(f'{binary}: non-system dependencies: {unexpected}') + print(f'PASS {binary}: {", ".join(names)}') + + +if __name__ == '__main__': + main()