Documentation
¶
Overview ¶
Package ingress implements the chassis's first-class router: it maps source-specific signal keys (HTTP host, TCP listener, cron job) to a `(tenant, stack)` target, before any txcl rule evaluation.
The router runs in the server's bus loop, between the inlets' envelope construction and `processor.Run`. Hits stamp the envelope with `_txc.tenant`/`_txc.stack`/`_txc.ingress` and dispatch into the resolved stack; misses fall back to the chassis's default entry stage (today `boot/%/0`) without stamping.
The Resolver interface is the seam between this in-process YAML implementation and a future DB-backed implementation (chassis-as- service). Callers depend only on Resolver; swapping a YAML resolver for a DB-backed one is additive.
LMTP routing is a separate concern. Each RCPT TO is resolved independently (one delivery, N rcpts, N decisions) and recipients that resolve to the same (tenant, stack) get batched into one envelope. The MailResolver interface is the add-on that LMTP-aware resolvers implement; the chassis tries it before falling back to per-envelope Resolve. See `internal docs/todo-lmtp-routing-v2.md`.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func StampEnvelope ¶
func StampEnvelope(raw string, target RouteTarget) string
StampEnvelope writes the resolver's target onto the envelope as _txc.tenant / _txc.stack / _txc.ingress / _txc.hostname_verified. The chassis owns these fields — rule authors read them but don't write them.
`_txc.hostname_verified` is the load-bearing trust flag: true means either the operator hand-configured this route in YAML, or the hostname's tenant_hostnames row has verified_at set. False means the row is unverified and the chassis is in permissive mode (would not have routed in strict mode). Stack rules can gate sensitive behaviour on this value without re-reading the DB.
Types ¶
type DBResolver ¶
type DBResolver struct {
// contains filtered or unexported fields
}
DBResolver wraps an inner Resolver (typically a yamlResolver) with a DB-backed hostname → tenant lookup. The inner Resolver is consulted first so existing YAML deployments keep their behavior; the DB lookup is the fallback for hostnames the YAML doesn't know about.
Lookups go against the dbcache mirror (`*sql.DB` pinned to one connection, in-memory) so the data-plane hot path stays in-process. Admin writes to the on-disk runtime.db trigger a synchronous reload (see chassis/server/admin/tenant_hostname_endpoints.go) so changes are visible on the next request.
The resolver does NOT import chassis/tenants; the host-parsing rules are duplicated here (canonicalizeHost) to keep the data-plane router independent of the admin package surface. Both implementations must stay in sync — the production wiring routes both writes and reads through the same canonical form.
func NewDBResolver ¶
func NewDBResolver(inner Resolver, db *sql.DB, logger *zap.Logger, requireVerified bool) *DBResolver
NewDBResolver builds a DBResolver. `inner` may be nil for chassis configured without an `--ingress-config` YAML; in that case the DB lookup is the only routing path. `db` should be the dbcache mirror handle (`dbc.Db`), not the on-disk runtime.db — admin mutations reload the mirror synchronously so the data plane sees them.
requireVerified gates whether unverified `tenant_hostnames` rows participate in routing. Permissive (false): unverified rows still route, with a once-per-row WARN. Strict (true): unverified rows miss the resolver and the bus falls through to `boot/%/0`. NewDBResolver pins a FIXED *sql.DB handle. Correct for tests and any caller whose DB pointer is stable for the resolver's lifetime. The data plane must NOT use this with dbc.Db (that pointer is swapped on every dbcache reload) — use NewDBResolverFunc with dbc.Snapshot.
func NewDBResolverFunc ¶
func NewDBResolverFunc(inner Resolver, dbFn func() *sql.DB, logger *zap.Logger, requireVerified bool) *DBResolver
NewDBResolverFunc takes a provider that returns the CURRENT mirror handle, evaluated per request. The data plane passes dbc.Snapshot so hostnames added after boot (operator-bound or auto-minted) route without a restart — the bug this seam fixes.
func (*DBResolver) AcceptMailDomain ¶ added in v0.2.6
func (r *DBResolver) AcceptMailDomain(domain string) (RouteTarget, bool)
AcceptMailDomain implements MailDomainAccepter with Strategy B layered in — the same domain-acceptance decision as ResolveRecipient minus the local-part (Strategy A) and listener-catch-all rules:
- Inner YAML domain rules (operator @domain override + verified_domains).
- THIS resolver's tenant_hostnames lookup (DB-backed Strategy B).
The mailmap head calls this to answer the edge Postfix's relay_domains lookup. It honors the SAME verified/requireVerified gating as routing (via lookupMailDomain), so Postfix only ACCEPTS mail for a domain the chassis would actually ROUTE — no point accepting mail destined to bounce at the LMTP head.
func (*DBResolver) Resolve ¶
func (r *DBResolver) Resolve(key RouteKey) (RouteTarget, bool)
Resolve implements Resolver. YAML wins for backward compatibility; the DB lookup is consulted only on YAML miss and only for HTTP sources (the `tenant_hostnames` table doesn't carry TCP listeners or cron jobs).
func (*DBResolver) ResolveRecipient ¶
func (r *DBResolver) ResolveRecipient(rcpt, listener string) (RouteTarget, bool)
ResolveRecipient implements MailResolver with Strategy B layered in. Resolution order, per `internal docs/todo-lmtp-routing-v2.md` §2.5:
- Inner.resolveBeforeListener — rules 1, 2, 3, 4(YAML). Operator overrides + Strategy A + YAML verified_domains.
- THIS resolver's tenant_hostnames lookup — DB-backed rule 4. `anything@<verified hostname>` → `<tenant>/_mail`.
- Inner.resolveListener — rule 5 (operator listener catch-all).
The split is load-bearing: a tenant-specific rule (Strategy B) must win over the operator's last-resort catch-all (rule 5), otherwise a verified tenant's mail gets blackholed into the `system/mail_drop` stack instead of their `<tenant>/_mail`.
Inner type-assertion via concrete *yamlResolver: the bundled inner is always a *yamlResolver (or nil). Downstream overlays that want a different inner write their own resolver wrapper.
type File ¶
type File struct {
Ingress struct {
HTTP struct {
Hosts map[string]Target `yaml:"hosts"`
} `yaml:"http"`
TCP struct {
Listeners map[string]Target `yaml:"listeners"`
} `yaml:"tcp"`
Cron struct {
Jobs map[string]Target `yaml:"jobs"`
} `yaml:"cron"`
// LMTP routes are tried in this priority order per RCPT TO:
// 1. recipients[<exact addr>] — operator exact override
// 2. recipients["@" + domain] — operator domain wildcard
// 3. Strategy A — tenant.stack[+mod]@<default-host> parse
// 4. verified_domains[<domain>] — YAML stand-in for the
// tenant_hostnames lookup (the DB-backed equivalent
// lives in DBResolver and queries the same table that
// authorizes HTTP routing)
// 5. listeners[<listener>] — operator listener catch-all
// Each recipient resolves independently; the LMTP inlet groups
// rcpts that resolved to the same (tenant, stack) into one
// envelope.
LMTP struct {
Listeners map[string]Target `yaml:"listeners"`
Recipients map[string]Target `yaml:"recipients"`
VerifiedDomains map[string]Target `yaml:"verified_domains"`
} `yaml:"lmtp"`
} `yaml:"ingress"`
}
File is the on-disk YAML schema v1 loads. It intentionally groups routes by source (http/tcp/cron/lmtp) so per-source matching is a single map lookup.
type MailDomainAccepter ¶ added in v0.2.6
type MailDomainAccepter interface {
AcceptMailDomain(domain string) (RouteTarget, bool)
}
MailDomainAccepter is an optional add-on to MailResolver: a domain-only acceptance check used by the mailmap head to answer the edge Postfix's `relay_domains` lookup ("is this an accepted mail domain?").
Unlike ResolveRecipient it consults ONLY domain-keyed rules — operator `@domain` overrides, YAML `verified_domains`, and the DB-backed `tenant_hostnames` lookup. It deliberately omits Strategy A (whose host is the chassis's own MX name, already a Postfix `mydestination`) and the listener catch-all (a routing fallback, not a statement that a domain is hosted — accepting on it would turn the edge into an open relay).
Callers type-assert a MailResolver to this; resolvers that don't implement it simply don't participate in dynamic relay_domains.
type MailResolver ¶
type MailResolver interface {
ResolveRecipient(rcpt, listener string) (RouteTarget, bool)
}
MailResolver is an add-on interface implemented by resolvers that understand LMTP recipient lookups. Each RCPT TO on a single LMTP transaction is resolved independently; rcpts that resolve to the same (tenant, stack) get batched into one envelope by the LMTP inlet.
The YAML resolver implements MailResolver against `ingress.lmtp.{recipients,listeners}`. The DB-backed resolver (the bundled DBResolver wrapper, or a downstream overlay) layers DB lookups for verified-domain bypass on top.
`listener` is the LMTP listener name the delivery arrived on (currently always "default"; multi-listener configs land later). It's used only for the listener-catch-all fallback.
type Resolver ¶
type Resolver interface {
Resolve(key RouteKey) (RouteTarget, bool)
}
Resolver maps a RouteKey to a RouteTarget. Returns ok=false when no rule matches; callers should fall back to the chassis default entry stage and leave the envelope unmodified.
func LoadResolverFromFile ¶
func LoadResolverFromFile(path string, opts ...ResolverOption) (Resolver, error)
LoadResolverFromFile reads path and returns a Resolver. If path is empty, returns (nil, nil) — callers interpret nil as "no ingress configured" and use the chassis default entry stage. A non-empty path that doesn't exist or doesn't parse returns an error so the chassis can fail-fast at startup rather than silently route nothing.
Options configure cross-cutting state the YAML file doesn't carry (e.g. CLI/env-supplied LMTP default hosts) — see ResolverOption.
type ResolverOption ¶
type ResolverOption func(*yamlResolver)
ResolverOption configures a yamlResolver at load time. Use with LoadResolverFromFile(path, WithDefaultMailHosts(...)) etc.
func WithDefaultMailHosts ¶
func WithDefaultMailHosts(hosts []string) ResolverOption
WithDefaultMailHosts configures the hosts on which LMTP Strategy A is recognized. An incoming rcpt's host (case-insensitive) must equal one of these for the local-part `tenant.stack[+mod]` parse to fire.
Empty hosts in the slice are dropped (defensive against viper's CSV-parsing edge cases). Hosts are lowercased so the operator's YAML / flag value can be in any case.
type RouteKey ¶
RouteKey is the bundle of source-specific signals the resolver consults when picking a tenant + entry stack for an event.
Each inlet populates a different subset:
http → Src, Hostname, Path tcp → Src, Listener cron → Src, Job
LMTP does NOT use Resolve(RouteKey) — see MailResolver. Each LMTP recipient is its own routing decision; a single RouteKey can't model that without losing the per-rcpt independence.
Unused fields stay empty; the resolver only matches on what its source provides. Path is captured for HTTP but unused in v1 — it's here so callers don't churn when path-prefix matching lands.
func KeyFromEnvelope ¶
KeyFromEnvelope extracts the routing signals an inlet stashed on the envelope. The field locations are part of the chassis contract:
_txc.src → RouteKey.Src _txc.web.req.host → RouteKey.Hostname (http; the Host header) _txc.web.req.url.path → RouteKey.Path (http) _txc.tcp.listener → RouteKey.Listener (tcp) _txc.cron.job → RouteKey.Job (cron)
LMTP does NOT use this function — its per-RCPT routing happens in the LMTP inlet directly against MailResolver.ResolveRecipient, and each envelope dispatched by the inlet carries a pre-stamped `_txc.route.*` proposal so detectTenantBody no-ops on it.
type RouteTarget ¶
RouteTarget is the resolver's output: the tenant the event belongs to, the stack to enter, the matched key string (for `_txc.ingress` observability), and a Verified flag stack rules can gate on.
Verified is true when the routing decision can be trusted to reflect proven ownership of the matched key:
- YAML matches → true (operator manually configured the route).
- DB matches → true iff `tenant_hostnames.verified_at IS NOT NULL`.
In permissive mode, an unverified DB row still produces a match but with Verified=false. Stack rules that need stricter behaviour can inspect `_txc.hostname_verified` and reject / branch as they see fit.
LMTP Strategy A parses a `+modifier` subaddress from the local-part (`tenant.stack+modifier@host`) but does NOT propagate it on RouteTarget — the modifier doesn't drive routing, and a "group exemplar" stamped on the envelope would be lossy when rcpts in the same group carry different modifiers. Rules that want per-rcpt modifiers parse `_txc.lmtp.rcpt[i]` directly (split on '+').