proxy

package
v0.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 2, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

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

View Source
const (
	AuditSinkTypeClient = "client"
	AuditSinkTypeNoop   = "noop"
)

AuditSinkType labels. Used by SetAuditSinkType and as the expected return values from cmd/proxy/main.go::buildAuditSink.

Variables

View Source
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.

View Source
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.

View Source
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.

View Source
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

func NewHTTPServer(addr string, handler http.Handler) *http.Server

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

type AllowRule struct {
	Host  string
	Ports []int
}

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

func (c *BrokerClient) ValidateEgress(ctx context.Context, host string, port int) (Decision, error)

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

type ClientAuditSink struct {
	Sink      auditing.Sink
	Namespace string
	RunName   string
}

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.

func (*DeniedCIDRSet) Contains

func (d *DeniedCIDRSet) Contains(ip net.IP) bool

Contains returns true when ip falls in any denied network. Nil receiver and empty set both report false. Nil ip reports false.

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

func NewLimitedListener(ln net.Listener, cap int, mode string, logger logr.Logger) *LimitedListener

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

type Resolver interface {
	LookupHost(ctx context.Context, host string) ([]net.IP, error)
}

Resolver looks up A/AAAA records for a hostname. IP-literal hosts short-circuit (no lookup, no cache touch).

func NewCachingResolver

func NewCachingResolver(ttl time.Duration, capacity int) Resolver

NewCachingResolver constructs a Resolver backed by net.DefaultResolver with a fixed-TTL LRU cache.

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

func (s *Server) HandleTransparentConn(ctx context.Context, conn net.Conn)

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.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP dispatches CONNECT (MITM path) from plain HTTP requests (rejected — plain HTTP egress has no MITM lever, so we treat it as a policy question for a later milestone).

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL