tenants

package
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 29, 2026 License: MPL-2.0 Imports: 13 Imported by: 0

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

View Source
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.

View Source
const DefaultTenantID = "tnt_default"

DefaultTenantID is the seeded primary key. Stable so callers can reference it without a slug round-trip.

View Source
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.

View Source
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?".

View Source
const SystemTenantID = "tnt_sys"

SystemTenantID is the system tenant's seeded primary key.

View Source
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

View Source
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.

View Source
var ErrChallengeExpired = errors.New("challenge expired")

ErrChallengeExpired signals the active challenge for (hostname, method) has aged past its expires_at. Caller should re-issue.

View Source
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.

View Source
var ErrNotFound = errors.New("not found")

ErrNotFound mirrors the same sentinel the auth registry uses; callers in admin handlers translate it to HTTP 404.

View Source
var ErrTokenMismatch = errors.New("token_mismatch")

ErrTokenMismatch signals the response body (or TXT record) didn't match the expected token.

View Source
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

func CanonicalizeHost(input string) (string, bool)

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

func IsValidHostname(canonical string) bool

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

func MintHandle(stack string) string

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

func ReservedSlug(slug string) bool

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

type Store struct {
	DB *sql.DB
}

Store is the thin façade over the tenants table. Backed by runtime.db.

func New

func New(db *sql.DB) *Store

New builds a Store against the given runtime *sql.DB.

func (*Store) ActiveChallenge

func (s *Store) ActiveChallenge(ctx context.Context, hostnameID, method string) (*Challenge, error)

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

func (s *Store) AttachHostname(ctx context.Context, hostname, stack string) error

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

func (s *Store) AttachHostnameTx(ctx context.Context, tx *sql.Tx, hostname, stack string) error

AttachHostnameTx is the tx-accepting variant of AttachHostname. Returns ErrNotFound when no active row matches the canonical hostname.

func (*Store) Create

func (s *Store) Create(ctx context.Context, t Tenant) error

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

func (s *Store) CreateHostname(ctx context.Context, h Hostname) (Hostname, error)

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

func (s *Store) CreateHostnameTx(ctx context.Context, tx *sql.Tx, h Hostname) error

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

func (s *Store) CreateTx(ctx context.Context, tx *sql.Tx, t Tenant) error

CreateTx inserts a tenants row inside the caller's tx. Same validation and SQL as Create. Returns the same errors.

func (*Store) List

func (s *Store) List(ctx context.Context) ([]Tenant, error)

List returns every non-revoked tenant, newest first.

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

func (s *Store) Lookup(ctx context.Context, tenantID string) (*Tenant, error)

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

func (s *Store) LookupActiveHostname(ctx context.Context, hostname string) (Hostname, error)

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

func (s *Store) LookupBySlug(ctx context.Context, slug string) (*Tenant, error)

LookupBySlug resolves a slug to a tenant. Used by the admin mux when it sees `/v1/tenants/{slug}/…`.

func (*Store) LookupChallengeByToken

func (s *Store) LookupChallengeByToken(ctx context.Context, token string) (*Challenge, error)

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

func (s *Store) MarkHostnameVerified(ctx context.Context, hostnameID string, when time.Time) error

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

func (s *Store) RevokeHostname(ctx context.Context, hostname string) error

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

func (v *Verifier) VerifyDNS(ctx context.Context, hostname, token string) error

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

func (v *Verifier) VerifyHTTP(ctx context.Context, hostname, portSuffix, token string) error

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".

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL