mirror of
https://github.com/hanzoai/egress.git
synced 2026-10-30 15:08:38 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
244 lines
9.8 KiB
Go
244 lines
9.8 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 the HTTP transport exchanges client
|
|
// credentials at. Required when KMS is an http(s) endpoint, unused when it
|
|
// is zap:// — the two transports authenticate differently and the store SDK
|
|
// takes the same struct for both.
|
|
IAM string
|
|
|
|
// ClientID names this service's machine identity on the HTTP transport. 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.
|
|
ClientID 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, pair)
|
|
}
|
|
}
|
|
fs.Var(urls{&c.URLs}, "url", "upstream base URL for one provider, provider=url (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: EGRESS_URLS wants provider=url, got %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)
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|