Local caching for GitHub Actions self hosted runner using Squid Proxy

· Gist

11 min read Original article ↗
#!/bin/bash # # Set up a patched Squid caching proxy for self-hosted GitHub Actions runners. # # curl -fsSL https://gist.githubusercontent.com/osy/30c5c96d7575efd1d2a2db5e3def0815/raw/deploy.sh | bash # # or, to read it first (recommended): # # git clone https://gist.github.com/30c5c96d7575efd1d2a2db5e3def0815.git squid-cache # cd squid-cache && bash deploy.sh # # Safe to re-run: existing certificates and an existing squid.conf are left # alone unless you ask for them to be replaced. # # --force-config overwrite squid.conf and the store ID helper # --force-certs regenerate the CA and DH parameters (invalidates the CA # already trusted by your runners) # --skip-install don't touch Homebrew; only refresh config/certs/service # --system run squid and log rotation as LaunchDaemons that start at # boot (still as the current user) instead of LaunchAgents # that only run while this user is logged in; needs sudo # --uninstall stop the service and remove what this script installed # set -euo pipefail GIST_ID=30c5c96d7575efd1d2a2db5e3def0815 GIST_REPO="https://gist.github.com/${GIST_ID}.git" TAP=osy/local FORMULA=squid ASSETS=(squid.rb squid.conf github_store_id_helper.py org.squid-cache.logrotate.plist) CERT_DAYS=3650 DH_BITS=2048 force_config=0 force_certs=0 skip_install=0 system_service=0 uninstall=0 for arg in "$@"; do case "$arg" in --force-config) force_config=1 ;; --force-certs) force_certs=1 ;; --skip-install) skip_install=1 ;; --system) system_service=1 ;; --uninstall) uninstall=1 ;; -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;; *) echo "unknown option: $arg (try --help)" >&2; exit 2 ;; esac done say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } # ---------------------------------------------------------------- preflight -- [ "$(uname -s)" = "Darwin" ] || die "this script targets macOS" [ "$(id -u)" != "0" ] || die "do not run as root; Homebrew refuses to work under sudo" command -v brew >/dev/null || die "Homebrew is required: https://brew.sh" command -v openssl >/dev/null || die "openssl not found" # SQUID_PREFIX exists so the script can be exercised against a scratch # directory; normally the Homebrew prefix is the right answer. PREFIX="${SQUID_PREFIX:-$(brew --prefix)}" ETC="$PREFIX/etc" CONFDIR="$ETC/squid" VAR="$PREFIX/var" LOGS="$VAR/logs" CACHE="$VAR/cache/squid" AGENTS="$HOME/Library/LaunchAgents" PLIST_LABEL=org.squid-cache.logrotate PLIST="$AGENTS/$PLIST_LABEL.plist" # --system: the same two jobs as LaunchDaemons. A user LaunchAgent is started # only once its user logs in, so a machine that reboots into a different # autologin user (a CI runner account, say) would come up without its proxy. DAEMONS=/Library/LaunchDaemons SQUID_DAEMON_LABEL=org.squid-cache.squid SQUID_DAEMON="$DAEMONS/$SQUID_DAEMON_LABEL.plist" ROTATE_DAEMON="$DAEMONS/$PLIST_LABEL.plist" daemon_remove() { # daemon_remove <label> <plist> [ -f "$2" ] || return 0 sudo launchctl bootout "system/$1" 2>/dev/null || true sudo rm -f "$2" say "Removed $2" } # daemon_install <label> <source plist> <destination>: installs the plist with # UserName set to the current user and (re)loads it into the system domain. daemon_install() { local label="$1" src="$2" dst="$3" tmp tmp="$(mktemp)" cp "$src" "$tmp" plutil -replace Label -string "$label" "$tmp" plutil -replace UserName -string "$(id -un)" "$tmp" # Only meaningful for agents; a daemon with it set may refuse to load. plutil -remove LimitLoadToSessionType "$tmp" 2>/dev/null || true sudo launchctl bootout "system/$label" 2>/dev/null || true sudo install -m 644 -o root -g wheel "$tmp" "$dst" rm -f "$tmp" sudo launchctl bootstrap system "$dst" } # --------------------------------------------------------------- uninstall -- if [ "$uninstall" = 1 ]; then say "Stopping service" brew services stop "$FORMULA" 2>/dev/null || true daemon_remove "$SQUID_DAEMON_LABEL" "$SQUID_DAEMON" daemon_remove "$PLIST_LABEL" "$ROTATE_DAEMON" if [ -f "$PLIST" ]; then launchctl unload "$PLIST" 2>/dev/null || true rm -f "$PLIST" say "Removed $PLIST" fi brew unpin "$FORMULA" 2>/dev/null || true say "Leaving $CONFDIR, $CACHE and the Homebrew package in place." say "Remove them yourself if you want a clean slate:" echo " brew uninstall $FORMULA && brew untap $TAP" echo " rm -rf $CONFDIR $CACHE" exit 0 fi # ------------------------------------------------------------------ assets -- # Run from a checkout if the files are next to us; otherwise pull the gist. here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)" SRC="" if [ -n "$here" ] && [ -f "$here/squid.rb" ] && [ -f "$here/squid.conf" ]; then SRC="$here" say "Using assets from $SRC" else SRC="$(mktemp -d)" trap 'rm -rf "$SRC"' EXIT say "Fetching assets from gist $GIST_ID" if command -v git >/dev/null && git clone --depth 1 -q "$GIST_REPO" "$SRC/gist" 2>/dev/null; then SRC="$SRC/gist" else warn "git clone failed; falling back to raw downloads" for f in "${ASSETS[@]}"; do curl -fsSL -o "$SRC/$f" \ "https://gist.githubusercontent.com/osy/$GIST_ID/raw/$f" \ || die "could not download $f" done fi fi for f in "${ASSETS[@]}"; do [ -f "$SRC/$f" ] || die "missing asset: $f" done # ----------------------------------------------------------------- install -- if [ "$skip_install" = 1 ]; then say "Skipping Homebrew install (--skip-install)" elif [ -n "${SQUID_PREFIX:-}" ]; then die "SQUID_PREFIX is set; re-run with --skip-install (it only makes sense for testing)" else if ! brew tap | grep -qx "$TAP"; then say "Creating local tap $TAP" brew tap-new "$TAP" --no-git >/dev/null fi tapdir="$(brew --repository)/Library/Taps/${TAP%/*}/homebrew-${TAP#*/}" mkdir -p "$tapdir/Formula" cp "$SRC/squid.rb" "$tapdir/Formula/$FORMULA.rb" say "Installed formula into $tapdir/Formula/$FORMULA.rb" # Whatever is installed now gets replaced, so there is no need to tell # homebrew-core's bottle apart from an older build of this formula. if brew list --versions "$FORMULA" >/dev/null 2>&1; then say "Removing the currently installed squid" brew services stop "$FORMULA" 2>/dev/null || true [ ! -f "$SQUID_DAEMON" ] || sudo launchctl bootout "system/$SQUID_DAEMON_LABEL" 2>/dev/null || true brew unpin "$FORMULA" 2>/dev/null || true brew uninstall "$FORMULA" fi say "Building squid from source (this takes a few minutes)" brew install "$TAP/$FORMULA" # Without this a later "brew upgrade" swaps in homebrew-core's unpatched # bottle and range caching stops working. brew pin "$FORMULA" # grep the binary directly: "strings | grep -q" fails under pipefail # whenever grep exits before strings has finished writing (SIGPIPE, 141). if ! LC_ALL=C grep -a -q "x-ms-range" "$(brew --prefix "$FORMULA")/sbin/squid"; then die "the installed squid does not contain the patch; check the build output" fi say "Patched squid $(squid -v 2>/dev/null | head -1 | sed 's/.*Version //') installed" fi # ------------------------------------------------------------- directories -- say "Creating directories" mkdir -p "$CONFDIR" "$LOGS" "$VAR/log" "$CACHE" "$AGENTS" # ------------------------------------------------------------ certificates -- CA_KEY="$CONFDIR/squid-self-signed.key" CA_CRT="$CONFDIR/squid-self-signed.crt" CA_PEM="$CONFDIR/squid-self-signed.pem" DHPARAM="$CONFDIR/squid-self-signed_dhparam.pem" if [ "$force_certs" = 1 ] || [ ! -f "$CA_CRT" ] || [ ! -f "$CA_KEY" ]; then if [ -f "$CA_CRT" ]; then warn "replacing the existing CA; every runner must be given the new one" cp "$CA_CRT" "$CA_CRT.$(date +%Y%m%d%H%M%S).bak" fi say "Generating the SSL-bump CA (valid $CERT_DAYS days)" openssl req -new -newkey rsa:2048 -sha256 -days "$CERT_DAYS" -nodes -x509 \ -keyout "$CA_KEY" -out "$CA_CRT" \ -subj "/CN=Squid Runner Cache CA/O=$(hostname -s)" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign,cRLSign,digitalSignature" 2>/dev/null # NODE_EXTRA_CA_CERTS wants the certificate on its own, in PEM form. cp "$CA_CRT" "$CA_PEM" chmod 600 "$CA_KEY" chmod 644 "$CA_CRT" "$CA_PEM" else say "Keeping the existing CA ($CA_CRT)" fi if [ "$force_certs" = 1 ] || [ ! -f "$DHPARAM" ]; then say "Generating $DH_BITS-bit DH parameters (slow, up to a couple of minutes)" openssl dhparam -outform PEM -out "$DHPARAM" "$DH_BITS" 2>/dev/null chmod 644 "$DHPARAM" else say "Keeping the existing DH parameters" fi # Combined trust store: the public roots plus our bumping CA. # # NODE_EXTRA_CA_CERTS *adds* to Node's built-in roots, so Node is happy with the # bare CA. Python's SSL_CERT_FILE / REQUESTS_CA_BUNDLE and curl's CURL_CA_BUNDLE # *replace* the default bundle, so pointing those at the bare CA leaves the # client trusting Squid and nothing else -- it then rejects every host Squid # does not bump, with "unable to get local issuer certificate". Point them here. # # Rebuilt on every run so it picks up ca-certificates updates from Homebrew. CA_BUNDLE="$CONFDIR/ca-bundle.pem" base_roots="" for candidate in \ "$PREFIX/etc/ca-certificates/cert.pem" \ "$(python3 -m certifi 2>/dev/null || true)" \ "$(openssl version -d 2>/dev/null | sed 's/.*"\(.*\)"/\1/')/cert.pem" do if [ -n "$candidate" ] && [ -f "$candidate" ]; then base_roots="$candidate"; break; fi done if [ -n "$base_roots" ]; then cat "$base_roots" "$CA_CRT" > "$CA_BUNDLE" chmod 644 "$CA_BUNDLE" say "Built $CA_BUNDLE (public roots from $base_roots + the bumping CA)" else warn "no public CA bundle found; skipping $CA_BUNDLE" warn "clients using SSL_CERT_FILE/REQUESTS_CA_BUNDLE will only trust bumped hosts" fi # ------------------------------------------------------------------ config -- install_file() { # install_file <src> <dst> <mode> <what> local src="$1" dst="$2" mode="$3" what="$4" if [ -f "$dst" ] && [ "$force_config" != 1 ]; then if cmp -s "$src" "$dst"; then say "$what already up to date" else cp "$dst" "$dst.$(date +%Y%m%d%H%M%S).bak" warn "$dst differs from the gist version; kept yours, saved a .bak" warn "re-run with --force-config to overwrite it" fi return fi [ -f "$dst" ] && cp "$dst" "$dst.$(date +%Y%m%d%H%M%S).bak" install -m "$mode" "$src" "$dst" say "Installed $what" } # squid.conf ships with /opt/homebrew paths; rewrite them for this machine. conf_tmp="$(mktemp)" sed "s|/opt/homebrew|$PREFIX|g" "$SRC/squid.conf" > "$conf_tmp" install_file "$conf_tmp" "$ETC/squid.conf" 644 "squid.conf" rm -f "$conf_tmp" install_file "$SRC/github_store_id_helper.py" "$CONFDIR/github_store_id_helper.py" 755 "store ID helper" # ---------------------------------------------------------------- ssl_db ---- SSL_DB="$LOGS/ssl_db" CERTGEN="$PREFIX/opt/squid/libexec/security_file_certgen" if [ -x "$CERTGEN" ]; then if [ ! -d "$SSL_DB" ]; then say "Initialising the generated-certificate database" "$CERTGEN" -c -s "$SSL_DB" -M 20MB >/dev/null else say "Certificate database already present" fi else warn "$CERTGEN not found; skipping ssl_db init (run again after installing squid)" fi # ----------------------------------------------------------- cache + start -- if command -v squid >/dev/null && [ -z "${SQUID_PREFIX:-}" ]; then say "Validating the configuration" if squid -k parse 2>&1 | grep -E "^.*(ERROR|FATAL)" ; then die "squid -k parse reported errors (see above)" fi if [ ! -d "$CACHE/00" ]; then say "Building the cache directory structure" squid -z --foreground >/dev/null 2>&1 || true fi # Only one of the two service flavours may exist, or the second copy # crash-loops on the port the first one holds. if [ "$system_service" = 1 ]; then say "Starting squid (LaunchDaemon $SQUID_DAEMON_LABEL)" brew services stop "$FORMULA" >/dev/null 2>&1 || true # The launchd plist Homebrew generates from the formula's service # block: sh.brew.<formula>.plist since Homebrew 7, homebrew.mxcl.* before. keg_plist="" for f in "$PREFIX/opt/$FORMULA/sh.brew.$FORMULA.plist" \ "$PREFIX/opt/$FORMULA/homebrew.mxcl.$FORMULA.plist"; do if [ -f "$f" ]; then keg_plist="$f"; break; fi done [ -n "$keg_plist" ] || die "no launchd plist for $FORMULA in $PREFIX/opt/$FORMULA" # Start squid through a wrapper that drops a stale PID file. Squid # refuses to start while its PID file names a live process, and the # file outlives a reboot or power cut: launchd kills squid before its # 30-second shutdown_lifetime is up, so squid never removes it. Boot # order is deterministic, so after a reboot the recorded PID often # belongs to some other daemon and squid crash-loops with "Squid is # already running". A PID that really is a squid is left alone. squid_plist="$(mktemp)" cp "$keg_plist" "$squid_plist" pidfile="$VAR/run/$FORMULA.pid" exec_args="" n="$(plutil -extract ProgramArguments raw -o - "$squid_plist")" i=0 while [ "$i" -lt "$n" ]; do exec_args="$exec_args '$(plutil -extract "ProgramArguments.$i" raw -o - "$squid_plist")'" i=$((i + 1)) done wrapper="pid=\$(cat '$pidfile' 2>/dev/null); if [ -n \"\$pid\" ]; then case \$(ps -p \"\$pid\" -o comm= 2>/dev/null) in *squid*) ;; *) rm -f '$pidfile' ;; esac; fi; exec$exec_args" plutil -remove ProgramArguments "$squid_plist" plutil -insert ProgramArguments -array "$squid_plist" plutil -insert ProgramArguments.0 -string /bin/sh "$squid_plist" plutil -insert ProgramArguments.1 -string -c "$squid_plist" plutil -insert ProgramArguments.2 -string "$wrapper" "$squid_plist" daemon_install "$SQUID_DAEMON_LABEL" "$squid_plist" "$SQUID_DAEMON" rm -f "$squid_plist" else say "Starting squid" daemon_remove "$SQUID_DAEMON_LABEL" "$SQUID_DAEMON" brew services restart "$FORMULA" >/dev/null fi sleep 3 if squid -k check 2>/dev/null; then say "squid is running" else warn "squid did not come up; check $LOGS/cache.log" fi fi # ------------------------------------------------------------ log rotation -- say "Installing daily log rotation ($PLIST_LABEL)" [ -f "$PLIST" ] && launchctl unload "$PLIST" 2>/dev/null || true if [ "$system_service" = 1 ]; then rm -f "$PLIST" rotate_tmp="$(mktemp)" sed "s|@PREFIX@|$PREFIX|g" "$SRC/org.squid-cache.logrotate.plist" > "$rotate_tmp" daemon_install "$PLIST_LABEL" "$rotate_tmp" "$ROTATE_DAEMON" \ || warn "launchctl bootstrap failed; load $ROTATE_DAEMON by hand" rm -f "$rotate_tmp" else daemon_remove "$PLIST_LABEL" "$ROTATE_DAEMON" sed "s|@PREFIX@|$PREFIX|g" "$SRC/org.squid-cache.logrotate.plist" > "$PLIST" chmod 644 "$PLIST" launchctl load "$PLIST" 2>/dev/null || warn "launchctl load failed; load $PLIST by hand" fi # -------------------------------------------------------------------- done -- if [ "$system_service" = 1 ]; then restart_cmd="sudo launchctl kickstart -k system/$SQUID_DAEMON_LABEL" else restart_cmd="brew services restart squid" fi cat <<EOF $(printf '\033[1;32mDone.\033[0m') Point each runner at the proxy by adding these to its .env: http_proxy=http://127.0.0.1:3128 https_proxy=http://127.0.0.1:3128 NODE_EXTRA_CA_CERTS=$CA_PEM SSL_CERT_FILE=$CA_BUNDLE REQUESTS_CA_BUNDLE=$CA_BUNDLE CURL_CA_BUNDLE=$CA_BUNDLE NODE_EXTRA_CA_CERTS gets the bare CA because Node adds it to its own roots. The other three get the combined bundle because they replace the default one: point them at the bare CA and the client will trust Squid and nothing else, breaking every host Squid does not bump. The proxy listens on 127.0.0.1 and [::1] only. If a runner is on another host, change the http_port lines in $ETC/squid.conf and firewall the port accordingly -- there is no authentication. Useful commands: $restart_cmd # after editing squid.conf squid -k parse # check the config tail -f $LOGS/access.log awk '{print \$4}' $LOGS/access.log | sort | uniq -c | sort -rn | head The CA expires $(date -v+${CERT_DAYS}d '+%Y-%m-%d' 2>/dev/null || echo "in $CERT_DAYS days"); re-run with --force-certs to replace it (every runner then needs the new $CA_PEM). EOF