Documentation
¶
Overview ¶
Package proxy implements the per-run egress proxy sidecar for Paddock v0.3. In cooperative mode (M4) it is an HTTP/1.1 CONNECT proxy that intercepts TLS destinations, forges a leaf certificate signed by the run-scoped MITM CA, re-issues the client request upstream, and emits AuditEvents on denials. Transparent mode (M5) reuses the same MITM engine but fronts it with an iptables-init redirect and SO_ORIGINAL_DST lookup.
See docs/internal/specs/0002-broker-proxy-v0.3.md §7 and ADR-0013.
Index ¶
- Constants
- Variables
- func NewHTTPServer(addr string, handler http.Handler) *http.Server
- func SetAuditSinkType(active string)
- func TransparentInterceptionSupported() bool
- type AllowRule
- type AuditSink
- type BrokerClient
- type ClientAuditSink
- type ConnLimiter
- type Decision
- type DeniedCIDRSet
- type EgressEvent
- type LimitedListener
- type MITMCertificateAuthority
- type NoopAuditSink
- type Resolver
- type Server
- type StaticValidator
- type Substituter
- type Validator
Constants ¶
const ( AuditSinkTypeClient = "client" AuditSinkTypeNoop = "noop" )
AuditSinkType labels. Used by SetAuditSinkType and as the expected return values from cmd/proxy/main.go::buildAuditSink.
Variables ¶
var ActiveConnections = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "paddock_proxy_active_connections",
Help: "Currently held proxy connections, both modes.",
})
ActiveConnections is the gauge of currently held proxy connections, covering both cooperative and transparent listeners. F-26.
var AuditSinkGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "paddock_proxy_audit_sink", Help: `Audit sink type currently in use (1=active type, 0=other). Alert when type="noop" is set in production.`, }, []string{"type"})
AuditSinkGauge tracks which audit sink type is currently in use. Exactly one label value is 1 at any time; the others are 0. Alert when type="noop" is set in production — it means audit emission is silently disabled.
var ConnectionsRejected = prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "paddock_proxy_connections_rejected_total", Help: "Connections rejected before reaching the validator, by reason.", }, []string{"reason"})
ConnectionsRejected counts connections rejected before reaching the validator. Reasons: cap_exceeded, denied_destination_cidr, dns_rebinding_mismatch, dns_resolution_failed, handshake_failed. (read_timeout is governed by http.Server's ReadTimeout and is not emitted on this counter; it would require an http.ServerHandler wrapper to trap.) F-22, F-26.
var HandshakeFailures = prometheus.NewCounter(prometheus.CounterOpts{
Name: "paddock_proxy_handshake_failures_total",
Help: "Inner-TLS handshake failures (agent or upstream).",
})
HandshakeFailures counts inner-TLS handshake failures (agent-side or upstream-side). F-26.
Functions ¶
func NewHTTPServer ¶
NewHTTPServer constructs the cooperative-mode http.Server with the proxy's standard timeouts and limits. Caller wires it onto a LimitedListener via Serve(). F-26.
func SetAuditSinkType ¶
func SetAuditSinkType(active string)
SetAuditSinkType sets AuditSinkGauge so that the named active type is 1 and all other known types are 0. Call this once after the refuse-to-start gates pass, using the type string returned by buildAuditSink. Known types: AuditSinkTypeClient, AuditSinkTypeNoop.
func TransparentInterceptionSupported ¶
func TransparentInterceptionSupported() bool
TransparentInterceptionSupported reports whether the current build has a working SO_ORIGINAL_DST implementation. True on Linux, false elsewhere. Used by cmd/proxy to fail fast on non-Linux builds when --mode=transparent is selected.
Types ¶
type AllowRule ¶
AllowRule is one entry in a StaticValidator. Ports is evaluated as a whitelist — an empty slice is equivalent to "any port".
type AuditSink ¶
type AuditSink interface {
RecordEgress(ctx context.Context, e EgressEvent) error
}
AuditSink records per-connection decisions. Phase 2c migrated this from a noop-on-error best-effort interface to one that returns an error; callers fail-close on the deny path and log+counter on the allow path.
type BrokerClient ¶
type BrokerClient struct {
// TokenReader, when non-nil, overrides the inner client's TokenReader
// on every ValidateEgress / SubstituteAuth call. NewBrokerClient
// initialises this field and the inner client's TokenReader to the
// same closure (re-reads tokenPath on every call), so production paths
// see no behavioural change. Tests can mutate this field after
// construction to inject inline byte slices; the override is
// propagated on the next ValidateEgress / SubstituteAuth call.
// Setting this field back to nil after construction is a no-op — it
// does not reset the inner client's TokenReader to the default; to
// "reset", re-call NewBrokerClient.
TokenReader brokerclient.TokenReader
// contains filtered or unexported fields
}
BrokerClient talks to the paddock-broker over HTTPS, authenticated with a ProjectedServiceAccountToken. Implements both Validator and Substituter — a single client because both endpoints share the same TLS + auth plumbing.
Zero value not usable; construct via NewBrokerClient.
BrokerClient is held per-run, so RunName and RunNamespace are immutable after construction. Tests may mutate TokenReader; production paths do not.
func NewBrokerClient ¶
func NewBrokerClient(endpoint, tokenPath, caPath, runName, runNamespace string) (*BrokerClient, error)
NewBrokerClient builds a client against the broker at endpoint. caPath is the CA bundle verifying the broker's serving cert; empty falls back to the system trust store, only correct if the broker's cert chains to a publicly trusted root (not Paddock's default).
func (*BrokerClient) SubstituteAuth ¶
func (c *BrokerClient) SubstituteAuth(ctx context.Context, host string, port int, headers http.Header) (brokerapi.SubstituteResult, error)
SubstituteAuth implements Substituter by calling the broker's /v1/substitute-auth. Returns an error — not a fallback — on denied substitution so the MITM path drops the connection rather than forwarding the agent's Paddock-issued bearer upstream.
func (*BrokerClient) ValidateEgress ¶
ValidateEgress implements Validator by calling the broker's /v1/validate-egress. On HTTP or broker error, returns err so the caller can fail-closed per ADR-0013.
type ClientAuditSink ¶
ClientAuditSink writes via the shared auditing.Sink. Sink is the production injection point; for back-compat with old call sites that supply only a controller-runtime Client + namespace + run name we fall back to wrapping a KubeSink internally.
func (*ClientAuditSink) RecordEgress ¶
func (s *ClientAuditSink) RecordEgress(ctx context.Context, e EgressEvent) error
RecordEgress writes one AuditEvent via the configured Sink. Returns the Sink's error (or nil on success). Callers decide whether to fail the connection or log+counter.
type ConnLimiter ¶
type ConnLimiter struct {
// contains filtered or unexported fields
}
ConnLimiter is a non-blocking bounded counting semaphore. Acquire returns (releaseFn, true) on success or (nil, false) when capacity is exhausted. Caller takes a fast-fail path on (nil, false) — reject with 503 / RST / audit — instead of blocking the listener.
func NewConnLimiter ¶
func NewConnLimiter(cap int) *ConnLimiter
NewConnLimiter constructs a limiter with the given capacity. cap<=0 returns a no-op limiter (Acquire always succeeds).
func (*ConnLimiter) Acquire ¶
func (l *ConnLimiter) Acquire() (func(), bool)
Acquire attempts to take one slot. Returns a release function on success, or (nil, false) when the cap is exhausted.
type Decision ¶
type Decision struct {
Allowed bool
MatchedPolicy string
Reason string
// SubstituteAuth declares that the MITM path must call the broker's
// SubstituteAuth endpoint per request and rewrite headers before
// forwarding upstream. False means the proxy either relays bytes
// (cooperative/transparent without substitution) or still MITMs for
// visibility but doesn't rewrite credentials.
SubstituteAuth bool
// DiscoveryAllow mirrors ValidateEgressResponse.DiscoveryAllow.
// When true, the proxy emits an egress-discovery-allow AuditEvent
// instead of egress-allow.
DiscoveryAllow bool
}
Decision captures a single egress verdict. Mirrors the broker's ValidateEgress response shape so BrokerValidator's output goes straight through.
type DeniedCIDRSet ¶
type DeniedCIDRSet struct {
// contains filtered or unexported fields
}
DeniedCIDRSet is a closed set of CIDR networks the proxy will refuse to dial regardless of whether the BrokerPolicy allow-list passed. Used by F-22 layer 2 (private/cluster-internal IP rejection) on post-resolution IPs and on the agent's transparent SO_ORIGINAL_DST.
func ParseDeniedCIDRs ¶
func ParseDeniedCIDRs(csv string) (*DeniedCIDRSet, error)
ParseDeniedCIDRs parses a comma-separated CIDR list. Empty input returns an empty (no-deny) set; whitespace around entries is tolerated. A malformed entry returns an error.
type EgressEvent ¶
type EgressEvent struct {
Host string
Port int
Decision paddockv1alpha1.AuditDecision
MatchedPolicy string
Reason string
When time.Time
Kind paddockv1alpha1.AuditKind
}
EgressEvent is what the MITM engine hands to the sink.
type LimitedListener ¶
type LimitedListener struct {
// contains filtered or unexported fields
}
LimitedListener wraps a net.Listener and silently drops accepted connections that exceed the cap. Each over-cap conn increments paddock_proxy_connections_rejected_total{reason="cap_exceeded"} and is closed abruptly (SetLinger(0) when supported, RST on the wire) so the agent sees a connection drop rather than a hung accept.
Returned conns are wrapped in *limitedConn whose Close releases the limiter slot. The wrapper is idempotent under multiple Close calls.
func NewLimitedListener ¶
NewLimitedListener wraps ln. mode ("cooperative" or "transparent") is recorded in rejection log lines for operator visibility. logger may be a zero logr.Logger (logging is suppressed when GetSink returns nil).
func (*LimitedListener) Accept ¶
func (l *LimitedListener) Accept() (net.Conn, error)
Accept hands back conns that fit under the cap; over-cap conns are closed internally and Accept is retried.
func (*LimitedListener) Addr ¶
func (l *LimitedListener) Addr() net.Addr
Addr returns the inner listener's address.
func (*LimitedListener) Close ¶
func (l *LimitedListener) Close() error
Close closes the inner listener; in-flight accepted conns continue.
type MITMCertificateAuthority ¶
type MITMCertificateAuthority struct {
// contains filtered or unexported fields
}
MITMCertificateAuthority forges leaf certificates on demand, signed by a root CA keypair loaded from disk. The forged leaves terminate the agent-side TLS connection so the proxy can inspect (and, in later milestones, rewrite) the plaintext HTTP exchange before re-encrypting upstream. Cert-manager owns the root; the controller copies the keypair into a per-run Secret (see ADR-0013 §7.3).
The forged-leaf cache is bounded LRU (default 1024 entries; F-28). Concurrent forges for the same SNI are coalesced via singleflight.
func LoadMITMCertificateAuthority ¶
func LoadMITMCertificateAuthority(certFile, keyFile string) (*MITMCertificateAuthority, error)
LoadMITMCertificateAuthority reads a PEM cert + key pair from certFile/keyFile and returns a ready CA. The leaf key is generated in-process — it never touches disk and is reused for every forged leaf to keep per-connection CPU minimal.
func LoadMITMCertificateAuthorityFromDir ¶
func LoadMITMCertificateAuthorityFromDir(dir string) (*MITMCertificateAuthority, error)
LoadMITMCertificateAuthorityFromDir looks for tls.crt and tls.key inside dir. Matches the layout cert-manager writes into a Secret volume mount.
func NewMITMCertificateAuthority ¶
func NewMITMCertificateAuthority(certPEM, keyPEM []byte) (*MITMCertificateAuthority, error)
NewMITMCertificateAuthority builds a CA from raw PEM bytes. Exported for tests; production callers should use LoadMITMCertificateAuthority.
func (*MITMCertificateAuthority) ForgeFor ¶
func (ca *MITMCertificateAuthority) ForgeFor(host string) (*tls.Certificate, error)
ForgeFor returns (or synthesises) a leaf cert for the given host. Useful when we know the CONNECT target up-front and can warm the cache before the TLS handshake.
func (*MITMCertificateAuthority) GetCertificate ¶
func (ca *MITMCertificateAuthority) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
GetCertificate returns a TLS certificate for the SNI hostname on the supplied ClientHello. Cached per-host so we pay the sign cost once.
func (*MITMCertificateAuthority) SetCacheCapacity ¶
func (ca *MITMCertificateAuthority) SetCacheCapacity(n int)
SetCacheCapacity adjusts the LRU bound at runtime. If n <= 0 the default (1024) is used. Any entries beyond the new bound are evicted immediately. Exported so tests can use a small capacity without recompiling.
type NoopAuditSink ¶
type NoopAuditSink struct{}
NoopAuditSink silently drops records; never errors.
func (NoopAuditSink) RecordEgress ¶
func (NoopAuditSink) RecordEgress(_ context.Context, _ EgressEvent) error
RecordEgress implements AuditSink.
type Resolver ¶
Resolver looks up A/AAAA records for a hostname. IP-literal hosts short-circuit (no lookup, no cache touch).
type Server ¶
type Server struct {
// CA is the Paddock MITM CA. Every intercepted TLS connection is
// re-signed with a leaf forged by this CA; the agent trusts it via
// the projected ca-bundle Secret (ADR-0013 §7.3).
CA *MITMCertificateAuthority
// Validator decides allow/deny per (host, port). M4 shipped a
// StaticValidator; M7 passes a BrokerClient that calls the broker's
// ValidateEgress endpoint so the same BrokerPolicy store the
// admission webhook consulted decides runtime flow too.
Validator Validator
// Substituter, when non-nil, rewrites outbound request headers when
// the matched egress grant declared SubstituteAuth=true. The MITM
// path drops to a request-by-request loop so headers can be swapped
// mid-connection (required for the AnthropicAPI x-api-key swap —
// ADR-0015 §"AnthropicAPIProvider"). nil falls back to
// bytes-both-ways shuttle, same as cooperative M4 behaviour.
Substituter Substituter
// Audit receives every denial (and, later, summarised allows). nil
// defaults to NoopAuditSink.
Audit AuditSink
// UpstreamDialer is used for the upstream TLS leg. nil defaults to
// net.Dialer{}.DialContext. Tests swap it for an in-memory dialer
// against an httptest server.
UpstreamDialer func(ctx context.Context, network, addr string) (net.Conn, error)
// UpstreamTLSConfig seeds the upstream tls.Config. The proxy fills
// in ServerName per-connection; callers set RootCAs and TLS
// versions. nil defaults to a zero tls.Config (system roots).
UpstreamTLSConfig *tls.Config
// HandshakeTimeout caps each inner TLS handshake (agent-side and
// upstream-side). Defaults to 30s.
HandshakeTimeout time.Duration
// IdleTimeout caps the idle-read interval on the bytes-shuttle and
// substitute-loop paths. When no data arrives within IdleTimeout the
// proxy closes the connection so a revoked BrokerPolicy takes effect
// within IdleTimeout on opaque tunnels too. Defaults to
// defaultProxyIdleTimeout (60s). Zero is treated as "use default";
// callers wanting to disable the timeout pass a deliberately-large
// duration. F-25 part 2.
IdleTimeout time.Duration
// Logger, if set, receives per-connection diagnostic lines. nil
// disables logging (tests typically pass logr.Discard()).
Logger logr.Logger
// OriginalDestination, if non-nil, replaces the SO_ORIGINAL_DST
// syscall path in HandleTransparentConn. Tests use this to inject
// pre-determined IP/port pairs against net.Pipe() conns that aren't
// *net.TCPConn. Production callers leave it nil; the package-level
// originalDestination from transparent_linux.go (or the no-op stub
// in transparent_other.go) is used.
OriginalDestination func(net.Conn) (net.IP, int, error)
// Resolver is the proxy's own DNS resolver, used for the F-22 dial-time
// re-resolve in transparent mode and the post-allowlist denied-CIDR
// filter in cooperative mode. nil defaults to NewCachingResolver(30s, 256).
Resolver Resolver
// DeniedCIDRs is the closed set of destination networks the proxy
// refuses to dial regardless of validator outcome. Populated from
// cmd/proxy/main.go --deny-cidr (controller passes RFC1918 + link-local
// + cluster pod+service CIDRs). nil means no denied-CIDR check.
DeniedCIDRs *DeniedCIDRSet
// contains filtered or unexported fields
}
Server is the HTTP CONNECT proxy. Zero value is not usable; populate CA and Validator at minimum.
func (*Server) HandleTransparentConn ¶
HandleTransparentConn is the entry point for an iptables-redirected TCP connection. Transparent mode differs from cooperative (CONNECT) mode in where the target information comes from:
- Cooperative: the HTTP CONNECT line carries host:port — proxy parses, validates, forges, MITM.
- Transparent: SO_ORIGINAL_DST recovers the original IP:port that the kernel would have routed to; SNI from the client's TLS ClientHello supplies the hostname for leaf forging + validation. The upstream leg dials the original IP:port directly.
Hostname-less traffic (no SNI) is dropped with a deny AuditEvent — M4's HTTPS-only stance holds under transparent mode too. Plain HTTP traffic on :80 is handled by reading the Host header out of the first request line before forging; that is deferred to M8 (the gitforge work) since no v0.3 agent makes plain-HTTP egress calls.
type StaticValidator ¶
type StaticValidator struct {
Allow []AllowRule
}
StaticValidator accepts a caller-provided host:port allow-list. This is the cooperative-mode M4 path — the broker wiring lands in M7 with the AnthropicAPIProvider.
Hostnames support a leading "*." wildcard (matches any one-level subdomain). Port 0 in the allow-list matches any port.
func NewStaticValidatorFromEnv ¶
func NewStaticValidatorFromEnv(raw string) (*StaticValidator, error)
NewStaticValidatorFromEnv parses a PADDOCK_PROXY_ALLOW-style value into a StaticValidator. Format: comma-separated "host:port" entries. Port "*" (or an empty port) means any. Host may start with "*." for a wildcard subdomain match.
"api.anthropic.com:443,*.githubusercontent.com:443,github.com:*"
Returns a validator that denies everything when the input is empty. That posture is deliberate: the proxy must fail closed when no allow-list is configured. Operators who genuinely want open egress in a test install set a catch-all "*:*".
func (*StaticValidator) ValidateEgress ¶
func (v *StaticValidator) ValidateEgress(_ context.Context, host string, port int) (Decision, error)
ValidateEgress returns allowed=true when (host, port) matches at least one configured rule. StaticValidator has no per-policy attribution; MatchedPolicy is set to the literal "static-allow" so downstream AuditEvents still carry a non-empty policy name when an allow rule matched.
type Substituter ¶
type Substituter interface {
SubstituteAuth(ctx context.Context, host string, port int, headers http.Header) (brokerapi.SubstituteResult, error)
}
Substituter rewrites outbound request headers just before the proxy forwards them upstream. The MITM path calls SubstituteAuth once per request whose matched egress grant declared SubstituteAuth=true.
Errors are fatal to the connection — the proxy drops it rather than forward the agent's Paddock-issued bearer upstream (spec 0002 §7.1 "no credential reaches upstream except through the broker").
type Validator ¶
type Validator interface {
ValidateEgress(ctx context.Context, host string, port int) (Decision, error)
}
Validator decides whether the proxy should allow an outbound TLS connection to host:port. Implementations may consult local state, call the broker's ValidateEgress endpoint, or both (admission + runtime re-check — spec 0002 §8.2).
An implementation that returns allowed=false must provide a Reason that is safe to surface to tenants (no upstream policy details, no broker internals — just the shape "no BrokerPolicy grants egress to evil.com:443"). Matched policies are emitted on allow so the proxy can attach them to AuditEvents.