fix(network): create user-defined egress bridge for pipelock sidecar

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
This commit is contained in:
2026-05-08 01:16:46 -04:00
parent 8d2110ba06
commit 55bb230969
3 changed files with 130 additions and 54 deletions
+19 -7
View File
@@ -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.
+87 -33
View File
@@ -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-<slug>. 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-<slug> (internal),
# claude-bottle-egress-<slug> (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 <slug> — prints the canonical network name for a
# given agent slug. No conflict resolution; that lives in
# network_name_for_slug <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 <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 <name> — 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 <slug>
# _network_create_with_prefix <prefix> <internal: 0|1>
#
# 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
# <prefix> (with -2, -3, ... appended on conflict, capped at 100).
# When <internal> 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 <slug>
#
# 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 <slug>
#
# 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 <network> <container>
#
# Attaches an already-running container to the named network. Used to
+24 -14
View File
@@ -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:<digest>. 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 <slug> <internal_network> <yaml_dir> <yaml_filename>
# pipelock_start <slug> <internal_network> <egress_network> <yaml_dir> <yaml_filename>
#
# Boots the pipelock sidecar:
# 1. `docker run -d` on the internal network with the canonical
@@ -284,6 +287,9 @@ pipelock_write_yaml() {
# Args:
# <slug> — agent slug; sidecar name will be claude-bottle-pipelock-<slug>
# <internal_network> — name of the agent's internal docker network
# <egress_network> — name of the agent's user-defined egress
# network; the sidecar joins this so it can
# reach upstream hostnames with working DNS
# <yaml_dir> — host directory containing the YAML
# <yaml_filename> — 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