Files
warmbly/internal/app/instancecheck/checks_infra.go
T
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

150 lines
4.7 KiB
Go

package instancecheck
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/warmbly/warmbly/internal/config"
)
const (
docsEventBus = "/development/configuration/#event-bus"
docsStorage = "/development/configuration/#storage"
docsHealthWorker = "/development/instance-health/#workers"
docsHealthDB = "/development/instance-health/#database"
docsHealthRedis = "/development/instance-health/#redis"
)
// workerLivenessWindow is how stale a heartbeat may be before the placement
// layer stops considering a worker live.
const workerLivenessWindow = 5 * time.Minute
func infraChecks() []check {
return []check{
{id: "no_worker_heartbeat", run: checkNoWorkerHeartbeat},
{id: "codec_not_json", run: checkCodecNotJSON},
{id: "migrations_dirty", run: checkMigrationsDirty},
{id: "blob_root_missing", run: checkBlobRootMissing},
{id: "redis_unreachable", run: checkRedisUnreachable},
}
}
func checkNoWorkerHeartbeat(ctx context.Context, d Deps, in Input) *Finding {
if d.DB == nil {
return nil
}
var lastSeen *time.Time
var assigned int
err := d.DB.QueryRow(ctx, `
SELECT (SELECT max(last_seen_at) FROM workers),
(SELECT count(*) FROM email_accounts WHERE worker_id IS NOT NULL)
`).Scan(&lastSeen, &assigned)
if err != nil || assigned == 0 {
return nil
}
const tail = "Nothing is being sent or synced. Check that the worker process is running and that " +
"ENCRYPTED_KEYS_BACKEND_URL and ENCRYPTED_KEYS_WORKER_TOKEN are set: an empty value makes the worker " +
"start normally and never register."
if lastSeen == nil {
return result(CategoryWorkers, SeverityError, "No worker has checked in",
fmt.Sprintf("No worker has ever checked in, and %d mailboxes are assigned to workers. %s", assigned, tail),
docsHealthWorker)
}
age := time.Since(*lastSeen)
if age < workerLivenessWindow {
return nil
}
return result(CategoryWorkers, SeverityError, "No worker has checked in",
fmt.Sprintf("No worker has checked in for %s, and %d mailboxes are assigned to workers. %s",
humanizeDuration(age), assigned, tail),
docsHealthWorker)
}
func checkCodecNotJSON(ctx context.Context, d Deps, in Input) *Finding {
codec := config.CodecProvider()
if codec == "json" || d.DB == nil {
return nil
}
var workers int
if err := d.DB.QueryRow(ctx, `SELECT count(*) FROM workers`).Scan(&workers); err != nil || workers == 0 {
return nil
}
return result(CategoryWorkers, SeverityError, "Codec is not JSON",
fmt.Sprintf("CODEC_PROVIDER is %s. Worker command and result envelopes carry untyped bodies that Avro "+
"cannot serialize, so every worker command will fail to encode. Set CODEC_PROVIDER=json.", codec),
docsEventBus)
}
func checkMigrationsDirty(ctx context.Context, d Deps, in Input) *Finding {
if d.DB == nil {
return nil
}
var version int64
var dirty bool
if err := d.DB.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`).Scan(&version, &dirty); err != nil {
return nil
}
if !dirty {
return nil
}
return result(CategoryData, SeverityError, "The database schema is dirty",
fmt.Sprintf("The database schema is dirty at version %d. The backend applies migrations at boot; "+
"a dirty row means one failed halfway and must be resolved before this instance is used.", version),
docsHealthDB)
}
func checkBlobRootMissing(ctx context.Context, d Deps, in Input) *Finding {
if config.BlobProvider() != "filesystem" {
return nil
}
root := env("BLOB_FS_ROOT")
if root == "" {
return result(CategoryData, SeverityError, "Blob storage root is unusable",
"BLOB_PROVIDER is filesystem but BLOB_FS_ROOT is not set. Email bodies, attachments and avatars cannot be stored. "+
"The backend, the consumer and every worker on this host must share this path.",
docsStorage)
}
problem := ""
info, err := os.Stat(root)
switch {
case err != nil || !info.IsDir():
problem = "does not exist"
default:
probe, cerr := os.CreateTemp(root, ".warmbly-health-*")
if cerr != nil {
problem = "is not writable"
} else {
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
}
}
if problem == "" {
return nil
}
return result(CategoryData, SeverityError, "Blob storage root is unusable",
fmt.Sprintf("BLOB_FS_ROOT (%s) %s. Email bodies, attachments and avatars cannot be stored. "+
"The backend, the consumer and every worker on this host must share this path.",
filepath.Clean(root), problem),
docsStorage)
}
func checkRedisUnreachable(ctx context.Context, d Deps, in Input) *Finding {
if d.Cache == nil {
return nil
}
if err := d.Cache.Ping(ctx).Err(); err == nil {
return nil
}
return result(CategoryData, SeverityError, "Redis is not reachable",
"Redis is not reachable. Rate limits, the organization key cache and the realtime bridge are all down.",
docsHealthRedis)
}