From 5dd2dc7834109dbec3559c711d55f7e486ec5fbc Mon Sep 17 00:00:00 2001 From: "serhii.n" Date: Sat, 24 Oct 2026 01:36:51 +0300 Subject: [PATCH 1/6] Add portable static build with daily release workflow Add portable/Dockerfile that builds a fully statically linked https_dns_proxy with HTTP/3 (QUIC) support on Alpine (musl). OpenSSL, nghttp3, ngtcp2, nghttp2 and curl are built from source, because Alpine's libcurl has no HTTP/3 and its static library pulls in dependencies a DoH proxy does not use. libev and c-ares come from Alpine packages. The Dockerfile has two targets: - binary: exports only the executable - image: the executable and a CA bundle in a single layer on an empty base image, running as an unprivileged user Add a daily "Portable release" workflow. It runs only on the default branch and only when src, CMakeLists.txt or portable/ changed since the last release. It creates a release tagged with the build date (YYYY.MM.DD) with the binary, a systemd unit and SHA256SUMS, and pushes the image to ghcr.io//https_dns_proxy. Add a hardened systemd unit for the portable binary and document the portable build and Docker image in the README, including why the static binary is about 8 MB instead of <30 kiB. --- .dockerignore | 13 +++ .github/workflows/portable-release.yml | 155 +++++++++++++++++++++++++ .gitignore | 2 + README.md | 70 ++++++++++- portable/Dockerfile | 106 +++++++++++++++++ portable/https_dns_proxy.service | 51 ++++++++ 6 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/portable-release.yml create mode 100644 portable/Dockerfile create mode 100644 portable/https_dns_proxy.service diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5b61c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Local in-tree build results would break the build inside the container. +# .git is kept on purpose: CMake uses it to embed the version. +build/ +out/ +CMakeCache.txt +CMakeFiles/ +Makefile +cmake_install.cmake +CTestTestfile.cmake +Testing/ +custom_curl/ +/https_dns_proxy +*_unittest diff --git a/.github/workflows/portable-release.yml b/.github/workflows/portable-release.yml new file mode 100644 index 0000000..79d762a --- /dev/null +++ b/.github/workflows/portable-release.yml @@ -0,0 +1,155 @@ +name: Portable release + +# Daily: builds the static (portable) binary and a Docker image, but only if the +# source changed since the last release. The release tag is the build date (YYYY.MM.DD). +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + force: + description: Release even if nothing changed since the last release + type: boolean + default: false + +permissions: + contents: write + packages: write + +concurrency: + group: portable-release + cancel-in-progress: false + +jobs: + check: + # Scheduled runs always use the default branch; this also stops manual runs on other + # branches, so only merged code is released + if: >- + github.event_name == 'schedule' || + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.check.outputs.changed }} + version: ${{ steps.check.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check for changes since the last release + id: check + env: + GH_TOKEN: ${{ github.token }} + FORCE: ${{ inputs.force }} + run: | + # Only these paths affect the binary or the release assets + watched_paths=(src CMakeLists.txt portable .dockerignore) + + last_tag=$(gh release view --json tagName --jq .tagName 2>/dev/null || true) + if [ -z "$last_tag" ]; then + echo "No previous release" + changed=true + elif ! git diff --quiet "$last_tag" HEAD -- "${watched_paths[@]}"; then + echo "Changes since $last_tag:" + git log --oneline "$last_tag"..HEAD -- "${watched_paths[@]}" + changed=true + else + echo "No changes since $last_tag" + changed=false + fi + if [ "$FORCE" = "true" ]; then + changed=true + fi + + # One release per day; a forced second run on the same day gets a suffix + version=$(date -u +%Y.%m.%d) + candidate=$version + n=1 + while git rev-parse -q --verify "refs/tags/$candidate" >/dev/null; do + n=$((n + 1)) + candidate="$version.$n" + done + + echo "changed=$changed" >> "$GITHUB_OUTPUT" + echo "version=$candidate" >> "$GITHUB_OUTPUT" + + release: + needs: check + if: needs.check.outputs.changed == 'true' + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.check.outputs.version }} + steps: + - uses: actions/checkout@v5 + + - name: Set image name + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build portable binary + uses: docker/build-push-action@v6 + with: + context: . + file: portable/Dockerfile + target: binary + outputs: type=local,dest=out + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test + run: | + file out/https_dns_proxy + file out/https_dns_proxy | grep -q 'static' + out/https_dns_proxy -V | tee versions.txt + grep -q HTTP3 versions.txt + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: portable/Dockerfile + target: image + push: true + tags: | + ${{ env.IMAGE }}:${{ env.VERSION }} + ${{ env.IMAGE }}:latest + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ env.VERSION }} + org.opencontainers.image.description=Static https_dns_proxy (DNS-over-HTTPS proxy with HTTP/3) on an empty base image + cache-from: type=gha + + - name: Prepare release assets + run: | + mkdir dist + cp out/https_dns_proxy dist/https_dns_proxy-linux-amd64 + cp portable/https_dns_proxy.service dist/ + cd dist && sha256sum -- * > SHA256SUMS + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + { + echo "Portable (statically linked) build of https_dns_proxy for Linux x86_64." + echo + echo '```' + cat versions.txt + echo '```' + echo + echo "Docker image: \`docker pull $IMAGE:$VERSION\`" + echo + echo "See the README section \"Portable binary and Docker image\" for usage." + } > notes.md + gh release create "$VERSION" dist/* \ + --target "$GITHUB_SHA" \ + --title "$VERSION" \ + --notes-file notes.md diff --git a/.gitignore b/.gitignore index 300d808..559d9b0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ cmake_install.cmake https_dns_proxy *_unittest https_dns_proxy.service +!portable/https_dns_proxy.service +out/ install_manifest.txt .ninja_deps .ninja_log diff --git a/README.md b/README.md index 312a45c..00bc854 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ only makes sense if you trust your DoH provider. ## Features -* Tiny Size (<30kiB). +* Tiny Size (<30kiB) when linked against system libraries + (the [portable build](#portable-binary-and-docker-image) is larger, see below). * Uses curl for HTTP/2 and pipelining, keeping resolve latencies extremely low. * Single-threaded, non-blocking select() server for use on resource-starved embedded systems. @@ -111,6 +112,70 @@ ExecStart=/usr/local/bin/https_dns_proxy \ -u nobody -g nogroup -r https://doh.opendns.com/dns-query ``` +### Portable binary and Docker image + +Every day a GitHub Action checks this repository for changes since the last +release. If there are any, it builds a new [release](../../releases/latest), +tagged with the build date (`YYYY.MM.DD`), with these files: + +* `https_dns_proxy-linux-amd64` - portable, statically linked binary for Linux x86_64 +* `https_dns_proxy.service` - systemd unit for it +* `SHA256SUMS` - checksums + +It also publishes a [Docker image](../../pkgs/container/https_dns_proxy) with the +same binary at `ghcr.io/aarond10/https_dns_proxy` (tags: the release date and `latest`). +Forks with the workflow enabled publish to their own `ghcr.io//https_dns_proxy` +and own releases. + +The portable binary has HTTP/3 (QUIC) support (`-q`) and needs no libraries on the +target system. It runs on any Linux x86_64 distribution, regardless of its libc +or installed packages. + +**Size:** the portable binary is about 8 MB instead of the <30 kiB of a regular +build. It is statically linked: libcurl, OpenSSL, ngtcp2, nghttp3, nghttp2, c-ares, +libev and the musl C library are all inside the executable, instead of being shared +with the rest of the system. For the same reason, security fixes in these libraries +reach the binary only through a new release, not through system package updates. + +Other differences from a regular build: + +* No systemd notify support (libsystemd has no static library), so the provided + unit uses `Type=simple`. +* CA certificates are read from `/etc/ssl/certs/ca-certificates.crt` + (Debian, Ubuntu, Alpine, Arch and others). On distributions that keep them + elsewhere, use `-C`, for example `-C /etc/pki/tls/certs/ca-bundle.crt` + on Fedora/RHEL. + +Install with systemd: +``` +$ curl -LO https://github.com/aarond10/https_dns_proxy/releases/latest/download/https_dns_proxy-linux-amd64 +$ curl -LO https://github.com/aarond10/https_dns_proxy/releases/latest/download/https_dns_proxy.service +$ sudo install -m 755 https_dns_proxy-linux-amd64 /usr/local/bin/https_dns_proxy +$ sudo install -m 644 https_dns_proxy.service /etc/systemd/system/ +$ sudo systemctl daemon-reload +$ sudo systemctl enable --now https_dns_proxy +``` +The unit runs the proxy with default options. To change them, use +`sudo systemctl edit https_dns_proxy.service` as shown above. + +Run with Docker: +``` +$ docker run -d --name https_dns_proxy \ + -p 127.0.0.1:5053:5053/udp -p 127.0.0.1:5053:5053/tcp \ + ghcr.io/aarond10/https_dns_proxy \ + -r https://cloudflare-dns.com/dns-query +``` +The image contains only the binary and a CA certificate bundle, in a single layer +on an empty base image. It runs as an unprivileged user (65534) and listens on +`0.0.0.0:5053` inside the container. Arguments after the image name are passed +to `https_dns_proxy`. + +To build both locally (needs Docker with buildx), run from the repository root: +``` +$ docker buildx build -f portable/Dockerfile --target binary --output type=local,dest=./out . +$ docker buildx build -f portable/Dockerfile --target image -t https_dns_proxy . +``` + ### OpenWRT package install There is a package in the [OpenWRT packages](https://github.com/openwrt/packages) repository as well. @@ -142,7 +207,8 @@ user@arch:~# yay -S https-dns-proxy-git ### Docker install -There is also an externally maintained [Docker image](https://hub.docker.com/r/bwmoran/https-dns-proxy) for latest git version. Documentation, Dockerfile, and entrypoint script can be viewed on [GitHub](https://github.com/moranbw/https-dns-proxy-docker). An example run: +See [Portable binary and Docker image](#portable-binary-and-docker-image) for the +image built by this repository. There is also an externally maintained [Docker image](https://hub.docker.com/r/bwmoran/https-dns-proxy) for latest git version. Documentation, Dockerfile, and entrypoint script can be viewed on [GitHub](https://github.com/moranbw/https-dns-proxy-docker). An example run: ``` ### points towards AdGuard DNS, only use IPv4, increase logging ### diff --git a/portable/Dockerfile b/portable/Dockerfile new file mode 100644 index 0000000..2dde0cc --- /dev/null +++ b/portable/Dockerfile @@ -0,0 +1,106 @@ +# Builds a fully static, portable https_dns_proxy binary with HTTP/3 (QUIC) support. +# Dependencies are built from source (libev and c-ares come from Alpine), based on upstream development_build_with_http3.sh. +# +# Why some libraries are built from source instead of taken from Alpine packages +# (the goal is one statically linked executable, so only static libraries (.a) can be used): +# curl - Alpine's libcurl is built without HTTP/3 (no ngtcp2/nghttp3), and its static +# library also pulls in brotli, zstd, zlib, libidn2 and libpsl, which a DoH +# proxy does not use. A custom build keeps only what is needed. +# ngtcp2 - QUIC for curl. Its OpenSSL crypto backend must be built against the same +# OpenSSL that is linked into the binary. +# nghttp3 - HTTP/3 for curl, built together with ngtcp2 to keep matching versions. +# OpenSSL - Alpine ships the 3.5 LTS line, which would also work (ngtcp2 needs >= 3.5); +# it is built from source to get the latest release. +# nghttp2 - HTTP/2 for curl; Alpine has a static package, built from source to stay on +# the latest release together with the rest of the HTTP stack. +# libev and c-ares are taken from Alpine: their packages already include static +# libraries and match the latest upstream releases. +# +# Run from the repository root. +# +# Portable binary only: +# docker buildx build -f portable/Dockerfile --target binary --output type=local,dest=./out . +# -> ./out/https_dns_proxy +# +# Docker image (single layer on top of an empty image): +# docker buildx build -f portable/Dockerfile --target image -t https_dns_proxy . +FROM alpine:3.24 AS build + +ARG OPENSSL_VERSION=openssl-3.6.5 +ARG NGHTTP3_VERSION=v1.18.0 +ARG NGTCP2_VERSION=v1.25.0 +ARG NGHTTP2_VERSION=v1.70.0 +ARG CURL_VERSION=curl-8_22_0 + +RUN apk add --no-cache build-base linux-headers cmake git perl pkgconf autoconf automake libtool curl \ + libev-dev c-ares-dev ca-certificates + +ENV PREFIX=/opt/static +ENV PKG_CONFIG_PATH=/opt/static/lib/pkgconfig +ENV CFLAGS="-O2 -fPIC" +WORKDIR /usr/src + +RUN git clone --depth 1 -b ${OPENSSL_VERSION} https://github.com/openssl/openssl \ + && cd openssl \ + && ./config --prefix=$PREFIX --libdir=lib --openssldir=/etc/ssl no-shared no-docs no-tests \ + && make -j$(nproc) && make install_dev + +RUN git clone --depth 1 -b ${NGHTTP3_VERSION} https://github.com/ngtcp2/nghttp3 \ + && cd nghttp3 && git submodule update --init --depth 1 \ + && autoreconf -fi \ + && ./configure --prefix=$PREFIX --enable-lib-only --disable-shared --enable-static \ + && make -j$(nproc) && make install + +RUN git clone --depth 1 -b ${NGTCP2_VERSION} https://github.com/ngtcp2/ngtcp2 \ + && cd ngtcp2 && git submodule update --init --depth 1 \ + && autoreconf -fi \ + && ./configure --prefix=$PREFIX --enable-lib-only --disable-shared --enable-static --with-openssl \ + && make -j$(nproc) && make install + +RUN git clone --depth 1 -b ${NGHTTP2_VERSION} https://github.com/nghttp2/nghttp2 \ + && cd nghttp2 && git submodule update --init --depth 1 \ + && autoreconf -fi \ + && ./configure --prefix=$PREFIX --enable-lib-only --disable-shared --enable-static \ + && make -j$(nproc) && make install + +RUN git clone --depth 1 -b ${CURL_VERSION} https://github.com/curl/curl \ + && cd curl \ + && autoreconf -fi \ + && ./configure --prefix=$PREFIX --disable-shared --enable-static \ + --with-openssl=$PREFIX --with-nghttp2=$PREFIX --with-ngtcp2=$PREFIX --with-nghttp3=$PREFIX \ + --enable-ares \ + --with-ca-bundle=/etc/ssl/certs/ca-certificates.crt --with-ca-path=/etc/ssl/certs \ + --without-libpsl --without-libidn2 --without-brotli --without-zstd --without-zlib \ + --without-libssh2 --without-gssapi --without-librtmp \ + --disable-ldap --disable-ldaps --disable-docs --disable-manual \ + && make -j$(nproc) && make install + +COPY . /usr/src/https_dns_proxy +WORKDIR /usr/src/https_dns_proxy + +# libsystemd has no static library, so the build goes without sd_notify support +RUN cmake . -DUSE_CLANG_TIDY=OFF \ + -DCMAKE_PREFIX_PATH=$PREFIX \ + -DCUSTOM_LIBCURL_INSTALL_PATH=$PREFIX \ + -DCMAKE_EXE_LINKER_FLAGS="-static" \ + -DCMAKE_C_STANDARD_LIBRARIES="$(pkg-config --static --libs libcurl libcares)" \ + && make -j$(nproc) \ + && strip https_dns_proxy + +# Everything the image needs is gathered in one directory, so it is copied as a single layer. +# /etc/services is needed by c-ares to resolve the "https" service name when bootstrapping +# a resolver given by hostname. +RUN mkdir -p /rootfs/etc/ssl/certs \ + && cp https_dns_proxy /rootfs/https_dns_proxy \ + && cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ \ + && cp /etc/services /rootfs/etc/services + +FROM scratch AS binary +COPY --from=build /usr/src/https_dns_proxy/https_dns_proxy /https_dns_proxy + +FROM scratch AS image +COPY --from=build /rootfs/ / +USER 65534:65534 +EXPOSE 5053/udp 5053/tcp +# Listen on all interfaces inside the container; a later -a overrides it +ENTRYPOINT ["/https_dns_proxy", "-a", "0.0.0.0"] diff --git a/portable/https_dns_proxy.service b/portable/https_dns_proxy.service new file mode 100644 index 0000000..73e13ea --- /dev/null +++ b/portable/https_dns_proxy.service @@ -0,0 +1,51 @@ +# systemd unit for the portable (static) https_dns_proxy binary. +# +# Install: +# install -m 755 https_dns_proxy-linux-amd64 /usr/local/bin/https_dns_proxy +# install -m 644 https_dns_proxy.service /etc/systemd/system/ +# systemctl daemon-reload && systemctl enable --now https_dns_proxy +# +# Change options with: systemctl edit https_dns_proxy +[Unit] +Description=https_dns_proxy - DNS-over-HTTPS (HTTP/3) proxy +Wants=network-online.target nss-lookup.target +After=network-online.target +Before=nss-lookup.target + +[Service] +# Static build has no libsystemd, so no sd_notify +Type=simple +DynamicUser=yes +# Listens on 127.0.0.1:5053 by default +ExecStart=/usr/local/bin/https_dns_proxy -v -v +Restart=on-failure +RestartSec=5 +TimeoutStopSec=10 + +# Hardening +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources + +[Install] +WantedBy=multi-user.target From f92c9082baf953da782cda354f7d0510785c51fb Mon Sep 17 00:00:00 2001 From: "serhii.n" Date: Sat, 24 Oct 2026 14:44:59 +0300 Subject: [PATCH 2/6] Pin portable build dependencies by commit hash and digest Git tags, Docker tags and GitHub Action tags are mutable: anyone who controls an upstream repository can move a tag to different content, and the next build would use it without notice. Commit hashes and image digests are computed from the content itself, so they cannot be redirected. - Pin the Alpine base image by digest. - Add a pinned commit for OpenSSL, nghttp3, ngtcp2, nghttp2 and curl. Each one is still cloned by its tag, but the build fails if the tag no longer points to the pinned commit. Submodules are pinned by the parent commit. - Pin all GitHub Actions in the release workflow by commit hash, with the version in a comment. The workflow can write releases and packages, so it must not run code that changed after review. Comments in both files explain the reason and how to update the pins. --- .github/workflows/portable-release.yml | 17 +++++++++----- portable/Dockerfile | 32 ++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/.github/workflows/portable-release.yml b/.github/workflows/portable-release.yml index 79d762a..fabcf7f 100644 --- a/.github/workflows/portable-release.yml +++ b/.github/workflows/portable-release.yml @@ -12,6 +12,11 @@ on: type: boolean default: false +# Actions are pinned by commit hash, not by tag (the version is in the comment). +# Tags like @v6 are mutable: if an action's repository is compromised, the tag can be +# moved to malicious code, as happened with tj-actions/changed-files in 2025. This +# workflow can write releases and packages, so it runs only reviewed, fixed code. +# To update an action: git ls-remote https://github.com// 'refs/tags/^{}' permissions: contents: write packages: write @@ -32,7 +37,7 @@ jobs: changed: ${{ steps.check.outputs.changed }} version: ${{ steps.check.outputs.version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 @@ -80,21 +85,21 @@ jobs: env: VERSION: ${{ needs.check.outputs.version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Set image name run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - uses: docker/login-action@v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - name: Build portable binary - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: portable/Dockerfile @@ -111,7 +116,7 @@ jobs: grep -q HTTP3 versions.txt - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: portable/Dockerfile diff --git a/portable/Dockerfile b/portable/Dockerfile index 2dde0cc..72104cb 100644 --- a/portable/Dockerfile +++ b/portable/Dockerfile @@ -24,13 +24,29 @@ # # Docker image (single layer on top of an empty image): # docker buildx build -f portable/Dockerfile --target image -t https_dns_proxy . -FROM alpine:3.24 AS build +# +# Why commit hashes and digests, not only tags: +# git tags and Docker tags are mutable. Whoever controls an upstream repository (or an +# attacker who got access to it) can move a tag to a different commit or image, and the +# next build would silently use it. A commit hash or an image digest is a hash of the +# content itself, so it cannot point to anything else. Each library below is cloned by +# its tag (readable), and the build fails if the tag no longer points to the pinned +# commit. Git submodules are pinned by the parent commit, so they are covered too. +# To update a library, change both the tag and the commit, for example: +# git ls-remote https://github.com/curl/curl 'refs/tags/curl-8_22_0^{}' +# To update the base image: docker buildx imagetools inspect alpine:3.24 +FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS build ARG OPENSSL_VERSION=openssl-3.6.5 +ARG OPENSSL_COMMIT=c8bd5a57108599ac650bbae77fcabe3109dab2e8 ARG NGHTTP3_VERSION=v1.18.0 +ARG NGHTTP3_COMMIT=dbfc24286138cb0b6490160e7ca87fe1ce6722a0 ARG NGTCP2_VERSION=v1.25.0 +ARG NGTCP2_COMMIT=f9e9ff01ad2c8116bc09de4f644b0028a61486a6 ARG NGHTTP2_VERSION=v1.70.0 +ARG NGHTTP2_COMMIT=85e300c79fb6dbcfa9c1013215c8710c1c2cd3d2 ARG CURL_VERSION=curl-8_22_0 +ARG CURL_COMMIT=01346829096c61b372692f6dc43ffa778c6caccd RUN apk add --no-cache build-base linux-headers cmake git perl pkgconf autoconf automake libtool curl \ libev-dev c-ares-dev ca-certificates @@ -42,29 +58,37 @@ WORKDIR /usr/src RUN git clone --depth 1 -b ${OPENSSL_VERSION} https://github.com/openssl/openssl \ && cd openssl \ + && test "$(git rev-parse HEAD)" = "${OPENSSL_COMMIT}" \ && ./config --prefix=$PREFIX --libdir=lib --openssldir=/etc/ssl no-shared no-docs no-tests \ && make -j$(nproc) && make install_dev RUN git clone --depth 1 -b ${NGHTTP3_VERSION} https://github.com/ngtcp2/nghttp3 \ - && cd nghttp3 && git submodule update --init --depth 1 \ + && cd nghttp3 \ + && test "$(git rev-parse HEAD)" = "${NGHTTP3_COMMIT}" \ + && git submodule update --init --depth 1 \ && autoreconf -fi \ && ./configure --prefix=$PREFIX --enable-lib-only --disable-shared --enable-static \ && make -j$(nproc) && make install RUN git clone --depth 1 -b ${NGTCP2_VERSION} https://github.com/ngtcp2/ngtcp2 \ - && cd ngtcp2 && git submodule update --init --depth 1 \ + && cd ngtcp2 \ + && test "$(git rev-parse HEAD)" = "${NGTCP2_COMMIT}" \ + && git submodule update --init --depth 1 \ && autoreconf -fi \ && ./configure --prefix=$PREFIX --enable-lib-only --disable-shared --enable-static --with-openssl \ && make -j$(nproc) && make install RUN git clone --depth 1 -b ${NGHTTP2_VERSION} https://github.com/nghttp2/nghttp2 \ - && cd nghttp2 && git submodule update --init --depth 1 \ + && cd nghttp2 \ + && test "$(git rev-parse HEAD)" = "${NGHTTP2_COMMIT}" \ + && git submodule update --init --depth 1 \ && autoreconf -fi \ && ./configure --prefix=$PREFIX --enable-lib-only --disable-shared --enable-static \ && make -j$(nproc) && make install RUN git clone --depth 1 -b ${CURL_VERSION} https://github.com/curl/curl \ && cd curl \ + && test "$(git rev-parse HEAD)" = "${CURL_COMMIT}" \ && autoreconf -fi \ && ./configure --prefix=$PREFIX --disable-shared --enable-static \ --with-openssl=$PREFIX --with-nghttp2=$PREFIX --with-ngtcp2=$PREFIX --with-nghttp3=$PREFIX \ From 14a2d7589bb13f0f52ec41381ca5d0d870c24a21 Mon Sep 17 00:00:00 2001 From: "serhii.n" Date: Sat, 24 Oct 2026 14:59:41 +0300 Subject: [PATCH 3/6] Keep release binary and image binary identical The smoke test wrote versions.txt into the workspace between the two builds. That changed the build context, so the image build compiled the binary again with a different build time, and the binary in the image did not match the release asset or SHA256SUMS. Write all temporary files to the runner temp directory, and add a step that fails the release if the binary in the pushed image differs from the release binary. --- .github/workflows/portable-release.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/portable-release.yml b/.github/workflows/portable-release.yml index fabcf7f..dcda755 100644 --- a/.github/workflows/portable-release.yml +++ b/.github/workflows/portable-release.yml @@ -104,11 +104,15 @@ jobs: context: . file: portable/Dockerfile target: binary - outputs: type=local,dest=out + # All temporary files go outside the workspace: a new file in the workspace changes + # the build context, and the image build below would compile the binary again + # (with a different build time) instead of reusing this one + outputs: type=local,dest=${{ runner.temp }}/out cache-from: type=gha cache-to: type=gha,mode=max - name: Smoke test + working-directory: ${{ runner.temp }} run: | file out/https_dns_proxy file out/https_dns_proxy | grep -q 'static' @@ -132,14 +136,25 @@ jobs: org.opencontainers.image.description=Static https_dns_proxy (DNS-over-HTTPS proxy with HTTP/3) on an empty base image cache-from: type=gha + - name: Check that the image has the same binary + working-directory: ${{ runner.temp }} + run: | + docker pull "$IMAGE:$VERSION" + container=$(docker create "$IMAGE:$VERSION") + docker cp "$container:/https_dns_proxy" image_binary + docker rm "$container" + cmp out/https_dns_proxy image_binary + - name: Prepare release assets + working-directory: ${{ runner.temp }} run: | mkdir dist cp out/https_dns_proxy dist/https_dns_proxy-linux-amd64 - cp portable/https_dns_proxy.service dist/ + cp "$GITHUB_WORKSPACE/portable/https_dns_proxy.service" dist/ cd dist && sha256sum -- * > SHA256SUMS - name: Create release + working-directory: ${{ runner.temp }} env: GH_TOKEN: ${{ github.token }} run: | @@ -154,7 +169,9 @@ jobs: echo echo "See the README section \"Portable binary and Docker image\" for usage." } > notes.md + # --repo is needed because the working directory is not the git checkout gh release create "$VERSION" dist/* \ + --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ --title "$VERSION" \ --notes-file notes.md From 226aeeb4732837e922836b03f0243ae10365f61e Mon Sep 17 00:00:00 2001 From: "serhii.n" Date: Sat, 24 Oct 2026 23:39:09 +0300 Subject: [PATCH 4/6] Update OpenSSL to 4.1.0 and Curl to 8.23.0 --- portable/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/portable/Dockerfile b/portable/Dockerfile index 72104cb..e41b295 100644 --- a/portable/Dockerfile +++ b/portable/Dockerfile @@ -37,16 +37,16 @@ # To update the base image: docker buildx imagetools inspect alpine:3.24 FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS build -ARG OPENSSL_VERSION=openssl-3.6.5 -ARG OPENSSL_COMMIT=c8bd5a57108599ac650bbae77fcabe3109dab2e8 +ARG OPENSSL_VERSION=openssl-4.1.0 +ARG OPENSSL_COMMIT=2f4e3a5fec05394068cfa7379890ab552483a8a6 ARG NGHTTP3_VERSION=v1.18.0 ARG NGHTTP3_COMMIT=dbfc24286138cb0b6490160e7ca87fe1ce6722a0 ARG NGTCP2_VERSION=v1.25.0 ARG NGTCP2_COMMIT=f9e9ff01ad2c8116bc09de4f644b0028a61486a6 ARG NGHTTP2_VERSION=v1.70.0 ARG NGHTTP2_COMMIT=85e300c79fb6dbcfa9c1013215c8710c1c2cd3d2 -ARG CURL_VERSION=curl-8_22_0 -ARG CURL_COMMIT=01346829096c61b372692f6dc43ffa778c6caccd +ARG CURL_VERSION=curl-8_23_0 +ARG CURL_COMMIT=3163ec065643ba20709f190111f42c3e1fe95273 RUN apk add --no-cache build-base linux-headers cmake git perl pkgconf autoconf automake libtool curl \ libev-dev c-ares-dev ca-certificates From 04349da336b9bbfe6be6b214d12bf470260fe13c Mon Sep 17 00:00:00 2001 From: "serhii.n" Date: Mon, 26 Oct 2026 12:01:46 +0200 Subject: [PATCH 5/6] Run unit, integration and functional tests for portable builds - Build and run unit tests inside the Docker build, linked against the same static libraries as the release binary. - Run the TCP lifecycle integration test and the Robot Framework functional tests on the runner against the built static binary, from a copy outside the workspace so logs do not change the build context. - Test the pushed image with a resolver given by hostname, over UDP and TCP, and move the "latest" tag only after it passes. --- .github/workflows/portable-release.yml | 56 ++++++++++++++++++++++++-- portable/Dockerfile | 6 ++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/.github/workflows/portable-release.yml b/.github/workflows/portable-release.yml index dcda755..e757a8c 100644 --- a/.github/workflows/portable-release.yml +++ b/.github/workflows/portable-release.yml @@ -119,6 +119,39 @@ jobs: out/https_dns_proxy -V | tee versions.txt grep -q HTTP3 versions.txt + # Unit tests already ran inside the Docker build. These tests need network access, + # dig, valgrind and Robot Framework, so they run here against the built binary. + - name: Install test dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends dnsutils valgrind + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install robotframework + + - name: Integration tests + run: | + python3 tests/integration/test_tcp_lifecycle.py \ + --binary "$RUNNER_TEMP/out/https_dns_proxy" --openssl "$(command -v openssl)" + + # Robot and valgrind write logs into the current directory, so the tests run from a + # copy outside the workspace (a changed workspace would change the build context) + - name: Functional tests + run: | + cp -r tests/robot "$RUNNER_TEMP/robot" + cd "$RUNNER_TEMP/robot" + "$RUNNER_TEMP/venv/bin/python3" -m robot.run \ + --variable "BINARY_PATH:$RUNNER_TEMP/out/https_dns_proxy" \ + --outputdir "$RUNNER_TEMP/robot-results" \ + functional_tests.robot + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: failure() + with: + name: functional-test-logs + path: | + ${{ runner.temp }}/robot-results + ${{ runner.temp }}/robot/valgrind-*.log + - name: Build and push Docker image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: @@ -126,9 +159,8 @@ jobs: file: portable/Dockerfile target: image push: true - tags: | - ${{ env.IMAGE }}:${{ env.VERSION }} - ${{ env.IMAGE }}:latest + # "latest" is added only after the image passes the checks below + tags: ${{ env.IMAGE }}:${{ env.VERSION }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -145,6 +177,24 @@ jobs: docker rm "$container" cmp out/https_dns_proxy image_binary + # The resolver is given by hostname, so this also checks the bundled CA certificates + # and /etc/services, which only the image provides + - name: Image test + run: | + docker run -d --name image_test -p 127.0.0.1:5053:5053/udp -p 127.0.0.1:5053:5053/tcp \ + "$IMAGE:$VERSION" -4 -v -v -v -r https://cloudflare-dns.com/dns-query + sleep 3 + status=0 + dig +tries=3 +time=5 @127.0.0.1 -p 5053 example.com | grep -q 'status: NOERROR' || status=1 + dig +tcp +tries=3 +time=5 @127.0.0.1 -p 5053 example.com | grep -q 'status: NOERROR' || status=1 + docker logs image_test + docker rm -f image_test + exit "$status" + + - name: Tag image as latest + # Copies the tag in the registry, keeping the same digest and attestations + run: docker buildx imagetools create --tag "$IMAGE:latest" "$IMAGE:$VERSION" + - name: Prepare release assets working-directory: ${{ runner.temp }} run: | diff --git a/portable/Dockerfile b/portable/Dockerfile index e41b295..1346a23 100644 --- a/portable/Dockerfile +++ b/portable/Dockerfile @@ -102,13 +102,17 @@ RUN git clone --depth 1 -b ${CURL_VERSION} https://github.com/curl/curl \ COPY . /usr/src/https_dns_proxy WORKDIR /usr/src/https_dns_proxy -# libsystemd has no static library, so the build goes without sd_notify support +# libsystemd has no static library, so the build goes without sd_notify support. +# Unit tests are built with the same static libraries as the binary and must pass, +# otherwise the build fails. Tests that need network or root run in CI instead. RUN cmake . -DUSE_CLANG_TIDY=OFF \ + -DBUILD_TESTING=ON \ -DCMAKE_PREFIX_PATH=$PREFIX \ -DCUSTOM_LIBCURL_INSTALL_PATH=$PREFIX \ -DCMAKE_EXE_LINKER_FLAGS="-static" \ -DCMAKE_C_STANDARD_LIBRARIES="$(pkg-config --static --libs libcurl libcares)" \ && make -j$(nproc) \ + && ctest -L unit --output-on-failure \ && strip https_dns_proxy # Everything the image needs is gathered in one directory, so it is copied as a single layer. From a984f8185f3da4e7df4862523416f9a532dc2995 Mon Sep 17 00:00:00 2001 From: "serhii.n" Date: Mon, 26 Oct 2026 12:14:23 +0200 Subject: [PATCH 6/6] Test the Docker image with HTTP/3 only Run the pushed image with -q against a resolver given by hostname, query it over UDP and TCP, and require curl to log "using HTTP/3", so a silent fallback or a broken QUIC stack blocks the release. --- .github/workflows/portable-release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/portable-release.yml b/.github/workflows/portable-release.yml index e757a8c..c855760 100644 --- a/.github/workflows/portable-release.yml +++ b/.github/workflows/portable-release.yml @@ -191,6 +191,24 @@ jobs: docker rm -f image_test exit "$status" + # -q allows only HTTP/3, so a successful answer means QUIC works in the image. + # "using HTTP/3" is logged by curl once the connection really uses HTTP/3. + - name: Image test (HTTP/3 only) + run: | + docker run -d --name image_test_http3 -p 127.0.0.1:5053:5053/udp -p 127.0.0.1:5053:5053/tcp \ + "$IMAGE:$VERSION" -4 -q -v -v -v -r https://cloudflare-dns.com/dns-query + sleep 3 + status=0 + dig +tries=3 +time=5 @127.0.0.1 -p 5053 example.com | grep -q 'status: NOERROR' || status=1 + dig +tcp +tries=3 +time=5 @127.0.0.1 -p 5053 example.com | grep -q 'status: NOERROR' || status=1 + docker logs image_test_http3 > "$RUNNER_TEMP/image_test_http3.log" 2>&1 + docker rm -f image_test_http3 + grep -q 'using HTTP/3' "$RUNNER_TEMP/image_test_http3.log" || status=1 + if [ "$status" -ne 0 ]; then + cat "$RUNNER_TEMP/image_test_http3.log" + fi + exit "$status" + - name: Tag image as latest # Copies the tag in the registry, keeping the same digest and attestations run: docker buildx imagetools create --tag "$IMAGE:latest" "$IMAGE:$VERSION"