egressbroker

package
v0.0.2 Latest Latest
Warning

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

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package egressbroker leases the control plane's Internet connection to isolated executors through an authenticated forward proxy.

It closes the last gap in the grantable-resource set. pkg/secretbroker brokers GitHub repositories, PATs, and Kubernetes clusters; network egress was the missing pillar, and it is the one that matters most for isolation, because a sandbox that needs the network has until now had to be given a network — which means all of it.

The shape of the answer is a proxy rather than a firewall rule. A container or Pod runs with --network=none-style isolation and no route of its own; it reaches the outside world only by asking the control plane, which decides per connection. That inverts the usual arrangement: the sandbox has no egress capability to lose, and the allowlist lives somewhere the workload cannot edit.

Three types, mirroring pkg/secretbroker so the two read alike:

Grant    who may make outbound connections, to which hosts, ports, and
         methods, under what byte quota, until when.
Session  a short-lived redemption of a matching grant, carrying a
         single-use proxy credential and live byte counters.
Proxy    the enforcement point: an HTTP forward proxy speaking CONNECT
         for TLS and absolute-URI GET/POST/... for plain HTTP.

What is actually enforced, honestly scoped:

  • Host, port, and method are checked before a single byte leaves.
  • The destination name is resolved exactly once, every returned address is checked, and the dial goes to the resolved literal — so a name that answers "93.184.216.34" to the policy check and "127.0.0.1" to the dial has nowhere to put the second answer. See netguard.go.
  • Loopback, RFC1918, CGNAT, link-local (which is where the cloud metadata service at 169.254.169.254 lives), multicast, and unspecified addresses are refused unless an allowed CIDR explicitly covers them.
  • Byte quotas are enforced during the copy, so an over-quota transfer is cut mid-stream rather than noticed afterwards.
  • A session's TTL applies to open tunnels, not just to new connections.

What is not enforced: anything inside a CONNECT tunnel. Those bytes are TLS, cloop does not hold the origin's key, and no CA is installed in the sandbox — deliberately, because a proxy that could read the tunnel would be a far more valuable thing to compromise than the credential it protects. So Grant.Methods gates plain HTTP only, and the tunnel is authorised at its host:port and then accounted, not inspected.

Index

Constants

View Source
const DefaultGrantTTL = 24 * time.Hour

DefaultGrantTTL matches pkg/secretbroker's, so `--ttl` means the same thing in `cloop secret grant` and `cloop egress grant`.

View Source
const DefaultListenAddr = "127.0.0.1:0"

DefaultListenAddr binds loopback only.

This is the safe default and frequently the wrong one, which is worth being explicit about: a container on a bridge network cannot reach the host's loopback, so an operator running sandboxes will set an address the sandbox can route to (a podman gateway, a docker0 address) — and at that moment the proxy becomes reachable by anything else on that network too. The authentication is what holds there, not the bind address.

View Source
const DefaultSessionTTL = 15 * time.Minute

DefaultSessionTTL bounds one redemption when neither the grant nor the broker says otherwise.

Fifteen minutes matches pkg/secretbroker's lease TTL on purpose. The two are the same kind of object — a short-lived materialisation of a longer authorisation — and an operator who has learned "credentials in a sandbox last fifteen minutes and renew" should not have to learn a second number for the network.

View Source
const MaxHostLength = 253

MaxHostLength bounds a destination hostname. 253 is the DNS maximum for a fully-qualified name; anything longer cannot resolve and is more likely to be an attempt to overflow something downstream.

View Source
const MaxHostPatterns = 256

MaxHostPatterns bounds an allowlist. The limit exists because every pattern is walked on every connection; it is generous enough that no legitimate grant reaches it.

View Source
const MaxSessionTTLCeiling = 4 * time.Hour

MaxSessionTTLCeiling is the hard upper bound on a session, regardless of what a grant or the config asks for. A proxy credential that lives for hours is not a lease, it is a configuration.

View Source
const ProxyAuthScheme = "Basic"

ProxyAuthScheme is the only authentication scheme the proxy accepts. Basic over a loopback- or VPN-reachable proxy carries a 256-bit random token, not a password, so there is nothing to guess and nothing reused elsewhere; the scheme is chosen because every HTTP client and every language runtime already speaks it from a proxy URL's userinfo.

View Source
const TokenBytes = 32

