- no docker0 yet (dockerd is socket-activated and starts on first use): fall back to the default gateway instead of exiting silently under pipefail - an ERR trap in the main process prints file, line and command of an unhandled failure - egress layers list returns success when the last layer is absent - drop the stale OCSB_EGRESS_BASE from the settings list (broke `config`)
225 lines
8.9 KiB
Bash
225 lines
8.9 KiB
Bash
# 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):
|
|
# <package>/egress/defaults.conf
|
|
# ~/.config/opencode-sandbox/egress.conf
|
|
# <project>/.opencode-sandbox/egress.conf (committed with the project)
|
|
# <project>/.opencode-sandbox/local/egress.conf (git-ignored)
|
|
#
|
|
# egress.conf lines (# starts a comment):
|
|
# allow <domain>... allowed even if a block rule matches (.domain = with subdomains)
|
|
# allow-regex <regex>... same, by regular expression
|
|
# block <domain>... denied
|
|
# block-regex <regex>...
|
|
# connect <host:port>... 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 <url> [curl args] self-test: must be refused (egress verify)
|
|
# expect-pass <url> [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 "directive<TAB>arg" 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)
|
|
if [[ ${connect} == 403 || ${http} == 403 ]]; then got=block
|
|
elif [[ ${connect} == 200 || ${http} != 000 ]]; then got=pass
|
|
else got=error; fi
|
|
if [[ ${got} == "${want}" ]]; then
|
|
printf ' ✓ %-5s %s\n' "${want}" "${url}"
|
|
else
|
|
printf ' ✗ %-5s %s → %s (connect=%s http=%s)\n' "${want}" "${url}" "${got}" "${connect}" "${http}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
ocsb_egress_verify() {
|
|
local fail=0 d a url args
|
|
echo "Egress self-test (127.0.0.1:${OCSB_EGRESS_PORT}):"
|
|
ocsb_egress_check block https://localhost-name-without-dot/ || fail=1
|
|
ocsb_egress_check block https://10.255.255.1/ || fail=1
|
|
ocsb_egress_check block telnet://example.com:22 -p || fail=1
|
|
while IFS=$'\t' read -r d a; do
|
|
[[ ${d} == expect-* ]] || continue
|
|
read -r url args <<<"${a}"
|
|
# shellcheck disable=SC2086 # args are curl options from egress.conf
|
|
ocsb_egress_check "${d#expect-}" "${url}" ${args} || fail=1
|
|
done < <(ocsb_egress_rules)
|
|
return ${fail}
|
|
}
|
|
|
|
ocsb_egress() {
|
|
local sub=${1:-status}
|
|
case "${sub}" in
|
|
up) ocsb_egress_up ;;
|
|
down)
|
|
docker rm -f "${OCSB_CONTAINER}" >/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
|
|
}
|