Documentation
¶
Overview ¶
Package domain owns hostnames: the one the platform issues for an app, and the rule about which names a tenant may never claim.
The policy in this file is deliberately pure — no database, no Kubernetes. The reserved-suffix rule is the piece of this subsystem that is unsafe to get subtly wrong, so it is kept somewhere it can be exhaustively tested on its own.
Index ¶
- Constants
- Variables
- func CoveredByWildcard(host, appDomain string) bool
- func EnsureManaged(ctx context.Context, q *dbgen.Queries, in ManagedInput) (string, error)
- func HostsForApp(ctx context.Context, q *dbgen.Queries, appID uuid.UUID) ([]string, error)
- func Issue(appName, appDomain string) (string, error)
- func NextCheck(state State, attempts int) time.Duration
- func RemoveCustom(ctx context.Context, q *dbgen.Queries, ownerID string, id uuid.UUID) error
- func Reserved(host, appDomain string, extra []string) bool
- func ResolverName(res Resolver) string
- func RetireManaged(ctx context.Context, q *dbgen.Queries, appID uuid.UUID) error
- func RoutableHosts(ctx context.Context, q *dbgen.Queries, appID uuid.UUID) ([]string, error)
- func Verify(ctx context.Context, q *dbgen.Queries, res Resolver, ownerID string, ...) error
- func WildcardHosts(hosts []string, appDomain string) []string
- type AuthoritativeResolver
- type Checker
- type Custom
- type Describer
- type DirectResolver
- type ManagedInput
- type NetResolver
- type Observation
- type Resolver
- type Router
- type State
Constants ¶
const CheckInterval = 5 * time.Second
CheckInterval is how often the checker looks for work.
Short, because the work it finds is mostly nothing: the query is an index scan over rows whose next check is due, and the schedule on each row is what actually decides how often any given domain is looked at. This is the granularity of the whole system, not its load.
Variables ¶
var ErrDomainNotFound = errors.New("domain: no such domain")
ErrDomainNotFound means the app has no such custom domain.
var ErrHostReserved = errors.New("domain: hostname is reserved")
ErrHostReserved means the operator has put this hostname out of reach.
Distinct from ErrHostTaken: nobody holds it, and waiting will not free it. Telling somebody a name is taken when it is reserved sends them to look for the app that has it.
var ErrHostTaken = errors.New("domain: hostname already taken")
ErrHostTaken means another app already holds the hostname.
Hostnames are globally unique because DNS has one owner per name. The engine is single-owner so it cannot collide with itself, but a multi-tenant wrapper can — two tenants each naming an app "web" resolve to the same host. This exists so that reads as a taken name rather than as a constraint violation.
var ErrNoAppDomain = errors.New("domain: no app domain configured")
ErrNoAppDomain means no platform domain is configured. Callers treat this as the feature being switched off rather than as a failure, so it is a sentinel rather than a generic error.
var ErrNoTarget = errors.New("domain: no CNAME target is configured for this install")
ErrNoTarget means the install has not been told what custom domains point at.
var ErrNotVerified = errors.New("domain: the DNS for this name does not point here yet")
ErrNotVerified means the domain's DNS does not point at the platform yet.
Functions ¶
func CoveredByWildcard ¶
CoveredByWildcard reports whether a single *.<appDomain> certificate can serve this hostname.
One label, and exactly one. A wildcard matches web.apps.example.com and does not match a.b.apps.example.com — that is the rule in RFC 6125, and browsers enforce it whatever anybody intended.
The reason this exists: an Ingress used to be given a TLS block listing every host on it whenever wildcard TLS was on. A custom domain can never be under the platform domain — the reserved-name check guarantees it — so every custom domain was being served from a certificate that could not match it, and the dashboard showed a green "routed" beside it.
func EnsureManaged ¶
EnsureManaged issues the app's platform hostname, or moves it if the app domain has changed since it was issued.
Takes a *dbgen.Queries rather than holding its own, so a caller inside a transaction passes q.WithTx(tx) and the hostname is written in the same transaction as the app itself. An app cannot then exist without its URL.
With no app domain configured it returns ("", nil): the feature is off, and that is a state to pass through quietly rather than an error to handle at every call site.
func HostsForApp ¶
HostsForApp returns every hostname routed to an app, managed first.
func Issue ¶
Issue returns the platform hostname for an app: <app name>.<app domain>.
It returns ErrNoAppDomain when no domain is configured, which is not a failure — it is how an install with the feature switched off is detected.
func NextCheck ¶
NextCheck says how long to wait before looking again.
attempts is how many consecutive checks have now run without the domain settling; it resets when the state changes or somebody asks for a check.
func RemoveCustom ¶
RemoveCustom releases a claim.
func Reserved ¶
Reserved reports whether host falls under the platform domain or any additionally reserved domain.
The platform suffix is reserved whether or not an operator remembered to list it, because otherwise a tenant could claim a name under it in the window before the platform issued that name itself.
Matching is on the label boundary, never on the raw string: evilapps.example.com ends with apps.example.com as a substring but is a different domain entirely, and treating it as reserved-by-suffix is the classic form of this bug.
func ResolverName ¶
ResolverName describes a resolver, falling back to something honest.
func RetireManaged ¶
RetireManaged removes an app's platform-issued hostname, releasing the name for reuse. It is a no-op when the app has none.
func RoutableHosts ¶
RoutableHosts returns the hostnames an app's Ingress should carry.
A managed host is routable because the platform issued it. A custom one only once it is proven — the gate is in the query rather than in a caller, so nothing can route an unverified claim by forgetting to check.
func Verify ¶
func Verify( ctx context.Context, q *dbgen.Queries, res Resolver, ownerID string, id uuid.UUID, ) error
Verify proves a claim by resolving it, now.
The check is that the name resolves to the target, by CNAME or by resolving to the same addresses. Requiring a literal CNAME record would fail for an apex domain, which cannot have one — and telling somebody their correctly configured apex is wrong is worse than accepting the address match.
This and the background checker are the same code: both call Check, which is the only function that decides what state a domain is in. Two paths writing state independently is how they end up disagreeing.
func WildcardHosts ¶
WildcardHosts keeps only the hosts a *.<appDomain> certificate can serve.
Types ¶
type AuthoritativeResolver ¶
type AuthoritativeResolver struct {
// Fallback answers when no authoritative server can be reached at all —
// an install whose egress does not allow arbitrary port 53, or a name
// whose delegation cannot be found.
//
// Not used when an authoritative server answers and the answer is "no".
// Falling back there would reintroduce the cached negative this exists to
// avoid, which is the whole point.
Fallback Resolver
Timeout time.Duration
}
AuthoritativeResolver asks the nameservers that own the name.
The default, and the only arrangement that actually answers the question this package asks. Every recursive resolver caches negative answers — RFC 2308, for the zone's SOA minimum, commonly five minutes to an hour — and the check Yacht makes moments before somebody creates a record is exactly what puts one there. Pointing at a public resolver instead does not remove a cache from the path, it swaps one for another, and then fills that one too.
A zone's own nameservers hold no cache. A record created two seconds ago is visible two seconds later.
Finding them is one ordinary lookup: walk up the labels until something answers with NS records. That lookup goes through the system resolver on purpose — NS records are positive answers with long lifetimes, which is precisely what a cache is good at.
func (AuthoritativeResolver) Describe ¶
func (a AuthoritativeResolver) Describe() string
func (AuthoritativeResolver) LookupCNAME ¶
func (AuthoritativeResolver) LookupHost ¶
type Checker ¶
type Checker struct {
// contains filtered or unexported fields
}
Checker proves claimed domains in the background.
func NewChecker ¶
NewChecker builds a checker. A nil resolver disables it entirely, which is the right answer for an install that cannot look anything up: a checker that recorded a lookup failure every five seconds forever would fill the log with one fact.
func (*Checker) Pass ¶
Pass claims the domains that are due and checks each one.
Exported so a test can run exactly one pass rather than starting a loop and waiting on a wall clock.
func (*Checker) Run ¶
Run checks domains until the context is cancelled.
Runs once immediately. A restart is exactly when a domain is most likely to be sitting in a state nothing has looked at — the process that would have checked it went away — and waiting a full interval to notice adds that delay to every claim made while it was down.
type Custom ¶
type Custom struct {
ID uuid.UUID
Host string
Verified bool
// State is the detail Verified flattens away. Verified is kept because it
// is what routing is gated on, and it is generated in the database from
// exactly this value — the two cannot disagree.
State State
// Target is what the CNAME has to point at. Carried on the row so a change
// to the platform target shows up as a domain needing re-verification
// rather than silently invalidating one that was proven against the old.
Target string
// Observed is what the name resolved to when it was last looked at, as a
// sentence. Empty when the last check found nothing, or found what it
// wanted — a domain that is working has nothing to explain.
Observed string
// LastError is why the last check could not answer. Distinct from Observed:
// a resolver timing out says nothing about the domain, and merging the two
// would present an outage as a misconfiguration.
LastError string
// LastCheckedAt is zero until something has actually looked.
LastCheckedAt time.Time
NextCheckAt time.Time
// CreatedAt is when the claim was made. The first step of the progression
// the page draws, and the only one that is true the moment a domain exists.
CreatedAt time.Time
// VerifiedAt is when this was first proven, not when it was last checked.
VerifiedAt time.Time
// Attempts is how many consecutive checks have run without it settling.
// Drives the backoff, and is reset by asking for a check by hand.
Attempts int
}
Custom is a hostname somebody brought, and how far it has got.
func AddCustom ¶
func AddCustom( ctx context.Context, q *dbgen.Queries, ownerID string, appID uuid.UUID, host, target, appDomain string, reserved []string, ) (Custom, error)
AddCustom claims a hostname for an app, unverified.
Nothing routes to it yet. The claim is recorded so the person can be shown which record to create, and proving it is a separate act — see Verify.
func ListCustom ¶
func ListCustom( ctx context.Context, q *dbgen.Queries, ownerID string, appID uuid.UUID, ) ([]Custom, error)
ListCustom returns an app's custom domains.
func ToCustom ¶
ToCustom converts a stored row into the shape the rest of the system reads.
Exported because the install-wide list joins domains to apps and so cannot go through ListCustom, which is scoped to one app. Everything about how a row becomes a Custom still lives here rather than being reimplemented at the join.
type Describer ¶
type Describer interface {
Describe() string
}
Describer is implemented by resolvers that can say who they are. Optional, so a substituted resolver in a test or a wrapping application does not have to implement it.
type DirectResolver ¶
type DirectResolver struct {
// Server is host:port, for example "1.1.1.1:53".
Server string
// Timeout bounds a single lookup. Zero means the package default.
Timeout time.Duration
}
DirectResolver asks one nameserver directly, over its own connection.
Not a cache-busting trick. Go's resolver keeps no cache of its own; what it consults is the operating system's, via whatever /etc/resolv.conf points at. Dialling a chosen server bypasses that path, so the answer comes from a resolver this process picked rather than from whatever the host decided to remember.
func NewDirectResolver ¶
func NewDirectResolver(server string) DirectResolver
NewDirectResolver builds a resolver that queries one server.
The server is a deliberate choice by the operator rather than a default, because it is a decision about where this install's DNS questions go. An air-gapped install must not silently start depending on a public resolver, which is why nothing here is switched on unless it is configured.
func (DirectResolver) Describe ¶
func (d DirectResolver) Describe() string
Describe names the resolver that answered, for the page to show.
Worth surfacing because "this name does not resolve" is a claim about a specific resolver's opinion, and somebody comparing it against their own dig output needs to know the two asked the same question of the same server.
func (DirectResolver) LookupCNAME ¶
func (DirectResolver) LookupHost ¶
type ManagedInput ¶
type ManagedInput struct {
OwnerID string
AppID uuid.UUID
AppName string
AppDomain string
TLS bool
// Reserved are the operator's additional reserved suffixes. An app name
// that would claim one of them is refused rather than issued.
Reserved []string
}
ManagedInput describes the platform hostname to issue for an app.
type NetResolver ¶
NetResolver is the standard library's resolver.
func (NetResolver) Describe ¶
func (n NetResolver) Describe() string
Describe on the standard resolver names the thing it actually consults.
func (NetResolver) LookupCNAME ¶
func (NetResolver) LookupHost ¶
type Observation ¶
type Observation struct {
// Resolves reports whether the name answered at all, by any record type.
Resolves bool
// CNAME is the canonical name the host answered with. Empty when there is
// no CNAME, including the common case where a resolver answers a plain A
// record by echoing the name back.
CNAME string
// Addrs is what the host resolves to; TargetAddrs what the target does.
// Both kept so the diagnosis can show the comparison that was actually
// made rather than asserting its conclusion.
Addrs []string
TargetAddrs []string
// PointsHere is the verdict the routing gate is built on.
PointsHere bool
// Err is why the lookup could not answer. A non-nil Err means this
// observation says nothing about the domain.
Err error
}
Observation is what one look at DNS actually saw.
pointsAt used to answer this question with a bool, which is why the failure message was a black box: the code knew the name resolved to ghs.googlehosted.com and threw that away before anybody could be told.
func Probe ¶
func Probe(ctx context.Context, res Resolver, host, target string) Observation
Probe looks up a host and reports everything it saw.
The lookups are the same two pointsAt has always made, in the same order and with the same meaning. What is different is that the detail survives the call.
func (Observation) Describe ¶
func (o Observation) Describe() string
Describe renders what was seen, for somebody who has to fix it.
One line, because it sits under a step in a list. The CNAME is preferred over addresses: it is what the person typed into their provider, so it is what they can recognise as wrong.
type Resolver ¶
type Resolver interface {
LookupCNAME(ctx context.Context, host string) (string, error)
LookupHost(ctx context.Context, host string) ([]string, error)
}
Resolver looks up DNS. An interface so verification can be tested without a network, and so a caller can substitute a resolver that does not use the host's own cache — a freshly created record is exactly the case where a cached negative answer is wrong.
type Router ¶
Router records that an app's routing projection changed.
An interface here, implemented by the app service, because a domain becoming routable has to reach the Ingress and this package must not import the one that owns workloads — app already imports this one.
The app reconciler owns the actual cluster apply and promotes verified domains only after its workload commit marker is observable.
type State ¶
type State string
How far a custom domain has got toward serving traffic.
This replaced a boolean, and the reason is worth stating: "not verified" was the same value for a name that does not exist, a name still propagating, and a name pointing at somebody else's load balancer. Those need three different things done about them, and the UI could not tell them apart because the database never recorded which one it was.
const ( // StatePending is a claim nothing has looked at yet. It lasts seconds — // the checker picks it up on its next pass — but it is a real state rather // than an absence, because "we have not looked" and "we looked and found // nothing" are different answers to the only question being asked. StatePending State = "pending" // StateAwaitingDNS means the name does not resolve at all. Usually the // record has not been created, or has not propagated. StateAwaitingDNS State = "awaiting_dns" // StateMisdirected means the name resolves, but not here. The most // actionable state there is, and the one the old boolean could not express: // the row's observed column holds what it actually points at. StateMisdirected State = "misdirected" // StateVerified means DNS points here and nothing has applied it yet. // A brief step, and separate from routed on purpose — a domain proven but // absent from the Ingress is a real situation, and one that used to be // invisible. StateVerified State = "verified" // StateRouted means proven and carried by the Ingress. The finished state. StateRouted State = "routed" // StateDrifted means this was routed and a later check found it no longer // points here. Nothing could reach this state before, because nothing ever // re-checked and nothing ever cleared the boolean: a domain whose DNS was // deleted stayed "verified" and stayed in the Ingress forever. StateDrifted State = "drifted" )
func Check ¶
func Check( ctx context.Context, q *dbgen.Queries, res Resolver, row dbgen.Domain, now time.Time, ) (State, error)
Check looks one domain up and records what it found.
The single place a domain's state is decided and written. Returns the state it settled on so a caller can act on a transition — applying the Ingress when a domain becomes routable, or withdrawing it when one drifts.
now is passed rather than read so the backoff can be tested against a clock the test controls.
func Classify ¶
func Classify(obs Observation, previous State) State
Classify decides a domain's state from what a lookup saw.
A pure function of the observation and where the domain already was, so the whole state machine is testable without a database, a network or a clock.
previous matters in exactly one way: falling out of routed is drift, and drift is worth distinguishing from never having worked. Somebody whose live site just stopped resolving needs a different sentence from somebody who has not created the record yet.
func (State) Routable ¶
Routable reports whether this state may carry traffic.
The database answers the same question in SQL — verified is generated from exactly this set — and the Ingress is built from that query rather than from this method. Both exist because the gate belongs in SQL where no caller can forget it, and the UI still needs to explain the answer.