TokenBytes is the size of a minted proxy token. 256 bits from crypto/rand is well past the point where guessing is the attack anyone would choose, which is what lets the token be stored as a bare SHA-256 rather than run through a password KDF: there is no dictionary to attack and no low-entropy user choice to protect.

Variables

View Source
var (
	// ErrHostNotAllowed: the destination hostname is outside the grant's
	// host allowlist and its resolved address is outside every allowed CIDR.
	ErrHostNotAllowed = errors.New("egressbroker: destination host not allowed")

	// ErrPortNotAllowed: the destination port is outside the grant's port
	// allowlist.
	ErrPortNotAllowed = errors.New("egressbroker: destination port not allowed")

	// ErrMethodNotAllowed: the HTTP method is outside the grant's method
	// allowlist. Only reachable on the plain-HTTP path — see Grant.Methods
	// for why a CONNECT tunnel cannot be method-filtered.
	ErrMethodNotAllowed = errors.New("egressbroker: HTTP method not allowed")

	// ErrDestinationBlocked: the destination resolved to a loopback,
	// RFC1918, carrier-grade-NAT, link-local, multicast, or unspecified
	// address, and no allowed CIDR covers it. This is the SSRF guard, and it
	// fires after resolution, so a public name that resolves inward is
	// caught as surely as a literal 169.254.169.254.
	ErrDestinationBlocked = errors.New("egressbroker: destination address is in a blocked range")

	// ErrQuotaExceeded: the session transferred more bytes than the grant
	// permits. Raised mid-stream, which tears the transfer down rather than
	// letting it finish — a quota that only applies at the end of a
	// download is not a quota.
	ErrQuotaExceeded = errors.New("egressbroker: transfer quota exceeded")

	// ErrSessionExpired: the proxy session's TTL elapsed. Also raised
	// mid-tunnel: an open CONNECT tunnel does not outlive its lease.
	ErrSessionExpired = errors.New("egressbroker: proxy session expired")

	// ErrUnauthenticated: the Proxy-Authorization header was missing,
	// malformed, named an unknown session, or carried the wrong token.
	// One sentinel for all four on purpose — distinguishing them to the
	// caller would turn the proxy into a session-ID oracle.
	ErrUnauthenticated = errors.New("egressbroker: proxy credential missing or invalid")

	// ErrGrantNotFound: no egress grant with that ID exists.
	ErrGrantNotFound = errors.New("egressbroker: grant not found")

	// ErrGrantExpired: the grant's TTL elapsed.
	ErrGrantExpired = errors.New("egressbroker: grant expired")

	// ErrGrantRevoked: the grant was revoked.
	ErrGrantRevoked = errors.New("egressbroker: grant revoked")

	// ErrNoGrant: no active grant matches the requester, so there is
	// nothing to redeem. Distinct from ErrGrantNotFound, which is about a
	// specific ID.
	ErrNoGrant = errors.New("egressbroker: no active egress grant matches this requester")

	// ErrInvalidGrant: the grant failed structural validation and was never
	// stored. A validation failure is not a denial: it never became a
	// security decision, so it does not produce a deny audit row.
	ErrInvalidGrant = errors.New("egressbroker: invalid grant")

	// ErrInvalidRequest: the proxy request was malformed — an unparseable
	// CONNECT target, a relative URI on the plain-HTTP path, an unsupported
	// scheme.
	ErrInvalidRequest = errors.New("egressbroker: malformed proxy request")

	// ErrResolveFailed: DNS lookup for the destination failed or returned
	// no usable address.
	ErrResolveFailed = errors.New("egressbroker: destination resolution failed")

	// ErrDialFailed: the connection to the (already authorised) destination
	// could not be established.
	ErrDialFailed = errors.New("egressbroker: destination dial failed")
)

Sentinel errors. Every refusal in this package wraps exactly one of them, and the choice of sentinel is what the audit row's verdict is derived from — so "why was this request refused" is answerable by errors.Is at any call site, not by string matching on a message.

The denial set is deliberately fine-grained. "Blocked" is not a useful verdict to an operator staring at a sandbox that cannot reach GitHub: the difference between "you forgot to allow port 443", "the host is not on the list", "you have burned your download quota", and "the lease died under you" is the difference between a one-line fix and an afternoon.

View Source
var DefaultPorts = []int{80, 443}

