From b9c488787751728ecd0ad6b626c357cf7cda30c2 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:16 +0200 Subject: [PATCH 1/3] Run the proxy and TLS inspection test on macOS and Windows The proxy-tls workflow becomes a matrix over Linux, macOS and Windows. Each runner uses the native mitmproxy release (pinned by checksum), and the test script puts the proxy's CA in the system store the way an administrator would: security add-trusted-cert on macOS, Import-Certificate on Windows. SSL_CERT_FILE is asserted to have no effect there, as the README states. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/scripts/proxy-tls-test.sh | 83 ++++++++++++++++++++++++------- .github/workflows/proxy-tls.yml | 69 ++++++++++++++++++------- README.md | 4 +- 3 files changed, 119 insertions(+), 37 deletions(-) diff --git a/.github/scripts/proxy-tls-test.sh b/.github/scripts/proxy-tls-test.sh index 145a0dd..e17d30a 100755 --- a/.github/scripts/proxy-tls-test.sh +++ b/.github/scripts/proxy-tls-test.sh @@ -2,30 +2,43 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Hodeitek S.L. # -# Proves the README's "Networking: proxies and TLS inspection" claims on Linux, end to end, with a -# real TLS-inspecting proxy (mitmproxy) and a fake API, all on this machine: +# Proves the README's "Networking: proxies and TLS inspection" claims on Linux, macOS and Windows, +# end to end, with a real TLS-inspecting proxy (mitmproxy) and a fake API, all on this machine: # # 1. HTTPS_PROXY set, proxy CA not trusted -> exit 7, "error: Could not reach the API:", certificate # 2. proxy CA in the system store -> works, and the proxy saw the request -# 3. proxy CA only in SSL_CERT_FILE -> works +# 3. proxy CA only in SSL_CERT_FILE -> works on Linux; no effect on macOS and +# Windows (same failure as case 1) # 4. NO_PROXY covers the host -> the proxy is not used # 5. lower-case https_proxy -> works like HTTPS_PROXY # 6. --verbose -> the request line goes to stderr only # -# The API is reached as api.test:8443, mapped to 127.0.0.1 in /etc/hosts, because the CLI never uses +# The API is reached as api.test:8443, mapped to 127.0.0.1 in the hosts file, because the CLI never uses # a proxy for a loopback URL. The fake API's certificate comes from one throwaway CA that only the # proxy trusts; the proxy re-signs with another throwaway CA, the one the CLI must trust. # -# This is meant to run in CI (GitHub Actions): it edits /etc/hosts and the system certificate store, -# and case 1 is a deliberate failure. It refuses to run elsewhere. +# The CA goes into the system store the way an administrator would do it: update-ca-certificates +# on Linux, `security add-trusted-cert` on macOS, Import-Certificate on Windows. On macOS and +# Windows the fake API's CA goes there too (case 4), since SSL_CERT_FILE is not read there. +# +# This is meant to run in CI (GitHub Actions): it edits the hosts file and the system certificate +# store, and case 1 is a deliberate failure. It refuses to run elsewhere. On Windows it runs in Git +# Bash, as an administrator. # # Usage: proxy-tls-test.sh PATH_TO_HODEISHIELD PATH_TO_MITMPROXY_DIR set -euo pipefail if [ "${GITHUB_ACTIONS:-}" != "true" ]; then - echo "proxy-tls-test.sh edits /etc/hosts and the system certificate store: it only runs in GitHub Actions." >&2 + echo "proxy-tls-test.sh edits the hosts file and the system certificate store: it only runs in GitHub Actions." >&2 exit 2 fi +export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep arguments such as -subj /CN=... as they are +case "$(uname -s)" in + Linux) OS=linux ;; + Darwin) OS=macos ;; + MINGW* | MSYS* | CYGWIN*) OS=windows ;; + *) echo "unsupported system: $(uname -s)" >&2; exit 2 ;; +esac BIN=$(realpath "${1:?usage: proxy-tls-test.sh HODEISHIELD MITMPROXY_DIR}") MITM=$(realpath "${2:?usage: proxy-tls-test.sh HODEISHIELD MITMPROXY_DIR}") SCRIPTS=$(cd "$(dirname "$0")" && pwd) @@ -37,11 +50,12 @@ API_URL="https://$HOST:$API_PORT" REQ_RE="GET https://$HOST:$API_PORT/v1/vendors" WORK=$(mktemp -d) +if [ "$OS" = windows ]; then WORK=$(cygpath -m "$WORK"); fi # C:/... form, understood by native programs PIDS=() # shellcheck disable=SC2329 # invoked by the EXIT trap cleanup() { - for pid in "${PIDS[@]}"; do kill "$pid" 2> /dev/null || true; done - rm -rf "$WORK" + for pid in ${PIDS[@]+"${PIDS[@]}"}; do kill "$pid" 2> /dev/null || true; done + rm -rf "$WORK" || true # on Windows a file can still be open for a moment } trap cleanup EXIT @@ -65,10 +79,37 @@ cat "$WORK/proxy-ca.key" "$WORK/proxy-ca.crt" > "$WORK/confdir/mitmproxy-ca.pem" cp "$WORK/proxy-ca.crt" "$WORK/confdir/mitmproxy-ca-cert.pem" # --- Name, fake API, proxy -------------------------------------------------------------------- -echo "127.0.0.1 $HOST" | sudo tee -a /etc/hosts > /dev/null +SUDO=sudo +HOSTS_FILE=/etc/hosts +PY=python3 +if [ "$OS" = windows ]; then + SUDO= # the runner account is already an administrator + HOSTS_FILE=/c/Windows/System32/drivers/etc/hosts + PY=python +fi +echo "127.0.0.1 $HOST" | $SUDO tee -a "$HOSTS_FILE" > /dev/null +if [ "$OS" = macos ]; then sudo dscacheutil -flushcache; fi +if [ "$OS" = windows ]; then ipconfig /flushdns > /dev/null; fi + +# Puts a CA certificate (PEM) into the system store, the way an administrator would. +trust_ca() { + case "$OS" in + linux) + sudo cp "$1" "/usr/local/share/ca-certificates/hodeishield-test-$2.crt" + sudo update-ca-certificates > /dev/null + ;; + macos) + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$1" + ;; + windows) + powershell.exe -NoProfile -Command \ + "Import-Certificate -FilePath '$1' -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null" + ;; + esac +} : > "$WORK/api.log" -python3 -I "$SCRIPTS/proxy-tls-fake-api.py" "$WORK/api.crt" "$WORK/api.key" "$API_PORT" "$WORK/api.log" & +"$PY" -I "$SCRIPTS/proxy-tls-fake-api.py" "$WORK/api.crt" "$WORK/api.key" "$API_PORT" "$WORK/api.log" & PIDS+=($!) PYTHONUNBUFFERED=1 "$MITM/mitmdump" --set "confdir=$WORK/confdir" \ @@ -148,16 +189,23 @@ else fi # --- Case 3: the proxy's CA only in SSL_CERT_FILE (run before it enters the system store) ----- -p0=$(proxy_seen) +# Linux reads the variable; macOS and Windows use the system store only (README), so there it +# must fail like case 1. +p0=$(proxy_seen) a0=$(api_seen) cli HTTPS_PROXY="$PROXY" SSL_CERT_FILE="$WORK/proxy-ca.crt" -- vendors list --json -if works && wait_proxy_above "$p0"; then - record 3 "proxy CA only in SSL_CERT_FILE: works through the proxy" PASS "exit $rc" +if [ "$OS" = linux ]; then + desc="proxy CA only in SSL_CERT_FILE: works through the proxy" + if works && wait_proxy_above "$p0"; then ok=1; else ok=0; fi else - record 3 "proxy CA only in SSL_CERT_FILE: works through the proxy" FAIL "exit $rc" - show_failure 3 + desc="proxy CA only in SSL_CERT_FILE: no effect here, exit 7, certificate error" + sleep 1 + if [ "$rc" -eq 7 ] && [[ "$(cat "$WORK/err")" == "error: Could not reach the API:"* ]] \ + && [ "$(proxy_seen)" -eq "$p0" ] && [ "$(api_seen)" -eq "$a0" ]; then ok=1; else ok=0; fi fi +if [ "$ok" -eq 1 ]; then record 3 "$desc" PASS "exit $rc"; else record 3 "$desc" FAIL "exit $rc"; show_failure 3; fi # --- Case 4: NO_PROXY covers the host: direct, the proxy never sees it ------------------------ +if [ "$OS" != linux ]; then trust_ca "$WORK/upstream-ca.crt" upstream; fi p0=$(proxy_seen) a0=$(api_seen) cli HTTPS_PROXY="$PROXY" NO_PROXY="$HOST" SSL_CERT_FILE="$WORK/upstream-ca.crt" -- vendors list --json sleep 1 @@ -169,8 +217,7 @@ else fi # --- Case 2: the proxy's CA in the system store ------------------------------------------------ -sudo cp "$WORK/proxy-ca.crt" /usr/local/share/ca-certificates/hodeishield-test-proxy-ca.crt -sudo update-ca-certificates > /dev/null +trust_ca "$WORK/proxy-ca.crt" proxy p0=$(proxy_seen) cli HTTPS_PROXY="$PROXY" -- vendors list --json if works && wait_proxy_above "$p0"; then diff --git a/.github/workflows/proxy-tls.yml b/.github/workflows/proxy-tls.yml index fa89115..89d8b48 100644 --- a/.github/workflows/proxy-tls.yml +++ b/.github/workflows/proxy-tls.yml @@ -1,7 +1,8 @@ name: Proxy and TLS inspection -# Checks the README's "Networking: proxies and TLS inspection" claims on Linux against a real -# TLS-inspecting proxy (mitmproxy) and a fake API, both on the runner, with throwaway certificates: +# Checks the README's "Networking: proxies and TLS inspection" claims on Linux, macOS and Windows +# against a real TLS-inspecting proxy (mitmproxy) and a fake API, both on the runner, with +# throwaway certificates: # nothing secret is used and nothing leaves the machine except the download of mitmproxy. Case 1 # is a deliberate certificate failure, so the script is only meant to run here (see # .github/scripts/proxy-tls-test.sh). @@ -34,19 +35,42 @@ env: jobs: proxy-tls: - name: proxy-tls - runs-on: ubuntu-latest - timeout-minutes: 15 + name: proxy-tls (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 permissions: contents: read + strategy: + fail-fast: false + matrix: + # Official mitmproxy releases, pinned by version and checksum. Bumped by hand. + # The download site publishes no checksum file, so each SHA-256 is the one of the + # release archive as downloaded when it was pinned (trust on first use). mitmproxy + # is only a test tool here and is never shipped. When bumping, record the new + # archives' SHA-256 the same way. + include: + - os: ubuntu-latest + mitm_archive: mitmproxy-12.2.3-linux-x86_64.tar.gz + mitm_sha256: 2e95286b618fa6fd33e5e62a78c2e5112571d85f42ec2bac29b97ee242bdb5c5 + mitm_dir: mitmproxy + bin: target/debug/hodeishield + - os: macos-latest + mitm_archive: mitmproxy-12.2.3-macos-arm64.tar.gz + mitm_sha256: 0a09ee3b82569e8985aff8186e4792618b8e5d0c766098db093d09a87d4b013a + mitm_dir: mitmproxy/mitmproxy.app/Contents/MacOS + bin: target/debug/hodeishield + - os: windows-latest + mitm_archive: mitmproxy-12.2.3-windows-x86_64.zip + mitm_sha256: 04a01ea95ae96df75058a893e774957d294e69012dab1f4e256ce2b0c6725483 + mitm_dir: mitmproxy + bin: target/debug/hodeishield.exe + defaults: + run: + shell: bash env: - # Official Linux release of mitmproxy, pinned by version and checksum. Bumped by hand. - # The download site publishes no checksum file, so the SHA-256 is the one of the - # release archive as downloaded when it was pinned (trust on first use). mitmproxy - # is only a test tool here and is never shipped. When bumping, record the new - # archive's SHA-256 the same way. MITMPROXY_VERSION: "12.2.3" - MITMPROXY_SHA256: 2e95286b618fa6fd33e5e62a78c2e5112571d85f42ec2bac29b97ee242bdb5c5 + MITM_ARCHIVE: ${{ matrix.mitm_archive }} + MITM_SHA256: ${{ matrix.mitm_sha256 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -67,12 +91,23 @@ jobs: - name: Install mitmproxy run: | set -euo pipefail - archive="mitmproxy-${MITMPROXY_VERSION}-linux-x86_64.tar.gz" - curl --proto '=https' --tlsv1.2 -fsSL -o "$RUNNER_TEMP/$archive" \ - "https://downloads.mitmproxy.org/${MITMPROXY_VERSION}/${archive}" - echo "${MITMPROXY_SHA256} $RUNNER_TEMP/$archive" | sha256sum -c - + curl --proto '=https' --tlsv1.2 -fsSL -o "$RUNNER_TEMP/$MITM_ARCHIVE" \ + "https://downloads.mitmproxy.org/${MITMPROXY_VERSION}/${MITM_ARCHIVE}" + if command -v sha256sum > /dev/null; then sum=sha256sum; else sum="shasum -a 256"; fi + echo "${MITM_SHA256} $RUNNER_TEMP/$MITM_ARCHIVE" | $sum -c - mkdir "$RUNNER_TEMP/mitmproxy" - tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP/mitmproxy" + case "$MITM_ARCHIVE" in + *.zip) 7z x -y "-o$RUNNER_TEMP/mitmproxy" "$RUNNER_TEMP/$MITM_ARCHIVE" > /dev/null ;; + *) tar -xzf "$RUNNER_TEMP/$MITM_ARCHIVE" -C "$RUNNER_TEMP/mitmproxy" ;; + esac + + # macOS ships LibreSSL as `openssl`; the script needs OpenSSL 3 (req -addext). + - name: Use Homebrew OpenSSL on macOS + if: runner.os == 'macOS' + run: echo "$(brew --prefix openssl@3)/bin" >> "$GITHUB_PATH" - name: Run the proxy cases - run: bash .github/scripts/proxy-tls-test.sh target/debug/hodeishield "$RUNNER_TEMP/mitmproxy" + env: + BIN: ${{ matrix.bin }} + MITM_DIR: ${{ matrix.mitm_dir }} + run: bash .github/scripts/proxy-tls-test.sh "$BIN" "${RUNNER_TEMP//\\//}/$MITM_DIR" diff --git a/README.md b/README.md index 8d4e54b..280898d 100644 --- a/README.md +++ b/README.md @@ -751,8 +751,8 @@ message that starts with `Could not reach the API:` (or `Could not reach the app followed by the cause. A certificate error there usually means the proxy's CA is missing from the store described above. -This behaviour comes from the CLI's HTTP library. It is verified on Linux in CI against a -TLS-inspecting proxy; macOS and Windows have not been verified yet. +This behaviour comes from the CLI's HTTP library. It is verified in CI on Linux, macOS and Windows +against a TLS-inspecting proxy whose CA is installed in the system store. ## Repository layout From 38d1aac09399ace14e00e031b076df6b75a35e1e Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:39:36 +0200 Subject: [PATCH 2/3] Write the test certificate's extensions to a file, which openssl on Windows can open Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/scripts/proxy-tls-test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/proxy-tls-test.sh b/.github/scripts/proxy-tls-test.sh index e17d30a..26ec601 100755 --- a/.github/scripts/proxy-tls-test.sh +++ b/.github/scripts/proxy-tls-test.sh @@ -70,9 +70,10 @@ make_ca proxy # the "corporate" CA the proxy re-signs with; what the CLI must openssl req -newkey rsa:2048 -nodes -subj "/CN=$HOST" \ -keyout "$WORK/api.key" -out "$WORK/api.csr" 2> /dev/null +# A file, not <(...): a native openssl.exe on Windows cannot open /dev/fd paths. +printf 'subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\n' "$HOST" > "$WORK/api.ext" openssl x509 -req -in "$WORK/api.csr" -CA "$WORK/upstream-ca.crt" -CAkey "$WORK/upstream-ca.key" \ - -CAcreateserial -days 2 -out "$WORK/api.crt" \ - -extfile <(printf 'subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\n' "$HOST") 2> /dev/null + -CAcreateserial -days 2 -out "$WORK/api.crt" -extfile "$WORK/api.ext" 2> /dev/null mkdir "$WORK/confdir" cat "$WORK/proxy-ca.key" "$WORK/proxy-ca.crt" > "$WORK/confdir/mitmproxy-ca.pem" From 5c866e3c8a890ce130dcc4c822b13b92669821f6 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:45:03 +0200 Subject: [PATCH 3/3] Hand python on Windows the scripts folder in a form it can open Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/scripts/proxy-tls-test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/proxy-tls-test.sh b/.github/scripts/proxy-tls-test.sh index 26ec601..a4fbf2d 100755 --- a/.github/scripts/proxy-tls-test.sh +++ b/.github/scripts/proxy-tls-test.sh @@ -42,6 +42,7 @@ esac BIN=$(realpath "${1:?usage: proxy-tls-test.sh HODEISHIELD MITMPROXY_DIR}") MITM=$(realpath "${2:?usage: proxy-tls-test.sh HODEISHIELD MITMPROXY_DIR}") SCRIPTS=$(cd "$(dirname "$0")" && pwd) +if [ "$OS" = windows ]; then SCRIPTS=$(cygpath -m "$SCRIPTS"); fi # handed to python.exe HOST=api.test API_PORT=8443