diff --git a/.github/scripts/proxy-tls-test.sh b/.github/scripts/proxy-tls-test.sh index 145a0dd..a4fbf2d 100755 --- a/.github/scripts/proxy-tls-test.sh +++ b/.github/scripts/proxy-tls-test.sh @@ -2,33 +2,47 @@ # 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) +if [ "$OS" = windows ]; then SCRIPTS=$(cygpath -m "$SCRIPTS"); fi # handed to python.exe HOST=api.test API_PORT=8443 @@ -37,11 +51,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 @@ -56,19 +71,47 @@ 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" 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 +191,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 +219,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