Files
zooqueenandHanzo Dev 33fd345129 Egress signs a caller's request only for an AWS service it lists, EC2, and never for an identity service (L1).
Signed as the platform's role, STS, IAM, SSO and sign-in answer with credentials, so an EGRESS_AWS entry naming one refuses to start, and one that reaches the endpoint table another way is still not signed for.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 20:10:08 -07:00

409 lines
16 KiB
Go

// Package egress is the outbound trust boundary: callers ask for a metered
// upstream call, the provider credential stays in KMS and is never handed back.
//
// It runs as one process on a host we provision — not in the managed cluster —
// so everything it needs arrives from the host (flags, environment) and nothing
// is read from an orchestrator. Inbound is ZAP; outbound is HTTPS to the
// provider and ZAP to KMS.
package egress
import (
"errors"
"flag"
"fmt"
"net"
"os"
"strings"
"time"
)
// Config is everything the process needs to run. Every field either has a safe
// default or is required; there is no third state where a missing value is
// quietly tolerated, because the values without defaults are the ones that
// decide who may spend.
type Config struct {
// Listen is the inbound ZAP address callers reach. A bare host:port is ZAP
// over TCP, which is what a caller inside the cluster dials across the
// network.
Listen string
// Issuer is the IAM issuer every access token must name in `iss`.
Issuer string
// JWKS is where the issuer publishes its signing keys.
JWKS string
// Audience is the `aud` a token must carry to spend here. Required and
// unguessable-by-default on purpose: a service that accepts any audience
// accepts a token minted for a different app.
Audience string
// KMS is the credential store endpoint, e.g. "zap://kms.hanzo.ai:9999".
KMS string
// KMSOrg is the KMS tenant the custody tree lives under. It is NOT the
// caller's org — the caller's org is a path segment inside this tenant, put
// there by the validated principal.
KMSOrg string
// KMSPath is this service's own identity path, the name its signing key is
// derived at and the name KMS authorizes.
KMSPath string
// Recipient is this host's public sealing key, `age1pq1…`. Every credential
// in KMS is sealed to it, and it can only seal — the half that opens is the
// identity, which never leaves this host in the clear. So this is public by
// construction: a
// flag, a manifest, a commit are all fine places for it, and that is the
// point. Whoever enrols a credential does not thereby become able to read
// one.
Recipient string
// IAM is the identity server this service's own machine identity is
// exchanged at: by the HTTP store transport, and for the web identity an AWS
// role is assumed with. Required when KMS is an http(s) endpoint or AWS
// lists an endpoint.
IAM string
// ClientID names this service's machine identity at IAM. It is an
// identifier, not a secret; the matching secret is held as a host-encrypted
// unit credential and never appears here, in the environment, or in a file
// this process can be asked to print. An AWS role that egress assumes trusts
// this identity, and nothing a caller holds.
ClientID string
// Platform lists the platform's own cloud accounts, <provider>/<label>, and
// for AWS the account id the label is pinned to:
// "aws/hanzo-compute@123456789012". A fetch by a principal of the platform's
// own org (KMSOrg) falls through from its own custody to the org's shared
// account ONLY for a label listed here; every other label is the caller's own
// custody or nothing. Without it, every program and person filed under the
// platform org spends the platform's accounts under any label it names. The
// pin means a descriptor rewritten in KMS to name a role in another account is
// refused rather than assumed.
Platform map[string]string
// Callers are the programs that may spend each platform account, by client
// id: "aws/hanzo-compute=hanzo-visor". IAM stamps `owner` with the minting
// application's org and takes RFC 8707 `resource` from any client, so every
// application filed under the platform org — and a person signed in through
// one — is verified here as a principal of that org. Membership says nothing
// about which of them runs the platform's machines; this does. A platform
// account with no caller is spent by no one.
Callers map[string][]string
// AWS lists the AWS API endpoints egress signs requests for, by host:
// "ec2.us-east-1.amazonaws.com". The service and region a signature is
// scoped to are read from the host, so an entry is spelled
// <service>.<region>.amazonaws.com and nothing else, and the service is one
// egress signs for (`signable`: no identity service, ever). A caller's request names
// one of these or is refused; empty means egress carries no AWS call at all.
AWS []string
// Postgres is where egress listens for database clients: an absolute path
// for a unix socket, or a host:port. Empty — the default — means this host
// brokers no database sessions at all.
//
// An absolute path is a socket and anything else is host:port, which is the
// rule libpq itself uses, so an operator writes the address their client
// already understands. The socket is named the way libpq names one,
// `<dir>/.s.PGSQL.<port>`, because that is what a client derives from
// `host=<dir>`.
//
// The caller's token crosses this leg, so it belongs on a socket or on a
// link the operator trusts. Egress answers no to a client asking for TLS
// here; the encryption that matters on this path is the far end's.
Postgres string
// RPM is how many calls one principal may make per minute.
RPM int
// Deadline bounds one upstream call.
Deadline time.Duration
// URLs OVERRIDES where a provider answers. It is empty in the ordinary case:
// a model dialect carries its vendor's endpoint and a cloud's address is
// stated once in `clouds`, because where DigitalOcean answers is a fact about
// DigitalOcean rather than a choice this host makes. An entry moves one —
// a regional or sovereign endpoint — and can never admit a provider that is
// otherwise unserved.
//
// This is deliberately NOT a request field, and that is the rule that
// matters. A caller that could name the upstream could name its own, and
// egress would hand it the credential. An operator naming it adds nothing to
// that, which is why it is not required.
URLs map[string]string
// unreadable holds EGRESS_URLS entries that are not provider=url. A typo
// there would otherwise be silent, and its symptom — "no upstream is
// configured for this provider" at call time — points at the operator who
// did configure it, just badly. Check turns it into a refusal to start.
unreadable []string
}
// Flags binds cfg to fs and returns cfg, so a caller can add flags of its own
// before parsing. Every flag falls back to the environment, which is how a
// systemd unit supplies configuration without putting it in a command line.
func (c *Config) Flags(fs *flag.FlagSet) {
fs.StringVar(&c.Listen, "listen", env("EGRESS_LISTEN", ":9653"), "inbound ZAP address")
fs.StringVar(&c.Issuer, "issuer", env("EGRESS_ISSUER", "https://hanzo.id"), "IAM issuer tokens must name")
fs.StringVar(&c.JWKS, "jwks", env("EGRESS_JWKS", ""), "issuer JWKS document URL")
fs.StringVar(&c.Audience, "audience", env("EGRESS_AUDIENCE", ""), "audience a token must carry")
fs.StringVar(&c.KMS, "kms", env("EGRESS_KMS", ""), "KMS endpoint, zap://host:port")
fs.StringVar(&c.KMSOrg, "kms-org", env("EGRESS_KMS_ORG", "hanzo"), "KMS tenant holding the custody tree")
fs.StringVar(&c.KMSPath, "kms-path", env("EGRESS_KMS_PATH", "hanzo/egress"), "this service's identity path")
fs.StringVar(&c.Recipient, "recipient", env("EGRESS_RECIPIENT", ""), "this host's public sealing key, age1pq1...")
fs.StringVar(&c.IAM, "iam", env("EGRESS_IAM", ""), "IAM endpoint for the http transport")
fs.StringVar(&c.ClientID, "client-id", env("EGRESS_CLIENT_ID", ""), "machine identity for the http transport")
fs.StringVar(&c.Postgres, "postgres", env("EGRESS_POSTGRES", ""), "address to broker postgres sessions on, /path/.s.PGSQL.5432 or host:port")
fs.IntVar(&c.RPM, "rpm", envInt("EGRESS_RPM", 600), "calls per minute per principal")
fs.DurationVar(&c.Deadline, "deadline", envDuration("EGRESS_DEADLINE", 5*time.Minute), "upstream call deadline")
// The environment first, then the flags, so a systemd unit can override an
// upstream without a command line — which is how this host is configured.
// Both are ordinarily empty.
for pair := range strings.SplitSeq(env("EGRESS_URLS", ""), ",") {
if strings.TrimSpace(pair) == "" {
continue
}
if err := (urls{&c.URLs}).Set(pair); err != nil {
c.unreadable = append(c.unreadable, "EGRESS_URLS "+pair)
}
}
fs.Var(urls{&c.URLs}, "url", "upstream base URL for one provider, provider=url (repeatable)")
for host := range strings.SplitSeq(env("EGRESS_AWS", ""), ",") {
if host = strings.TrimSpace(host); host != "" {
c.AWS = append(c.AWS, host)
}
}
fs.Var(hosts{&c.AWS}, "aws", "AWS API endpoint egress signs for, <service>.<region>.amazonaws.com (repeatable)")
for entry := range strings.SplitSeq(env("EGRESS_PLATFORM", ""), ",") {
if strings.TrimSpace(entry) == "" {
continue
}
if err := (platform{&c.Platform}).Set(entry); err != nil {
c.unreadable = append(c.unreadable, "EGRESS_PLATFORM "+entry)
}
}
fs.Var(platform{&c.Platform}, "platform", "a platform account, <provider>/<label>, aws/<label>@<account id> (repeatable)")
for entry := range strings.SplitSeq(env("EGRESS_PLATFORM_CALLERS", ""), ",") {
if strings.TrimSpace(entry) == "" {
continue
}
if err := (callers{&c.Callers}).Set(entry); err != nil {
c.unreadable = append(c.unreadable, "EGRESS_PLATFORM_CALLERS "+entry)
}
}
fs.Var(callers{&c.Callers}, "platform-caller", "a program that may spend a platform account, <provider>/<label>=<client id> (repeatable)")
}
// Check reports why this configuration cannot be served. It refuses rather than
// filling in a default for anything that decides who may spend or where a
// credential may be sent.
func (c *Config) Check() error {
var missing []string
for name, v := range map[string]string{
"listen": c.Listen, "issuer": c.Issuer, "jwks": c.JWKS,
"audience": c.Audience, "kms": c.KMS, "kms-org": c.KMSOrg, "kms-path": c.KMSPath,
"recipient": c.Recipient,
} {
if strings.TrimSpace(v) == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("egress: %s must be set", strings.Join(sorted(missing), ", "))
}
if c.RPM <= 0 || c.Deadline <= 0 {
return errors.New("egress: rpm and deadline must be positive")
}
if len(c.unreadable) > 0 {
return fmt.Errorf("egress: unreadable configuration: %s", strings.Join(c.unreadable, ", "))
}
// An override moves an upstream egress already carries, so it has to be
// spelled the way that upstream is reached: a cloud API by an https URL, a
// base of ours by an address. An entry that is neither would never be
// dialled, and finding that out at a caller's request rather than at boot is
// the whole reason Check exists.
for provider, u := range c.URLs {
if _, ours := bases[provider]; ours {
if _, _, err := where(u); err != nil {
return fmt.Errorf("egress: url for %q: %w", provider, err)
}
continue
}
if !strings.HasPrefix(u, "https://") {
return fmt.Errorf("egress: url for %q must be https", provider)
}
}
// An AWS endpoint is signed for by what its name says, so a name that does
// not say a service and a region cannot be served, and finding that out at
// boot is the point. A role is assumed with this service's own identity,
// which means an IAM to exchange it at, over https because the exchange
// carries the client secret.
for _, host := range c.AWS {
if _, ok := endpointOf(host); !ok {
return fmt.Errorf("egress: aws endpoint %q is not <service>.<region>.amazonaws.com for a service egress signs for", host)
}
}
if len(c.AWS) > 0 {
if c.ClientID == "" || !strings.HasPrefix(c.IAM, "https://") {
return errors.New("egress: aws needs -iam (https) and -client-id: a role is assumed with this service's own identity")
}
}
// A platform account is spent by the programs named for it and no one else,
// and a caller named for an account that is not one is a typo that would
// otherwise grant nothing silently.
for key, pin := range c.Platform {
if !pinned(key, pin) {
return fmt.Errorf("egress: platform account %s wants its AWS account id, %s@<12 digits>, and only an AWS label takes one", key, key)
}
if len(c.Callers[key]) == 0 {
return fmt.Errorf("egress: platform account %s has no caller: set EGRESS_PLATFORM_CALLERS %s=<client id>", key, key)
}
}
for key, who := range c.Callers {
if _, ok := c.Platform[key]; !ok {
return fmt.Errorf("egress: EGRESS_PLATFORM_CALLERS names %s, which EGRESS_PLATFORM does not list", key)
}
for _, client := range who {
if client == c.ClientID {
return fmt.Errorf("egress: %s names egress itself as a caller", key)
}
}
}
// A listener takes the same grammar an origin does with one difference: the
// host may be empty, because ":5432" means every interface. There is no
// dialling equivalent of that, which is why `where` refuses it.
if c.Postgres != "" && !socket(c.Postgres) {
if _, _, err := net.SplitHostPort(c.Postgres); err != nil {
return fmt.Errorf("egress: postgres wants an absolute socket path or host:port, got %q", c.Postgres)
}
}
return nil
}
// urls collects the repeatable -url flag into a map.
type urls struct{ into *map[string]string }
func (u urls) String() string { return "" }
func (u urls) Set(v string) error {
provider, target, ok := strings.Cut(v, "=")
if !ok || strings.TrimSpace(provider) == "" || strings.TrimSpace(target) == "" {
return fmt.Errorf("want provider=url, got %q", v)
}
if *u.into == nil {
*u.into = map[string]string{}
}
(*u.into)[strings.TrimSpace(provider)] = strings.TrimSpace(target)
return nil
}
// platform collects the repeatable -platform flag: <provider>/<label>.
type platform struct{ into *map[string]string }
func (p platform) String() string { return "" }
func (p platform) Set(v string) error {
name, pin, _ := strings.Cut(strings.TrimSpace(v), "@")
key, err := named(name)
if err != nil {
return err
}
if *p.into == nil {
*p.into = map[string]string{}
}
(*p.into)[key] = pin
return nil
}
// pinned reports whether a platform account's pin is right for its provider:
// an AWS label names the twelve-digit account its role must be in, and a cloud
// that takes a bearer has no account to pin.
func pinned(key, pin string) bool {
if !strings.HasPrefix(key, "aws/") {
return pin == ""
}
if len(pin) != 12 {
return false
}
for i := 0; i < len(pin); i++ {
if pin[i] < '0' || pin[i] > '9' {
return false
}
}
return true
}
// callers collects the repeatable -platform-caller flag: <provider>/<label>=<client id>.
type callers struct{ into *map[string][]string }
func (c callers) String() string { return "" }
func (c callers) Set(v string) error {
name, client, ok := strings.Cut(strings.TrimSpace(v), "=")
key, err := named(strings.TrimSpace(name))
if !ok || err != nil || !segment(strings.TrimSpace(client)) {
return fmt.Errorf("want <provider>/<label>=<client id>, got %q", v)
}
if *c.into == nil {
*c.into = map[string][]string{}
}
(*c.into)[key] = append((*c.into)[key], strings.TrimSpace(client))
return nil
}
// named reads <provider>/<label>, each a custody segment.
func named(v string) (string, error) {
provider, label, ok := strings.Cut(v, "/")
if !ok || !segment(provider) || provider != strings.ToLower(provider) || !segment(label) {
return "", fmt.Errorf("want <provider>/<label>, got %q", v)
}
return provider + "/" + label, nil
}
// hosts collects the repeatable -aws flag.
type hosts struct{ into *[]string }
func (h hosts) String() string { return "" }
func (h hosts) Set(v string) error {
if v = strings.TrimSpace(v); v == "" {
return errors.New("want <service>.<region>.amazonaws.com")
}
*h.into = append(*h.into, v)
return nil
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
if d, err := time.ParseDuration(env(key, "")); err == nil && d > 0 {
return d
}
return fallback
}
func envInt(key string, fallback int) int {
var n int
if _, err := fmt.Sscanf(env(key, ""), "%d", &n); err == nil && n > 0 {
return n
}
return fallback
}
// sorted returns s ordered, so a refusal reads the same every time.
func sorted(s []string) []string {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
return s
}