#!/usr/bin/env bash# Egress proxy kit (euler-proxy): Squid with a domain allowlist, the only way out to the internet.# ================================================================================================## Deploys / removes / upgrades the Squid forward proxy that is the *only* path# out to the internet for the shell, AI features, the problem scraper, and `gh`. It# enforces a **domain allowlist** (default-deny), operationalising the "plaintext must# never leave the repo" rule at the network layer. Sibling to scripts/setup/frontend.sh# (the inbound edge); see docs/web-server-guide.md § Egress.## Model:# - Squid listens on loopback 127.0.0.1:3128 (never network-exposed).# - Only the allowlisted domains are reachable (CONNECT to :443 + plain :80);# everything else is denied. The allowlist is /etc/euler-proxy/squid.allowlist.# - Runs as the dedicated `euler-proxy` user, in its own group — NOT in# `euler-web`, so it has no access to the app sockets.# - Config lives in /etc/euler-proxy (root:euler-proxy). The client-side proxy env# is written to /etc/euler/egress.env for the app-service units to load# (EnvironmentFile=), wiring HTTPS_PROXY for AI/scraper/gh.## /etc/euler-proxy/squid.conf root:euler-proxy 0640 (generated here)# /etc/euler-proxy/squid.allowlist root:euler-proxy 0640 (generated if absent; edit + reload)# /etc/euler/egress.env root:root 0644 (HTTPS_PROXY for clients)# /etc/systemd/system/euler-proxy.service (root-owned, boot-enabled)## Because the unit lives in root's systemd and runs as a locked-down user, lifecycle# (start/stop/restart) requires sudo.## Actions: deploy | remove | upgrade | status | reload | help## Author: Vikas Munshi <vikas.munshi@gmail.com># Copyright (c) 2026. All rights reserved.# Licensed under the MIT License.set-euopipefail# ── System locations ──────────────────────────────────────────────────────────────SYS_DIR="/etc/euler"# shared with frontend.sh (egress.env lives here)PROXY_CONF_DIR="/etc/euler-proxy"# euler-proxy's own config dirSQUID_CONF="${PROXY_CONF_DIR}/squid.conf"ALLOWLIST="${PROXY_CONF_DIR}/squid.allowlist"EGRESS_ENV="${SYS_DIR}/egress.env"# ── Service identity: euler-proxy is its own user/group, outside euler-web ──PROXY_USER="euler-proxy"PROXY_GROUP="euler-proxy"SERVICE_NAME="euler-proxy.service"SERVICE_DEST="/etc/systemd/system/${SERVICE_NAME}"# ── Proxy listener (loopback only) ────────────────────────────────────────────────PROXY_ADDR="127.0.0.1"PROXY_PORT="3128"# ── Default allowlist (leading dot = domain + subdomains). Edit ${ALLOWLIST} to change. ──DEFAULT_ALLOWLIST=("api.anthropic.com"# Claude API (AI features)"www.ecb.europa.eu"# ECB (Exchange Rate API)".projecteuler.net"# problem scraper".github.com"# gh: repo, API, codeload".githubusercontent.com"# gh: raw / release objects".claude.ai"# Claude Code: installer + downloads.claude.ai".claude.com"# Claude Code: code.claude.com (releases) + OAuth login".anthropic.com"# Claude Code: version/telemetry endpoints)usage(){cat<<USAGEUsage: $0 [deploy|remove|upgrade|status|reload|help] deploy Install Squid, create the euler-proxy user, generate the allowlist config + egress.env, and install the root-owned ${SERVICE_NAME} (boot-enabled). Idempotent. remove Remove the unit and Squid; prompt before deleting ${PROXY_CONF_DIR}, ${EGRESS_ENV}, and the euler-proxy user. upgrade Upgrade the Squid package itself and regenerate the config + unit — unlike the other kits, this is NOT an alias of deploy. status Show deploy state, unit state, and an allow/deny probe through the proxy. reload Reconfigure the running proxy (sudo systemctl reload). Listener: ${PROXY_ADDR}:${PROXY_PORT} (loopback). Allowlist: ${ALLOWLIST}.USAGE}# ── Helpers ───────────────────────────────────────────────────────────────────────check_can_sudo(){if!command-vsudo&>/dev/null;thenecho"Error: sudo is not installed or not found in PATH">&2return1fiif!sudo-v2>/dev/null;thenecho"Error: current user does not have sudo privileges">&2return1fi}require_systemd(){if[!-d/run/systemd/system]||!command-vsystemctl&>/dev/null;thenecho"Error: systemd is required for the egress proxy (root-owned unit); not active here.">&2return1fi}squid_bin(){command-vsquid||echo/usr/sbin/squid;}squid_is_installed(){[-x"$(command-vsquid||echo/usr/sbin/squid)"];}squid_version(){"$(squid_bin)"-v2>/dev/null|head-n1||echo"unknown";}# ── Squid (apt) ───────────────────────────────────────────────────────────────────install_squid_pkg(){ifsquid_is_installed;thenecho"Squid already installed: $(squid_version)"elseecho"Installing Squid..."sudoapt-getinstall-ysquidecho"Squid installed: $(squid_version)"fi# The apt package starts a default squid.service on :3128 as the `proxy` user;# disable it so it cannot clash with our euler-proxy.service.ifsystemctllist-unit-filessquid.service&>/dev/null;thenecho"Disabling the default squid.service (superseded by ${SERVICE_NAME})..."sudosystemctldisable--nowsquid.service2>/dev/null||truefi}# ── Service identity + config ──────────────────────────────────────────────ensure_proxy_user(){if!getentgroup"${PROXY_GROUP}">/dev/null;thenecho"Creating group ${PROXY_GROUP}..."sudogroupadd--system"${PROXY_GROUP}"fiif!getentpasswd"${PROXY_USER}">/dev/null;thenecho"Creating system user ${PROXY_USER} (group ${PROXY_GROUP})..."sudouseradd--system--no-create-home--shell/usr/sbin/nologin\-g"${PROXY_GROUP}""${PROXY_USER}"fi}ensure_dirs(){sudoinstall-d-oroot-g"${PROXY_GROUP}"-m0750"${PROXY_CONF_DIR}"sudomkdir-p"${SYS_DIR}"# shared dir; frontend.sh owns its perms if present}# Write the default allowlist if absent; otherwise UNION any new default entries# into it (same shape as auth.sh's policy merge): operator additions always# survive, and a domain the defaults gained (e.g. .claude.ai for the Claude Code# installer) reaches a live host on `upgrade` instead of silently staying blocked.# A default the operator deliberately REMOVED comes back — remove it again or# keep a local comment; default-deny plus a named list makes re-adding visible.generate_allowlist(){localentryadded=()if!sudotest-f"${ALLOWLIST}";thenecho"Writing default allowlist ${ALLOWLIST}..."printf'%s\n'"${DEFAULT_ALLOWLIST[@]}"|sudotee"${ALLOWLIST}">/dev/nullsudochownroot:"${PROXY_GROUP}""${ALLOWLIST}"sudochmod0640"${ALLOWLIST}"return0fiforentryin"${DEFAULT_ALLOWLIST[@]}";doif!sudogrep-qxF"${entry}""${ALLOWLIST}";thenecho"${entry}"|sudotee-a"${ALLOWLIST}">/dev/nulladded+=("${entry}")fidoneif((${#added[@]}>0));thenecho"Allowlist ${ALLOWLIST}: added new default entries: ${added[*]}"elseecho"Allowlist ${ALLOWLIST}: up to date (operator edits kept)"fi}generate_squid_conf(){echo"Writing ${SQUID_CONF}..."sudotee"${SQUID_CONF}">/dev/null<<EOF# Squid forward proxy for euler egress.## GENERATED by scripts/setup/egress.sh — edits are overwritten on install/upgrade# (edit the allowlist at ${ALLOWLIST} instead). Default-deny domain allowlist; loopback# only. See docs/web-server-guide.md.http_port ${PROXY_ADDR}:${PROXY_PORT}visible_hostname euler-proxyacl SSL_ports port 443acl Safe_ports port 80acl Safe_ports port 443acl CONNECT method CONNECT# The allowlist: one domain per line; a leading dot also matches subdomains.acl allowed_domains dstdomain "${ALLOWLIST}"# Default-deny: refuse unsafe ports and any CONNECT to a non-TLS port, then permit# only the allowlisted domains and deny everything else.http_access deny !Safe_portshttp_access deny CONNECT !SSL_portshttp_access allow allowed_domainshttp_access deny all# Pure gateway: no disk cache, and do not leak client/proxy identity upstream.cache deny allvia offforwarded_for deletehttpd_suppress_version_string on# Runtime paths (systemd provides these dirs, owned by ${PROXY_USER}).pid_filename /run/euler-proxy/squid.pidaccess_log stdio:/var/log/euler-proxy/access.logcache_log /var/log/euler-proxy/cache.logcoredump_dir /var/cache/euler-proxyshutdown_lifetime 5 seconds# No ICMP pinger. It cannot open raw ICMP sockets as an unprivileged uid anyway, and it talks# to the main process over a loopback UDP socket, which the euler firewall drops (loopback is# an allowlist — firewall.sh). Left on, Squid stalls at startup on that IPC ("hello write test# failed") and never opens its port. The netdb it feeds goes with it.pinger_enable offnetdb_filename noneEOFsudochownroot:"${PROXY_GROUP}""${SQUID_CONF}"sudochmod0640"${SQUID_CONF}"}# The client-side proxy env the app-service units load (EnvironmentFile=) so AI# features, the scraper, and gh egress only via Squid. Not secret -> world-readable.generate_egress_env(){echo"Writing ${EGRESS_ENV}..."sudotee"${EGRESS_ENV}">/dev/null<<EOF# GENERATED by scripts/setup/egress.sh. Route outbound HTTP(S) through the euler# egress proxy (Squid allowlist). Loaded by app-service units via EnvironmentFile=;# see docs/web-server-guide.md § Egress. To use it in a shell: set -a; . ${EGRESS_ENV}HTTP_PROXY=http://${PROXY_ADDR}:${PROXY_PORT}HTTPS_PROXY=http://${PROXY_ADDR}:${PROXY_PORT}http_proxy=http://${PROXY_ADDR}:${PROXY_PORT}https_proxy=http://${PROXY_ADDR}:${PROXY_PORT}NO_PROXY=localhost,127.0.0.1,::1no_proxy=localhost,127.0.0.1,::1EOFsudochownroot:root"${EGRESS_ENV}"sudochmod0644"${EGRESS_ENV}"}# ── systemd unit (root-owned, boot-enabled) ─────────────────────────────────install_service(){require_systemd||return1if!squid_is_installed;thenecho"Warning: squid not installed; skipping ${SERVICE_NAME}">&2return0filocalsquidsquid="$(squid_bin)"echo"Installing ${SERVICE_NAME} (User=${PROXY_USER})..."sudotee"${SERVICE_DEST}">/dev/null<<EOF[Unit]Description=euler egress proxy (Squid, domain allowlist)Documentation=https://github.com/vikasmunshi/euler/blob/master/docs/web-server-guide.mdAfter=network-online.targetWants=network-online.target[Service]Type=simpleUser=${PROXY_USER}Group=${PROXY_GROUP}ExecStart=${squid} -N -f ${SQUID_CONF}ExecReload=${squid} -k reconfigure -f ${SQUID_CONF}Restart=on-failureRestartSec=5s# Writable runtime / logs / spool, owned by ${PROXY_USER}.RuntimeDirectory=euler-proxyLogsDirectory=euler-proxyCacheDirectory=euler-proxy# Hardening (loopback listener on :3128 needs no privileged port). Its writes all go to# the three managed dirs above, so the rest of the filesystem is read-only.NoNewPrivileges=trueProtectHome=trueProtectSystem=strictPrivateTmp=truePrivateDevices=trueProtectKernelModules=trueCapabilityBoundingSet=RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINKProtectKernelTunables=trueProtectKernelLogs=trueProtectControlGroups=trueProtectClock=trueProtectHostname=trueRestrictNamespaces=trueRestrictSUIDSGID=trueRestrictRealtime=trueLockPersonality=trueMemoryDenyWriteExecute=trueSystemCallArchitectures=nativeSystemCallFilter=@system-serviceSystemCallErrorNumber=EPERM[Install]WantedBy=multi-user.targetEOFsudosystemctldaemon-reloadsudosystemctlenable"${SERVICE_NAME}"2>/dev/null||trueifsudo"${squid}"-kparse-f"${SQUID_CONF}"&>/dev/null;thenifsudosystemctlrestart"${SERVICE_NAME}";thenecho"${SERVICE_NAME} started"elseecho"Warning: ${SERVICE_NAME} failed to start; check: systemctl status ${SERVICE_NAME}">&2fielseecho"${SERVICE_NAME} enabled but not started: ${SQUID_CONF} did not parse."fi}remove_service(){if[-f"${SERVICE_DEST}"];thenecho"Removing ${SERVICE_NAME}..."sudosystemctldisable--now"${SERVICE_NAME}"2>/dev/null||truesudorm-f"${SERVICE_DEST}"sudosystemctldaemon-reloadfi}# ── Probe: a CONNECT through the proxy succeeds for an allowlisted domain and is# refused for anything else. Returns the HTTP code (000 = blocked/unreachable).# curl's -w already prints 000 on failure; capture it (|| true so set -e is happy)# rather than appending another 000._probe(){localcodecode="$(curl-sS-o/dev/null-w'%{http_code}'-x"http://${PROXY_ADDR}:${PROXY_PORT}"\--max-time12"$1"2>/dev/null||true)"printf'%s'"${code:-000}"}# ── Allow-probe with a short settle retry: right after `install`, Squid may not# yet be ready to dial upstream and refuses the first CONNECT, printing a false# ✗ HTTP 000. Retry the *allow* probe until it succeeds (a warm proxy passes on# the first try, so this adds no delay to the common path)._probe_allow(){localcodefor_in12345;docode="$(_probe"$1")"[["${code}"=~^[23]]]&&{printf'%s'"${code}";return;}sleep1doneprintf'%s'"${code:-000}"}# ── Actions ───────────────────────────────────────────────────────────────────────do_deploy(){check_can_sudo||return1require_systemd||return1install_squid_pkgensure_proxy_userensure_dirsgenerate_allowlistgenerate_squid_confgenerate_egress_envinstall_serviceecho"Egress proxy setup complete (${PROXY_ADDR}:${PROXY_PORT})."do_status||true}do_upgrade(){check_can_sudo||return1require_systemd||return1ifsquid_is_installed;thenecho"Upgrading Squid..."sudoapt-getupdatesudoapt-getinstall-y--only-upgradesquidelseinstall_squid_pkgfiensure_proxy_userensure_dirsgenerate_allowlist# keeps existing editsgenerate_squid_confgenerate_egress_envinstall_serviceecho"Egress upgrade complete: $(squid_version)"}do_reload(){check_can_sudo||return1sudosystemctlreload"${SERVICE_NAME}"echo"Reloaded ${SERVICE_NAME}"}do_remove(){check_can_sudo||return1remove_serviceifdpkg-query-W-f='${Status}'squid2>/dev/null|grep-q"install ok installed";thenecho"Uninstalling Squid..."sudoapt-get--purgeremove-ysquidsudoapt-getautoremove-yfilocalreplyread-r-p"Remove ${PROXY_CONF_DIR} and ${EGRESS_ENV}? [y/N] "replyif[["${reply}"=~^[Yy]$]];thensudorm-rf"${PROXY_CONF_DIR}"sudorm-f"${EGRESS_ENV}"firead-r-p"Remove service user ${PROXY_USER} and group ${PROXY_GROUP}? [y/N] "replyif[["${reply}"=~^[Yy]$]];thenifgetentpasswd"${PROXY_USER}">/dev/null;thensudouserdel"${PROXY_USER}"2>/dev/null||true;fiifgetentgroup"${PROXY_GROUP}">/dev/null;thensudogroupdel"${PROXY_GROUP}"2>/dev/null||true;fifiecho"Egress uninstall complete."}do_status(){ifsquid_is_installed;thenecho"Squid: ✓ installed ($(squid_bin)) — $(squid_version)"elseecho"Squid: ✗ not installed"fiifcommand-vsystemctl&>/dev/null&&[-f"${SERVICE_DEST}"];thenecho"${SERVICE_NAME}: $(systemctlis-active"${SERVICE_NAME}"2>/dev/null)/$(systemctlis-enabled"${SERVICE_NAME}"2>/dev/null)"elseecho"${SERVICE_NAME}: ✗ not installed"fiifsudotest-f"${ALLOWLIST}";thenecho"Allowlist (${ALLOWLIST}):"sudogrep-vE'^\s*(#|$)'"${ALLOWLIST}"|sed's/^/ /'fi# Allow/deny probe through the proxy.localallowdenyallow="$(_probe_allowhttps://projecteuler.net/)"deny="$(_probehttps://example.com/)"if[["${allow}"=~^[23]]];thenecho"allow probe (projecteuler.net): ✓ HTTP ${allow}"elseecho"allow probe (projecteuler.net): ✗ HTTP ${allow} (expected 2xx/3xx)"fiif["${deny}"="000"]||["${deny}"="403"];thenecho"deny probe (example.com): ✓ blocked (HTTP ${deny})"elseecho"deny probe (example.com): ✗ NOT blocked (HTTP ${deny})"fi}# ── Dispatch ──────────────────────────────────────────────────────────────────────ACTION="${1:-status}"case"${ACTION}"indeploy)do_deploy;;remove)do_remove;;upgrade)do_upgrade;;reload)do_reload;;status)do_status;;-h|--help|help)usage;;*)echo"Unknown action: ${ACTION}";usage;exit1;;esac