Files
warmbly/cmd/backend/boot.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

132 lines
4.9 KiB
Go

package main
import (
"context"
"log"
"net/url"
"os"
"sort"
"strings"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/notify"
)
// insecureDefaults are the working secret values shipped in docker-compose.yml
// and the Makefile so `docker compose up` boots with no .env. They are public
// in this repository, so a deployment still running on one is not protected by
// it at all: a forged AUTH_SECRET JWT is accepted by the realtime service, and
// the local KMS master key unwraps every organization DEK offline.
//
// The map is keyed by env var so the check is exact rather than a heuristic.
var insecureDefaults = map[string]string{
"AUTH_SECRET": "local-dev-auth-secret-minimum-32-characters-long",
"KMS_LOCAL_MASTER_KEY": "Xr0JA7gqF2POy29a7MRByyqddivTNt8WOyKsOXklazk=",
"CREDENTIALS_ENCRYPTION_KEY": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"INTERNAL_API_TOKEN": "local-dev-internal-token",
"SECRET_KEY_BASE": "local-development-secret-key-base-minimum-64-characters-for-phoenix",
}
// checkSecrets refuses to start a deployment that reaches the network while
// still using a published default secret. Local development is exempt: that is
// what the defaults exist for.
//
// ALLOW_INSECURE_DEFAULTS=true downgrades the refusal to a warning, for an
// operator who genuinely wants a throwaway instance on a trusted LAN.
// It returns the offenders so the health page can keep reporting them for as
// long as they are in use, not just once at boot.
func checkSecrets() []string {
var offenders []string
for env, def := range insecureDefaults {
if os.Getenv(env) == def {
offenders = append(offenders, env)
}
}
if len(offenders) == 0 {
return nil
}
sort.Strings(offenders)
list := strings.Join(offenders, ", ")
if isDevEnv() || strings.EqualFold(os.Getenv("ALLOW_INSECURE_DEFAULTS"), "true") {
log.Printf("Warning: using the published default value for %s. Anyone can read these from the Warmbly repository. Generate real values before this instance is reachable by other people (make gen-key).", list)
return offenders
}
log.Fatalf("Refusing to start: %s still hold the published default value from docker-compose.yml. "+
"Those defaults are public, so they provide no protection. Generate real values (make gen-key) and put them in .env, "+
"or set ALLOW_INSECURE_DEFAULTS=true if this instance is genuinely disposable.", list)
return offenders
}
func isDevEnv() bool {
env := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
return env == "" || env == "dev" || env == "development" || env == "local"
}
// passkeysUsableFor reports whether WebAuthn can work on this deployment's
// origin. Passkeys require a secure context and an RP ID that is a real
// domain, so a plain-http LAN address fails in the browser with an opaque
// error. Detecting it here lets GET /auth/config hide the button instead.
func passkeysUsableFor(appURL string) bool {
if appURL == "" {
return false
}
u, err := url.Parse(appURL)
if err != nil {
return false
}
if u.Scheme == "https" {
return true
}
// http is a secure context only on loopback.
host := u.Hostname()
return host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasSuffix(host, ".localhost")
}
// oidcRedirectURL is where the provider sends the browser back. Explicit
// OIDC_REDIRECT_URL wins; otherwise it derives from the backend's public base,
// which is where the callback handler actually lives.
func oidcRedirectURL() string {
if v := strings.TrimSpace(os.Getenv("OIDC_REDIRECT_URL")); v != "" {
return v
}
base := strings.TrimRight(os.Getenv("API_PUBLIC_URL"), "/")
if base == "" {
return ""
}
// The route is registered on /v1, not /api/v1: there is no /api prefix.
return base + "/v1/auth/oidc/callback"
}
// splitList parses a comma-separated env list.
func splitList(v string) []string {
out := []string{}
for _, part := range strings.Split(v, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func mailTransportKind(t *notify.Transport) string {
if t == nil {
return ""
}
return t.Kind
}
// warnDeploymentURLs surfaces the configuration mistakes that silently break
// auth once an operator moves off localhost, each of which previously appeared
// only as a failure in the browser.
func warnDeploymentURLs(ctx context.Context, appURL string) {
if appURL == "" {
log.Printf("Warning: APP_URL is not set. Password reset and team invitation emails will link to %s, which is almost certainly not this deployment.", config.AppBaseURL())
return
}
if !passkeysUsableFor(appURL) {
log.Printf("Warning: APP_URL is %s. Passkeys need a secure context, so they are disabled: browsers refuse WebAuthn on plain http outside localhost, and an IP address cannot be a relying-party ID. Put the dashboard behind HTTPS to enable them.", appURL)
}
}