DefaultPorts is what a grant gets when the operator names no ports. It is written into the stored grant rather than applied at decision time, so `cloop egress list` shows the ports that will actually be honoured and there is no invisible default to be surprised by later.

View Source
var MetadataIPv4 = netip.MustParseAddr("169.254.169.254")

MetadataIPv4 is the cloud instance metadata endpoint. It is inside link-local and would be blocked by that rule alone; it is named separately so the denial says "metadata service" rather than "link-local", because that is the sentence an operator needs to see in an audit log.

Functions

func BlockReason

func BlockReason(addr netip.Addr) string

BlockReason classifies an address against the hard-block set and returns a human-readable reason, or "" when the address is on the public Internet.

Ordering is by specificity, not by prefix length: the reason text is what lands in the audit log and in the operator's terminal, so "cloud metadata service" beats the technically-equally-true "link-local".

func FormatBytes

func FormatBytes(n int64) string

FormatBytes renders a byte count in the same units the CLI accepts, so a value copied out of `cloop egress list` can be pasted back into `cloop egress grant`.

func FormatProxyCredential

func FormatProxyCredential(sessionID, token string) string

FormatProxyCredential builds the header value a client sends. Used by the `cloop egress test` client and by the round-trip tests, so the encoding has one definition on both sides.

func NormalizeDestinationHost

func NormalizeDestinationHost(host string) (string, error)

NormalizeDestinationHost canonicalises and validates a destination hostname or address literal.

Both request parsers route through it, and that shared definition is the point rather than a convenience: if CONNECT and the plain-HTTP authority disagreed about what counts as a host, one of them would be the way past the allowlist. A fuzz run found exactly that — splitHostPort had no charset rule at all and accepted "0\r0", which would have reached both a dial target and an audit row.

func ParseBytes

func ParseBytes(s string) (int64, error)

ParseBytes reads a byte quota: a bare integer (bytes) or a suffixed size ("64k", "10m", "2g"). Empty yields 0, meaning unlimited.

func ParseConnectTarget

func ParseConnectTarget(target string) (host string, port int, err error)

ParseConnectTarget parses the authority-form request-target of a CONNECT request ("example.com:443", "[2001:db8::1]:443") into a host and port.

RFC 9110 requires authority-form to carry a port, and this parser enforces it rather than defaulting: a CONNECT with no port is malformed, and inventing 443 for it would mean the policy check ran against a port the client never asked for.

Everything that is not unambiguously a host and a port is rejected. In particular a userinfo prefix, a path, a query, a fragment, a wildcard, any whitespace, any control byte, and an unbracketed IPv6 literal are all errors, because each of them is a way to make one reader see a different host than another.

func ParseProxyCredential

func ParseProxyCredential(header string) (sessionID, token string, err error)

ParseProxyCredential extracts the session ID and token from a Proxy-Authorization header value.

It returns ErrUnauthenticated for every failure shape, and never says which one. A parser that distinguished "no such session" from "wrong token" would be an oracle for enumerating live sessions.

Types

type Broker

type Broker struct {
	// contains filtered or unexported fields
}

Broker issues and enforces egress grants.

It owns the policy and the live sessions; pkg/egressbroker's Proxy owns the wire. The split is the same one pkg/secretbroker draws between broker and store: the part worth testing exhaustively should be testable without a socket, and it is — every decision below runs against an in-memory store with no network in sight.

Safe for concurrent use.

func New

func New(store Store, opts ...Option) (*Broker, error)

New builds a Broker over store.

func (*Broker) Authenticate

func (b *Broker) Authenticate(sessionID, token string) (*Session, error)

Authenticate resolves a presented credential to its session.

Every failure returns the same ErrUnauthenticated, and the token comparison runs even when the session ID is unknown — against a zero hash, which no token can match. Both are there so that response timing does not reveal whether a session ID exists, which would otherwise turn the proxy into an enumeration oracle for live sessions.

func (*Broker) CloseSession

func (b *Broker) CloseSession(id, reason string)

CloseSession retires a session, closes any socket it still holds, and records its final byte counts.

Closing the sockets is what makes revocation immediate rather than TTL-bounded. That is the payoff for brokering a capability instead of handing over a token: a leaked PAT cannot be recalled from a running container, but a tunnel can be severed at the proxy, mid-transfer, by the process that owns the socket.

It is idempotent: a session torn down by the proxy and then released by the orchestrator produces one close row, not two.

func (*Broker) Endpoint

func (b *Broker) Endpoint() string

Endpoint returns the address handed to sandboxes.

func (*Broker) Grant

func (b *Broker) Grant(ctx context.Context, req GrantRequest) (Grant, error)

Grant authorises a subject to reach the network through the proxy.

func (*Broker) ListGrants

func (b *Broker) ListGrants(f GrantFilter) ([]Grant, error)

ListGrants returns grants matching the filter, newest first.

func (*Broker) ReapExpired

func (b *Broker) ReapExpired() int

ReapExpired retires sessions whose TTL has elapsed and returns how many. The proxy calls it on a ticker so that an idle expired session does not linger in memory until someone happens to present its credential.

func (*Broker) Redeem

func (b *Broker) Redeem(ctx context.Context, req RedeemRequest) (*Redemption, error)

Redeem mints a single-use proxy credential for the first active grant that matches the requester.

"Single-use" is about minting, not about requests: the token authorises one session, which may carry many connections, and cannot be presented to mint a second session. The plaintext is returned here and nowhere else — the broker keeps only its SHA-256, so a memory dump of the control plane taken after this call does not yield a usable credential.

When several grants match, the first by the store's newest-first ordering wins rather than the union of all of them. Unioning would let two narrow grants silently compose into a wide one, and "why can this sandbox reach that host" would stop having a single answer.

func (*Broker) Revoke

func (b *Broker) Revoke(ctx context.Context, grantID, actor string) error

Revoke marks a grant unusable and closes every live session it authorised.

Closing the sessions is the difference between this and secretbroker.Revoke, and it is available here precisely because the capability is brokered rather than handed over: a leaked PAT cannot be recalled from a running container, but a proxy session can be torn down at the proxy, mid-tunnel, from the control plane. Revocation is therefore immediate rather than bounded by the session TTL.

func (*Broker) Session

func (b *Broker) Session(id string) *Session

Session returns a live session by ID, or nil.

func (*Broker) Sessions

func (b *Broker) Sessions() []*Session

Sessions returns a snapshot of the live sessions, for the CLI and UI.

func (*Broker) SetEndpoint

func (b *Broker) SetEndpoint(addr string)

SetEndpoint updates the advertised address. The proxy calls it after binding, so an operator who configured port 0 still gets a usable HTTPS_PROXY value rather than one naming a port nothing listens on.

type Destination

type Destination struct {
	// Host is the requested name (or address literal), lowercased.
	Host string
	Port int
	// Addr is the resolved address the dial will target. Every other
	// address the name resolved to was checked too — see Resolve.
	Addr netip.Addr
	// Resolved is the full answer set, for the audit trail.
	Resolved []netip.Addr
	// Literal reports whether Host was already an address, so no lookup
	// happened and there is nothing to rebind.
	Literal bool
}

Destination is an authorised, pinned destination: the name the client asked for, and the single address that will be dialled for it.

func Resolve

func Resolve(ctx context.Context, r Resolver, g Grant, host string, port int) (Destination, error)

Resolve turns a requested host:port into a pinned, authorised Destination, or returns the typed reason it may not be reached.

The rules, in order:

  1. The port must be allowed. Checked first because it is free and needs no network traffic — a grant that permits only 443 should not generate a DNS query for a request to port 22.
  2. The name must be on the host allowlist, unless the grant is CIDR-only. Also free, also before any lookup.
  3. The name is resolved exactly once.
  4. *Every* returned address must pass CheckAddr. Not just the one that will be dialled: a name that answers with a mix of public and private addresses is either misconfigured or hostile, and picking the public one would let the sandbox retry until the resolver's rotation handed it the other. Failing the whole name is the only reading with no race in it.
  5. The first surviving address is pinned, and the caller dials that literal.

func (Destination) AddrPort

func (d Destination) AddrPort() string

AddrPort renders the pinned destination for net.Dial.

func (Destination) String

func (d Destination) String() string

String renders "host:port" for logs and audit rows.

type DialFunc

type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)

DialFunc is the connection factory the proxy uses. Tests replace it to avoid touching the real network; production leaves it nil and gets a net.Dialer.

type Grant

