|
#!/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 |