Skip to content

Add portable static build with HTTP/3 and daily releases (binary + Docker image) - #216

Open
serhii-nakon wants to merge 6 commits into
aarond10:masterfrom
serhii-nakon:release_cicd
Open

serhii-nakon wants to merge 6 commits into
aarond10:masterfrom
serhii-nakon:release_cicd

Conversation

@serhii-nakon

@serhii-nakon serhii-nakon commented Oct 26, 2026 •

Copy link
Copy Markdown

Summary

This adds an optional, self-contained way to ship https_dns_proxy as a single
portable executable and as a minimal Docker image, built and published
automatically by GitHub Actions. Nothing in src/ or in the existing CMake
build changes.

What is added

  • portable/Dockerfile: builds a fully static https_dns_proxy with
    HTTP/3 (QUIC) support on Alpine (musl). It follows
    development_build_with_http3.sh. OpenSSL, nghttp3, ngtcp2, nghttp2 and curl
    are built from source. libev and c-ares come from Alpine packages, which
    already include static libraries. The unit tests (ctest -L unit) are built
    with the same static libraries and must pass, or the build fails. The release
    binary is byte-identical with and without BUILD_TESTING. It has two targets:
    • binary: exports only the executable.
    • image: the executable, a CA bundle and /etc/services in one layer on
      an empty (scratch) image
      . It runs as UID 65534 and listens on
      0.0.0.0:5053.
  • .github/workflows/portable-release.yml: runs daily and on manual
    dispatch, on the default branch only. If src/, CMakeLists.txt or
    portable/ changed since the last release, it:
    1. builds the binary (unit tests run inside the build) and runs a smoke test
      (it must be static and report HTTP3),
    2. runs the TCP lifecycle integration test and the Robot Framework functional
      tests against the built static binary,
    3. pushes ghcr.io/<owner>/https_dns_proxy:<YYYY.MM.DD>,
    4. checks that the binary in the image is byte-identical to the release
      binary, and tests the image over UDP and TCP with a resolver given by
      hostname, once with default settings and once with -q (HTTP/3 only,
      checking that curl reports using HTTP/3),
    5. only then moves the latest tag to this image,
    6. creates a GitHub release tagged YYYY.MM.DD with
      https_dns_proxy-linux-amd64, https_dns_proxy.service and SHA256SUMS.
  • portable/https_dns_proxy.service: a systemd unit for the portable
    binary, with DynamicUser and sandboxing options.
  • README: a "Portable binary and Docker image" section.

Why

  • A single binary that runs on any Linux x86_64 system, regardless of its libc
    or installed packages. No build step and no libcurl with HTTP/3 needed on
    the target.
  • An official, minimal image as an alternative to the externally maintained one.

Trade-offs to be aware of

  • Size: about 8 MB instead of <30 KiB. curl, OpenSSL, ngtcp2, nghttp3,
    nghttp2, c-ares, libev and musl are all linked in. The README explains this.
  • Updates: security fixes in the bundled libraries reach users only
    through a new release, not through distribution updates.
  • No sd_notify: libsystemd has no static library, so the unit uses
    Type=simple.
  • HTTP/3 build: development_build_with_http3.sh describes this kind of
    build as for development and testing. The portable build pins exact,
    current releases (OpenSSL 4.1.0, curl 8.23.0, ngtcp2 1.25.0, nghttp3 1.18.0,
    nghttp2 1.70.0), but you may want to decide whether you are comfortable
    publishing it as an official artifact.
  • Network tests gate the release: the functional tests use public DoH
    servers. If one of them is down, that day's release fails, and the next
    daily run retries it.
  • valgrind on a static binary: the Robot valgrind tests pass, but valgrind
    cannot replace malloc in a static binary, so leak detection is weaker
    than in the regular dynamic build that cmake.yml already checks.

Supply-chain hardening

  • The Alpine base image is pinned by digest.
  • Each library is cloned by its tag, and the build fails if the tag does not
    point to the pinned commit hash.
  • All GitHub Actions are pinned by commit hash, because the workflow can write
    releases and packages.
  • curl is built without brotli, zstd, zlib, libidn2, libpsl, libssh2, gssapi
    and LDAP, which a DoH proxy does not need.
  • Buildx adds a provenance attestation to the image, so the registry shows
    untagged sha256 entries next to each tag.

Setup needed after merge

  1. Make sure GitHub Actions are enabled. The workflow requests
    contents: write and packages: write.
  2. Run Actions → Portable release → Run workflow once to create the first
    release and image.

The image is linked to this repository through the
org.opencontainers.image.source label and gets the repository's visibility.
In my fork it was public right after the first push, with no extra settings.

GitHub disables scheduled workflows after 60 days without repository
activity. A commit or a re-enable restarts them.

Testing

I have been running this build on my home router (behind dnsmasq, with -q)
for about a month: first with OpenSSL 3.6.5 and curl 8.22.0, and for the last
3 days with OpenSSL 4.1.0 and curl 8.23.0. I have seen no issues so far.

The workflow is also running in my fork (serhii-nakon/https_dns_proxy) and has
already published releases and images. Checks done on the static build:

  • Unit tests in the Docker build: 8/8 pass.
  • TCP lifecycle integration test: 5/5 pass.
  • Robot Framework functional tests: 10/10 pass (in an Ubuntu 24.04 container
    similar to the GitHub runner).
  • In CI, the pushed image is tested with -q and must report using HTTP/3.
  • The release binary is static-pie linked, -V reports HTTP2 HTTP3, and
    SHA256SUMS verifies.
  • On the host with -q and a hostname resolver (cloudflare-dns.com):
    bootstrap works, using HTTP/3, queries resolve.
  • In the scratch image with -q -4: the certificate is verified from the
    bundled CA file, using HTTP/3, UDP and TCP queries resolve.
  • actionlint passes.

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/<owner>/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.
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.
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.
- 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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant