From 55bb23096982a4691948295da383d2bc2c3d7053 Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 8 May 2026 01:16:46 -0400 Subject: [PATCH] fix(network): create user-defined egress bridge for pipelock sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker's legacy `bridge` network has no embedded DNS resolver — only user-defined bridges do — so attaching the pipelock sidecar to `bridge` made it unable to resolve `api.anthropic.com` and dead-ended Claude Code traffic. Add `network_create_egress`, refactored around a shared `_network_create_with_prefix` helper, and wire it through `pipelock_start` and `cli.sh` so the sidecar straddles the agent's --internal network and a per-agent user-defined egress bridge instead. The agent container itself still attaches to the internal network only. Assisted-by: Claude Code --- cli.sh | 26 ++++++++--- lib/network.sh | 120 +++++++++++++++++++++++++++++++++++------------- lib/pipelock.sh | 38 +++++++++------ 3 files changed, 130 insertions(+), 54 deletions(-) diff --git a/cli.sh b/cli.sh index e55dc9c..6cdc798 100755 --- a/cli.sh +++ b/cli.sh @@ -513,21 +513,30 @@ cmd_start() { build_image_with_cwd "$DERIVED_IMAGE" "$IMAGE" "$USER_CWD" fi - # PRD 0001: per-agent egress topology. Create the --internal Docker - # network and start the pipelock sidecar on it BEFORE the agent - # container, so the agent's HTTPS_PROXY target exists at the moment - # the agent boots. + # PRD 0001: per-agent egress topology. Create the two Docker + # networks the sidecar needs, then start the pipelock sidecar on + # them BEFORE the agent container, so the agent's HTTPS_PROXY target + # exists at the moment the agent boots. + # + # The agent container itself stays on INTERNAL_NETWORK only — only + # the sidecar straddles both. The egress network is the sidecar's + # path to the upstream internet (must be a user-defined bridge so + # Docker's embedded DNS resolves api.anthropic.com et al.; the + # legacy `bridge` network has no embedded DNS and is the wrong + # answer here — see lib/network.sh). # # Not declared local: needed by cleanup_all after cmd_start returns # (same reason as MANIFEST_FILE / STAGE_DIR / CONTAINER above). INTERNAL_NETWORK="" + EGRESS_NETWORK="" PIPELOCK_CONTAINER="" INTERNAL_NETWORK="$(network_create_internal "$SLUG")" - PIPELOCK_CONTAINER="$(pipelock_start "$SLUG" "$INTERNAL_NETWORK" "$STAGE_DIR" "$PIPELOCK_YAML_FILENAME")" + EGRESS_NETWORK="$(network_create_egress "$SLUG")" + PIPELOCK_CONTAINER="$(pipelock_start "$SLUG" "$INTERNAL_NETWORK" "$EGRESS_NETWORK" "$STAGE_DIR" "$PIPELOCK_YAML_FILENAME")" # Cleanup container on exit too. Compose with stage cleanup. - # Order matters: sidecar first, then internal network — docker - # refuses to remove a network with attached containers. + # Order matters: sidecar first, then networks — docker refuses to + # remove a network with attached containers. cleanup_all() { if container_exists "$CONTAINER"; then docker rm -f "$CONTAINER" >/dev/null 2>&1 || true @@ -538,6 +547,9 @@ cmd_start() { if [ -n "${INTERNAL_NETWORK:-}" ]; then network_remove "$INTERNAL_NETWORK" fi + if [ -n "${EGRESS_NETWORK:-}" ]; then + network_remove "$EGRESS_NETWORK" + fi cleanup_stage } # Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally. diff --git a/lib/network.sh b/lib/network.sh index 707782f..ad0a819 100644 --- a/lib/network.sh +++ b/lib/network.sh @@ -9,18 +9,24 @@ # LinuxKit VM, so the only address the agent can reach is the pipelock # sidecar attached to the same network. The pipelock sidecar itself # also needs egress to the upstream internet, so it is placed on a -# second (default-bridge) network as well; that wiring lives in -# lib/pipelock.sh. +# second (user-defined bridge) network as well. We deliberately do +# NOT use Docker's legacy `bridge` network for this: the legacy bridge +# has no embedded DNS resolver, so pipelock would be unable to resolve +# `api.anthropic.com` and Claude Code traffic would dead-end. Only +# user-defined bridges run Docker's built-in DNS, so we create one +# per agent. # # This module is the network-only half of that split: create / attach -# / teardown of the per-agent internal network, with no pipelock -# specifics. Keeping pipelock-agnostic helpers here means a future PRD -# can reuse them for a different sidecar (e.g. an iptables-only -# layer) without entangling the two concerns. +# / teardown of both the per-agent internal network and the per-agent +# user-defined egress bridge, with no pipelock specifics. Keeping +# pipelock-agnostic helpers here means a future PRD can reuse them +# for a different sidecar (e.g. an iptables-only layer) without +# entangling the two concerns. # -# Naming: claude-bottle-net-. On conflict we append a numeric -# suffix (-2, -3, ...) to mirror the container-naming scheme in -# cli.sh, so two parallel starts of the same agent get distinct +# Naming: claude-bottle-net- (internal), +# claude-bottle-egress- (egress). On conflict we append a +# numeric suffix (-2, -3, ...) to mirror the container-naming scheme +# in cli.sh, so two parallel starts of the same agent get distinct # networks. # # Idempotent: safe to source multiple times. @@ -34,14 +40,22 @@ _iso_lib_network_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd # shellcheck source=./log.sh . "${_iso_lib_network_dir}/log.sh" -# network_name_for_slug — prints the canonical network name for a -# given agent slug. No conflict resolution; that lives in +# network_name_for_slug — prints the canonical internal-network +# name for a given agent slug. No conflict resolution; that lives in # network_create_internal. network_name_for_slug() { local slug="${1:?network_name_for_slug: missing slug}" printf 'claude-bottle-net-%s' "$slug" } +# network_egress_name_for_slug — prints the canonical egress-network +# name for a given agent slug. No conflict resolution; that lives in +# network_create_egress. +network_egress_name_for_slug() { + local slug="${1:?network_egress_name_for_slug: missing slug}" + printf 'claude-bottle-egress-%s' "$slug" +} + # network_exists — returns 0 if the named docker network exists, # else 1. Uses `docker network inspect` (not `docker network ls -f name=...`) # because the latter does substring matching, which would falsely report @@ -52,24 +66,16 @@ network_exists() { docker network inspect "$name" >/dev/null 2>&1 } -# network_create_internal +# _network_create_with_prefix # -# Creates a Docker `--internal` network for the agent and prints the -# resolved network name on stdout. If the canonical name is already -# taken, appends -2, -3, ... (capped at 100, matching the -# container-name retry loop in cli.sh) until a free name is found. -# -# `--internal` is the load-bearing flag: Docker creates the bridge -# without a default route, so the agent container attached here cannot -# reach the public internet directly. The pipelock sidecar (attached -# to both this network and a default-bridge network) is the only -# egress route. -# -# Side effect: emits one info line naming the network actually created. -network_create_internal() { - local slug="${1:?network_create_internal: missing slug}" - local base - base="$(network_name_for_slug "$slug")" +# Internal helper. Creates a per-agent Docker network whose name is +# (with -2, -3, ... appended on conflict, capped at 100). +# When is 1, the network is created with `--internal` (no +# default gateway). When 0, it's a plain user-defined bridge with +# upstream connectivity. Echoes the resolved name on stdout. +_network_create_with_prefix() { + local base="${1:?_network_create_with_prefix: missing prefix}" + local internal_flag="${2:?_network_create_with_prefix: missing internal flag}" local name="$base" local _suffix=2 @@ -81,15 +87,63 @@ network_create_internal() { fi done - info "creating internal network ${name}" - # `--internal` is the only flag we set — defaults give us a bridge - # driver with Docker-managed addressing, which is what we want. - if ! docker network create --internal "$name" >/dev/null; then - die "docker network create --internal ${name} failed" + local kind="bridge (egress)" + local args=() + if [ "$internal_flag" = "1" ]; then + kind="internal" + args+=(--internal) + fi + info "creating ${kind} network ${name}" + # Defaults give us a bridge driver with Docker-managed addressing, + # which is what we want for both internal and egress networks. + if ! docker network create "${args[@]}" "$name" >/dev/null; then + die "docker network create ${args[*]} ${name} failed" fi printf '%s' "$name" } +# network_create_internal +# +# Creates a Docker `--internal` network for the agent and prints the +# resolved network name on stdout. If the canonical name is already +# taken, appends -2, -3, ... (capped at 100, matching the +# container-name retry loop in cli.sh) until a free name is found. +# +# `--internal` is the load-bearing flag: Docker creates the bridge +# without a default route, so the agent container attached here cannot +# reach the public internet directly. The pipelock sidecar (attached +# to both this network and a per-agent egress network) is the only +# egress route. +# +# Side effect: emits one info line naming the network actually created. +network_create_internal() { + local slug="${1:?network_create_internal: missing slug}" + local base + base="$(network_name_for_slug "$slug")" + _network_create_with_prefix "$base" 1 +} + +# network_create_egress +# +# Creates a per-agent user-defined bridge network used by the pipelock +# sidecar for upstream egress, and prints the resolved network name on +# stdout. Conflict resolution mirrors network_create_internal. +# +# We use a user-defined bridge (NOT the legacy `bridge` network) +# because only user-defined bridges run Docker's embedded DNS resolver +# — pipelock needs DNS to resolve `api.anthropic.com` and similar +# upstream hostnames. The legacy `bridge` network would force pipelock +# onto the host's resolv.conf and fail in environments where Docker +# Desktop's NAT path is the only working DNS route. +# +# Side effect: emits one info line naming the network actually created. +network_create_egress() { + local slug="${1:?network_create_egress: missing slug}" + local base + base="$(network_egress_name_for_slug "$slug")" + _network_create_with_prefix "$base" 0 +} + # network_attach # # Attaches an already-running container to the named network. Used to diff --git a/lib/pipelock.sh b/lib/pipelock.sh index 49a37e4..f09197f 100644 --- a/lib/pipelock.sh +++ b/lib/pipelock.sh @@ -6,10 +6,13 @@ # forward proxy with hostname allowlisting + DLP scanning + URL-entropy # checks. We run one sidecar container per agent, attached to the # agent's --internal network (created by lib/network.sh) and to a -# default-bridge network for upstream egress. The agent's HTTPS_PROXY / -# HTTP_PROXY env vars point at the sidecar's service name on the -# internal network; combined with --internal (which omits the default -# gateway), pipelock is the only egress route the agent has. +# per-agent user-defined bridge network for upstream egress (also +# created by lib/network.sh — see the comment in network_create_egress +# for why we don't use Docker's legacy `bridge` network). The agent's +# HTTPS_PROXY / HTTP_PROXY env vars point at the sidecar's service +# name on the internal network; combined with --internal (which omits +# the default gateway), pipelock is the only egress route the agent +# has. # # Image pin: ghcr.io/luckypipewrench/pipelock@sha256:. The # digest is resolved by hand against ghcr.io for tag 2.3.0 (the @@ -261,7 +264,7 @@ pipelock_write_yaml() { # --- Sidecar lifecycle ----------------------------------------------------- -# pipelock_start +# pipelock_start # # Boots the pipelock sidecar: # 1. `docker run -d` on the internal network with the canonical @@ -284,6 +287,9 @@ pipelock_write_yaml() { # Args: # — agent slug; sidecar name will be claude-bottle-pipelock- # — name of the agent's internal docker network +# — name of the agent's user-defined egress +# network; the sidecar joins this so it can +# reach upstream hostnames with working DNS # — host directory containing the YAML # — filename within yaml_dir # @@ -291,8 +297,9 @@ pipelock_write_yaml() { pipelock_start() { local slug="${1:?pipelock_start: missing slug}" local internal_network="${2:?pipelock_start: missing internal network}" - local yaml_dir="${3:?pipelock_start: missing yaml dir}" - local yaml_filename="${4:?pipelock_start: missing yaml filename}" + local egress_network="${3:?pipelock_start: missing egress network}" + local yaml_dir="${4:?pipelock_start: missing yaml dir}" + local yaml_filename="${5:?pipelock_start: missing yaml filename}" local name name="$(pipelock_container_name "$slug")" @@ -340,14 +347,17 @@ pipelock_start() { die "failed to copy pipelock yaml into ${name}" fi - # Attach to a default-bridge network for upstream egress (the - # internal network has no gateway by definition, so without a second - # network the sidecar can't reach the public internet either). - # Using the well-known `bridge` network is the simplest way to give - # it a default route; we do not create a per-agent egress network. - if ! docker network connect bridge "$name" >/dev/null 2>&1; then + # Attach to a per-agent user-defined bridge network for upstream + # egress. The internal network has no gateway by definition, so + # without a second network the sidecar can't reach the public + # internet at all. We deliberately do NOT use Docker's legacy + # `bridge` network: only user-defined bridges run Docker's embedded + # DNS resolver, which pipelock needs to resolve `api.anthropic.com` + # and similar upstream hostnames. The egress network is created by + # network_create_egress in lib/network.sh. + if ! docker network connect "$egress_network" "$name" >/dev/null 2>&1; then docker rm -f "$name" >/dev/null 2>&1 || true - die "failed to attach pipelock sidecar ${name} to bridge network for upstream egress" + die "failed to attach pipelock sidecar ${name} to egress network ${egress_network}" fi if ! docker start "$name" >/dev/null 2>&1; then