Documentation
¶
Overview ¶
Package proxybinding parses and loads declarative host->credential binding descriptors and converts them into the binding-table entries the TLS forward-proxy boundary consults (ADR-0019, umbrella #1191).
A descriptor is the "config, not code" half of the credential-sealing substrate. The binding table (#1193, internal/binding.HostBindings) and the scheme-keyed injector (#1194, internal/credential/inject) are the "code" half: they consult a host->credential mapping at egress and bind the resolved secret onto the outbound request. This package supplies the missing declarative format so a CLI vendor or community profile can ship a descriptor that flows generically through the loader into the table, with zero per-CLI proxy code. The proving example is Linear (api.linear.app, header-template emitting a verbatim "Authorization: <token>" with no Bearer prefix); nothing in the proxy path branches on "linear" or any CLI name.
Secret handling ¶
A descriptor carries only a credential *reference* (a vault path), never the credential bytes. This package never reads, resolves, or logs secret material: resolution happens daemon-side at injection time (#1193's responsibility). The descriptor format is therefore safe to embed, commit, and ship.
Scope ¶
This package defines the descriptor format and the two-layer loader. It does not implement the binding-table consult (#1193) or the injectors (#1194). It accepts both emit mechanisms: "inject" (inject at the proxy) and "sentinel-swap". A sentinel-swap binding declares a non-secret `sentinel` block (a placeholder value plus the env-var name the launcher plants it under); this loader validates that schema and the egress code consumes those fields. Persisting a stateful CLI's local cache across ephemeral sandboxes is out of scope and tracked separately (#1190).
Index ¶
- Constants
- Variables
- func DefaultUserPath() string
- func LoadHostBindings(opts LoadOptions) (binding.HostBindings, error)
- func LoadHostBindingsWithWarnings(opts LoadOptions) (binding.HostBindings, []string, error)
- func ToHostBindings(entries []Entry) (binding.HostBindings, error)
- func Upsert(path string, entries ...Entry) error
- type Descriptor
- type Entry
- type LoadOptions
- type Sentinel
Constants ¶
const SchemaVersion = "v1"
SchemaVersion is the only descriptor schema version this loader understands. The format is versioned so it can evolve under 0.0.x without a silent misparse: a descriptor that names any other version is a load-time error rather than a best-effort decode against the wrong field set.
const TokenPlaceholder = "{token}"
TokenPlaceholder is the substring a header-template's Template substitutes with the resolved secret at inject time. It mirrors the placeholder the injector (#1194) uses, so a descriptor author and the egress injector agree on the token slot.
Variables ¶
var EmitMechanisms = map[string]struct{}{ string(binding.EmitMechanismInject): {}, string(binding.EmitMechanismSentinelSwap): {}, }
EmitMechanisms is the closed set of emit-mechanism values a descriptor may declare at load time. "inject" adds the credential at the proxy and covers two client shapes: a request that arrives with no credential (the proxy adds one), and a self-signing client such as `aws`/botocore that emits a placeholder-signed request the `sigv4-resign` scheme strips and re-signs with the vault credential at the boundary. "sentinel-swap" plants a non-secret sentinel the proxy swaps for the real credential at egress; a sentinel-swap binding must declare a `sentinel` block naming the placeholder value and the env-var name the launcher plants it under. A value outside this set (e.g. "C") is a load-time error, so a descriptor never validates against a mechanism the proxy cannot enforce.
Functions ¶
func DefaultUserPath ¶
func DefaultUserPath() string
DefaultUserPath returns the per-user descriptor file path, `~/.aileron/binding-descriptors.yaml`, the highest-precedence layer of the two-layer config convention. When the home directory cannot be resolved it falls back to a relative path under `.aileron`, matching the rest of the config package's home-dir handling. The file need not exist; an absent user layer contributes no entries.
func LoadHostBindings ¶
func LoadHostBindings(opts LoadOptions) (binding.HostBindings, error)
LoadHostBindings is the convenience the apiServer wiring calls: it loads the two merged descriptor layers (built-in -> user) and adapts them to the canonical binding table in one step. A load or adaptation error is surfaced rather than swallowed so a malformed descriptor fails construction loudly instead of silently shipping an empty (passthrough) table.
func LoadHostBindingsWithWarnings ¶
func LoadHostBindingsWithWarnings(opts LoadOptions) (binding.HostBindings, []string, error)
LoadHostBindingsWithWarnings is LoadHostBindings plus the non-fatal startup warnings aggregated from the merged entries (see Entry.Warnings). Warnings never block construction; the caller (the daemon boot path) logs them so an operator sees a suspect-but-well-formed binding before it fails at launch. Warnings are collected from the post-merge entry set, so a warning reflects the entry that actually reaches the table (a user override is warned on, the shadowed built-in is not).
func ToHostBindings ¶
func ToHostBindings(entries []Entry) (binding.HostBindings, error)
ToHostBindings adapts a slice of validated entries into a binding.HostBindings table, preserving order. A nil or empty slice yields a nil table, which internal/binding treats as a valid empty table whose Match always misses, preserving today's passthrough behavior when no descriptors are configured.
func Upsert ¶
Upsert idempotently merges entries into the user binding-descriptors file at path, writing the result as a strict, human-editable v1 descriptor with 0600 permissions. It is the programmatic counterpart to hand-editing ~/.aileron/binding-descriptors.yaml: callers pass DefaultUserPath (tests inject a temp path) and one or more entries to add or replace.
The merge is order-preserving and last-write-wins per [Entry.dedupKey]: an incoming entry whose key matches an existing entry replaces it in place, and an incoming entry with a new key is appended. Existing entries the caller did not name are never touched or reordered, so repeatedly upserting the same entries is a no-op on the document's meaningful content. Among the incoming entries themselves, a later entry overrides an earlier one with the same key.
Validation happens entirely before any filesystem mutation. The merged document is marshaled and re-parsed through Parse as the single pre-write gate: this reuses Entry.Validate, the strict-decode check, and the duplicate-key check, and simultaneously proves the serialized bytes re-parse clean. A document that fails validation returns a non-nil error and writes nothing, so an invalid Upsert never leaves a partial or corrupt file (an existing file is left byte-identical, an absent file is not created).
A missing file (or an empty one) is treated as an empty v1 document, so the first Upsert into a fresh path creates a valid descriptor. The parent directory is created 0700 if absent.
A zero-entry Upsert is a no-op: it returns nil without reading, creating, or rewriting the file, so an absent path stays absent and an existing file is left byte-identical.
The write itself is atomic: the merged document is written to a temp file in the same directory and renamed over path, so a crash or concurrent writer never leaves a truncated or partially-written descriptor.
Upsert handles only Entry values, which carry a credential *reference* (a vault path) and never credential bytes. It never resolves or logs secret material, inheriting this package's secret-hygiene invariant.
Types ¶
type Descriptor ¶
type Descriptor struct {
// Version is the schema version. It must equal [SchemaVersion]; any
// other value is rejected at parse time so the format can evolve under
// 0.0.x without a silent misparse.
Version string `yaml:"version"`
// Bindings is the ordered list of per-host binding entries in this
// document. Within a single document, host keys must be unique; across
// layers, a later layer's entry for a host overrides an earlier one
// (see [Load]).
Bindings []Entry `yaml:"bindings"`
}
Descriptor is a parsed, versioned binding-descriptor document. One document carries a version and an ordered list of per-host entries. A CLI vendor or community profile ships a Descriptor; the loader merges the built-in and user layers into a single validated set.
func Parse ¶
func Parse(data []byte) (Descriptor, error)
Parse strictly decodes a single descriptor document and validates it. Decoding is strict: an unknown YAML key is an error rather than a silently ignored field, so a typo in a descriptor fails fast instead of shipping a binding that does nothing. Malformed YAML, a wrong or missing version, and any entry that fails Entry.Validate are all errors.
Parse never reads secret bytes; a descriptor carries only a credential reference.
type Entry ¶
type Entry struct {
// Host is the upstream host pattern matched at the proxy boundary. It
// is an exact host ("api.linear.app") or a single leading-wildcard
// form ("*.example.com"), mirroring internal/binding.HostBinding's
// match semantics rather than inventing a second matcher. Ports are
// not part of the pattern.
//
// Host is optional when the entry declares a complete credential
// identity ([Entry.Kind] + [Entry.IdentityLabel]): such an identity
// binding is selected at egress by its (kind, label) pair, not by host
// (#1978). An entry with neither a host nor a complete identity is a
// load-time error.
Host string `yaml:"host,omitempty"`
// Kind and IdentityLabel are the non-secret manifest credential-identity
// pair (#1978): the credential `kind` (e.g. "aws-sigv4") and the manifest
// `identity_label` naming which credential of that kind to inject. When
// both are set, the entry is a host-less-permitted identity binding the
// proxy selects by identity at egress. The pair is canonical: declare both
// or neither. A half-identity (exactly one set) is a load-time error. Maps
// onto internal/binding.HostBinding via [binding.WithIdentity].
Kind string `yaml:"kind,omitempty"`
IdentityLabel string `yaml:"identity_label,omitempty"`
// CredentialRef is a vault credential reference resolved daemon-side
// at injection time, never to the container. It is a connector-style
// binding name ("<kind>/<service>/<identity>") or a user-level ref
// ("user/<service>"), the same name contract internal/binding
// enforces. It is never the credential bytes.
CredentialRef string `yaml:"credential_ref,omitempty"`
// Scheme is one of the closed injection-scheme set (#1194):
// bearer | basic | header-template | query-param | sigv4-resign. An
// unknown scheme is a load-time error (fail closed, no silent skip).
Scheme string `yaml:"scheme,omitempty"`
// EmitMechanism declares how the credential reaches egress: "inject"
// (inject at the proxy) or "sentinel-swap". Optional; empty defaults to
// "inject". A sentinel-swap binding must declare a non-empty
// [Entry.Sentinel] block; an inject binding (explicit or defaulted)
// must declare none. Any value outside the closed set is a load-time
// error.
EmitMechanism string `yaml:"emit_mechanism,omitempty"`
// Sentinel is the sentinel-swap placeholder declaration: the non-secret
// value the launcher plants inside the container and the env-var name it
// plants under. It is required and non-empty for "sentinel-swap" and
// forbidden for "inject" (a stray sentinel on an inject binding is a
// load-time error, since the field is meaningless without sentinel-swap).
// It is a pointer so an absent block is distinguishable from a present
// block with empty fields. See [Sentinel].
Sentinel *Sentinel `yaml:"sentinel,omitempty"`
// Username is the non-secret HTTP basic-auth username, required only
// for the basic scheme (e.g. "x-access-token" for git-over-HTTPS).
Username string `yaml:"username,omitempty"`
// Header is the header name to set, required only for the
// header-template scheme (e.g. "Authorization" or a vendor header).
Header string `yaml:"header,omitempty"`
// Template is the verbatim header value for the header-template
// scheme, with the "{token}" placeholder substituted with the secret
// at inject time. Required for header-template. To emit Linear's
// verbatim "Authorization: <key>" with no Bearer prefix, set
// Template to "{token}".
Template string `yaml:"template,omitempty"`
// QueryParam is the query-parameter name to set, required only for the
// query-param scheme.
QueryParam string `yaml:"query_param,omitempty"`
// AccessKeyID is the non-secret AWS access key ID, required only for the
// sigv4-resign scheme. It appears verbatim in the signed request's
// Credential= field; the secret access key travels in the resolved
// credential value, never here. A sigv4-resign entry carries no region or
// service: the egress injector derives the SigV4 credential scope from the
// resolved upstream host (#1978), so there is no second, operator-supplied
// copy of the region that could drift from the host being signed for.
AccessKeyID string `yaml:"access_key_id,omitempty"`
// AllowedHosts is the optional per-binding trust-contract host
// allowlist. Empty means unconstrained: egress on the bound host stays
// scoped only to [Entry.Host], exactly as before. A non-empty list gates
// egress at the proxy injection point (#1735): the upstream host must
// match an entry (host or host:port form) or the request is denied and
// audited. Non-secret. Maps onto internal/binding.HostBinding via
// [binding.WithTrustContract].
AllowedHosts []string `yaml:"allowed_hosts,omitempty"`
// Effect is the optional per-binding trust-contract effect, one of
// internal/binding.HostBindingEffects (read | write | delete | spend |
// external-send), mirroring the runtime's effect vocabulary. Empty means
// no effect gate (unconstrained). A `read` effect constrains egress on
// the bound host to HTTP-safe methods at the proxy (#1735); a write-class
// effect admits all methods (the proxy cannot distinguish write from
// delete/spend/external-send on the wire). An unknown effect is a
// load-time error (fail closed). Non-secret.
Effect string `yaml:"effect,omitempty"`
}
Entry is a single declarative host->credential binding: the {host, credential-ref, scheme, emit-mechanism} quad plus the scheme-specific non-secret params. It is a self-contained value type so this package is independently testable; Entry.ToHostBinding adapts it to the canonical internal/binding.HostBinding the proxy table consumes.
func Load ¶
func Load(opts LoadOptions) ([]Entry, error)
Load merges the configuration layers (built-in defaults, then the optional in-memory unit-derived layer, then user) into a single validated, ordered set of entries keyed on host. This mirrors the layered policy/config convention used elsewhere in the codebase: the later layer overrides the earlier one per host key, so a user descriptor can replace a shipped community profile for the same host without editing it.
Precedence is strictly built-in < unit-derived < user. The unit-derived layer (opts.ExtraEntries) is the image-projected layer; an unset (nil) extra layer is a no-op that reproduces the two-layer table. Within the merged result, entries are ordered deterministically by host so the binding table is reproducible across loads.
Every layer is parsed strictly (unknown keys, wrong version, malformed YAML, and invalid entries are errors). An invalid layer fails the whole load with a clear error and never silently drops entries: a typo in a descriptor must not degrade to a partial, surprising binding set. A missing user file is not an error (an absent layer is an empty layer); only a present-but-unreadable or present-but-invalid file fails.
func (*Entry) ToHostBinding ¶
func (e *Entry) ToHostBinding() (binding.HostBinding, error)
ToHostBinding adapts a descriptor Entry into the canonical internal/binding.HostBinding the proxy table (#1193) consumes. It maps the declared scheme and emit-mechanism plus the scheme-specific non-secret params onto the constructor's options.
The constructor is the single source of truth for binding legality: host-pattern form and credential-ref name contract are validated there, so the descriptor format and the binding table can never disagree about what a well-formed binding is. ToHostBinding carries no secret bytes; a HostBinding, like an Entry, names where the credential lives, never its value.
func (*Entry) Validate ¶
Validate checks that an Entry's required fields are present and internally consistent. It enforces the closed scheme set, the closed emit-mechanism set, and the scheme-specific param requirements. It does not resolve the credential or touch any secret.
Validation reuses the canonical internal/binding constructor so the descriptor format and the binding table agree on host-pattern and credential-ref legality: there is exactly one matcher and one name contract, not two.
func (*Entry) Warnings ¶
Warnings returns non-fatal advisories about a well-formed Entry that loads successfully but is suspect. Unlike Entry.Validate, a warning never blocks boot: it is logged at daemon startup so an operator sees a likely mistake before it fails at launch. The strings name the offending field and value so the aggregating caller can prefix them with the file and entry context.
Today the only warning is a sigv4-resign access_key_id that does not match the canonical AWS shape ([sigv4AccessKeyIDPattern]). This is warn-only by design: the value is non-secret, the AWS format may evolve, and the documented "AKIDEXAMPLE" test vector must keep loading clean, so a shape mismatch is surfaced without failing construction.
type LoadOptions ¶
type LoadOptions struct {
// UserPath is the per-user descriptor file (e.g. under ~/.aileron),
// the highest-precedence layer. It overrides built-in entries for the
// same host. Empty or absent contributes nothing.
UserPath string
// ExtraEntries is the in-memory unit-derived layer applied between the
// built-in defaults and the user layer (built-in < unit-derived < user).
// It carries entries projected from a sandbox image's
// devcontainer.metadata CLI units (#1322). An entry here overrides a
// built-in for the same host and is overridden by a user entry for the
// same host. Nil or empty contributes nothing, so a caller that sets it to
// nil reproduces today's two-layer table exactly.
ExtraEntries []Entry
}
LoadOptions selects the user descriptor layer that overrides the built-in defaults. UserPath is optional: an empty path or an absent file contributes no entries, so an operator who ships nothing gets exactly the built-in profiles.
func DefaultLoadOptions ¶
func DefaultLoadOptions() LoadOptions
DefaultLoadOptions returns the standard user descriptor layer path for daemon construction. The built-in defaults layer is always embedded; the user path selects the optional override layer.
type Sentinel ¶
type Sentinel struct {
// Value is the non-secret, format-mimicking placeholder the launcher
// plants and the proxy recognizes (e.g.
// "ghp_AILERONSENTINELAAAAAAAAAAAAAAAAAAAAA"). It is required and
// non-empty for "sentinel-swap". It is non-secret and safe to commit.
Value string `yaml:"value"`
// Env is the environment-variable name the launcher sets to [Sentinel.Value]
// inside the container (e.g. "GH_TOKEN"). It is required and non-empty for
// "sentinel-swap". It generalizes the plant target so the launcher is not
// hardcoded to a single CLI's env var.
Env string `yaml:"env"`
}
Sentinel is the per-binding sentinel-swap placeholder declaration. It makes both the placeholder value and its plant location data rather than Go constants, so a token-in-env CLI is expressible purely by a descriptor.
Every field is non-secret. The Value carries no authority: presenting it upstream authenticates nothing, so it is safe to embed in source, commit, and print in logs. The launcher plants Value under the Env env-var name inside the container so the CLI's local validation passes and it issues the request; the proxy then recognizes Value at egress and swaps in the real credential. The placeholder bytes never reach upstream.