PRD 0006: pipelock native TLS interception #9
+4
-2
@@ -19,9 +19,11 @@ FROM node:22-slim
|
|||||||
# clarity in case the base ever drops it. socat is the privileged
|
# clarity in case the base ever drops it. socat is the privileged
|
||||||
# forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent
|
# forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent
|
||||||
# runs as root and rejects non-root connections, so socat sits between
|
# runs as root and rejects non-root connections, so socat sits between
|
||||||
# node and the agent socket.
|
# node and the agent socket. curl is here so any HTTPS_PROXY-aware
|
||||||
|
# tool (curl itself, plus anything that shells out to it) works
|
||||||
|
# against pipelock's bumped TLS without the agent needing local DNS.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat \
|
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install claude-code globally. Pinned to the version verified in the v1
|
# Install claude-code globally. Pinned to the version verified in the v1
|
||||||
|
|||||||
@@ -204,24 +204,36 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: PlanT, target: str) -> str | None:
|
def provision(self, plan: PlanT, target: str) -> str | None:
|
||||||
"""Copy host-side files (prompt, skills, SSH keys, .git) into
|
"""Copy host-side files (CA cert, prompt, skills, SSH keys,
|
||||||
the running bottle. Called from `launch` after the container/
|
.git) into the running bottle. Called from `launch` after the
|
||||||
machine is up. `target` identifies the running instance in
|
container/machine is up. `target` identifies the running
|
||||||
backend-specific terms (Docker: resolved container name; fly:
|
instance in backend-specific terms (Docker: resolved
|
||||||
machine id). Returns the in-container prompt path if a prompt
|
container name; fly: machine id). Returns the in-container
|
||||||
was provisioned, else None — the Bottle handle uses it to
|
prompt path if a prompt was provisioned, else None — the
|
||||||
decide whether to add --append-system-prompt-file to claude's
|
Bottle handle uses it to decide whether to add
|
||||||
argv.
|
--append-system-prompt-file to claude's argv.
|
||||||
|
|
||||||
Default orchestration: prompt → skills → ssh → git. Subclasses
|
Default orchestration: ca → prompt → skills → ssh → git.
|
||||||
typically don't override this; they implement the four
|
CA install runs first so the agent's trust store is rebuilt
|
||||||
sub-methods below."""
|
before anything inside the agent makes a TLS call. Subclasses
|
||||||
|
typically don't override this; they implement the sub-methods
|
||||||
|
below."""
|
||||||
|
self.provision_ca(plan, target)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
prompt_path = self.provision_prompt(plan, target)
|
||||||
self.provision_skills(plan, target)
|
self.provision_skills(plan, target)
|
||||||
self.provision_ssh(plan, target)
|
self.provision_ssh(plan, target)
|
||||||
self.provision_git(plan, target)
|
self.provision_git(plan, target)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
|
def provision_ca(self, plan: PlanT, target: str) -> None:
|
||||||
|
"""Install pipelock's per-bottle CA into the agent's trust
|
||||||
|
store so the agent trusts the bumped CONNECT cert pipelock
|
||||||
|
presents. Default impl is a no-op so backends that don't
|
||||||
|
yet support TLS interception (every backend except Docker
|
||||||
|
today) aren't forced to implement it. The Docker backend
|
||||||
|
overrides to docker-cp the cert in and run
|
||||||
|
`update-ca-certificates`."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
||||||
"""Copy the prompt file into the running bottle. Returns the
|
"""Copy the prompt file into the running bottle. Returns the
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .bottle import DockerBottle
|
|||||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
|
from .provision import ca as _ca
|
||||||
from .provision import git as _git
|
from .provision import git as _git
|
||||||
from .provision import prompt as _prompt
|
from .provision import prompt as _prompt
|
||||||
from .provision import skills as _skills
|
from .provision import skills as _skills
|
||||||
@@ -47,6 +48,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
with _launch.launch(plan, proxy=self._proxy, provision=self.provision) as bottle:
|
with _launch.launch(plan, proxy=self._proxy, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
|
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
_ca.provision_ca(plan, target)
|
||||||
|
|
||||||
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
||||||
return _prompt.provision_prompt(plan, target)
|
return _prompt.provision_prompt(plan, target)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from . import util as docker_mod
|
|||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||||
|
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
|
|
||||||
|
|
||||||
# Where the repo root lives, for `docker build` context. Computed once.
|
# Where the repo root lives, for `docker build` context. Computed once.
|
||||||
@@ -77,7 +78,11 @@ def launch(
|
|||||||
ca_cert_host_path=ca_cert_host,
|
ca_cert_host_path=ca_cert_host,
|
||||||
ca_key_host_path=ca_key_host,
|
ca_key_host_path=ca_key_host,
|
||||||
)
|
)
|
||||||
pipelock_name = proxy.start(proxy_plan)
|
# Re-bind the outer plan so provision_ca (which runs later
|
||||||
|
# from `provision(plan, container)`) can read the populated
|
||||||
|
# CA paths off plan.proxy_plan.
|
||||||
|
plan = dataclasses.replace(plan, proxy_plan=proxy_plan)
|
||||||
|
pipelock_name = proxy.start(plan.proxy_plan)
|
||||||
stack.callback(proxy.stop, pipelock_name)
|
stack.callback(proxy.stop, pipelock_name)
|
||||||
|
|
||||||
container = _run_agent_container(plan, internal_network)
|
container = _run_agent_container(plan, internal_network)
|
||||||
@@ -102,6 +107,16 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
|||||||
"-e", f"HTTPS_PROXY={proxy_url}",
|
"-e", f"HTTPS_PROXY={proxy_url}",
|
||||||
"-e", f"HTTP_PROXY={proxy_url}",
|
"-e", f"HTTP_PROXY={proxy_url}",
|
||||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||||
|
# CA trust trio for the agent process. Docker propagates
|
||||||
|
# run-time env into `docker exec`, so `claude` sees these
|
||||||
|
# without per-exec threading. NODE_EXTRA_CA_CERTS points at
|
||||||
|
# the cert file (Node appends it to its bundled roots);
|
||||||
|
# SSL_CERT_FILE / REQUESTS_CA_BUNDLE point at the system
|
||||||
|
# bundle that `update-ca-certificates` rebuilds in
|
||||||
|
# provision_ca.
|
||||||
|
"-e", f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||||
|
"-e", f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
||||||
|
"-e", f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
||||||
]
|
]
|
||||||
if plan.use_runsc:
|
if plan.use_runsc:
|
||||||
docker_args.extend(["--runtime", "runsc"])
|
docker_args.extend(["--runtime", "runsc"])
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Install pipelock's per-bottle CA into the agent container's trust
|
||||||
|
store (PRD 0006).
|
||||||
|
|
||||||
|
By the time this provisioner runs, `pipelock_tls_init` has generated
|
||||||
|
a fresh CA into `plan.stage_dir/pipelock-ca/` and the pipelock sidecar
|
||||||
|
is up with `tls_interception: { enabled: true }` referencing the
|
||||||
|
in-container CA paths. This step makes the agent trust certs signed
|
||||||
|
by that CA so the agent's TLS handshake with the bumped CONNECT
|
||||||
|
succeeds.
|
||||||
|
|
||||||
|
Cert lands on Debian's standard source path
|
||||||
|
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
|
||||||
|
rebuilds `/etc/ssl/certs/ca-certificates.crt`, which is what curl,
|
||||||
|
Python `ssl`, and OpenSSL-based tools all read by default. The env
|
||||||
|
trio set on the agent's `docker run` covers Node
|
||||||
|
(`NODE_EXTRA_CA_CERTS`) and Python `requests` /
|
||||||
|
`SSL_CERT_FILE`-honoring libraries that don't load the system
|
||||||
|
bundle.
|
||||||
|
|
||||||
|
The fingerprint is computed via stdlib (`ssl.PEM_cert_to_DER_cert`
|
||||||
|
+ `hashlib.sha256`) and logged once to stderr. The private key
|
||||||
|
stays on the host (under `stage_dir`) until teardown wipes the
|
||||||
|
stage dir; nothing in the agent ever sees it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ....log import info
|
||||||
|
from ..bottle_plan import DockerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
# Debian-family path for sources that `update-ca-certificates` reads.
|
||||||
|
# Bundle path is what the command rebuilds and what every standard
|
||||||
|
# TLS consumer in the image reads.
|
||||||
|
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-pipelock-ca.crt"
|
||||||
|
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
|
||||||
|
|
||||||
|
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
"""Copy pipelock's CA cert into the agent, rebuild the trust
|
||||||
|
bundle, emit a one-line fingerprint log. Called from
|
||||||
|
`BottleBackend.provision` after the agent container is up."""
|
||||||
|
container = target
|
||||||
|
cert_host_path = plan.proxy_plan.ca_cert_host_path
|
||||||
|
if not cert_host_path or not cert_host_path.is_file():
|
||||||
|
# Defensive: provision runs after launch wires CA paths
|
||||||
|
# onto the plan via dataclasses.replace; an empty path here
|
||||||
|
# would mean that wiring was skipped.
|
||||||
|
from ....log import die
|
||||||
|
die(
|
||||||
|
f"pipelock CA cert missing at {cert_host_path or '(empty)'}; "
|
||||||
|
f"launch must have called pipelock_tls_init and re-bound "
|
||||||
|
f"the plan before provision"
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "update-ca-certificates"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stdlib SHA-256 of the cert's DER bytes — the standard
|
||||||
|
# fingerprint form. Never the private key.
|
||||||
|
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
|
||||||
|
fingerprint = hashlib.sha256(der).hexdigest()
|
||||||
|
info(f"pipelock ca fingerprint: sha256:{fingerprint[:32]}...")
|
||||||
Reference in New Issue
Block a user