Files
bot-bottle/lib/network.sh
T
didericis b0d8987c68 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
2026-05-08 00:56:51 -04:00

129 lines
5.0 KiB
Bash

#!/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-<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
# 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 <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 <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
# 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 <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 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 <name>'"
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 <network> <container>
#
# 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 <name>` 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 <name>
#
# 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
}