Files
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

162 lines
4.4 KiB
Go

// Package instancecheck runs the operator-facing setup and health checks.
//
// A check answers one question an operator would otherwise have to answer by
// reading source: is this instance misconfigured, and what is the exact fix.
// Every message names the variable and the command, because a health page that
// only says "something is wrong" costs more time than it saves.
package instancecheck
import (
"context"
"sort"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/app/instanceconfig"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/notify"
)
// perCheckTimeout bounds each check so one dead dependency cannot stall the
// whole page. Sized for the slowest check: two probe URLs, two attempts each.
const perCheckTimeout = 8 * time.Second
// Severity levels. Only non-ok findings are returned, so there is no "ok".
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
SeverityInfo Severity = "info"
)
// Categories the admin panel groups findings under.
const (
CategorySecurity = "security"
CategoryURLs = "urls"
CategoryMail = "mail"
CategoryAccess = "access"
CategoryWorkers = "workers"
CategoryData = "data"
)
// Finding is one thing that is wrong, plus how to fix it.
type Finding struct {
ID string `json:"id"`
Category string `json:"category"`
Severity Severity `json:"severity"`
Title string `json:"title"`
Message string `json:"message"`
Docs string `json:"docs"`
// Target names the subject of a per-record finding (a mailbox address, a
// worker name). Empty when the finding is instance-wide.
Target string `json:"target"`
}
// Summary counts findings by severity.
type Summary struct {
Error int `json:"error"`
Warning int `json:"warning"`
Info int `json:"info"`
}
// Input carries the per-request facts a check cannot derive from the process.
type Input struct {
// Host is the Host header the operator reached this backend on.
Host string
// Origin is the browser origin of the calling panel, when it sent one.
Origin string
// Forwarded reports whether the request carried X-Forwarded-For.
Forwarded bool
}
// Deps are the process handles the checks read. Every field is optional: a
// check whose input is missing is skipped silently, never reported as failing.
type Deps struct {
Runtime *instanceconfig.Runtime
Transport *notify.Transport
Policy *config.AuthPolicy
DB *pgxpool.Pool
Cache *cache.Cache
}
type check struct {
id string
run func(ctx context.Context, d Deps, in Input) *Finding
}
// Registry holds the check set. Build it once at boot and reuse it.
type Registry struct {
deps Deps
checks []check
}
// New builds the phase 1 registry.
func New(deps Deps) *Registry {
r := &Registry{deps: deps}
r.checks = append(r.checks, securityChecks()...)
r.checks = append(r.checks, urlChecks()...)
r.checks = append(r.checks, mailChecks()...)
r.checks = append(r.checks, accessChecks()...)
r.checks = append(r.checks, infraChecks()...)
return r
}
// Run executes every check in parallel and returns the non-ok findings, most
// severe first, with ties broken by registration order.
func (r *Registry) Run(ctx context.Context, in Input) ([]Finding, Summary) {
found := make([]*Finding, len(r.checks))
var wg sync.WaitGroup
for i, c := range r.checks {
wg.Add(1)
go func(i int, c check) {
defer wg.Done()
// A panicking check must degrade to a skip, never to a 500 on the
// page an operator opens because something is already broken.
defer func() { _ = recover() }()
cctx, cancel := context.WithTimeout(ctx, perCheckTimeout)
defer cancel()
found[i] = c.run(cctx, r.deps, in)
}(i, c)
}
wg.Wait()
out := make([]Finding, 0, len(found))
var summary Summary
for i, f := range found {
if f == nil {
continue
}
f.ID = r.checks[i].id
switch f.Severity {
case SeverityError:
summary.Error++
case SeverityWarning:
summary.Warning++
default:
summary.Info++
}
out = append(out, *f)
}
sort.SliceStable(out, func(i, j int) bool {
return severityRank(out[i].Severity) > severityRank(out[j].Severity)
})
return out, summary
}
func severityRank(s Severity) int {
switch s {
case SeverityError:
return 3
case SeverityWarning:
return 2
default:
return 1
}
}