# shellcheck shell=bash # Egress filter: squid in a container on the host network, listening on loopback and the # docker0 gateway. squid.conf is rendered from egress.conf layers (later layers add rules): # /egress/defaults.conf # ~/.config/opencode-sandbox/egress.conf # /.opencode-sandbox/egress.conf (committed with the project) # /.opencode-sandbox/local/egress.conf (git-ignored) # # egress.conf lines (# starts a comment): # allow ... allowed even if a block rule matches (.domain = with subdomains) # allow-regex ... same, by regular expression # block ... denied # block-regex ... # connect ... raw CONNECT tunnel to a non-443 port or an internal IP # (git over SSH, Kubernetes API); host may be an IP or CIDR # expect-block [curl args] self-test: must be refused (egress verify) # expect-pass [curl args] self-test: must go through # Built in, after all of the above: non-80/443 ports, names without a dot, private networks # are denied; everything else is allowed (blacklist policy). ocsb_egress_layers() { local f for f in "${OCSB_HOME}/egress/defaults.conf" \ "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/egress.conf" \ "${OCSB_PROJECT_DIR}/.opencode-sandbox/egress.conf" \ "${OCSB_PROJECT_DIR}/.opencode-sandbox/local/egress.conf"; do [[ -f ${f} ]] && echo "${f}" done return 0 } # Prints "directivearg" for every argument of every rule line ocsb_egress_rules() { local f line directive rest a args while IFS= read -r f; do while IFS= read -r line || [[ -n ${line} ]]; do line=${line%%#*} read -r directive rest <<<"${line}" [[ -z ${directive} ]] && continue case "${directive}" in allow | allow-regex | block | block-regex | connect) read -ra args <<<"${rest}" for a in "${args[@]}"; do printf '%s\t%s\n' "${directive}" "${a}"; done ;; expect-block | expect-pass) printf '%s\t%s\n' "${directive}" "${rest}" ;; *) ocsb_die "${f}: unknown directive '${directive}'" ;; esac done <"${f}" done < <(ocsb_egress_layers) } ocsb_is_ip() { [[ $1 =~ ^[0-9.]+(/[0-9]+)?$ || $1 =~ ^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]+)?$ ]]; } ocsb_egress_render() { local out=${OCSB_STATE_DIR}/egress/squid.conf rules allow=() allow_re=() block=() block_re=() local connect=() d a i=0 host port mkdir -p "${OCSB_STATE_DIR}/egress" rules=$(ocsb_egress_rules) while IFS=$'\t' read -r d a; do case "${d}" in allow) allow+=("${a}") ;; allow-regex) allow_re+=("${a}") ;; block) block+=("${a}") ;; block-regex) block_re+=("${a}") ;; connect) connect+=("${a}") ;; esac done <<<"${rules}" { echo "# Rendered by opencode-sandbox from egress.conf layers — do not edit, it is overwritten." echo "http_port 127.0.0.1:${OCSB_EGRESS_PORT}" echo "http_port ${OCSB_DOCKER_GW}:${OCSB_EGRESS_PORT}" echo "visible_hostname opencode-sandbox-egress" echo "pid_filename /tmp/squid.pid" echo "cache deny all" echo "forwarded_for off" echo "access_log /var/log/squid/access.log" echo "logfile_rotate 10" echo echo "acl CONNECT method CONNECT" echo "acl SSL_ports port 443" echo "acl Safe_ports port 80 443" for a in "${connect[@]}"; do host=${a%:*} port=${a##*:} [[ ${host} != "${a}" && ${port} =~ ^[0-9]+$ ]] || ocsb_die "connect ${a}: expected host:port" host=${host#[} host=${host%]} i=$((i + 1)) if ocsb_is_ip "${host}"; then echo "acl connect_${i}_host dst ${host}" else echo "acl connect_${i}_host dstdomain ${host}" fi echo "acl connect_${i}_port port ${port}" done [[ ${#allow[@]} -gt 0 ]] && echo "acl allowed dstdomain ${allow[*]}" [[ ${#allow_re[@]} -gt 0 ]] && echo "acl allowed_re dstdom_regex -i ${allow_re[*]}" [[ ${#block[@]} -gt 0 ]] && echo "acl blocked dstdomain ${block[*]}" [[ ${#block_re[@]} -gt 0 ]] && echo "acl blocked_re dstdom_regex -i ${block_re[*]}" echo "acl unqualified dstdom_regex ^[^.]+\$" echo "acl private_dst dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 127.0.0.0/8 fc00::/7 fe80::/10 ::1" echo for ((i = 1; i <= ${#connect[@]}; i++)); do echo "http_access allow CONNECT connect_${i}_host connect_${i}_port" done echo "http_access deny !Safe_ports" echo "http_access deny CONNECT !SSL_ports" [[ ${#allow[@]} -gt 0 ]] && echo "http_access allow allowed" [[ ${#allow_re[@]} -gt 0 ]] && echo "http_access allow allowed_re" [[ ${#block[@]} -gt 0 ]] && echo "http_access deny blocked" [[ ${#block_re[@]} -gt 0 ]] && echo "http_access deny blocked_re" echo "http_access deny unqualified" echo "http_access deny private_dst" echo "http_access allow all" } >"${out}.tmp" # Replace via rename into the mounted directory: squid -k reconfigure then sees the new file mv "${out}.tmp" "${out}" } ocsb_egress_src_hash() { cat "${OCSB_HOME}/egress/Dockerfile" "${OCSB_HOME}/egress/entrypoint.sh" | sha256sum | cut -c1-16 } ocsb_egress_running() { [[ $(docker inspect -f '{{.State.Running}}' "${OCSB_CONTAINER}" 2>/dev/null) == true ]] } ocsb_egress_up() { local hash hash=$(ocsb_egress_src_hash) ocsb_egress_render if [[ $(docker image inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_IMAGE}" 2>/dev/null) != "${hash}" ]]; then ocsb_info "egress: building image ${OCSB_IMAGE}" docker build -q --label "opencode-sandbox.src-hash=${hash}" -t "${OCSB_IMAGE}" "${OCSB_HOME}/egress" >/dev/null fi if ocsb_egress_running && [[ $(docker inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_CONTAINER}") == "${hash}" ]]; then ocsb_info "egress: ${OCSB_CONTAINER} running, reloading rules" docker exec "${OCSB_CONTAINER}" squid -k reconfigure -f /etc/opencode-sandbox/squid.conf sleep 1 return fi docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then ocsb_die "port ${OCSB_EGRESS_PORT} is busy (another project's filter?) — set OCSB_EGRESS_PORT in sandbox.conf" fi ocsb_info "egress: starting ${OCSB_CONTAINER} on 127.0.0.1:${OCSB_EGRESS_PORT}" docker run -d --name "${OCSB_CONTAINER}" --restart unless-stopped \ --label "opencode-sandbox.src-hash=${hash}" \ -v "${OCSB_STATE_DIR}/egress:/etc/opencode-sandbox:ro,z" \ --network host "${OCSB_IMAGE}" >/dev/null local i for i in $(seq 30); do (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null && return 0 sleep 0.2 done docker logs --tail 20 "${OCSB_CONTAINER}" >&2 ocsb_die "egress: squid did not start" } # One self-test: want = block|pass ocsb_egress_check() { local want=$1 url=$2 connect http got shift 2 read -r connect http < <(curl -s -o /dev/null -w '%{http_connect} %{http_code}' \ --max-time 15 -x "http://127.0.0.1:${OCSB_EGRESS_PORT}" "$@" "${url}" /dev/null 2>&1 || true echo "✓ ${OCSB_CONTAINER} removed" ;; status) if ocsb_egress_running; then echo " container ${OCSB_CONTAINER}: running" else echo " container ${OCSB_CONTAINER}: not running (opencode-sandbox egress up)" fi if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: open" else echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: closed" fi ;; logs) docker logs -f --tail 50 "${OCSB_CONTAINER}" ;; verify) ocsb_egress_verify ;; render) ocsb_egress_render cat "${OCSB_STATE_DIR}/egress/squid.conf" ;; *) ocsb_die "egress: unknown subcommand ${sub} (up|down|status|logs|verify|render)" ;; esac }