diff --git a/.gitignore b/.gitignore index 41626af..17be119 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ id_ed25519 id_ed25519_* *.pem +# Squid TLS-interception CA -- generated on the host at deploy time, never +# committed (the private key can MITM any client that trusts it). +deployments/squid/ssl/ + # ── Backups ───────────────────────────────────────────────────────────────── *.tar.gz.age *-backup-*.tar.gz* diff --git a/README.md b/README.md index ade3f37..585aa8c 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,13 @@ deployments// # one folder per stack | [`beszel`](deployments/beszel/) | Server monitoring hub. OIDC via pocket-id (post-deploy). | pocket-id (OIDC, optional) | | [`headscale`](deployments/headscale/) | Self-hosted Tailscale control server, OIDC login. | pocket-id (OIDC) | | [`webfinger`](deployments/webfinger/) | Serves `/.well-known/webfinger` for OIDC discovery; redirects the rest. | pocket-id (issuer) | +| [`squid`](deployments/squid/) | SSL-bump caching forward proxy — static-content cache + TLS interception via a local CA. **The exception: a forward proxy, not a Caddy/LE site.** | — | | [`simplex`](deployments/simplex/) | SimpleX SMP + XFTP relay with Tor hidden services + encrypted backups. | globals/age-pubkey.txt | ## Conventions -- **Alpine + Docker Compose + Caddy/Let's Encrypt** across every stack. +- **Alpine + Docker Compose + Caddy/Let's Encrypt** across every stack (except + `squid`, which is a forward proxy with a local TLS-interception CA — no Caddy/LE). - **`build.sh` → `deploy.sh`**: each stack's `build.sh` embeds its `docker-compose.yml` / `Caddyfile` / `.env.example` into a single self-contained `deploy.sh` (base64 tar.gz). That one file can be `scp`'d to a @@ -140,8 +142,8 @@ instance user-data, and the host configures itself on first boot. ## Multi-OS notes The host-provisioning scripts (`setup-host`, `harden-ssh`, `harden-jumphost`, -`sshuser`) and the four Docker stacks (pocket-id, beszel, headscale, webfinger) -run on Alpine, Debian, and Alma. Distro differences live in +`sshuser`) and the five Docker stacks (pocket-id, beszel, headscale, webfinger, +squid) run on Alpine, Debian, and Alma. Distro differences live in [`scripts/oslib.sh`](scripts/oslib.sh) — package manager (`apk`/`apt`/`dnf`), init system (OpenRC/systemd), sshd service name, the per-distro `sftp-server` path, hostname, boot hooks, and the sshguard log source/backend. diff --git a/automations.sh b/automations.sh index 43a5b19..893d233 100644 --- a/automations.sh +++ b/automations.sh @@ -62,7 +62,7 @@ fi . "$ROOT/scripts/lib.sh" load_globals -DEPLOYMENTS=(pocket-id beszel headscale webfinger simplex) +DEPLOYMENTS=(pocket-id beszel headscale webfinger squid simplex) SCRIPTS=(setup-host harden-ssh harden-jumphost sshuser auto-update) # ---------------------------------------------------------------------------- @@ -108,6 +108,11 @@ ask_deployment_vars() { ask ISSUER_URL "OIDC issuer URL (e.g. https://auth.example.com)" ask REDIRECT_URL "Redirect target for other traffic (e.g. https://example.org)" ask ACME_EMAIL "Let's Encrypt email" ;; + squid) + ask TRUSTED_CIDR "Trusted client CIDR(s) allowed to use the proxy (e.g. 100.64.0.0/10)" + ask BIND_ADDR "Host IP to bind the proxy on (blank = 0.0.0.0)" optional + ask CACHE_SIZE_MB "On-disk cache size in MB (blank = 5000)" optional + ask CACHE_ONLY_LISTED "Cache ONLY listed domains? (1=yes, blank=boost mode)" optional ;; simplex) ask DOMAIN "Apex domain (creates smp.DOMAIN, xftp.DOMAIN)" ask ACME_EMAIL "Let's Encrypt email" diff --git a/deployments/squid/.env.example b/deployments/squid/.env.example new file mode 100644 index 0000000..6152aa0 --- /dev/null +++ b/deployments/squid/.env.example @@ -0,0 +1,41 @@ +# Copy to .env and fill in. docker compose picks .env up automatically. +# +# Squid SSL-bump caching forward proxy. Unlike the other stacks there is NO +# public hostname / Let's Encrypt cert -- this is a forward proxy. The TLS +# interception CA is generated on first deploy (deploy.sh) and never overwritten. + +# ─── Who may use the proxy ────────────────────────────────────────────────── +# Space-separated CIDR(s) allowed to connect (Squid http_access). This is the +# REAL access gate -- keep it tight. Examples: 100.64.0.0/10 (Tailscale CGNAT), +# 10.0.0.0/8, 192.168.0.0/16. +TRUSTED_CIDR=100.64.0.0/10 + +# Host interface/IP to publish the proxy on, and the host-side port. Pin +# BIND_ADDR to a trusted interface (e.g. your Tailscale IP) -- a published +# Docker port BYPASSES the host INPUT firewall, so 0.0.0.0 exposes the proxy to +# every reachable network. Use 0.0.0.0 only if TRUSTED_CIDR + upstream +# firewalling already cover you. +BIND_ADDR=0.0.0.0 +PROXY_PORT=3128 + +# ─── Cache sizing ─────────────────────────────────────────────────────────── +CACHE_SIZE_MB=5000 # on-disk cache budget (MB) +MAX_OBJECT_SIZE_MB=256 # largest single object to cache (raise for ISOs/images) +CACHE_MEM_MB=256 # in-memory hot cache (MB) + +# ─── Cache scope ──────────────────────────────────────────────────────────── +# 0 = boost mode (default): cache everything per normal HTTP rules, and +# force-cache the domains in cache-domains.txt / .regex with long TTLs. +# 1 = strict allowlist: store ONLY the listed domains; pass the rest through. +CACHE_ONLY_LISTED=0 + +# ─── TLS interception CA (generated on first deploy) ──────────────────────── +CA_CN=Squid Proxy CA +CA_O=automations +CA_DAYS=3650 +DYNAMIC_CERT_MEM_MB=8 # in-RAM cache of generated per-host leaf certs (MB) + +# ─── Misc ─────────────────────────────────────────────────────────────────── +VISIBLE_HOSTNAME=squid-proxy +# Local build tag for the image (built from ./Dockerfile). +SQUID_IMAGE_TAG=automations/squid:latest diff --git a/deployments/squid/Dockerfile b/deployments/squid/Dockerfile new file mode 100644 index 0000000..6de7cf2 --- /dev/null +++ b/deployments/squid/Dockerfile @@ -0,0 +1,22 @@ +# Minimal Squid image with SSL-bump. +# +# Alpine ships its `squid` package built `--with-openssl`, so ssl-bump, +# https_port and security_file_certgen are all compiled in -- no +# compile-from-source needed (Debian/Ubuntu, by contrast, build squid against +# GnuTLS and need the separate `squid-openssl` package). openssl is included so +# deploy.sh can mint the CA via this image without a host openssl dependency. +FROM alpine:3.21 + +RUN apk add --no-cache squid ca-certificates openssl tini \ + && update-ca-certificates + +COPY squid.conf.tmpl /etc/squid/squid.conf.tmpl +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Explicit forward-proxy port. Caching/inspection happen here; clients set +# HTTP(S)_PROXY to this host:3128. +EXPOSE 3128 + +# tini reaps zombies and forwards signals so `docker stop` shuts squid cleanly. +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/deployments/squid/README.md b/deployments/squid/README.md new file mode 100644 index 0000000..7c3f4cb --- /dev/null +++ b/deployments/squid/README.md @@ -0,0 +1,134 @@ +# squid — SSL-bump caching forward proxy + +A [Squid](https://www.squid-cache.org/) **forward proxy** that caches static +content to cut bandwidth across a fleet and **intercepts TLS** (SSL-bump) with a +locally-generated CA so HTTPS can be cached and inspected. + +This is the one deployment that **breaks the repo's Caddy/Let's-Encrypt +convention on purpose**: it is not an inbound web service, has no public +hostname, and uses no ACME cert. Clients point `HTTP(S)_PROXY` at it and trust +its CA. + +> ⚠️ **TLS interception — read first.** SSL-bump decrypts your clients' HTTPS. +> The CA private key it generates can impersonate **any** site to **any** client +> that trusts it. Only run this on networks and devices **you own and are +> authorized to inspect**. Keep `ssl/squid-ca-key.pem` secret. Cert-pinned and +> HSTS sites (banking, app stores) will break unless you **splice** them +> (passthrough, see below). + +## What it does + +- **Explicit forward proxy** on port `3128`. Clients set `http_proxy` / + `https_proxy` (transparent/intercepting mode is future work — see end). +- **SSL-bump**: peek at the TLS SNI → **splice** (passthrough, no decryption) + the domains in `splice-domains.txt` → **bump** (decrypt) everything else, + minting per-host leaf certs on the fly from the local CA. +- **Hostname-targeted caching** with wildcards, and a storage gate that **never + caches HTML or dynamic content**. + +## Deploy + +**Via the launcher** (from a clone or the one-liner): pick `deploy: squid`, then +answer the prompts (trusted CIDR, etc.). + +**Standalone** (self-contained `deploy.sh`, scp'd to a host): + +```bash +TRUSTED_CIDR=100.64.0.0/10 BIND_ADDR=100.64.0.1 SKIP_PROMPTS=1 bash deploy.sh +``` + +**Fresh VM**: paste [`cloud-init.yml`](cloud-init.yml) as user-data (it hardens +SSH first, then deploys). + +The deploy is idempotent. On first run it builds the local image, generates the +CA into `ssl/` (never overwritten), seeds `.env`, registers the port with the +host firewall if present, and brings the stack up. + +## Point clients at it + +```bash +export http_proxy=http://:3128 +export https_proxy=http://:3128 +``` + +Per-tool: apt → `Acquire::http(s)::Proxy "http://:3128";`; dnf → +`proxy=http://:3128` in `/etc/dnf/dnf.conf`; apk → `http_proxy` env or +`--proxy`. + +**Trust the CA** (so bumped HTTPS validates) — distribute `ssl/squid-ca-cert.pem`: + +| Client | Install | +|---|---| +| Debian/Ubuntu | `cp squid-ca-cert.pem /usr/local/share/ca-certificates/squid-ca.crt && update-ca-certificates` | +| Alpine | `cp squid-ca-cert.pem /usr/local/share/ca-certificates/squid-ca.crt && update-ca-certificates` | +| Alma/RHEL | `cp squid-ca-cert.pem /etc/pki/ca-trust/source/anchors/squid-ca.pem && update-ca-trust` | + +Browsers, Java, and some language runtimes keep their own trust stores — import +there too if needed. + +## Caching model + +Two knobs and three lists, all in the stack dir; they are bind-mounted, so edit +then `docker compose restart`. + +- **`cache-domains.txt`** — hostnames to cache hard (long TTL, force-cache past + `Cache-Control: private/no-store`). A **leading dot** is a subdomain wildcard: + `.ubuntu.com` matches `ubuntu.com` and every subdomain. +- **`cache-domains.regex`** — optional `dstdom_regex` patterns for wildcards + *inside* a label (e.g. `^mirror[0-9]+\.example\.com$`). Comments-only = disabled. +- **`CACHE_ONLY_LISTED`** (`.env`): + - `0` (default, *boost*): cache everything per normal HTTP rules, and + force-cache the listed domains aggressively. + - `1` (*strict allowlist*): store **only** the listed domains; pass the rest + through uncached. + +**Never cached** (storage gate, applies even to boosted domains): HTML (by +`.html` extension and `text/html` content-type) and dynamic content (script +endpoints + query strings). Query strings are **exempt on boosted domains**, so +versioned static assets like `app.js?v=123` still cache there. + +### splice ⇄ cache are mutually exclusive + +A spliced domain is passed through encrypted — there is nothing to cache or +inspect. **Do not list the same domain in both** `splice-domains.txt` and +`cache-domains.txt`; splice wins. + +## Security posture + +- **Access control is Squid's `http_access`** (`TRUSTED_CIDR`), deny-by-default. + This matters because a **published Docker port bypasses the host `INPUT` + firewall** — so also pin **`BIND_ADDR`** to a trusted interface (e.g. your + Tailscale IP). The `/etc/firewall/ports.d/squid.rule` entry is belt-and-braces. +- **CA key** lives at `ssl/squid-ca-key.pem`, mode `0600` root, mounted + read-only; the container stages a squid-readable copy into `/run` (tmpfs) at + start. The key never enters the embedded archive and is git-ignored. +- **Upstream certs are validated** (`sslproxy_cert_error deny all`) — the proxy + won't silently launder a broken origin certificate to clients. + +## Caching caveats (be realistic) + +Even with bump, much of the web is `Cache-Control: private/no-store`, dynamic, +or personalized. The real wins are **distro packages** (apk/apt/dnf), +**container layers**, **OS updates**, and large static assets across many hosts +— which is what the default `cache-domains.txt` targets. It is not a blanket +"cache the whole internet." + +## Files + +| File | Purpose | +|---|---| +| `Dockerfile` | Minimal Alpine image (`apk add squid` — ssl-bump compiled in). | +| `entrypoint.sh` | Renders `squid.conf`, generates the cache policy from the lists, stages the CA, inits the cert DB/cache, starts squid. | +| `squid.conf.tmpl` | Static config (ports, bump policy, access control, cache sizing). | +| `cache-domains.txt` / `.regex` | Wildcard hostnames to cache hard. | +| `splice-domains.txt` | Domains to pass through without decrypting. | +| `docker-compose.yml` / `.env.example` | Stack definition + tunables. | +| `deploy.sh` / `build.sh` | Self-contained installer + archive embedder. | +| `cloud-init.yml` | Fresh-VM bootstrap (harden SSH, then deploy). | + +## Future work + +- **Transparent / intercepting mode** — add `intercept`/`tproxy` ports and an + iptables `REDIRECT` recipe for when the box is the gateway (clients need no + proxy config). Out of scope for v1. +- Upstream `cache_peer` chaining; access-log shipping / dashboard. diff --git a/deployments/squid/build.sh b/deployments/squid/build.sh new file mode 100644 index 0000000..3e67ea1 --- /dev/null +++ b/deployments/squid/build.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# build.sh -- (re)embed the loose deployment files into deploy.sh as a base64 +# tar.gz payload after __ARCHIVE_BELOW__. Idempotent: strips any existing +# payload first. +# +# Run this after editing ANY embedded file below, then re-stage deploy.sh -- +# the deployed stack uses the EMBEDDED copies, not the loose files. + +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SCRIPT="$DIR/deploy.sh" +MARKER="__ARCHIVE_BELOW__" + +# The CA (ssl/), README, cloud-init, and the build/deploy scripts are NOT +# embedded -- the CA is generated on the host at deploy time. +FILES=( + docker-compose.yml + Dockerfile + entrypoint.sh + squid.conf.tmpl + splice-domains.txt + cache-domains.txt + cache-domains.regex + .env.example +) + +[[ -f "$SCRIPT" ]] || { echo "deploy.sh not found at $SCRIPT" >&2; exit 1; } +for f in "${FILES[@]}"; do + [[ -f "$DIR/$f" ]] || { echo "Missing $DIR/$f" >&2; exit 1; } +done + +PAYLOAD=$(tar -czf - -C "$DIR" "${FILES[@]}" | base64) + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +sed "/^${MARKER}\$/,\$d" "$SCRIPT" > "$TMP" +{ + echo "$MARKER" + echo "$PAYLOAD" +} >> "$TMP" + +mv "$TMP" "$SCRIPT" +chmod +x "$SCRIPT" +trap - EXIT + +size=$(wc -c < "$SCRIPT") +echo "Built $SCRIPT (${size} bytes)" diff --git a/deployments/squid/cache-domains.regex b/deployments/squid/cache-domains.regex new file mode 100644 index 0000000..3220f75 --- /dev/null +++ b/deployments/squid/cache-domains.regex @@ -0,0 +1,14 @@ +# cache-domains.regex +# +# OPTIONAL. dstdom_regex patterns for glob-style hostnames that the leading-dot +# matching in cache-domains.txt can't express (e.g. a wildcard INSIDE a label). +# One POSIX extended regex per line, matched case-insensitively against the +# request hostname. Full-line "#" comments only. +# +# Leave this file with comments only to disable it -- the entrypoint skips it +# when there are no active entries (an empty ACL would otherwise error). +# +# Examples (uncomment / adapt): +# ^mirror[0-9]+\.example\.com$ +# ^cdn-.*\.fastly\.net$ +# ^.*\.pkg\.dev$ diff --git a/deployments/squid/cache-domains.txt b/deployments/squid/cache-domains.txt new file mode 100644 index 0000000..e629ed3 --- /dev/null +++ b/deployments/squid/cache-domains.txt @@ -0,0 +1,42 @@ +# cache-domains.txt +# +# Hostnames to cache aggressively (long TTL + force-cache, overriding +# Cache-Control: private/no-store). One entry per line; a LEADING DOT matches +# the domain and all subdomains (.ubuntu.com matches archive.ubuntu.com, +# security.ubuntu.com, ...). For wildcards INSIDE a label, use +# cache-domains.regex instead. +# +# These are BUMPED (decrypted) so HTTPS bodies can be cached -- do not also +# list them in splice-domains.txt. HTML and dynamic content are still skipped +# even here (see the storage gate); only static objects are stored. +# +# Defaults target high-bandwidth distro / package / container mirrors for a +# fleet of VMs. Trim or extend for your environment. + +# ── Debian / Ubuntu ── +.ubuntu.com +.debian.org +.launchpad.net + +# ── Alpine ── +.alpinelinux.org + +# ── RHEL family (Alma / Rocky / Fedora / EPEL) ── +.almalinux.org +.rockylinux.org +.fedoraproject.org +.centos.org + +# ── Container registries (layer blobs) ── +.docker.io +.docker.com +.ghcr.io +.quay.io +.gcr.io +.k8s.io + +# ── Language package registries ── +.pypi.org +.pythonhosted.org +.npmjs.org +.crates.io diff --git a/deployments/squid/cloud-init.yml b/deployments/squid/cloud-init.yml new file mode 100644 index 0000000..8f3fe47 --- /dev/null +++ b/deployments/squid/cloud-init.yml @@ -0,0 +1,41 @@ +#cloud-config +# +# Squid SSL-bump caching proxy — harden SSH, then deploy, on a fresh host. +# +# Fill in REPO_URL and the values in the runcmd block, then paste this as the +# instance user-data. Unlike the web stacks this is a FORWARD proxy: no public +# DNS record or Let's Encrypt cert is needed, but clients must be able to reach +# TRUSTED_CIDR and must trust the CA this generates on first boot. +# +# Only deploy this on networks/devices you own and are authorized to inspect. + +packages: + - git + +runcmd: + - hostnamectl set-hostname squid || true + - | + set -e + REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git + REPO_BRANCH=main + HARDEN_SSH=1 # harden SSH on this fresh VM (set 0 to skip) + SSH_PORT=22 + ALLOWED_IP= # optional: whitelist your client IP in sshguard + git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /opt/automations + cd /opt/automations + + # Harden SSH first (PQ KEX, key-only auth, sshguard + deny-by-default + # firewall). The firewall it installs is what deploy.sh registers the proxy + # port with. + if [ "$HARDEN_SSH" = 1 ]; then + SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" SKIP_PROMPTS=1 FORCE=1 \ + bash scripts/harden-ssh.sh + fi + + # Deploy the proxy. Set TRUSTED_CIDR to the network allowed to use it, and + # BIND_ADDR to a trusted interface (a published Docker port bypasses the + # host firewall, so this is the real exposure control). + TRUSTED_CIDR=100.64.0.0/10 \ + BIND_ADDR=0.0.0.0 \ + SKIP_PROMPTS=1 \ + bash deployments/squid/deploy.sh diff --git a/deployments/squid/deploy.sh b/deployments/squid/deploy.sh new file mode 100644 index 0000000..f15db96 --- /dev/null +++ b/deployments/squid/deploy.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# +# deploy.sh -- deploy the Squid SSL-bump caching forward proxy. +# +# What this does: +# 1. Installs docker + compose if missing. +# 2. Lays down the stack files in $STACK_DIR and builds the local image. +# 3. Generates the TLS interception CA on first run (never overwritten). +# 4. Generates .env on first run; existing .env is never overwritten. +# 5. Prompts for required values not preset (TRUSTED_CIDR). +# 6. Registers the proxy port with the host firewall if present. +# 7. Brings the stack up and waits for health. +# +# Role: this is a FORWARD proxy, not an inbound web service -- there is no +# public hostname or Let's Encrypt cert. Clients set HTTP(S)_PROXY to this host +# and trust the generated CA. Only run it on networks/devices you own. +# +# Idempotent: re-run to apply config changes / rebuild the image. +# +# Self-contained: the compose file, Dockerfile, entrypoint, config template and +# domain lists are embedded as a base64 tar.gz at the bottom of this file. +# Rebuild with build.sh after editing the loose source files. +# +# Usage: +# bash deploy.sh # interactive prompts +# TRUSTED_CIDR=10.0.0.0/8 BIND_ADDR=100.64.0.1 \ +# SKIP_PROMPTS=1 bash deploy.sh # non-interactive +# STACK_DIR=/opt/squid bash deploy.sh +# SKIP_DOCKER_INSTALL=1 bash deploy.sh + +set -euo pipefail + +: "${STACK_DIR:=/srv/squid}" +: "${SKIP_DOCKER_INSTALL:=0}" +: "${FORCE:=0}" +: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts +[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 +: "${TRUSTED_CIDR:=}" +: "${BIND_ADDR:=}" +: "${PROXY_PORT:=3128}" +: "${CACHE_SIZE_MB:=}" +: "${CACHE_ONLY_LISTED:=}" +: "${CA_CN:=Squid Proxy CA}" +: "${CA_O:=automations}" +: "${CA_DAYS:=3650}" +: "${SQUID_IMAGE_TAG:=automations/squid:latest}" + +log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Run as root." + +# --------------------------------------------------------------------------- +# OS detection + Docker install (Alpine / Debian / Alma). This deploy.sh is +# self-contained (scp'd standalone), so the OS logic is inlined here instead +# of sourced from scripts/oslib.sh. +# --------------------------------------------------------------------------- +osfam() { + local id="" like="" + if [[ -r /etc/os-release ]]; then + id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")" + like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")" + fi + case " $id $like " in + *" alpine "*) echo alpine ;; + *" debian "*|*" ubuntu "*) echo debian ;; + *" rhel "*|*" fedora "*|*" centos "*) echo rhel ;; + *) echo "${id:-unknown}" ;; + esac +} + +install_docker() { + if command -v docker >/dev/null 2>&1; then + log "Docker already installed: $(docker --version)" + else + log "Installing Docker (OS: $(osfam))..." + case "$(osfam)" in + alpine) apk add -q docker docker-cli-compose openrc ;; + debian|rhel) command -v curl >/dev/null 2>&1 || \ + { command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \ + { command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; } + curl -fsSL https://get.docker.com | sh ;; + *) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;; + esac + fi + if command -v rc-update >/dev/null 2>&1; then + rc-update add docker default >/dev/null 2>&1 || true + rc-service docker status >/dev/null 2>&1 || rc-service docker start + elif command -v systemctl >/dev/null 2>&1; then + systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true + fi +} + +open_proxy_port() { + # Register the proxy port. Prefer the host firewall (harden-firewall.sh) + # when present; else ufw/firewalld if active. + # + # NOTE: a Docker-published port reaches the host through nat/FORWARD and + # BYPASSES the INPUT firewall, so this is belt-and-braces -- the real access + # gate is Squid's http_access (TRUSTED_CIDR) plus pinning BIND_ADDR to a + # trusted interface. The ports.d format is port-only (no source CIDR), so + # restrict the source via BIND_ADDR / upstream firewalling, not here. + local port="${PROXY_PORT:-3128}" + if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering ${port}/tcp with host firewall..." + printf '%s/tcp\n' "$port" > /etc/firewall/ports.d/squid.rule + /usr/local/sbin/firewall-apply + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then + log "ufw active -- allowing ${port}/tcp..." + ufw allow "${port}/tcp" >/dev/null + elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then + log "firewalld active -- allowing ${port}/tcp..." + firewall-cmd -q --add-port="${port}/tcp" --permanent + firewall-cmd -q --reload + fi +} + +# ---------------------------------------------------------------------------- +# Extract embedded archive +# ---------------------------------------------------------------------------- +SCRIPT_DIR=$(mktemp -d -t squid-deploy.XXXXXX) +trap 'rm -rf "$SCRIPT_DIR"' EXIT + +extract_archive() { + grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ + | tail -n +2 \ + | base64 -d \ + | tar -xz -C "$SCRIPT_DIR" +} + +if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then + log "Extracting embedded deployment files..." + extract_archive +else + die "No embedded archive found. Run build.sh to embed deployment files." +fi + +EMBEDDED=(docker-compose.yml Dockerfile entrypoint.sh squid.conf.tmpl + splice-domains.txt cache-domains.txt cache-domains.regex .env.example) +for f in "${EMBEDDED[@]}"; do + [[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f" +done + +# ---------------------------------------------------------------------------- +# Prompt for required vars +# ---------------------------------------------------------------------------- +prompt() { + local varname="$1" message="$2" + local -n ref="$varname" + if [[ -z "${ref:-}" ]]; then + [[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)." + read -r -p "$message: " ref + [[ -n "$ref" ]] || die "$varname required." + fi +} + +prompt TRUSTED_CIDR "Trusted client CIDR(s) allowed to use the proxy (e.g. 100.64.0.0/10)" + +# ---------------------------------------------------------------------------- +# Docker + firewall +# ---------------------------------------------------------------------------- +if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then + install_docker +fi +open_proxy_port + +# ---------------------------------------------------------------------------- +# Stack directory + files +# ---------------------------------------------------------------------------- +log "Setting up $STACK_DIR..." +install -d -m 0750 "$STACK_DIR" +install -m 0644 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" +install -m 0644 "$SCRIPT_DIR/Dockerfile" "$STACK_DIR/Dockerfile" +install -m 0755 "$SCRIPT_DIR/entrypoint.sh" "$STACK_DIR/entrypoint.sh" +install -m 0644 "$SCRIPT_DIR/squid.conf.tmpl" "$STACK_DIR/squid.conf.tmpl" +install -m 0644 "$SCRIPT_DIR/splice-domains.txt" "$STACK_DIR/splice-domains.txt" +install -m 0644 "$SCRIPT_DIR/cache-domains.txt" "$STACK_DIR/cache-domains.txt" +install -m 0644 "$SCRIPT_DIR/cache-domains.regex" "$STACK_DIR/cache-domains.regex" + +ENV_FILE="$STACK_DIR/.env" +set_env() { # : update KEY in .env, or append if absent + local key="$1" val="$2" esc + esc=${val//\\/\\\\}; esc=${esc//|/\\|}; esc=${esc//&/\\&} + if grep -qE "^${key}=" "$ENV_FILE"; then + sed -i -e "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} + +if [[ ! -f "$ENV_FILE" ]]; then + log "Seeding $ENV_FILE..." + install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE" + set_env TRUSTED_CIDR "$TRUSTED_CIDR" + if [[ -n "${BIND_ADDR:-}" ]]; then set_env BIND_ADDR "$BIND_ADDR"; fi + if [[ -n "${PROXY_PORT:-}" ]]; then set_env PROXY_PORT "$PROXY_PORT"; fi + if [[ -n "${CACHE_SIZE_MB:-}" ]]; then set_env CACHE_SIZE_MB "$CACHE_SIZE_MB"; fi + if [[ -n "${CACHE_ONLY_LISTED:-}" ]]; then set_env CACHE_ONLY_LISTED "$CACHE_ONLY_LISTED"; fi + set_env CA_CN "$CA_CN" + set_env CA_O "$CA_O" + set_env SQUID_IMAGE_TAG "$SQUID_IMAGE_TAG" +else + log ".env exists; leaving it alone." +fi + +# Validate required value landed. +grep -E "^TRUSTED_CIDR=.+$" "$ENV_FILE" >/dev/null || die "TRUSTED_CIDR missing in $ENV_FILE." + +# Use the image tag actually recorded in .env (keeps build + CA-gen in sync). +IMAGE=$(grep -E '^SQUID_IMAGE_TAG=' "$ENV_FILE" | head -n1 | cut -d= -f2-) +IMAGE=${IMAGE:-$SQUID_IMAGE_TAG} + +# ---------------------------------------------------------------------------- +# Confirm (interception is a big deal) +# ---------------------------------------------------------------------------- +if [[ "$FORCE" != "1" ]]; then + cat <:${PROXY_PORT} + +Continue? [y/N] +EOF + read -r ans + [[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { warn "Aborted."; exit 0; } +fi + +cd "$STACK_DIR" + +# ---------------------------------------------------------------------------- +# Build image, then generate the CA (needs the built image's openssl) +# ---------------------------------------------------------------------------- +log "Building image $IMAGE..." +docker compose build + +SSL_DIR="$STACK_DIR/ssl" +install -d -m 0750 "$SSL_DIR" +if [[ ! -f "$SSL_DIR/squid-ca-key.pem" || ! -f "$SSL_DIR/squid-ca-cert.pem" ]]; then + log "Generating TLS interception CA (one-time; CN=${CA_CN})..." + docker run --rm -v "$SSL_DIR:/out" --entrypoint openssl "$IMAGE" \ + req -x509 -newkey rsa:4096 -sha256 -days "$CA_DAYS" -nodes \ + -keyout /out/squid-ca-key.pem -out /out/squid-ca-cert.pem \ + -subj "/CN=${CA_CN}/O=${CA_O}" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" + chmod 0600 "$SSL_DIR/squid-ca-key.pem" + chmod 0644 "$SSL_DIR/squid-ca-cert.pem" + log "CA created. Distribute $SSL_DIR/squid-ca-cert.pem to client trust stores." +else + log "CA already present in $SSL_DIR; leaving it." +fi + +# ---------------------------------------------------------------------------- +# Bring up the stack +# ---------------------------------------------------------------------------- +log "Starting stack..." +docker compose up -d --remove-orphans + +# ---------------------------------------------------------------------------- +# Wait for health +# ---------------------------------------------------------------------------- +log "Waiting for squid to become healthy (up to 120s)..." +deadline=$(( $(date +%s) + 120 )) +while (( $(date +%s) < deadline )); do + status=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null || true) + unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}') + if [[ -z "$unhealthy" && -n "$status" ]]; then + log "Squid healthy." + break + fi + sleep 5 +done + +echo +log "Stack status:" +docker compose ps +echo +CERT="$SSL_DIR/squid-ca-cert.pem" +cat <:${PROXY_PORT} +Stack dir: ${STACK_DIR} +CA cert: ${CERT} + +1. Point clients at the proxy: + export http_proxy=http://:${PROXY_PORT} + export https_proxy=http://:${PROXY_PORT} + (apk: /etc/apk/repositories via http_proxy; apt: Acquire::http(s)::Proxy; + dnf: proxy= in /etc/dnf/dnf.conf) + +2. Trust the CA on each client (so bumped HTTPS validates): + Debian/Ubuntu: cp ${CERT##*/} /usr/local/share/ca-certificates/squid-ca.crt && update-ca-certificates + Alpine: cp ${CERT##*/} /usr/local/share/ca-certificates/squid-ca.crt && update-ca-certificates + Alma/RHEL: cp ${CERT##*/} /etc/pki/ca-trust/source/anchors/squid-ca.pem && update-ca-trust + Browsers/Java keep their own trust stores -- import there too if needed. + +3. Tune behavior, then re-render (lists are bind-mounted): + ${STACK_DIR}/cache-domains.txt domains to cache hard (wildcards) + ${STACK_DIR}/cache-domains.regex glob/regex cache patterns (optional) + ${STACK_DIR}/splice-domains.txt domains to NOT decrypt + edit, then: cd ${STACK_DIR} && docker compose restart + +Logs / cache stats: + docker compose logs -f + docker exec squid squid -k parse # validate config + docker exec squid tail -f /var/log/squid/access.log # TCP_HIT / TCP_MISS + +Manage: + docker compose pull >/dev/null; docker compose build && docker compose up -d # update + docker compose down # stop, keep cache + CA + docker compose down -v # stop, WIPE cache + leaf-cert DB (CA on host survives) + +SECURITY: keep ${SSL_DIR}/squid-ca-key.pem secret (it can MITM every client +that trusts it). It is 0600 root and never leaves this host. Only intercept +traffic on networks/devices you own. + +Or just re-run this script -- it's idempotent. +================================================================ +EOF + +# IMPORTANT: do not put any code below this exit. Everything after the +# __ARCHIVE_BELOW__ marker is the embedded tar.gz payload (base64), appended by +# build.sh. +exit 0 +__ARCHIVE_BELOW__ +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 diff --git a/deployments/squid/docker-compose.yml b/deployments/squid/docker-compose.yml new file mode 100644 index 0000000..65304e0 --- /dev/null +++ b/deployments/squid/docker-compose.yml @@ -0,0 +1,51 @@ +# Squid SSL-bump caching forward proxy. +# +# Built locally from ./Dockerfile (the repo's one self-built image) -- there is +# no upstream ssl-bump image we want to trust in a hardened setup. The TLS +# interception CA lives on the host in ./ssl (generated by deploy.sh, mounted +# read-only); the leaf-cert DB, cache, and logs are named volumes. +# +# A published Docker port BYPASSES the host INPUT firewall, so BIND_ADDR should +# pin the listener to a trusted interface and TRUSTED_CIDR (Squid http_access) +# is the real access gate. + +name: squid + +volumes: + squid-cache: + squid-ssl-db: + squid-logs: + +services: + squid: + build: + context: . + image: ${SQUID_IMAGE_TAG:-automations/squid:latest} + container_name: squid + restart: unless-stopped + ports: + - "${BIND_ADDR:-0.0.0.0}:${PROXY_PORT:-3128}:3128" + environment: + TRUSTED_CIDR: "${TRUSTED_CIDR}" + CACHE_SIZE_MB: "${CACHE_SIZE_MB:-5000}" + MAX_OBJECT_SIZE_MB: "${MAX_OBJECT_SIZE_MB:-256}" + CACHE_MEM_MB: "${CACHE_MEM_MB:-256}" + DYNAMIC_CERT_MEM_MB: "${DYNAMIC_CERT_MEM_MB:-8}" + CACHE_ONLY_LISTED: "${CACHE_ONLY_LISTED:-0}" + VISIBLE_HOSTNAME: "${VISIBLE_HOSTNAME:-squid-proxy}" + volumes: + - ./ssl:/etc/squid/ssl:ro + - ./splice-domains.txt:/etc/squid/splice-domains.txt:ro + - ./cache-domains.txt:/etc/squid/cache-domains.txt:ro + - ./cache-domains.regex:/etc/squid/cache-domains.regex:ro + - squid-cache:/var/cache/squid + - squid-ssl-db:/var/lib/squid/ssl_db + - squid-logs:/var/log/squid + healthcheck: + # `squid -k check` signals the running master process via its pid file -- + # reliable and always present (no dependency on squidclient/nc). + test: ["CMD-SHELL", "squid -k check -f /etc/squid/squid.conf || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s diff --git a/deployments/squid/entrypoint.sh b/deployments/squid/entrypoint.sh new file mode 100644 index 0000000..ade5b6e --- /dev/null +++ b/deployments/squid/entrypoint.sh @@ -0,0 +1,145 @@ +#!/bin/sh +# +# entrypoint.sh -- container start-up for the Squid SSL-bump caching proxy. +# +# 1. Render /etc/squid/squid.conf from squid.conf.tmpl (@VAR@ -> env). +# 2. Generate the cache-policy include from cache-domains.txt / .regex and the +# CACHE_ONLY_LISTED toggle (boost ACLs, storage gate, per-domain TTLs). +# 3. Stage the CA into a squid-readable tmpfs copy (host key stays 0600 root). +# 4. Init the dynamic-cert DB + cache_dir structure (idempotent). +# 5. Validate the config, then exec squid in the foreground. +# +# Runs as root (needs chown/install); squid then drops to cache_effective_user. + +set -eu + +TMPL=/etc/squid/squid.conf.tmpl +CONF=/etc/squid/squid.conf +GEN_DIR=/etc/squid/conf.d +GEN="$GEN_DIR/cache-policy.conf" +SSL_SRC=/etc/squid/ssl +SSL_RUN=/run/squid +SSL_DB=/var/lib/squid/ssl_db +CACHE_DOMAINS=/etc/squid/cache-domains.txt +CACHE_REGEX=/etc/squid/cache-domains.regex + +# Tunables -- docker-compose passes these from .env; defaults keep the image +# runnable on its own for a smoke test. +: "${TRUSTED_CIDR:=127.0.0.1/32}" +: "${CACHE_SIZE_MB:=5000}" +: "${MAX_OBJECT_SIZE_MB:=256}" +: "${CACHE_MEM_MB:=256}" +: "${DYNAMIC_CERT_MEM_MB:=8}" +: "${CACHE_ONLY_LISTED:=0}" +: "${VISIBLE_HOSTNAME:=squid-proxy}" + +log() { printf '[entrypoint] %s\n' "$*" >&2; } + +# A list file "has entries" if it holds >=1 non-blank, non-comment line. +has_entries() { [ -f "$1" ] && grep -qE '^[[:space:]]*[^#[:space:]]' "$1"; } + +# --------------------------------------------------------------------------- +# 1. Render the static config from the template (@VAR@ placeholders). +# --------------------------------------------------------------------------- +sed \ + -e "s|@TRUSTED_CIDR@|${TRUSTED_CIDR}|g" \ + -e "s|@CACHE_SIZE_MB@|${CACHE_SIZE_MB}|g" \ + -e "s|@MAX_OBJECT_SIZE_MB@|${MAX_OBJECT_SIZE_MB}|g" \ + -e "s|@CACHE_MEM_MB@|${CACHE_MEM_MB}|g" \ + -e "s|@DYNAMIC_CERT_MEM_MB@|${DYNAMIC_CERT_MEM_MB}|g" \ + -e "s|@VISIBLE_HOSTNAME@|${VISIBLE_HOSTNAME}|g" \ + "$TMPL" > "$CONF" + +# --------------------------------------------------------------------------- +# 2. Generate the cache-policy include from the domain lists + toggle. +# Storage gate runs first (whether to store); per-domain refresh_patterns +# only tune freshness of what survived the gate. +# --------------------------------------------------------------------------- +mkdir -p "$GEN_DIR" +{ + echo "# AUTO-GENERATED by entrypoint.sh at container start -- do not edit." + echo "# Source: cache-domains.txt / cache-domains.regex + CACHE_ONLY_LISTED." + echo + + # Boost ACLs are only emitted when their backing file is non-empty, else + # squid would error on an empty ACL. + exc="" + if has_entries "$CACHE_DOMAINS"; then + echo 'acl boost dstdomain "/etc/squid/cache-domains.txt"' + exc="$exc !boost" + fi + if has_entries "$CACHE_REGEX"; then + echo 'acl boost_re dstdom_regex "/etc/squid/cache-domains.regex"' + exc="$exc !boost_re" + fi + + cat <<'ACLS' +acl has_query urlpath_regex \? +acl dyn_ext urlpath_regex (/cgi-bin/|\.(cgi|php|aspx?|jsp)$) +acl html_ext urlpath_regex \.html?(\?|$) +acl html_ct rep_mime_type -i ^text/html + +# Storage gate: never store HTML (by ext or content-type) or dynamic content. +cache deny html_ext +cache deny dyn_ext +ACLS + # Query strings are dynamic EXCEPT on boosted domains (versioned static + # assets like app.js?v=123 should still cache there). + echo "cache deny has_query${exc}" + echo 'store_miss deny html_ct' + + if [ "$CACHE_ONLY_LISTED" = "1" ]; then + echo + echo "# CACHE_ONLY_LISTED=1: store ONLY the listed domains." + if has_entries "$CACHE_DOMAINS"; then echo 'cache allow boost'; fi + if has_entries "$CACHE_REGEX"; then echo 'cache allow boost_re'; fi + echo 'cache deny all' + fi + + if has_entries "$CACHE_DOMAINS"; then + echo + echo "# Boost: long TTL + force-cache for each listed wildcard domain." + grep -E '^[[:space:]]*[^#[:space:]]' "$CACHE_DOMAINS" | while read -r dom _rest; do + # ".example.com" / "example.com" -> "example[.]com" ([.] matches a + # literal dot portably -- avoids sed backslash-escaping quirks). + d=$(printf '%s' "$dom" | sed 's/^\.//; s/\./[.]/g') + printf 'refresh_pattern -i %s 1440 100%% 43200 override-expire ignore-private ignore-no-store override-lastmod\n' \ + "^https?://([^/]+[.])?${d}/" + done + fi +} > "$GEN" + +# --------------------------------------------------------------------------- +# 3. Stage the CA into a squid-readable tmpfs copy. The host key is 0600 root +# (mounted read-only); root copies it to /run owned by squid so the signer +# can read it without loosening the host file. +# --------------------------------------------------------------------------- +if [ ! -f "$SSL_SRC/squid-ca-cert.pem" ] || [ ! -f "$SSL_SRC/squid-ca-key.pem" ]; then + log "FATAL: CA missing in $SSL_SRC (need squid-ca-cert.pem + squid-ca-key.pem)." + exit 1 +fi +install -d -m 0710 -o squid -g squid "$SSL_RUN" +install -m 0444 -o squid -g squid "$SSL_SRC/squid-ca-cert.pem" "$SSL_RUN/ca-cert.pem" +install -m 0400 -o squid -g squid "$SSL_SRC/squid-ca-key.pem" "$SSL_RUN/ca-key.pem" + +# --------------------------------------------------------------------------- +# 4. Init the dynamic-cert DB + cache_dir structure (idempotent). +# --------------------------------------------------------------------------- +if [ ! -s "$SSL_DB/index.txt" ]; then + log "Initializing TLS cert DB at $SSL_DB..." + find "$SSL_DB" -mindepth 1 -delete 2>/dev/null || true + /usr/lib/squid/security_file_certgen -c -s "$SSL_DB" -M "${DYNAMIC_CERT_MEM_MB}MB" +fi +chown -R squid:squid "$SSL_DB" /var/cache/squid /var/log/squid 2>/dev/null || true + +if [ ! -d /var/cache/squid/00 ]; then + log "Initializing cache_dir structure..." + squid -f "$CONF" -z --foreground || squid -f "$CONF" -z || true +fi + +# --------------------------------------------------------------------------- +# 5. Validate the rendered + generated config, then hand off to squid. +# --------------------------------------------------------------------------- +squid -k parse -f "$CONF" +log "Starting squid (visible_hostname=${VISIBLE_HOSTNAME})..." +exec squid -N -f "$CONF" "$@" diff --git a/deployments/squid/splice-domains.txt b/deployments/squid/splice-domains.txt new file mode 100644 index 0000000..663d4a8 --- /dev/null +++ b/deployments/squid/splice-domains.txt @@ -0,0 +1,30 @@ +# splice-domains.txt +# +# Domains Squid must NOT decrypt. With SSL-bump, "splice" = transparent +# passthrough: the client's TLS goes straight to the origin, so these are +# NOT cached and NOT inspected. Use for cert-pinned apps, banking, app-store / +# OS update channels, and anything that breaks under interception. +# +# One entry per line; a LEADING DOT matches all subdomains (.apple.com matches +# www.apple.com, gs.apple.com, ...). Full-line "#" comments only. +# +# IMPORTANT: do not also list a domain here AND in cache-domains.txt -- splice +# wins, so it would never cache. Tune this list for your environment. + +# ── OS / app-store update channels (commonly cert-pinned) ── +.apple.com +.icloud.com +.mzstatic.com +.windowsupdate.com +.update.microsoft.com +.android.clients.google.com +.play.googleapis.com + +# ── Banking / payments (examples -- add your own) ── +.paypal.com +.stripe.com + +# ── Messaging / pinned services ── +.whatsapp.net +.signal.org +.telegram.org diff --git a/deployments/squid/squid.conf.tmpl b/deployments/squid/squid.conf.tmpl new file mode 100644 index 0000000..e80d26e --- /dev/null +++ b/deployments/squid/squid.conf.tmpl @@ -0,0 +1,76 @@ +# squid.conf.tmpl +# +# Rendered to /etc/squid/squid.conf by entrypoint.sh at container start: +# - @VAR@ placeholders are substituted from the environment (.env); +# - the cache policy (boost ACLs, storage gate, per-domain refresh_patterns) +# is generated from cache-domains.txt / .regex into the include below. +# +# Role: EXPLICIT forward proxy with SSL-bump. Clients set HTTP(S)_PROXY to this +# host:3128. (Transparent/intercepting mode is future work -- see README.) + +visible_hostname @VISIBLE_HOSTNAME@ + +# ── Listener: explicit proxy, SSL-bump enabled ────────────────────────────── +# tls-cert/tls-key are the local CA used to mint per-host leaf certs on the fly. +# The key is staged into /run by the entrypoint so the squid user can read it +# while the on-host key file stays 0600 root. +http_port 3128 ssl-bump \ + tls-cert=/run/squid/ca-cert.pem \ + tls-key=/run/squid/ca-key.pem \ + generate-host-certificates=on \ + dynamic_cert_mem_cache_size=@DYNAMIC_CERT_MEM_MB@MB + +sslcrtd_program /usr/lib/squid/security_file_certgen -s /var/lib/squid/ssl_db -M @DYNAMIC_CERT_MEM_MB@MB +sslcrtd_children 8 startup=1 idle=1 + +# Validate UPSTREAM certificates -- never silently MITM a broken origin cert. +sslproxy_cert_error deny all + +# ── SSL-bump policy: peek at SNI → splice allowlist → bump everything else ── +acl step1 at_step SslBump1 +acl splice_dom ssl::server_name "/etc/squid/splice-domains.txt" +ssl_bump peek step1 +ssl_bump splice splice_dom +ssl_bump bump all + +# ── Access control: deny-by-default, trusted clients only ─────────────────── +acl SSL_ports port 443 +acl Safe_ports port 80 443 21 70 210 1025-65535 280 488 591 777 +acl CONNECT method CONNECT +http_access deny !Safe_ports +http_access deny CONNECT !SSL_ports + +# Cache manager: localhost only (used by the container healthcheck). +http_access allow localhost manager +http_access deny manager + +acl trusted_clients src @TRUSTED_CIDR@ +http_access allow localhost +http_access allow trusted_clients +http_access deny all + +# ── Cache sizing ──────────────────────────────────────────────────────────── +cache_mem @CACHE_MEM_MB@ MB +maximum_object_size_in_memory 1 MB +maximum_object_size @MAX_OBJECT_SIZE_MB@ MB +cache_dir ufs /var/cache/squid @CACHE_SIZE_MB@ 16 256 +coredump_dir /var/cache/squid + +# ── Cache policy (generated: boost ACLs, storage gate, per-domain TTLs) ───── +include /etc/squid/conf.d/cache-policy.conf + +# Base refresh patterns (fallbacks; per-domain boosts live in the include and +# are matched first). Packages get long, confident TTLs; everything else is +# conservative and revalidates. +refresh_pattern -i \.(deb|rpm|apk|pkg|tar\.(gz|xz|zst|bz2)|whl|jar)$ 10080 100% 43200 +refresh_pattern . 0 20% 4320 + +# ── Logging ───────────────────────────────────────────────────────────────── +access_log stdio:/var/log/squid/access.log +cache_log /var/log/squid/cache.log +logfile_rotate 7 + +# ── Privilege drop / lifecycle ────────────────────────────────────────────── +cache_effective_user squid +pid_filename /run/squid.pid +shutdown_lifetime 5 seconds