Documentation
¶
Overview ¶
Package tenants owns the tenants and tenant_hostnames tables in runtime.db.
Tenants are routing topology — slug, name, and the per-hostname mapping the data-plane router uses to resolve `Host: foo.local` → (tenant_id, stack). They live in runtime.db so a data-plane-only chassis can resolve them without opening auth.db.
Identity-side tables in auth.db (memberships, invitations, browser sessions, etc.) reference tenant_id as opaque TEXT; the cross-DB integrity is by-convention since SQLite cannot enforce FKs across files without ATTACH DATABASE.
Index ¶
- Constants
- Variables
- func CanonicalizeHost(input string) (string, bool)
- func EnsureSystemHostnameTx(ctx context.Context, tx *sql.Tx, tenantID, stack, suffix, now string) (string, error)
- func IsValidHostname(canonical string) bool
- func MintHandle(stack string) string
- func NewHostnameID() string
- func ReservedSlug(slug string) bool
- type Challenge
- type Hostname
- type Store
- func (s *Store) ActiveChallenge(ctx context.Context, hostnameID, method string) (*Challenge, error)
- func (s *Store) AttachHostname(ctx context.Context, hostname, stack string) error
- func (s *Store) AttachHostnameTx(ctx context.Context, tx *sql.Tx, hostname, stack string) error
- func (s *Store) Create(ctx context.Context, t Tenant) error
- func (s *Store) CreateChallenge(ctx context.Context, hostnameID, method, createdBy, token string) (Challenge, error)
- func (s *Store) CreateHostname(ctx context.Context, h Hostname) (Hostname, error)
- func (s *Store) CreateHostnameTx(ctx context.Context, tx *sql.Tx, h Hostname) error
- func (s *Store) CreateTx(ctx context.Context, tx *sql.Tx, t Tenant) error
- func (s *Store) List(ctx context.Context) ([]Tenant, error)
- func (s *Store) ListHostnames(ctx context.Context, tenantID string, includeRevoked bool) ([]Hostname, error)
- func (s *Store) Lookup(ctx context.Context, tenantID string) (*Tenant, error)
- func (s *Store) LookupActiveHostname(ctx context.Context, hostname string) (Hostname, error)
- func (s *Store) LookupBySlug(ctx context.Context, slug string) (*Tenant, error)
- func (s *Store) LookupChallengeByToken(ctx context.Context, token string) (*Challenge, error)
- func (s *Store) MarkHostnameVerified(ctx context.Context, hostnameID string, when time.Time) error
- func (s *Store) MarkHostnameVerifiedTx(ctx context.Context, tx *sql.Tx, hostnameID string, when time.Time) error
- func (s *Store) RecordChallengeAttempt(ctx context.Context, id, lastError string, verified bool) error
- func (s *Store) RevokeHostname(ctx context.Context, hostname string) error
- func (s *Store) RevokeHostnameTx(ctx context.Context, tx *sql.Tx, hostname string) (canonical string, revokedAt time.Time, err error)
- type Tenant
- type Verifier
Constants ¶
const ChallengeTTL = 24 * time.Hour
ChallengeTTL is how long a freshly-issued challenge stays valid. After expiry, /verify returns "expired, re-issue first." 24h matches the operator expectation set by ACME and is long enough for one DNS propagation cycle.
const DefaultTenantID = "tnt_default"
DefaultTenantID is the seeded primary key. Stable so callers can reference it without a slug round-trip.
const DefaultTenantSlug = "default"
DefaultTenantSlug is seeded by db/schema/sqlite/runtime/0002_tenants.sql. The first bootstrap actor claims it via the super_admin path.
const SystemStructuredHostCreatedBy = "system:structured-host"
SystemStructuredHostCreatedBy is the created_by sentinel stamped on chassis-minted hostname rows. It marks a row as managed (so list/UX can flag it, `hostnames remove` can refuse it, and a config-disable won't touch operator vanity rows) and is the idempotency key for "does this stack already have an auto-minted hostname?".
const SystemTenantID = "tnt_sys"
SystemTenantID is the system tenant's seeded primary key.
const SystemTenantSlug = "_sys"
SystemTenantSlug owns the chassis ingress-fallback namespace (`boot/*`). Requests that match no ingress route are stamped with this tenant and run pinned to it, so the data-plane op lookup is tenant-filtered like every other request (no global/unfiltered path). A `_sys` boot rule may re-tenant a request into a real tenant — the only place the request's pinned tenant may change, and only one-way (see processor's re-tenant gate). Seeded by db/schema/sqlite/runtime/0007_system_tenant.sql.
Variables ¶
var ErrAddressNotPublic = errors.New("address_not_public")
ErrAddressNotPublic signals an SSRF defense rejection: the hostname resolves to one or more non-public IPs and AllowPrivateAddresses is false.
var ErrChallengeExpired = errors.New("challenge expired")
ErrChallengeExpired signals the active challenge for (hostname, method) has aged past its expires_at. Caller should re-issue.
var ErrHostnameInUse = errors.New("hostname in use")
ErrHostnameInUse signals that an active row already exists for the requested hostname (partial unique index collision). The caller translates this to HTTP 409 and exposes the prior owner's tenant_id via the conflict body.
var ErrNotFound = errors.New("not found")
ErrNotFound mirrors the same sentinel the auth registry uses; callers in admin handlers translate it to HTTP 404.
var ErrTokenMismatch = errors.New("token_mismatch")
ErrTokenMismatch signals the response body (or TXT record) didn't match the expected token.
var ErrUnexpectedRedirect = errors.New("unexpected_redirect")
ErrUnexpectedRedirect signals that the HTTP-01 target returned a 3xx. We never follow — a malicious server could 302 us into an internal URL, defeating the pinned dialer.
Functions ¶
func CanonicalizeHost ¶
CanonicalizeHost parses a Host-header-or-similar string into the canonical form used as the lookup key in `tenant_hostnames`. Returns (canonical, ok). On any ambiguity (too many colons, empty result, leading/trailing punctuation that can't be cleaned up) it returns ok=false — the caller chooses whether that's a hard error (admin write) or a silent miss (resolver read).
Both the admin write path and the resolver read path call this function so a request with Host: "EXAMPLE.com:8080" matches a stored row keyed on "example.com". Centralised here because the cases are fiddly (`[::1]:8080`, trailing dot, mixed case, `host:bad:port`) and duplicating the logic across read/write is exactly the kind of place lookups silently diverge.
v1 does NOT IDNA/punycode. Non-ASCII input is rejected at the admin write boundary by `IsValidHostname`, and on the resolver path simply misses (falling through to boot/%/0).
func EnsureSystemHostnameTx ¶
func EnsureSystemHostnameTx(ctx context.Context, tx *sql.Tx, tenantID, stack, suffix, now string) (string, error)
EnsureSystemHostnameTx makes sure the (tenantID, stack) pair has an active chassis-minted hostname row, creating one if absent. It runs inside the caller's activation transaction (tx) so the row is atomic with the version flip; the caller owns commit and dbcache reload.
Idempotent: re-activations reuse the existing row (the URL never churns). Returns the canonical hostname (without scheme). suffix is the configured apex, e.g. ".stacks.example.com" or ".localhost" — a missing leading dot is tolerated. An empty suffix means the feature is off → ("", nil), a no-op (the caller also guards this).
func IsValidHostname ¶
IsValidHostname is the strict admin-write predicate. It's called AFTER CanonicalizeHost, so input is already lowercased / port- stripped / trailing-dot-stripped. Rejects IP literals and other shapes that pass canonicalisation but shouldn't be stored.
func MintHandle ¶
MintHandle builds the label for a stack's auto-minted hostname. Hinted form ("<sanitized-stack>-<rand>") per the design doc; for the opaque alternative, return randLabel() alone (one-line swap).
func NewHostnameID ¶
func NewHostnameID() string
NewHostnameID generates the canonical thn_ prefixed id callers pass into CreateHostnameTx. Lives here so the producer hook + the Store agree on the format.
func ReservedSlug ¶
ReservedSlug reports whether a slug is reserved and may not be claimed by a tenant. The whole `_`-prefixed namespace is reserved for chassis-internal tenants (e.g. `_sys`) so operator-owned system tenants can never collide with or be impersonated by a created tenant. Caller should pass the already-normalised (lower/trim) slug.
Types ¶
type Challenge ¶
type Challenge struct {
ID string
HostnameID string
Method string
Token string
CreatedAt time.Time
CreatedBy string
ExpiresAt time.Time
AttemptedAt *time.Time
LastError string
VerifiedAt *time.Time
RevokedAt *time.Time
}
Challenge mirrors a tenant_hostname_challenges row — the proof-of- ownership artifact created by `POST /hostnames/{host}/challenges`.
type Hostname ¶
type Hostname struct {
ID string
Hostname string
TenantID string
Stack string
CreatedAt time.Time
CreatedBy string
RevokedAt *time.Time
VerifiedAt *time.Time
}
Hostname mirrors a tenant_hostnames row.
type Store ¶
Store is the thin façade over the tenants table. Backed by runtime.db.
func (*Store) ActiveChallenge ¶
ActiveChallenge returns the live (non-verified, non-revoked) challenge for (hostnameID, method). Returns ErrNotFound when none exists, ErrChallengeExpired when the row exists but its expires_at has passed.
func (*Store) AttachHostname ¶
AttachHostname sets the stack on an existing active row, identified by canonical hostname. Stack must be non-empty (call RevokeHostname to remove the row, or pass an empty stack to detach in a future version). Returns ErrNotFound if no active row matches.
The caller is responsible for verifying that the stack exists in the same tenant — the store doesn't reach into the stacks table.
func (*Store) AttachHostnameTx ¶
AttachHostnameTx is the tx-accepting variant of AttachHostname. Returns ErrNotFound when no active row matches the canonical hostname.
func (*Store) Create ¶
Create inserts a new tenants row. Caller supplies the pre-generated tenant_id (hxid "tnt_…") and a slug. Slug is lower-cased and trimmed before insert because the UNIQUE index is case-sensitive.
func (*Store) CreateChallenge ¶
func (s *Store) CreateChallenge(ctx context.Context, hostnameID, method, createdBy, token string) (Challenge, error)
CreateChallenge issues a fresh challenge for (hostnameID, method). Any existing active challenge for that pair is soft-revoked first — both moves happen in one BEGIN IMMEDIATE transaction so the partial unique index never sees two active rows.
`tokenGen` is the source of the secret token (160 bits). Passed in so tests can pin it. Production callers use a crypto/rand-backed generator. The token MUST be unique across all challenges (the UNIQUE constraint on tenant_hostname_challenges.token enforces it, but generating 160 bits makes the collision risk negligible).
func (*Store) CreateHostname ¶
CreateHostname canonicalizes the supplied hostname, generates an hxid "thn_…", and inserts. On collision with an active row (driven by the partial unique index on hostname WHERE revoked_at IS NULL) returns ErrHostnameInUse and stamps the conflicting row's TenantID onto the returned Hostname so the caller can render a 409 with the prior owner's id.
Caller-supplied fields used: Hostname, TenantID, Stack, CreatedBy. Other fields (ID, CreatedAt) are filled in here.
func (*Store) CreateHostnameTx ¶
CreateHostnameTx is the tx-accepting variant of CreateHostname. Differs in one important way: the caller MUST pre-generate the hostname ID and pass it in via h.ID. (Generating inside the tx would prevent the producer hook from including the ID in its pre-tx artifact JSON.) An empty h.ID returns an error.
On unique-violation the caller has to do their own follow-up lookup — we can't run LookupActiveHostname inside the same tx without an explicit second query, and the original CreateHostname path's "lookup the conflicting owner" affordance isn't useful when the caller is the producer hook (which is publishing authoritative state, not reconciling with an existing row).
func (*Store) CreateTx ¶
CreateTx inserts a tenants row inside the caller's tx. Same validation and SQL as Create. Returns the same errors.
func (*Store) ListHostnames ¶
func (s *Store) ListHostnames(ctx context.Context, tenantID string, includeRevoked bool) ([]Hostname, error)
ListHostnames returns hostnames for a tenant. When includeRevoked is false (the default for admin list endpoints) only currently-active rows are returned, ordered by hostname for stable shell-script consumption. When true, revoked rows are included for "who used to own this?" debugging, ordered by created_at DESC so the most recent rows appear first.
func (*Store) Lookup ¶
Lookup reads a tenants row by primary key. Returns ErrNotFound when no row exists; satisfies the registry.TenantLookup interface so the auth registry can resolve slugs without importing this package.
func (*Store) LookupActiveHostname ¶
LookupActiveHostname is the read-side lookup: hostname → (Hostname row, Tenant row). The resolver in chassis/server/ingress does its own JOIN against the dbcache mirror for the data-plane hot path; this method is for admin handlers that want the same shape without re-implementing the JOIN.
func (*Store) LookupBySlug ¶
LookupBySlug resolves a slug to a tenant. Used by the admin mux when it sees `/v1/tenants/{slug}/…`.
func (*Store) LookupChallengeByToken ¶
LookupChallengeByToken is the read-side lookup for the /.well-known/txco-verify/{token} handler. Returns the active challenge for the token, ErrNotFound if no row matches, or ErrChallengeExpired if the matching row exists but is past its expires_at.
func (*Store) MarkHostnameVerified ¶
MarkHostnameVerified flips the parent row's verified_at to `when`. Called after RecordChallengeAttempt with verified=true so the resolver can see the hostname as verified on its next dbcache reload.
func (*Store) MarkHostnameVerifiedTx ¶
func (s *Store) MarkHostnameVerifiedTx(ctx context.Context, tx *sql.Tx, hostnameID string, when time.Time) error
MarkHostnameVerifiedTx is the tx-accepting variant of MarkHostnameVerified.
func (*Store) RecordChallengeAttempt ¶
func (s *Store) RecordChallengeAttempt(ctx context.Context, id, lastError string, verified bool) error
RecordChallengeAttempt updates attempted_at + last_error after a verification attempt. When verified is true, the row's verified_at is also set to now (and last_error cleared); callers should follow with MarkHostnameVerified to flip the parent row.
func (*Store) RevokeHostname ¶
RevokeHostname soft-deletes the active row matching the canonical hostname. Idempotent — revoking an absent or already-revoked hostname returns nil with no row affected.
func (*Store) RevokeHostnameTx ¶
func (s *Store) RevokeHostnameTx(ctx context.Context, tx *sql.Tx, hostname string) (canonical string, revokedAt time.Time, err error)
RevokeHostnameTx is the tx-accepting variant of RevokeHostname. Idempotent; reports the canonical hostname and the timestamp it stamped so the caller can build a consistent fleet-sync artifact even when the underlying row was already revoked (in which case no rows are affected but the timestamp returned is when the call was made — the producer's view of the moment).
type Tenant ¶
type Tenant struct {
TenantID string
Slug string
Name string
CreatedAt time.Time
RevokedAt *time.Time
}
Tenant mirrors the tenants table row.
type Verifier ¶
type Verifier struct {
// AllowPrivateAddresses bypasses the SSRF blocklist when running
// HTTP-01 against a hostname that resolves to a private,
// loopback, link-local, or otherwise non-public address. Default
// false (production-safe); `txco dev` flips it true in
// devDefaults so localhost workflows function.
AllowPrivateAddresses bool
}
Verifier runs hostname-ownership checks for the admin /verify endpoint. Two methods are supported:
- DNS TXT: look up `_txco-verify.<hostname>` and match any TXT value against the challenge token. Works before the hostname's A/CNAME points at the chassis.
- HTTP-01: build a per-attempt http.Client with a pinned-IP dialer and no redirect-following, then fetch `http://<hostname><port>/.well-known/txco-verify/<token>` and compare the body to the token.
Both methods accept a context and apply their own timeouts on top. All errors returned from this package are safe to surface verbatim to the operator (they describe what was attempted, not what the chassis saw internally).
func (*Verifier) VerifyDNS ¶
VerifyDNS resolves `_txco-verify.<hostname>` TXT records and matches any value (after TrimSpace) against either "txco-verify=<token>" or the bare "<token>". Uses `net.Resolver{PreferGo: true}` so /etc/hosts and cgo-only resolvers don't accidentally satisfy verification in dev.
func (*Verifier) VerifyHTTP ¶
VerifyHTTP fetches `http://<hostname><portSuffix>/.well-known/ txco-verify/<token>` with SSRF defenses (pinned-IP dialer + redirect refusal) and confirms the response body equals the token. `portSuffix` is "" for default port 80, ":<n>" otherwise — production behind an LB on :80 uses "", dev with the chassis on :8080 uses ":8080".