From b0d8987c68f65fe468cd0412bb7846594d6840f0 Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 8 May 2026 00:56:51 -0400 Subject: [PATCH] feat(network): add lib/network.sh for per-agent --internal Docker networks Adds the network half of the PRD 0001 egress topology: per-agent --internal Docker networks with a slug-derived name and a numeric conflict suffix that mirrors the container-name scheme in cli.sh. Helpers cover create / attach / remove and are pipelock-agnostic, so a future PRD can layer a different sidecar on top without entangling the two concerns. Refs: docs/prds/0001-per-agent-egress-proxy-via-pipelock.md Assisted-by: Claude Code --- lib/network.sh | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 lib/network.sh diff --git a/lib/network.sh b/lib/network.sh new file mode 100644 index 0000000..707782f --- /dev/null +++ b/lib/network.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Docker network plumbing for the per-agent egress-proxy topology +# (PRD 0001). +# +# The egress design (see docs/research/pipelock-assessment.md +# §"Deployment topology") puts the agent container on a Docker +# `--internal` network — Docker omits the default gateway from +# `internal: true` networks at the iptables level inside the engine / +# 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. +# +# 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. +# +# 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 +# networks. +# +# Idempotent: safe to source multiple times. + +if [ -n "${CLAUDE_BOTTLE_LIB_NETWORK_SOURCED:-}" ]; then + return 0 +fi +CLAUDE_BOTTLE_LIB_NETWORK_SOURCED=1 + +_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_create_internal. +network_name_for_slug() { + local slug="${1:?network_name_for_slug: missing slug}" + printf 'claude-bottle-net-%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 +# claude-bottle-net-foo as existing when only claude-bottle-net-foo-2 was +# present. +network_exists() { + local name="${1:?network_exists: missing network name}" + docker network inspect "$name" >/dev/null 2>&1 +} + +# 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 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")" + + local name="$base" + local _suffix=2 + while network_exists "$name"; do + name="${base}-${_suffix}" + _suffix=$((_suffix + 1)) + if [ "$_suffix" -gt 100 ]; then + die "could not find a free network name after ${base}-99; clean up old networks with 'docker network rm '" + 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" + fi + printf '%s' "$name" +} + +# network_attach +# +# Attaches an already-running container to the named network. Used to +# add the pipelock sidecar to a second (default-bridge) network so it +# has upstream egress, while staying reachable from the agent on the +# internal network. +# +# Note: for the agent container itself we pass `--network ` to +# `docker run` directly in cli.sh rather than using this function. The +# agent never touches anything except the internal network. +network_attach() { + local network="${1:?network_attach: missing network name}" + local container="${2:?network_attach: missing container name}" + if ! docker network connect "$network" "$container" >/dev/null 2>&1; then + die "docker network connect ${network} ${container} failed" + fi +} + +# network_remove +# +# Removes the named network. Idempotent: a missing network is treated +# as success so this can be called unconditionally from a teardown +# trap. A network that still has containers attached will fail to +# remove; the caller is expected to tear those containers down first. +network_remove() { + local name="${1:?network_remove: missing network name}" + if ! network_exists "$name"; then + return 0 + fi + if ! docker network rm "$name" >/dev/null 2>&1; then + # Don't `die` here: this runs in cleanup paths where we'd rather + # warn and continue than abort and leave more orphans behind. + warn "failed to remove network ${name}; clean up with 'docker network rm ${name}'" + return 1 + fi +}