type Grant struct {
	ID string `json:"id"`
	// Scope is a free-form operator label ("ci", "deps"). It carries no
	// authorisation weight, so editing it cannot widen the grant.
	Scope   string               `json:"scope,omitempty"`
	Subject secretbroker.Subject `json:"subject"`

	// Hosts is the destination allowlist, in pkg/secretbroker's host syntax:
	// an exact name, "*.example.com" for subdomains only (not the apex), or
	// "*" for everything. Matching is delegated to
	// secretbroker.Constraints.CheckHost so that "which hosts does this
	// pattern cover" has exactly one definition in the codebase.
	Hosts []string `json:"hosts,omitempty"`

	// CIDRs is a second, address-level allow dimension, and the *only* way
	// to reach a private address.
	//
	// A destination is authorised when its hostname matches Hosts or its
	// resolved address falls inside one of these prefixes. Listing a private
	// prefix here is what waives the SSRF block for that prefix and nothing
	// else — which is why there is no blanket allow_private flag. "Let this
	// sandbox reach the metadata service" should be a sentence an operator
	// has to write out as 169.254.169.254/32, not a checkbox.
	CIDRs []string `json:"cidrs,omitempty"`

	// Ports is the destination port allowlist. Never empty after Normalize.
	Ports []int `json:"ports,omitempty"`

	// Methods gates plain-HTTP requests. "*" allows every method.
	//
	// It does not, and cannot, gate a CONNECT tunnel: the method lives
	// inside the TLS record layer. Normalize writes "*" rather than leaving
	// the field empty so that a listing never implies a restriction that is
	// not there.
	Methods []string `json:"methods,omitempty"`

	// MaxBytesUp and MaxBytesDown cap one session's transfer, in bytes,
	// counted from the sandbox's point of view: "up" is what it sends. Zero
	// means unlimited.
	MaxBytesUp   int64 `json:"max_bytes_up,omitempty"`
	MaxBytesDown int64 `json:"max_bytes_down,omitempty"`

	// SessionTTL bounds one redemption. Zero means the broker's default.
	// The split from ExpiresAt is the same one pkg/secretbroker draws
	// between grant and lease: a day-long authorisation does not imply a
	// day-long credential in a sandbox.
	SessionTTL time.Duration `json:"session_ttl,omitempty"`

	ExpiresAt time.Time `json:"expires_at,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	CreatedBy string    `json:"created_by,omitempty"`
	RevokedAt time.Time `json:"revoked_at,omitempty"`
}

Grant authorises one subject to make outbound connections through the proxy, under constraints, until ExpiresAt.

Subject reuses pkg/secretbroker's: an egress grant is aimed at a project, an executor, or a labelled fleet by exactly the same rules as a credential grant, and an operator should not have to learn a second targeting syntax for the second resource.

func (Grant) Active

func (g Grant) Active(now time.Time) bool

Active reports whether the grant may be redeemed at now.

func (Grant) AllowsAddr

func (g Grant) AllowsAddr(addr netip.Addr) bool

AllowsAddr reports whether an address is inside one of the grant's CIDRs.

This is both the second allow dimension and the SSRF opt-in: netguard's blocked-range check consults it, so an operator who lists 10.0.0.0/8 gets exactly 10.0.0.0/8 and not the loopback, the metadata service, or their IPv6 equivalents.

func (Grant) CheckAddr

func (g Grant) CheckAddr(addr netip.Addr, hostAllowed bool) error

CheckAddr is the address gate: it applies the hard-block set, then the grant's own dimensions.

The order is deliberate. The block set is consulted first and can only be waived by an explicit CIDR in the grant — never by the host allowlist. That is what stops "--hosts '*'" from being a route to the metadata service: wildcards buy reach on the public Internet, and nothing else.

hostAllowed carries the result of the name-level check so the two allow dimensions compose. A destination is authorised when its name matched, or when its address is inside an allowed CIDR.

func (Grant) CheckHost

func (g Grant) CheckHost(host string) error

CheckHost reports whether the destination hostname is on the allowlist.

A grant with only CIDRs has no host opinion; the address check in CheckAddr is then the gate, and this returns nil so the two dimensions compose as an OR rather than an AND. A grant that lists hosts and a destination that matches none of them is refused here, before any DNS traffic is generated.

func (Grant) CheckMethod

func (g Grant) CheckMethod(method string) error

CheckMethod reports whether a plain-HTTP method is allowed.

func (Grant) CheckPort

func (g Grant) CheckPort(port int) error

CheckPort reports whether the destination port is allowed. An empty list denies: Normalize guarantees a stored grant always names its ports, so an empty list here means the grant was hand-built and skipped validation.

func (Grant) DenyReason

func (g Grant) DenyReason(now time.Time) string

DenyReason returns the audit-friendly reason this grant is unusable at now, or "" when it is usable. Mirrors secretbroker.Grant.DenyReason so a denial reads the same in both audit trails.

func (Grant) DenySentinel

func (g Grant) DenySentinel() error

DenySentinel returns the error matching DenyReason, so a caller can attach the right typed error to an inactive grant without re-deriving why.

func (Grant) HostMatches

func (g Grant) HostMatches(host string) bool

HostMatches is CheckHost as a boolean, for the OR with the CIDR dimension.

func (*Grant) Normalize

func (g *Grant) Normalize()

Normalize canonicalises a grant and fills in the defaults that must be visible in storage rather than applied silently at decision time.

It is called by Validate, so anything that reaches the store has already been through it.

func (Grant) SessionDeadline

func (g Grant) SessionDeadline(now time.Time, ceiling time.Duration) time.Time

SessionDeadline returns when a session redeemed at now must expire, given the broker's ceiling. A session never outlives the grant that authorised it, which is what makes revocation effective within one session period.

func (Grant) Summary

func (g Grant) Summary() string

Summary renders the grant's policy as a short, stable, audit-safe string. It contains allowlist patterns and limits only — an egress grant holds no credential material at all, which is one of the nicer properties of brokering a capability rather than a token.

func (*Grant) Validate

func (g *Grant) Validate() error

Validate normalises the grant and checks its invariants.

A grant with neither hosts nor CIDRs is rejected rather than treated as "allow nothing" or "allow everything": both readings are defensible, which is precisely why the operator has to say. Pass --hosts '*' to mean it.

type GrantFilter

type GrantFilter struct {
	// Subject keeps only grants whose subject renders to this string.
	Subject string
	// ActiveOnly drops expired and revoked grants.
	ActiveOnly bool
}

GrantFilter narrows ListGrants.

type GrantRequest

type GrantRequest struct {
	Subject      secretbroker.Subject
	Scope        string
	Hosts        []string
	CIDRs        []string
	Ports        []int
	Methods      []string
	MaxBytesUp   int64
	MaxBytesDown int64
	SessionTTL   time.Duration
	// TTL is the grant's own lifetime. Zero means DefaultGrantTTL.
	TTL   time.Duration
	Actor string
}

GrantRequest describes an egress grant to create.

type MemStore

type MemStore struct {
	// contains filtered or unexported fields
}

MemStore is an in-memory Store, used by tests and by `cloop egress test` when it needs a throwaway policy.

It is exported rather than test-only because the test command genuinely needs it: constructing a proxy for a one-shot connectivity check should not require writing a grant into the project database.

func NewMemStore

func NewMemStore() *MemStore

NewMemStore returns an empty in-memory store.

func (*MemStore) GetGrant

func (m *MemStore) GetGrant(id string) (Grant, error)

GetGrant returns one grant by ID.

func (*MemStore) ListGrants

func (m *MemStore) ListGrants() ([]Grant, error)

ListGrants returns every grant, newest first.

func (*MemStore) PutGrant

func (m *MemStore) PutGrant(g Grant) error

PutGrant stores a copy of g.

func (*MemStore) RevokeGrant

func (m *MemStore) RevokeGrant(id string, at time.Time) error

RevokeGrant stamps a grant revoked.

type Option

type Option func(*Broker)

Option configures a Broker.

func WithAuditor

func WithAuditor(a secretbroker.Auditor) Option

WithAuditor sets the audit sink. Without one, decisions are enforced but not recorded — acceptable for tests, never for a control plane, which is why cmd wires secretstore.NewAuditor unconditionally.

func WithClock

func WithClock(fn func() time.Time) Option

WithClock overrides the time source, so TTL expiry can be exercised without sleeping.

func WithEndpoint

func WithEndpoint(addr string) Option

WithEndpoint sets the address sandboxes should use to reach the proxy ("127.0.0.1:8899", "host.containers.internal:8899"). It is what goes into HTTPS_PROXY, so it must be routable *from the sandbox*, which is not always the same as the address the proxy listens on.

func WithMaxSessionTTL

func WithMaxSessionTTL(d time.Duration) Option

WithMaxSessionTTL sets the ceiling applied to every redemption. Values above MaxSessionTTLCeiling are clamped down to it: a config typo must not be able to mint an all-day credential.

type Options

type Options struct {
	// ListenAddr is the bind address. Empty means DefaultListenAddr.
	ListenAddr string
	// Endpoint is the address sandboxes should use to reach this proxy. It
	// is what goes into HTTPS_PROXY and is frequently *not* ListenAddr: a
	// container reaches the host by a gateway address, not by the address
	// the host bound. Empty falls back to the listener's own address.
	Endpoint string
	// DialTimeout bounds the connection to an authorised origin.
	DialTimeout time.Duration
	// Resolver overrides DNS. Tests use it to simulate rebinding.
	Resolver Resolver
	// Dial overrides the outbound dialer. Tests use it to avoid the network;
	// production leaves it nil.
	Dial DialFunc
}

Options configure a Proxy.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy is the forward proxy. Build it with NewProxy and run it with Serve or ListenAndServe.

func NewProxy

func NewProxy(b *Broker, opts Options) (*Proxy, error)

NewProxy builds a proxy over a broker.

func (*Proxy) Addr

func (p *Proxy) Addr() string

Addr returns the bound address, or "" before Serve.

func (*Proxy) Close

func (p *Proxy) Close() error

Close stops the proxy. It is idempotent and safe to call from a defer alongside an explicit call.

Shutdown is deliberately not graceful in the http.Server.Shutdown sense: an open CONNECT tunnel has no request boundary to wait for, so Shutdown would block until the session TTL expired. Closing outright is also the correct security behaviour — when the control plane stops, so does every sandbox's route to the Internet.

Making that true takes two steps, not one. http.Server.Close closes the connections the server still tracks, but a hijacked connection is deliberately not among them, so every live CONNECT tunnel survives it; they are closed explicitly from the registry. Skipping that step does not merely leak a tunnel — it also wedges the drain below, because the handler owning the tunnel never returns.

The drain then waits for the handlers to *finish reporting*. Each one still has an audit row to write, and http.Server.Close does not wait for handler goroutines. Without it those rows land after the caller has closed the database, which is how a decision ends up enforced but unrecorded — the one outcome this package exists to make impossible. The wait is bounded, so a wedged handler delays shutdown rather than preventing it.

func (*Proxy) ListenAndServe

func (p *Proxy) ListenAndServe() error

ListenAndServe binds ListenAddr and serves until Close.

It publishes the bound address to the broker before serving, so a configuration of ":0" still produces a usable HTTPS_PROXY value — which matters more than it sounds, because port 0 is the right choice for an ephemeral per-run proxy and the wrong one to have to know in advance.

func (*Proxy) Serve

func (p *Proxy) Serve(ln net.Listener) error

Serve serves on an existing listener and returns when it is closed.

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP authenticates the caller and dispatches to the tunnel or plain-HTTP path.

type RedeemRequest

type RedeemRequest struct {
	Requester secretbroker.Requester
	// TaskID is the unit of work, carried into every audit row this session
	// produces.
	TaskID string
	Actor  string
	// GrantID pins the redemption to one grant. Empty picks the widest
	// active match — see Redeem.
	GrantID string
}

RedeemRequest identifies who wants a proxy credential.

type Redemption

type Redemption struct {
	Session *Session
	// Token is the plaintext proxy token. Single-use in the sense that
	// matters: it is minted for exactly one session and cannot be presented
	// to mint another.
	Token string
	// ProxyURL is the full "http://id:token@host:port" the sandbox uses. It
	// carries the credential, so it is never audited or logged.
	ProxyURL string
}

Redemption is what a caller gets back from Broker.Redeem: the session, and the one and only copy of its token.

The token is a separate field rather than a Session field so that a Session can be marshalled, logged, or returned over an API without any possibility of carrying the credential with it — the same property pkg/secretbroker gets by keeping Secret.Sealed json:"-".

func (Redemption) Env

func (r Redemption) Env() map[string]string

Env returns the environment a workload needs to route through the proxy.

Both cases of each variable are set because the ecosystem never agreed: curl and most Unix tooling read the lowercase forms, Go's http.ProxyFromEnvironment and most language runtimes read either, and a handful of Windows-origin tools read only the uppercase. Setting one and not the other is how a sandbox ends up with a proxy that half its tools ignore — and a tool that ignores the proxy in a --network=none sandbox does not leak, it simply fails, which is a confusing way to discover the mistake.

NO_PROXY keeps loopback traffic local. Without it a workload that talks to its own sidecar on 127.0.0.1 would send that request to the proxy, which would refuse it as a blocked destination — correct, but for a request that never needed brokering.

func (Redemption) EnvLines

func (r Redemption) EnvLines() []string

EnvLines renders Env as sorted "K=V" strings, ready to append to an executor.Spec's Env.

type Resolver

type Resolver interface {
	LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}

Resolver is the DNS interface the proxy depends on. Narrowing it to the one method used lets a test substitute a resolver that answers differently on each call — which is exactly the rebinding attack, and exactly what the resolve-once discipline is meant to survive.

type Session

type Session struct {
	ID string `json:"id"`
	// GrantID names the authority. The policy itself is snapshotted into
	// Grant below rather than re-read per request, so a request cannot be
	// evaluated against a half-updated grant; revocation lands at the next
	// redemption, bounded by the session TTL.
	GrantID string `json:"grant_id"`
	Grant   Grant  `json:"-"`

	ExecutorID string `json:"executor_id,omitempty"`
	ProjectID  string `json:"project_id,omitempty"`
	// TaskID ties every audit row to the unit of work that caused it, which
	// is what makes "what did task 20163 talk to" an answerable question.
	TaskID string `json:"task_id,omitempty"`
	Actor  string `json:"actor,omitempty"`

	IssuedAt  time.Time `json:"issued_at"`
	ExpiresAt time.Time `json:"expires_at"`
	// contains filtered or unexported fields
}

Session is one redemption of a grant: a live proxy credential, the policy snapshot it was issued under, and the byte counters that bound it.

Sessions are memory-resident and die with the process. That is deliberate and matches pkg/secretbroker's decision not to persist leases: a session is a usable credential, and persisting one would create a durable artifact that outlives the proxy able to enforce its quota. A restarted control plane issues new sessions; it does not resurrect old ones.

Safe for concurrent use — a single session is shared by every connection the sandbox opens.

func (*Session) BindContext

func (s *Session) BindContext(parent context.Context) (context.Context, context.CancelFunc)

BindContext returns a context bounded by the session: it is cancelled at the session's TTL, and immediately if the session is revoked.

The plain-HTTP path needs this because it has no socket of its own to put a deadline on — the response body is streamed by an http.Transport, and the only handle on it is the request context. Without it a workload could hold an endless chunked response open long past its lease.

func (*Session) BytesDown

func (s *Session) BytesDown() int64

func (*Session) BytesUp

func (s *Session) BytesUp() int64

BytesUp and BytesDown report the running totals.

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session has been retired.

func (*Session) Done

func (s *Session) Done() <-chan struct{}

Done returns a channel closed when the session ends, by expiry or by revocation.

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

Expired reports whether the session's TTL has elapsed at now.

func (*Session) Requests

func (s *Session) Requests() int64

Requests reports how many proxy requests this session has made.

func (*Session) TTL

func (s *Session) TTL(now time.Time) time.Duration

TTL returns the remaining lifetime at now, clamped at zero.

type Store

type Store interface {
	// PutGrant inserts or replaces a grant.
	PutGrant(g Grant) error
	// GetGrant returns one grant by ID, or an error wrapping
	// ErrGrantNotFound.
	GetGrant(id string) (Grant, error)
	// ListGrants returns every grant, including expired and revoked ones —
	// filtering is the broker's job, and an audit reader needs the history.
	ListGrants() ([]Grant, error)
	// RevokeGrant stamps a grant revoked at the given time. Revocation is a
	// stamp rather than a delete so "who could reach the Internet in March"
	// stays answerable.
	RevokeGrant(id string, at time.Time) error
}

Store persists egress grants.

Only grants: sessions are memory-resident by design (see Session), so there is deliberately no session method here. A store that could write a session would be a store that could be asked to read one back after a restart, and a resurrected proxy credential is exactly what the TTL exists to prevent.

Implementations must be safe for concurrent use.

Jump to

Keyboard shortcuts

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