mirror of
https://github.com/hanzoai/egress.git
synced 2026-10-30 15:08:38 +00:00
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]>
409 lines
16 KiB
Go
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
|
|
}
|