739 lines
32 KiB
Go
739 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
"felis.lolicon.best/internal/config"
|
|
"felis.lolicon.best/internal/platform"
|
|
"felis.lolicon.best/internal/store"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// `felis breakGlass` is the local break-glass emergency console (spec §B). Its
|
|
// authority is local root, so it legitimately BYPASSES the web Zero-Trust + Passkey
|
|
// path: critical recovery runs direct-to-Postgres. The thin-thread operation it
|
|
// ships here is the one that bootstraps everything else — provision (or reset) the
|
|
// single Owner account and turn local session sign-in on — so that even with the web
|
|
// auth path unconfigured an operator can get into op.console. It is a genuine
|
|
// interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass`
|
|
// opens this full-screen console. It refuses to run unless euid is 0 (sudo/root).
|
|
//
|
|
// Root is necessary but NOT sufficient for accountability: root is machine
|
|
// authority, not a human identity, so the console additionally captures WHO is
|
|
// breaking the glass. When a staff account already exists the operator names one
|
|
// and types the one-time code the console mails to its verified address
|
|
// (breakglass_otp.go); that account is then the accountable actor. When no code can
|
|
// be sent or proven, the typed OVERRIDE proceeds as the OS user and the audit row
|
|
// says why. When no staff account exists yet it bootstraps the first Owner and
|
|
// attributes the act to the OS user. The audit row records which of these
|
|
// happened. This attribution is best-effort, not tamper-proof — whoever runs this
|
|
// is root and can edit Postgres directly — but it produces an honest trail for an
|
|
// honest operator, which is the point.
|
|
//
|
|
// When a staff account already exists the console opens on a thin top-level menu
|
|
// (menuModel) so that operations are peers, not tails of one wizard. Two account
|
|
// operations are wired today: (1) provision/reset the Owner — the thin thread above,
|
|
// which also re-enables local session sign-in — and (2) add an Operator: an
|
|
// insert-only mint of an additional staff admin (provisionOperator) that
|
|
// deliberately never touches the global local_auth toggle. On a fresh machine (no
|
|
// Owner yet) the menu is skipped: bootstrapping the first Owner is the only sensible
|
|
// operation, and adding an Operator first would mint a staff account the login gate
|
|
// still rejects. The remaining ops (halt, sync, S3) land in a later phase as further
|
|
// menu peers.
|
|
//
|
|
// An OPTIONAL Cloudflare Tunnel + Access edge (internal/cfsetup) is offered by the
|
|
// first-run SETUP flow (runSetupTUI / the connection chooser), not by this
|
|
// break-glass menu, though its helpers live in this file. It is kept "锦上添花":
|
|
// reachable WITHOUT touching the Owner credential, supported but never required, and
|
|
// gated entirely on the operator's own Cloudflare account. The edge flow's verifiable
|
|
// logic lives in cfsetup (fail-closed policy, ingress, gating, all unit-tested); what
|
|
// this file adds for it is the untested bubbletea shell plus a `tea.ExecProcess`
|
|
// suspension for the interactive `cloudflared tunnel login` browser consent.
|
|
|
|
// breakGlassOverrideToken is the literal an operator must type to proceed when no
|
|
// admin could be verified by a mailed code. Requiring an explicit, deliberate word (not a
|
|
// bare Enter) keeps the unverified root override from happening by reflex.
|
|
const breakGlassOverrideToken = "OVERRIDE"
|
|
|
|
// ownerStore is the minimal repo surface the break-glass / setup console needs.
|
|
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic
|
|
// (recovery, provisioning, accountability audit) is exercised without a
|
|
// database or a terminal.
|
|
type ownerStore interface {
|
|
// AdminExists reports whether any admin account already exists.
|
|
// It is the bootstrap-vs-recovery switch.
|
|
AdminExists(ctx context.Context) (bool, error)
|
|
// UserByUsername loads a staff login projection.
|
|
UserByUsername(ctx context.Context, username string) (*api.StaffUser, error)
|
|
// OwnerUsername names the single active Owner seat, or "" when none exists.
|
|
// provisionOwner refuses to re-target anything but this username: with the
|
|
// seat occupied, a fresh name would mint a second owner row (UpsertOwner's
|
|
// insert arm) while the existing — possibly compromised — seat stays live,
|
|
// and no supported path can delete an owner row afterwards.
|
|
OwnerUsername(ctx context.Context) (string, error)
|
|
UpsertOwner(ctx context.Context, id, username, email string) error
|
|
// InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is
|
|
// insert-only: a username already taken is a conflict (api.ErrConflict), never a
|
|
// silent reset, so adding an Operator can never clobber the Owner or an existing
|
|
// Operator. The row is role=admin — an Operator is staff BELOW the single
|
|
// role=owner identity (migration 0011 adds that role); the two are the only
|
|
// staff roles.
|
|
InsertOperator(ctx context.Context, id, username, email string) error
|
|
// CompleteOwnerSetup atomically consumes the in-game link code, creates or
|
|
// promotes the bound Owner, enables local auth, and stores the one-time setup
|
|
// token. A failure rolls all four writes back so setup is always retryable.
|
|
CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
|
|
tokenHash string, tokenExpiresAt time.Time) (userID, mcUUID, authSource string, err error)
|
|
SetSetting(ctx context.Context, key string, value []byte) error
|
|
// Audit records the break-glass accountability row.
|
|
Audit(ctx context.Context, e api.AuditEntry) error
|
|
}
|
|
|
|
// cmdBreakGlass is the `felis breakGlass` entrypoint: the root gate, config load,
|
|
// database open, accountability detection, and the interactive TUI. Everything
|
|
// below runBreakGlassTUI is I/O at the edge; the authentication/provisioning/audit
|
|
// logic itself is plain functions over ownerStore so it stays testable off a
|
|
// terminal.
|
|
func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
|
fs := flag.NewFlagSet("breakGlass", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// Root gate. The break-glass console's whole authority model is "you already
|
|
// have local root", so anything less is refused rather than degraded. On
|
|
// non-Unix os.Geteuid() returns -1, which also refuses — correct, since this is
|
|
// a Linux-VM recovery tool.
|
|
if os.Geteuid() != 0 {
|
|
fmt.Fprintln(stderr, "felis breakGlass: refused — the break-glass emergency console must run as root (try: sudo felis breakGlass)")
|
|
return 1
|
|
}
|
|
|
|
cfg, err := config.Load(*cfgPath)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
ctx := context.Background()
|
|
drv, err := store.Open(ctx, cfg.Database.URL)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis breakGlass: open database: %v\n", err)
|
|
return 1
|
|
}
|
|
defer drv.Close()
|
|
|
|
repo := api.NewPGRepo(drv.DB())
|
|
|
|
// Decide bootstrap (no admin yet → typed credential mints the first Owner) vs
|
|
// recovery (an admin exists → the operator proves one with a mailed code) BEFORE the
|
|
// alt-screen TUI takes over, so a database fault surfaces as a plain error.
|
|
adminExists, err := repo.AdminExists(ctx)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis breakGlass: detect existing admin: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Recovery mails its code through [smtp]; the relay is opened only if a code is
|
|
// asked for.
|
|
host, _ := os.Hostname()
|
|
recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, hostSMTPPasswordPath, platform.DefaultControlNamespace), host: host}
|
|
|
|
res, err := runBreakGlassTUI(ctx, repo, cfg.Database, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if !res.provisioned && !res.edgeConfigured && !res.halted && !res.backedUp {
|
|
fmt.Fprintln(stdout, "felis breakGlass: cancelled — no changes made.")
|
|
return 0
|
|
}
|
|
|
|
// The TUI runs on the alternate screen, which is torn down on exit and takes its
|
|
// display with it. Re-print a durable summary to the normal screen so the
|
|
// outcome — and the one-time setup URL — survives in scrollback long
|
|
// enough for the operator to log in.
|
|
if res.provisioned {
|
|
if res.isOperator {
|
|
// Adding an Operator does NOT flip local_auth_enabled (performAddOperator),
|
|
// so the summary must not claim it did — only the Owner thread enables login.
|
|
fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username)
|
|
} else {
|
|
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username)
|
|
}
|
|
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
|
|
if res.mode == "root_override" {
|
|
fmt.Fprintln(stdout, "No admin was proven by an email code; the audit row records this run as an unverified root override and why.")
|
|
}
|
|
if res.setupTokenURL != "" {
|
|
fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
|
|
}
|
|
if res.auditWarning != "" {
|
|
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
|
|
}
|
|
if url := adminLoginURL(res.rootDomain, res.adminHostname); url != "" {
|
|
fmt.Fprintf(stdout, "Admin console: %s\n", url)
|
|
}
|
|
}
|
|
|
|
if res.edgeConfigured {
|
|
// The Cloudflare edge was provisioned. The remaining steps are the operator's
|
|
// own (set the audience, run the tunnel) — we do NOT edit felis.toml for them:
|
|
// editing a config we did not write is a hard-to-reverse change, and the aud is
|
|
// the one value felis-api must adopt to trust the new edge (spec §14).
|
|
fmt.Fprintf(stdout, "\nfelis breakGlass: Cloudflare Tunnel + Access edge configured.\n")
|
|
if len(res.edgeRoutedHosts) > 0 {
|
|
fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", "))
|
|
}
|
|
if res.edgeConfigPath != "" {
|
|
fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath)
|
|
}
|
|
fmt.Fprintf(stdout, "\nACTION REQUIRED — make felis-api trust the edge:\n")
|
|
fmt.Fprintf(stdout, " in %s under [auth], set: access_jwt_aud = %q\n", *cfgPath, res.edgeAud)
|
|
fmt.Fprintln(stdout, "Then start the tunnel: cloudflared tunnel run")
|
|
fmt.Fprintln(stdout, "Verify the Access app actually guards the admin face before relying on it.")
|
|
}
|
|
|
|
if res.halted {
|
|
verb := "is now stopping"
|
|
if res.haltAlreadyStopped {
|
|
verb = "was already stopped"
|
|
}
|
|
fmt.Fprintf(stdout, "\nfelis breakGlass: server %q %s (namespace %s).\n", res.haltServer, verb, res.haltNamespace)
|
|
if res.haltSystemServer {
|
|
// login has no fallback (systemservers.go): stopping it takes the whole proxy
|
|
// front door down, so the summary says so explicitly rather than burying it.
|
|
fmt.Fprintf(stdout, "WARNING: %q is a system server — the shared front door is down until it is running again.\n", res.haltServer)
|
|
}
|
|
if res.haltAuditWarning != "" {
|
|
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.haltAuditWarning)
|
|
}
|
|
fmt.Fprintf(stdout, "Restart it from the panel, or set the MinecraftServer's spec.desiredState back to Running.\n")
|
|
}
|
|
|
|
if res.backedUp {
|
|
// The backup runs through the live felis-api (which audits it), so unlike halt
|
|
// there is no local audit-warning to surface — a resolve/HTTP failure would have
|
|
// come back as an error, not a backedUp result.
|
|
fmt.Fprintf(stdout, "\nfelis breakGlass: backup of %q started (status: %s).\n", res.backupServer, res.backupStatus)
|
|
fmt.Fprintln(stdout, "A one-shot Job writes the archive asynchronously; it appears in the panel's backups list when finished.")
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// accountableOSUser returns the human who escalated to root, best-effort, for the
|
|
// audit trail. sudo sets SUDO_USER to the invoking account; a direct root shell
|
|
// leaves it empty, in which case we record "root". This is attribution, not proof:
|
|
// the environment can be forged, so sudo's own syslog entry — not this value — is
|
|
// the tamper-evident record. The root gate is the real authority gate; this only
|
|
// answers "which human" for an honest operator.
|
|
func accountableOSUser() string {
|
|
if u := strings.TrimSpace(os.Getenv("SUDO_USER")); u != "" {
|
|
return u
|
|
}
|
|
return "root"
|
|
}
|
|
|
|
// newOwnerID returns a fresh, unguessable id for the Owner row. It mirrors the
|
|
// crypto/rand hex idiom used elsewhere (internal/submit, internal/build): 16 bytes
|
|
// give uuid-equivalent entropy and the value is path/argv-safe lowercase hex. A
|
|
// fresh id per run is fine because UpsertOwner preserves the existing id on a
|
|
// username conflict, so a reset keeps live sessions valid.
|
|
func newOwnerID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand only fails on a broken entropy source. Refuse rather than mint a
|
|
// predictable id for a privileged account.
|
|
return ""
|
|
}
|
|
return "usr-" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
|
|
// passwordless. The account is role=owner with no password — the Owner completes
|
|
// passwordless login setup via the web setup-token flow after `felis setup`.
|
|
// With a seat already occupied the reset must name that seat (ownerSeatTakenError
|
|
// otherwise): the upsert's insert arm would silently mint a SECOND owner, and
|
|
// every owner row is undeletable through the panel, so the tier could never
|
|
// converge back to one.
|
|
func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
return errors.New("owner username is required")
|
|
}
|
|
if seat, err := s.OwnerUsername(ctx); err != nil {
|
|
return fmt.Errorf("check the owner seat: %w", err)
|
|
} else if seat != "" && seat != username {
|
|
return &ownerSeatTakenError{seat: seat}
|
|
}
|
|
id := newOwnerID()
|
|
if id == "" {
|
|
return errors.New("generate owner id: entropy source failed")
|
|
}
|
|
if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email)); err != nil {
|
|
return fmt.Errorf("write owner: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ownerSeatTakenError refuses an Owner reset that names anything but the
|
|
// occupied seat, naming it so the operator can retype. Is reports
|
|
// api.ErrConflict so the TUI's recoverable-error branch (shared with the
|
|
// operator path's taken-name clash) routes back to the form instead of ending
|
|
// the console.
|
|
type ownerSeatTakenError struct{ seat string }
|
|
|
|
func (e *ownerSeatTakenError) Error() string {
|
|
return fmt.Sprintf("an Owner already exists as %q — enter that username to reset the Owner", e.seat)
|
|
}
|
|
|
|
func (e *ownerSeatTakenError) Is(target error) bool { return target == api.ErrConflict }
|
|
|
|
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. It is
|
|
// role=admin and passwordless — an additional staff admin below the single
|
|
// role=owner identity (migrations 0003 + 0011). UNLIKE provisionOwner, which
|
|
// upserts the single Owner and resets it on a username conflict, this is
|
|
// insert-only: a username already taken returns api.ErrConflict rather than
|
|
// overwriting a live account, so adding an Operator can never silently clobber
|
|
// the Owner's or another Operator's account.
|
|
func provisionOperator(ctx context.Context, s ownerStore, username, email string) error {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
return errors.New("operator username is required")
|
|
}
|
|
id := newOwnerID()
|
|
if id == "" {
|
|
return errors.New("generate operator id: entropy source failed")
|
|
}
|
|
if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email)); err != nil {
|
|
if errors.Is(err, api.ErrConflict) {
|
|
// Wrap %w so errors.Is(err, api.ErrConflict) still holds — the TUI can render
|
|
// a "name already taken" message — while keeping a clear human string.
|
|
return fmt.Errorf("operator %q already exists: %w", username, err)
|
|
}
|
|
return fmt.Errorf("write operator: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// enableLocalAuth flips the runtime local_auth_enabled toggle on
|
|
// direct-to-Postgres. It is a load-bearing write of break-glass: without it every
|
|
// session-minting door (passkey / email-OTP / bind / op-login) returns 403
|
|
// local_auth_disabled and the freshly provisioned Owner cannot log in, so a
|
|
// successful provisionOwner with local auth off is not a usable thin thread.
|
|
func enableLocalAuth(ctx context.Context, s ownerStore) error {
|
|
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
|
|
// value must be the literal true.
|
|
if err := s.SetSetting(ctx, api.LocalAuthEnabledKey, []byte("true")); err != nil {
|
|
return fmt.Errorf("enable local auth: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// breakGlassOp is a fully-resolved operation the TUI hands to the core once the
|
|
// operator has been identified (bootstrap) or authenticated (recovery / override).
|
|
type breakGlassOp struct {
|
|
mode string // "bootstrap" | "recovery" | "root_override"
|
|
accountable string // recorded as the audit actor (verified admin, or OS user)
|
|
osUser string // $SUDO_USER (or "root"); recorded in the payload
|
|
ownerUsername string
|
|
ownerEmail string
|
|
attemptedAdmin string // recovery / override: the admin username the operator typed
|
|
verifiedBy string // recovery: how the admin was proven (verifiedByEmailOTP)
|
|
codeSentTo string // recovery: the address the proving code went to
|
|
otpSkipped string // root_override: why no code proved an admin (otpSkip*)
|
|
otpSkipDetail string // root_override: what failed, when something did
|
|
}
|
|
|
|
// breakGlassOutcome is what performBreakGlass reports back to the TUI.
|
|
type breakGlassOutcome struct {
|
|
setupTokenURL string // non-empty when setup minted a one-time first-login URL
|
|
ownerIdentity string // verified Minecraft UUID for the setup Owner-bind path
|
|
auditErr error // non-nil if the accountability row could not be written
|
|
}
|
|
|
|
// performBreakGlass executes a resolved break-glass operation: provision (or reset)
|
|
// the Owner, enable local session sign-in, then record a best-effort accountability
|
|
// audit row. The Owner is passwordless — the setup-token flow handles first-login
|
|
// setup. The audit write is best-effort: a logging failure is reported via auditErr
|
|
// but does NOT fail the recovery — break-glass must still work when the audit sink
|
|
// is unhappy.
|
|
func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
|
|
if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
|
|
return breakGlassOutcome{}, err
|
|
}
|
|
// Record accountability the instant the account is written — BEFORE enabling
|
|
// local auth, which can still fail. The audit is best-effort, so doing it first
|
|
// never blocks the recovery.
|
|
out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)}
|
|
if err := enableLocalAuth(ctx, s); err != nil {
|
|
return breakGlassOutcome{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// setupTokenTTL bounds how long a one-time setup URL is valid. The operator opens
|
|
// it right after setup completes, so a generous-but-bounded window is enough.
|
|
const setupTokenTTL = 30 * time.Minute
|
|
|
|
// newSetupToken returns a fresh opaque setup token (256 bits, URL-safe) and its
|
|
// sha-256 hex hash. Only the hash is persisted; the raw value rides in the URL.
|
|
func newSetupToken() (raw, hash string, err error) {
|
|
var b [32]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", "", fmt.Errorf("generate setup token: %w", err)
|
|
}
|
|
raw = base64.RawURLEncoding.EncodeToString(b[:])
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return raw, hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
|
|
// binds their Minecraft account via a one-time link code the login gate handed
|
|
// them in-game, the bound user is promoted to role='owner' (passwordless Owner),
|
|
// local auth is enabled, and a one-time setup URL is minted for the first web
|
|
// login where the Owner verifies email / enrolls a passkey. adminHostname is the
|
|
// operator-console host the URL points at (op.console.<root>): the Owner is staff,
|
|
// so first-run onboarding belongs on the operator face, not the player panel. The
|
|
// passkey verifier's RP id is the panel host, but its permitted origins now include
|
|
// op.console (cmd/felis/api.go), so enrollment on op.console is a valid ceremony —
|
|
// one binding that works on both faces. osUser is recorded as the accountable actor.
|
|
//
|
|
// Local auth is as load-bearing here as it is in break-glass, and for a sharper
|
|
// reason: an MC-bound Owner has no password AND no email, so the setup token is
|
|
// their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth
|
|
// toggle, and token together; any failed write leaves the link code retryable.
|
|
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) {
|
|
code = strings.TrimSpace(strings.ToUpper(code))
|
|
if code == "" {
|
|
return breakGlassOutcome{}, errors.New("link code is required")
|
|
}
|
|
newID := newOwnerID()
|
|
if newID == "" {
|
|
return breakGlassOutcome{}, errors.New("generate owner id: entropy source failed")
|
|
}
|
|
raw, hash, err := newSetupToken()
|
|
if err != nil {
|
|
return breakGlassOutcome{}, err
|
|
}
|
|
now := time.Now()
|
|
_, mcUUID, authSource, err := s.CompleteOwnerSetup(
|
|
ctx, newID, code, now, hash, now.Add(setupTokenTTL))
|
|
if err != nil {
|
|
return breakGlassOutcome{}, fmt.Errorf("complete owner setup: %w", err)
|
|
}
|
|
// The load-bearing writes committed together above. Accountability remains
|
|
// best-effort: an unhappy audit sink never costs the operator their install.
|
|
out := breakGlassOutcome{
|
|
ownerIdentity: mcUUID,
|
|
auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
|
|
}
|
|
host := strings.TrimSpace(adminHostname)
|
|
if host == "" {
|
|
host = "op.console.localhost"
|
|
}
|
|
out.setupTokenURL = "https://" + host + "/setup?token=" + raw
|
|
return out, nil
|
|
}
|
|
|
|
// auditSetupMCBind records who claimed the Owner seat at setup. It carries the
|
|
// Minecraft identity rather than a username because that IS the evidence: the
|
|
// login gate only issues a link code to a player it authenticated, so mc_uuid +
|
|
// auth_source say which account was verified and by whom. Actor is the OS user who
|
|
// ran `felis setup` — honest attribution, not proof (root can edit the row).
|
|
func auditSetupMCBind(ctx context.Context, s ownerStore, osUser, mcUUID, authSource string) error {
|
|
blob, err := json.Marshal(map[string]any{
|
|
"mode": "setup",
|
|
"os_user": osUser,
|
|
"mc_uuid": mcUUID,
|
|
"auth_source": authSource,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Audit(ctx, api.AuditEntry{
|
|
Actor: osUser,
|
|
Source: "setup",
|
|
Action: "setup.owner_bind",
|
|
Payload: blob,
|
|
})
|
|
}
|
|
|
|
// auditBreakGlass writes the break-glass accountability row. The actor is the
|
|
// resolved human identity (a verified admin in recovery, the OS user otherwise);
|
|
// the payload carries the full who/what/how so an after-the-fact reader can tell a
|
|
// verified recovery from an unverified root override. It is best-effort — the caller
|
|
// does not fail the recovery if this write fails — and intentionally honest: it
|
|
// records attribution, it does not prove it (a malicious root can edit the row).
|
|
func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
|
blob, err := json.Marshal(breakGlassPayload(op, "owner"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Audit(ctx, api.AuditEntry{
|
|
Actor: op.accountable,
|
|
Source: "break-glass",
|
|
Action: "break_glass." + op.mode,
|
|
Payload: blob,
|
|
})
|
|
}
|
|
|
|
// breakGlassPayload is the who/how both account audits carry, with the account the
|
|
// run wrote under subjectKey. verified is true only for a run a mailed code proved;
|
|
// such a run names the address the code went to, and an override names why no code
|
|
// proved anyone.
|
|
func breakGlassPayload(op breakGlassOp, subjectKey string) map[string]any {
|
|
payload := map[string]any{
|
|
"mode": op.mode,
|
|
subjectKey: op.ownerUsername,
|
|
"os_user": op.osUser,
|
|
"verified": op.verifiedBy != "",
|
|
}
|
|
if op.attemptedAdmin != "" {
|
|
payload["admin_account"] = op.attemptedAdmin
|
|
}
|
|
if op.verifiedBy != "" {
|
|
payload["verified_by"] = op.verifiedBy
|
|
payload["code_sent_to"] = op.codeSentTo
|
|
}
|
|
if op.otpSkipped != "" {
|
|
payload["otp_skipped"] = op.otpSkipped
|
|
if op.otpSkipDetail != "" {
|
|
payload["otp_skip_detail"] = op.otpSkipDetail
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// performAddOperator mints a NEW Operator account and records a best-effort
|
|
// accountability row. It mirrors performBreakGlass — passwordless — with two
|
|
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it
|
|
// can never reset an existing account the way the Owner upsert does. (2) It does NOT
|
|
// touch local_auth_enabled: adding an Operator presupposes an already-configured,
|
|
// running system (an admin is present to authorize it), so flipping the global auth
|
|
// gate as a side effect of "add a user" would be surprising — that toggle belongs to
|
|
// the Owner break-glass thread alone. The audit is best-effort and written only after
|
|
// a successful provision; a conflict mints nothing, so there is nothing to attribute.
|
|
func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
|
|
if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
|
|
return breakGlassOutcome{}, err
|
|
}
|
|
out := breakGlassOutcome{auditErr: auditAddOperator(ctx, s, op)}
|
|
return out, nil
|
|
}
|
|
|
|
// auditAddOperator writes the operator-creation accountability row. It mirrors
|
|
// auditBreakGlass — same actor/source/payload shape, so one reader can tell a
|
|
// verified (recovery) add from an unverified (root_override) one — under the distinct
|
|
// break_glass.operator_create action, naming the new account under an "operator" key
|
|
// rather than "owner".
|
|
func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
|
blob, err := json.Marshal(breakGlassPayload(op, "operator"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Audit(ctx, api.AuditEntry{
|
|
Actor: op.accountable,
|
|
Source: "break-glass",
|
|
Action: "break_glass.operator_create",
|
|
Payload: blob,
|
|
})
|
|
}
|
|
|
|
// breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable
|
|
// post-exit summary. provisioned is false on cancel.
|
|
type breakGlassResult struct {
|
|
provisioned bool
|
|
// alreadySetUp marks the re-run landing (the status screen): setup ran, found an
|
|
// Owner, and deliberately changed nothing. Without it a re-run is indistinguishable
|
|
// from a cancel and reports itself as one.
|
|
alreadySetUp bool
|
|
isOperator bool // an Operator was added rather than the Owner provisioned
|
|
ownerSkipped bool // setup's Owner step was skipped; no Owner is bound
|
|
mode string
|
|
accountable string
|
|
osUser string
|
|
username string
|
|
setupTokenURL string // non-empty when setup minted a one-time first-login URL
|
|
auditWarning string
|
|
rootDomain string
|
|
adminHostname string
|
|
panelURL string
|
|
|
|
// connection outcome (independent of provisioned)
|
|
connectMethod connectMethod
|
|
connectConfigured bool
|
|
panelHostname string
|
|
reverseProxyGuide string
|
|
|
|
// storage backend outcome
|
|
storageMethod storageMethod
|
|
storageDetail string
|
|
|
|
// halt outcome (break-glass "halt a server" op #31)
|
|
halted bool
|
|
haltServer string
|
|
haltNamespace string
|
|
haltAlreadyStopped bool
|
|
haltSystemServer bool
|
|
haltAuditWarning string
|
|
|
|
// backup outcome (break-glass "back up a world now / Sync" op #31 §B4)
|
|
backedUp bool
|
|
backupServer string
|
|
backupStatus string
|
|
|
|
// Cloudflare-specific edge detail (set only when connectMethod is Cloudflare)
|
|
edgeConfigured bool
|
|
edgeAud string
|
|
edgeRoutedHosts []string
|
|
edgeConfigPath string
|
|
}
|
|
|
|
type consoleMode string
|
|
|
|
const (
|
|
consoleModeBreakGlass consoleMode = "breakGlass"
|
|
consoleModeSetup consoleMode = "setup"
|
|
)
|
|
|
|
const (
|
|
defaultTunnelName = "felis"
|
|
defaultTunnelConfigPath = "/etc/felis/cloudflared.yml"
|
|
|
|
// Cloudflare Dashboard prefill URL for the API token this flow actually uses:
|
|
// Access app/policy management. Tunnel creation and DNS routing are authorized by
|
|
// the operator's cloudflared browser login, not by this token.
|
|
cloudflareAccessTokenTemplateURL = "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22access%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=*&zoneId=all"
|
|
cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/"
|
|
)
|
|
|
|
func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, recovery recoveryConfig) (breakGlassResult, error) {
|
|
return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
|
|
}
|
|
|
|
// runSetupTUI never reaches recovery: setup with a staff account present lands on
|
|
// the status screen, so it has no relay to hand over.
|
|
// gameAddr is where the Owner step tells the operator to join (setupGameAddress).
|
|
func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, gameAddr string, adminExists bool) (breakGlassResult, error) {
|
|
rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
|
|
rm.gameAddr = gameAddr
|
|
return runConsoleRoot(rm)
|
|
}
|
|
|
|
// newConsoleRoot is the console's root model as the host runs it: the summary
|
|
// reads this host's alert route.
|
|
func newConsoleRoot(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) *rootModel {
|
|
rm := newRootModel(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
|
|
rm.recovery = recovery
|
|
rm.alertRoute = func(ctx context.Context) alertRoute {
|
|
return hostAlertRoute(ctx, hostSetupConfigPath, db.URL, defaultHeartbeatFile)
|
|
}
|
|
return rm
|
|
}
|
|
|
|
func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) {
|
|
return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery))
|
|
}
|
|
|
|
func runConsoleRoot(rm *rootModel) (breakGlassResult, error) {
|
|
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
|
if err != nil {
|
|
return breakGlassResult{}, err
|
|
}
|
|
root, ok := final.(*rootModel)
|
|
if !ok {
|
|
return breakGlassResult{}, errors.New("unexpected final model type")
|
|
}
|
|
if root.err != nil {
|
|
return breakGlassResult{}, root.err
|
|
}
|
|
return root.result, nil
|
|
}
|
|
|
|
func defaultPanelHostname(rootDomain, configured string) string {
|
|
if h := strings.TrimSpace(configured); h != "" {
|
|
return h
|
|
}
|
|
if rootDomain != "" {
|
|
return "console." + rootDomain
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func defaultAdminHostname(rootDomain, configured string) string {
|
|
if h := strings.TrimSpace(configured); h != "" {
|
|
return h
|
|
}
|
|
if rootDomain != "" {
|
|
return "op.console." + rootDomain
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeEdgeHostname(s string) string {
|
|
return strings.Trim(strings.TrimSpace(s), ".")
|
|
}
|
|
|
|
func validateEdgeHostname(label, host string, required bool) error {
|
|
if host == "" {
|
|
if required {
|
|
return fmt.Errorf("%s is required", label)
|
|
}
|
|
return nil
|
|
}
|
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/\\ \t\r\n") {
|
|
return fmt.Errorf("%s must be a hostname, not a URL", label)
|
|
}
|
|
if strings.Contains(host, ":") {
|
|
return fmt.Errorf("%s must not include a port", label)
|
|
}
|
|
if strings.HasPrefix(host, ".") {
|
|
return fmt.Errorf("%s must not start with a dot", label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isHex32(s string) bool {
|
|
if len(s) != 32 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func adminLoginURL(rootDomain, adminHostname string) string {
|
|
if h := strings.TrimSpace(adminHostname); h != "" {
|
|
return "https://" + h
|
|
}
|
|
if rootDomain != "" {
|
|
return "https://op.console." + rootDomain
|
|
}
|
|
return ""
|
|
}
|