package main import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "os" "os/exec" "strings" "felis.lolicon.best/internal/api" "felis.lolicon.best/internal/cfsetup" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/store" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "golang.org/x/crypto/bcrypt" ) // `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-password login 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 it asks the operator to // authenticate as an existing admin (the verified identity is the accountable // actor); when none exists yet it bootstraps the first Owner from the typed // credential and attributes the act to the OS user. The audit row records the // difference. 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. // // The console opens on a thin top-level router (stepMenu) so that operations // are peers, not tails of one wizard. Two are wired today: (1) provision/reset // the Owner — the thin thread above — and (2) an OPTIONAL Cloudflare Tunnel + // Access edge (internal/cfsetup), kept "锦上添花": it is reachable WITHOUT touching // the Owner credential, supported but never required, and gated entirely on the // operator's own Cloudflare account. The remaining ops (halt, sync, S3) land in a // later phase as further menu peers. 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 credential could be verified. Requiring an explicit, deliberate word (not a // bare Enter) keeps the unverified root override from happening by reflex. const breakGlassOverrideToken = "OVERRIDE" // bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a // human can transcribe a generated one-time password off a terminal without error. const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" // ownerStore is the minimal repo surface the break-glass console needs. // *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic // (authentication, provisioning, accountability audit) is exercised without a // database or a terminal. type ownerStore interface { // AdminExists reports whether any authenticatable staff account already exists. // It is the bootstrap-vs-recovery switch. AdminExists(ctx context.Context) (bool, error) // UserByUsername loads a staff login projection for credential verification. UserByUsername(ctx context.Context, username string) (*api.StaffUser, error) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) 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 must authenticate as one) 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 } res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 } if !res.provisioned && !res.edgeConfigured { 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 any generated one-time password — survives in scrollback long // enough for the operator to log in. if res.provisioned { fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login 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.displayPassword != "" { // A one-time password was generated (recovery / root override). It is shown, // never persisted: only the bcrypt hash reached the database. fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword) } else { // Bootstrap: the operator typed the password themselves, so we do NOT echo it // back into scrollback. fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).") } 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, "Log in at %s with that username and password.\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.") } 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[:]) } // generateBootstrapPassword returns a fresh one-time password from the unambiguous // alphabet. It rejection-samples to avoid modulo bias, so every position is uniform // over the alphabet. 20 chars over a 57-symbol alphabet is ~116 bits — far more than // the must-change credential needs, and it is rotated on first login regardless. func generateBootstrapPassword() (string, error) { const n = 20 // Largest multiple of the alphabet size that fits in a byte; bytes at or above it // are discarded so the surviving values map uniformly (no modulo bias). limit := byte(256 - (256 % len(bootstrapPasswordAlphabet))) out := make([]byte, 0, n) var b [1]byte for len(out) < n { if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("generate bootstrap password: %w", err) } if b[0] >= limit { continue } out = append(out, bootstrapPasswordAlphabet[int(b[0])%len(bootstrapPasswordAlphabet)]) } return string(out), nil } // validateOwnerPassword mirrors api.validateNewPassword (handlers_auth.go): a // break-glass credential must satisfy the SAME 8–72-byte rule the panel's own // change-password enforces, so an operator can never set a password here that the // web change-password flow would later reject. 72 is bcrypt's hard input limit. func validateOwnerPassword(pw string) error { if len(pw) < 8 { return errors.New("password must be at least 8 characters") } if len(pw) > 72 { return errors.New("password must be at most 72 bytes") } return nil } // authenticateAdmin verifies a typed credential against an existing admin account // for recovery-mode attribution. matched is the stored username on success. // // ok==false with err==nil is NOT a failure to surface — it means the credential did // not match any admin password. The caller offers an explicit root override instead // of refusing, because break-glass must still recover when no admin credential can // be produced (a forgotten password is the canonical reason the web login is // unreachable in the first place). Only a real datastore fault returns err. func authenticateAdmin(ctx context.Context, s ownerStore, username, password string) (matched string, ok bool, err error) { username = strings.TrimSpace(username) if username == "" || password == "" { return "", false, nil } u, err := s.UserByUsername(ctx, username) if errors.Is(err, api.ErrNotFound) { return "", false, nil } if err != nil { return "", false, err } // Only an admin row carrying a bcrypt hash is an authenticatable staff identity; // a player row (role=user, hash NULL → empty PasswordHash) can never attribute a // break-glass action. if u.Role != "admin" || u.PasswordHash == "" { return "", false, nil } if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { return "", false, nil } return u.Username, true, nil } // provisionOwner mints or resets the single Owner account direct-to-Postgres with // the given (already-validated-by-the-caller) password. The account is created with // must_change_password=true, which is load-bearing: it is what arms the API's // lockdown middleware so the Owner can do nothing but change the password on first // login. Only the bcrypt hash reaches the database; the plaintext never does. func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error { username = strings.TrimSpace(username) if username == "" { return errors.New("owner username is required") } if err := validateOwnerPassword(password); err != nil { return err } id := newOwnerID() if id == "" { return errors.New("generate owner id: entropy source failed") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash owner password: %w", err) } if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil { return fmt.Errorf("write owner: %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 // handleLogin returns 403 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 ownerPassword string // typed (bootstrap); "" => generate a one-time password attemptedAdmin string // recovery / override: the admin username the operator typed } // breakGlassOutcome is what performBreakGlass reports back to the TUI. type breakGlassOutcome struct { displayPassword string // non-empty only when a one-time password was generated 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-password login, then record a best-effort accountability // audit row. A typed ownerPassword (bootstrap) is used as-is; an empty one (recovery // / root override) is replaced with a generated one-time password returned for // one-time display. 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) { password := op.ownerPassword generated := false if password == "" { p, err := generateBootstrapPassword() if err != nil { return breakGlassOutcome{}, err } password, generated = p, true } if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil { return breakGlassOutcome{}, err } // Record accountability the instant the credential changes — BEFORE enabling // local auth, which can still fail. Auditing only after both writes would let a // failed enableLocalAuth leave a just-reset credential with no "who did it" row; // the audit is best-effort, so doing it first never blocks the recovery. out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)} if generated { out.displayPassword = password } if err := enableLocalAuth(ctx, s); err != nil { return breakGlassOutcome{}, err } return out, nil } // 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 { payload := map[string]any{ "mode": op.mode, "owner": op.ownerUsername, "os_user": op.osUser, "verified": op.mode == "recovery", } if op.attemptedAdmin != "" { payload["admin_account"] = op.attemptedAdmin } blob, err := json.Marshal(payload) if err != nil { return err } return s.Audit(ctx, api.AuditEntry{ Actor: op.accountable, Source: "break-glass", Action: "break_glass." + op.mode, Payload: blob, }) } // ---- interactive TUI (the untested shell over the tested core) ---- // 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 mode string accountable string osUser string username string displayPassword string // empty when the operator typed their own bootstrap password auditWarning string rootDomain string adminHostname string // optional Cloudflare edge outcome (independent of provisioned) edgeConfigured bool edgeAud string edgeRoutedHosts []string edgeConfigPath string edgePanelHostname string edgeAdminHostname string } type consoleMode string const ( consoleModeBreakGlass consoleMode = "breakGlass" consoleModeSetup consoleMode = "setup" ) type bgStep int const ( stepMenu bgStep = iota // top-level router: provision vs. optional edge stepAuth // recovery: authenticate as an existing admin stepOverride // recovery: admin auth failed → deliberate root override stepProvision // collect the Owner target (and password, in bootstrap) stepWorking stepDone stepError // optional Cloudflare edge flow (锦上添花) stepEdgeIntro // preconditions + interactive `cloudflared tunnel login` stepEdgeInput // API token / account / who-to-admit / tunnel name / config path stepEdgeWorking // cfsetup.Setup runs against the operator's Cloudflare account stepEdgeDone stepEdgeError ) // Edge-flow defaults the operator can accept as-is. 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/" ) // authResultMsg carries the outcome of the off-goroutine admin credential check. type authResultMsg struct { matched string ok bool err error } // performedMsg carries the outcome of the off-goroutine break-glass writes. type performedMsg struct { outcome breakGlassOutcome err error } // loginDoneMsg fires when the suspended `cloudflared tunnel login` returns. A // non-nil err (or a cancelled login) returns to the intro, never an error exit — // the operator may simply have closed the browser. type loginDoneMsg struct { err error } // edgeDoneMsg carries the outcome of the off-goroutine cfsetup.Setup run. type edgeDoneMsg struct { result *cfsetup.Result err error } var ( bgTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("88")).Padding(0, 1) bgLabelStyle = lipgloss.NewStyle().Bold(true) bgHintStyle = lipgloss.NewStyle().Faint(true) bgErrStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) bgWarnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) bgOKStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")) bgPwStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(lipgloss.Color("11")).Padding(0, 1) bgBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3) ) // bgModel is the bubbletea model for the break-glass console. It is a pointer model // so Update can mutate in place; fields touched from a background command are read // only after that command returns via authResultMsg / performedMsg. type bgModel struct { ctx context.Context store ownerStore consoleMode consoleMode rootDomain string osUser string adminExists bool // web hostnames sourced from [auth] config (never re-derived in the shell) — // what the optional edge flow routes. adminHostname is required for the edge; // panelHostname is optional. adminHostname string panelHostname string step bgStep inputs []textinput.Model focus int formErr string working string // resolved as the flow advances mode string accountable string attemptedAdmin string // result ownerUsername string displayPassword string auditWarning string err error // optional Cloudflare edge flow cloudflaredPath string // detected; empty = not on PATH certExists bool // ~/.cloudflared/cert.pem present (logged in) loginNote string // soft note after a cancelled/failed login edgeResult *cfsetup.Result // populated on stepEdgeDone edgePanelHostname string edgeAdminHostname string } func newBGModel(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) *bgModel { return newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass) } func newSetupBGModel(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) *bgModel { return newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup) } func newBGModelForMode(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *bgModel { return &bgModel{ ctx: ctx, store: s, consoleMode: mode, rootDomain: rootDomain, adminHostname: adminHostname, panelHostname: panelHostname, osUser: osUser, adminExists: adminExists, step: stepMenu, } } func (m *bgModel) Init() tea.Cmd { return textinput.Blink } // bgInput builds a styled text input; password fields echo a mask, never the glyphs, // because this is typed on a shared root console. func bgInput(placeholder string, charLimit int, password bool) textinput.Model { ti := textinput.New() ti.Placeholder = placeholder ti.CharLimit = charLimit ti.Width = 44 ti.Prompt = "" if password { ti.EchoMode = textinput.EchoPassword ti.EchoCharacter = '•' } return ti } // setInputs installs a fresh input set, focuses the first, and returns its blink cmd. func (m *bgModel) setInputs(ins []textinput.Model) tea.Cmd { m.inputs = ins m.focus = 0 var cmd tea.Cmd for i := range m.inputs { if i == 0 { cmd = m.inputs[i].Focus() } else { m.inputs[i].Blur() } } return cmd } func (m *bgModel) buildAuth() tea.Cmd { user := bgInput("admin username", 64, false) pass := bgInput("admin password", 128, true) return m.setInputs([]textinput.Model{user, pass}) } func (m *bgModel) buildOverride() tea.Cmd { confirm := bgInput("type "+breakGlassOverrideToken, 16, false) return m.setInputs([]textinput.Model{confirm}) } // buildProvision installs the Owner-target inputs. withPassword adds the password + // confirm fields used only in bootstrap mode; in recovery/override a one-time // password is generated, so the operator does not type one. func (m *bgModel) buildProvision(withPassword bool) tea.Cmd { user := bgInput("owner", 64, false) user.SetValue("owner") email := bgInput("(optional)", 254, false) ins := []textinput.Model{user, email} if withPassword { ins = append(ins, bgInput("at least 8 characters", 128, true)) ins = append(ins, bgInput("re-enter password", 128, true)) } return m.setInputs(ins) } func (m *bgModel) enterProvision() tea.Cmd { m.step = stepProvision m.formErr = "" return m.buildProvision(m.mode == "bootstrap") } // enterProvisionFlow is the menu entry into the Owner provision/reset op. It takes // the same bootstrap-vs-recovery branch newBGModel used to take at construction: // no admin → bootstrap the first Owner from a typed credential; an admin exists → // authenticate first so the recovery is attributable. func (m *bgModel) enterProvisionFlow() tea.Cmd { m.formErr = "" if m.adminExists { m.step = stepAuth return m.buildAuth() } m.mode = "bootstrap" m.accountable = m.osUser m.step = stepProvision return m.buildProvision(true) } func (m *bgModel) focusInput(i int) tea.Cmd { if i < 0 { i = len(m.inputs) - 1 } if i >= len(m.inputs) { i = 0 } m.focus = i var cmd tea.Cmd for j := range m.inputs { if j == i { cmd = m.inputs[j].Focus() } else { m.inputs[j].Blur() } } return cmd } func (m *bgModel) updateInputs(msg tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } return tea.Batch(cmds...) } func (m *bgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case authResultMsg: if msg.err != nil { m.step, m.err = stepError, msg.err return m, nil } if msg.ok { // Verified: this admin is the accountable identity for the recovery. m.mode = "recovery" m.accountable = msg.matched return m, m.enterProvision() } // The credential did not verify. Do NOT refuse — break-glass must still // recover when no admin password can be produced. Offer a deliberate root // override, attributed to the OS user and audited as unverified. m.step = stepOverride return m, m.buildOverride() case performedMsg: if msg.err != nil { m.step, m.err = stepError, msg.err return m, nil } m.step = stepDone m.displayPassword = msg.outcome.displayPassword if msg.outcome.auditErr != nil { m.auditWarning = msg.outcome.auditErr.Error() } return m, nil case loginDoneMsg: // `cloudflared tunnel login` returned. Re-detect to pick up a freshly written // cert.pem; a cancelled/failed login is NOT fatal — it just lands back on the // intro with a note, because the operator may have closed the browser. pre := cfsetup.DetectPreconditions("") m.cloudflaredPath = pre.CloudflaredPath m.certExists = pre.CertExists switch { case msg.err != nil && !m.certExists: m.loginNote = "cloudflared login did not complete: " + msg.err.Error() case !m.certExists: m.loginNote = "login finished but ~/.cloudflared/cert.pem still not found — try again" default: m.loginNote = "" } m.step = stepEdgeIntro return m, nil case edgeDoneMsg: if msg.err != nil { m.step, m.err = stepEdgeError, msg.err return m, nil } m.step = stepEdgeDone m.edgeResult = msg.result return m, nil case tea.KeyMsg: switch m.step { case stepMenu: return m.handleMenuKey(msg) case stepEdgeIntro: return m.handleEdgeIntroKey(msg) case stepDone, stepError, stepEdgeDone, stepEdgeError: // Any key dismisses the terminal screen. return m, tea.Quit case stepWorking, stepEdgeWorking: // Ignore input while a write / setup is in flight. return m, nil default: return m.handleFormKey(msg) } } return m, m.updateInputs(msg) } func (m *bgModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": if m.step == stepOverride { // Back out of the override to re-enter the admin credential. m.step, m.formErr = stepAuth, "" return m, m.buildAuth() } if m.step == stepEdgeInput { // Back to the edge intro (re-detects preconditions), not a hard exit. return m.enterEdgeIntro() } return m, tea.Quit case "tab", "down": return m, m.focusInput(m.focus + 1) case "shift+tab", "up": return m, m.focusInput(m.focus - 1) case "enter": return m.submit() } return m, m.updateInputs(msg) } func (m *bgModel) submit() (tea.Model, tea.Cmd) { switch m.step { case stepAuth: return m.submitAuth() case stepOverride: return m.submitOverride() case stepProvision: return m.submitProvision() case stepEdgeInput: return m.submitEdge() } return m, nil } func (m *bgModel) submitAuth() (tea.Model, tea.Cmd) { user := strings.TrimSpace(m.inputs[0].Value()) pass := m.inputs[1].Value() if user == "" || pass == "" { m.formErr = "enter the username and password of an existing admin" return m, nil } m.attemptedAdmin = user m.formErr, m.working = "", "Verifying the admin credential…" m.step = stepWorking return m, authCmd(m.ctx, m.store, user, pass) } func (m *bgModel) submitOverride() (tea.Model, tea.Cmd) { if m.inputs[0].Value() != breakGlassOverrideToken { m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed, or esc to go back" return m, nil } m.mode = "root_override" m.accountable = m.osUser return m, m.enterProvision() } func (m *bgModel) submitProvision() (tea.Model, tea.Cmd) { owner := strings.TrimSpace(m.inputs[0].Value()) if owner == "" { m.formErr = "owner username is required" return m, m.focusInput(0) } email := m.inputs[1].Value() password := "" // empty => performBreakGlass generates a one-time password if m.mode == "bootstrap" { pw := m.inputs[2].Value() confirm := m.inputs[3].Value() if err := validateOwnerPassword(pw); err != nil { m.formErr = err.Error() return m, m.focusInput(2) } if pw != confirm { m.formErr = "the two passwords do not match" return m, m.focusInput(3) } password = pw } m.ownerUsername = owner op := breakGlassOp{ mode: m.mode, accountable: m.accountable, osUser: m.osUser, ownerUsername: owner, ownerEmail: email, ownerPassword: password, attemptedAdmin: m.attemptedAdmin, } m.formErr, m.working = "", "Provisioning the Owner account…" m.step = stepWorking return m, performCmd(m.ctx, m.store, op) } // authCmd runs the admin credential check off the UI goroutine. func authCmd(ctx context.Context, s ownerStore, user, pass string) tea.Cmd { return func() tea.Msg { matched, ok, err := authenticateAdmin(ctx, s, user, pass) return authResultMsg{matched: matched, ok: ok, err: err} } } // performCmd runs the break-glass writes off the UI goroutine. func performCmd(ctx context.Context, s ownerStore, op breakGlassOp) tea.Cmd { return func() tea.Msg { out, err := performBreakGlass(ctx, s, op) return performedMsg{outcome: out, err: err} } } // ---- top-level router ---- func (m *bgModel) menuOptionCount() int { if m.consoleMode == consoleModeSetup { return 2 } return 1 } func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "up", "shift+tab": if m.focus > 0 { m.focus-- } return m, nil case "down", "tab": if m.focus < m.menuOptionCount()-1 { m.focus++ } return m, nil case "1": m.focus = 0 return m.selectMenu() case "2": if m.menuOptionCount() > 1 { m.focus = 1 return m.selectMenu() } return m, nil case "enter": return m.selectMenu() } return m, nil } func (m *bgModel) selectMenu() (tea.Model, tea.Cmd) { if m.consoleMode == consoleModeSetup && m.focus == 1 { return m.enterEdgeIntro() } if m.consoleMode == consoleModeSetup && m.adminExists { m.formErr = "an Owner/admin already exists; use breakGlass for emergency reset, or choose Cloudflare edge" return m, nil } return m, m.enterProvisionFlow() } // ---- optional Cloudflare edge flow (锦上添花) ---- // enterEdgeIntro detects the operator-only preconditions (cloudflared on PATH, a // completed `cloudflared tunnel login`) and shows the intro. Detection is READ-ONLY // and re-runs every time the screen is entered so a fresh login is picked up. func (m *bgModel) enterEdgeIntro() (tea.Model, tea.Cmd) { m.step = stepEdgeIntro m.formErr, m.loginNote = "", "" pre := cfsetup.DetectPreconditions("") m.cloudflaredPath = pre.CloudflaredPath m.certExists = pre.CertExists return m, nil } // edgeReady reports whether the edge flow can proceed to credential entry: the // operator must have cloudflared installed and be logged in. Hostnames are chosen // on the next screen, so an empty [auth] hostname no longer blocks setup. func (m *bgModel) edgeReady() bool { return m.cloudflaredPath != "" && m.certExists } func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": // Back to the setup router with the edge option still highlighted. m.step, m.focus, m.loginNote = stepMenu, 1, "" return m, nil case "l", "L": // Offer the interactive login only when it is the actual blocker. if m.cloudflaredPath != "" && !m.certExists { return m.startCloudflaredLogin() } return m, nil case "enter": if m.edgeReady() { return m, m.enterEdgeInput() } return m, nil } return m, nil } // startCloudflaredLogin suspends the TUI to run the interactive browser consent. // This is the one step cfsetup cannot perform or fake: it authenticates the // operator against their OWN Cloudflare account and writes ~/.cloudflared/cert.pem. func (m *bgModel) startCloudflaredLogin() (tea.Model, tea.Cmd) { c := exec.CommandContext(m.ctx, m.cloudflaredPath, "tunnel", "login") return m, tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }) } // enterEdgeInput installs the credential/scope inputs, pre-filling safe defaults. // Hostnames are explicit setup inputs so an operator can choose console.mc and // op.console.mc instead of accepting whatever the bootstrap config guessed. func (m *bgModel) enterEdgeInput() tea.Cmd { m.step = stepEdgeInput m.formErr = "" token := bgInput("Cloudflare API token", 200, true) account := bgInput("Cloudflare account ID", 64, false) identity := bgInput("you@example.com or @your-domain", 254, false) panelHost := bgInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false) panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname)) adminHost := bgInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false) adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname)) tunnel := bgInput(defaultTunnelName, 64, false) tunnel.SetValue(defaultTunnelName) cfgPath := bgInput(defaultTunnelConfigPath, 256, false) cfgPath.SetValue(defaultTunnelConfigPath) return m.setInputs([]textinput.Model{token, account, identity, panelHost, adminHost, tunnel, cfgPath}) } // submitEdge validates the edge inputs and launches cfsetup.Setup. The fail-closed // guard and empty-identity refusal live in cfsetup — this only translates the typed // identity into an AccessIdentity (a leading "@" means an org email domain, else a // specific person) and surfaces cfsetup's errors as a clean stepEdgeError. func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) { token := strings.TrimSpace(m.inputs[0].Value()) account := strings.TrimSpace(m.inputs[1].Value()) identity := strings.TrimSpace(m.inputs[2].Value()) panelHost := normalizeEdgeHostname(m.inputs[3].Value()) adminHost := normalizeEdgeHostname(m.inputs[4].Value()) tunnel := strings.TrimSpace(m.inputs[5].Value()) cfgPath := strings.TrimSpace(m.inputs[6].Value()) if token == "" { m.formErr = "a Cloudflare API token is required" return m, m.focusInput(0) } if account == "" { m.formErr = "the Cloudflare account ID is required" return m, m.focusInput(1) } if !isHex32(account) { if strings.HasPrefix(account, "cfat_") { m.formErr = "you entered an API token (starting with cfat_) instead of the Cloudflare Account ID" } else { m.formErr = "the Cloudflare Account ID must be a 32-character hexadecimal string" } return m, m.focusInput(1) } if identity == "" { m.formErr = "enter who Access should admit — your email, or @your-domain" return m, m.focusInput(2) } // A bare "@" would scope Access to an empty email domain. cfsetup is fail-closed // (an empty domain admits no one), but reject it here so the operator fixes the // typo rather than silently locking everyone out. if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" { m.formErr = "enter a domain after the @, e.g. @your-domain" return m, m.focusInput(2) } if err := validateEdgeHostname("player console hostname", panelHost, false); err != nil { m.formErr = err.Error() return m, m.focusInput(3) } if err := validateEdgeHostname("admin console hostname", adminHost, true); err != nil { m.formErr = err.Error() return m, m.focusInput(4) } if panelHost != "" && strings.EqualFold(panelHost, adminHost) { m.formErr = "player console and admin console hostnames must be different" return m, m.focusInput(4) } if tunnel == "" { tunnel = defaultTunnelName } if cfgPath == "" { cfgPath = defaultTunnelConfigPath } var id cfsetup.AccessIdentity if strings.HasPrefix(identity, "@") { id.EmailDomains = []string{strings.TrimPrefix(identity, "@")} } else { id.Emails = []string{identity} } m.edgePanelHostname = panelHost m.edgeAdminHostname = adminHost p := cfsetup.Params{ PanelHostname: panelHost, AdminHostname: adminHost, TunnelName: tunnel, ConfigPath: cfgPath, AccessIdentity: id, // Re-detect with the token so a cert.pem written by the in-flow login is seen. Pre: cfsetup.DetectPreconditions(token), } runner := &cfsetup.ExecRunner{ Cloudflared: m.cloudflaredPath, APIToken: token, AccountID: account, } m.formErr, m.working = "", "Configuring the Cloudflare Tunnel + Access edge…" m.step = stepEdgeWorking return m, edgeSetupCmd(m.ctx, runner, p) } // edgeSetupCmd runs cfsetup.Setup off the UI goroutine. func edgeSetupCmd(ctx context.Context, runner cfsetup.Runner, p cfsetup.Params) tea.Cmd { return func() tea.Msg { res, err := cfsetup.Setup(ctx, runner, p) return edgeDoneMsg{result: res, err: err} } } func (m *bgModel) View() string { var b strings.Builder title := "FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE" if m.consoleMode == consoleModeSetup { title = "FELIS SETUP — LOCAL SETUP CONSOLE" } b.WriteString(bgTitleStyle.Render("⚠ "+title) + "\n\n") switch m.step { case stepMenu: prompt := "Choose a break-glass operation:" provisionTitle := "Emergency reset the Owner account" provisionDesc := "Authenticate as an existing admin, or use a deliberate root override." if !m.adminExists { provisionTitle = "Create the first Owner account" provisionDesc = "No staff account exists yet — bootstrap the first Owner." } opts := []struct{ title, desc string }{{provisionTitle, provisionDesc}} if m.consoleMode == consoleModeSetup { prompt = "Choose a setup operation:" provisionTitle = "Create the Owner account" provisionDesc = "No staff account exists yet — bootstrap the first Owner." if m.adminExists { provisionDesc = "Already exists — use breakGlass only for emergency reset." } opts[0] = struct{ title, desc string }{provisionTitle, provisionDesc} opts = append(opts, struct{ title, desc string }{"Set up the Cloudflare edge", "Choose web hostnames, create Tunnel DNS, and guard the admin face with Access."}) } b.WriteString(prompt + "\n\n") for i, o := range opts { cursor, title := " ", o.title if i == m.focus { cursor, title = bgLabelStyle.Render(" ▸ "), bgLabelStyle.Render(o.title) } b.WriteString(fmt.Sprintf("%s%d. %s\n", cursor, i+1, title)) b.WriteString(" " + bgHintStyle.Render(o.desc) + "\n\n") } if m.formErr != "" { b.WriteString(bgWarnStyle.Render(m.formErr) + "\n\n") } hint := "↑↓ move · 1 select · enter confirm · esc exit" if m.menuOptionCount() > 1 { hint = "↑↓ move · 1/2 select · enter confirm · esc exit" } b.WriteString(bgHintStyle.Render(hint) + "\n") case stepAuth: b.WriteString("A staff account already exists. Identify yourself before breaking the glass.\n") b.WriteString("Authenticate as an existing admin — this records WHO performed the recovery.\n") b.WriteString(bgHintStyle.Render("Best-effort attribution, not a second authority gate (root already let you in).") + "\n\n") b.WriteString(bgLabelStyle.Render("Admin username") + "\n") b.WriteString(m.inputs[0].View() + "\n\n") b.WriteString(bgLabelStyle.Render("Admin password") + "\n") b.WriteString(m.inputs[1].View() + "\n\n") if m.formErr != "" { b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") } b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter verify · esc cancel") + "\n") case stepOverride: b.WriteString(bgErrStyle.Render("✗ That credential did not match any admin account.") + "\n\n") b.WriteString("You can still proceed under local-root authority. This is a ROOT OVERRIDE:\n") b.WriteString("it will be recorded as an UNVERIFIED break-glass attributed to the OS user\n") b.WriteString(bgLabelStyle.Render("\""+m.osUser+"\"") + ", not to a verified admin.\n\n") b.WriteString(bgLabelStyle.Render("Type "+breakGlassOverrideToken+" to proceed") + "\n") b.WriteString(m.inputs[0].View() + "\n\n") if m.formErr != "" { b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") } b.WriteString(bgHintStyle.Render("enter confirm · esc go back to admin login") + "\n") case stepProvision: if m.mode == "bootstrap" { b.WriteString("No staff account exists yet — bootstrapping the first Owner.\n") b.WriteString("You are recorded as OS user " + bgLabelStyle.Render("\""+m.osUser+"\"") + ".\n\n") } else if m.mode == "root_override" { b.WriteString(bgWarnStyle.Render("ROOT OVERRIDE") + " by OS user " + bgLabelStyle.Render("\""+m.osUser+"\"") + " — resetting the Owner account.\n") b.WriteString("A new one-time password will be generated and shown once.\n\n") } else { b.WriteString("Authenticated as admin " + bgLabelStyle.Render("\""+m.accountable+"\"") + " — resetting the Owner account.\n") b.WriteString("A new one-time password will be generated and shown once.\n\n") } b.WriteString(bgLabelStyle.Render("Owner username") + "\n") b.WriteString(m.inputs[0].View() + "\n\n") b.WriteString(bgLabelStyle.Render("Owner email (optional)") + "\n") b.WriteString(m.inputs[1].View() + "\n\n") if m.mode == "bootstrap" { b.WriteString(bgLabelStyle.Render("Owner password") + bgHintStyle.Render(" (you will change it on first login)") + "\n") b.WriteString(m.inputs[2].View() + "\n\n") b.WriteString(bgLabelStyle.Render("Confirm password") + "\n") b.WriteString(m.inputs[3].View() + "\n\n") } if m.formErr != "" { b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") } b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n") case stepEdgeIntro: b.WriteString(bgLabelStyle.Render("Cloudflare Tunnel + Access edge") + bgHintStyle.Render(" (optional)") + "\n") b.WriteString("Publishes the web faces over a Cloudflare Tunnel and fronts the SysAdmin\n") b.WriteString("console with a fail-closed Access policy, using YOUR own Cloudflare account.\n") b.WriteString(bgHintStyle.Render("Hostnames are editable on the next screen; the Minecraft game host is not tunneled.") + "\n\n") b.WriteString(bgLabelStyle.Render("Cloudflare authorization:") + "\n") b.WriteString(" 1. Press " + bgLabelStyle.Render("l") + " for `cloudflared tunnel login` browser consent.\n") b.WriteString(" 2. Create the Access API token from:\n") b.WriteString(" " + cloudflareAccessTokenTemplateURL + "\n") b.WriteString(bgHintStyle.Render("The link pre-fills the Dashboard token form; Cloudflare still asks you to review and create it.") + "\n") b.WriteString(bgHintStyle.Render("Docs: "+cloudflareAPITokenDocsURL) + "\n\n") b.WriteString(bgLabelStyle.Render("Default web hostnames:") + "\n") if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" { b.WriteString(" • " + panel + bgHintStyle.Render(" (Player console)") + "\n") } if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" { b.WriteString(" • " + admin + bgHintStyle.Render(" (Operator + SysAdmin console — Access-guarded)") + "\n") } b.WriteString("\n") if m.cloudflaredPath == "" { b.WriteString(bgErrStyle.Render("✗ cloudflared not found on PATH") + " — install it, then esc and re-enter.\n") } else { b.WriteString(bgOKStyle.Render("✓ cloudflared") + " " + bgHintStyle.Render(m.cloudflaredPath) + "\n") if m.certExists { b.WriteString(bgOKStyle.Render("✓ logged in") + bgHintStyle.Render(" (~/.cloudflared/cert.pem present)") + "\n") } else { b.WriteString(bgWarnStyle.Render("• not logged in to Cloudflare") + " — press " + bgLabelStyle.Render("l") + " to run `cloudflared tunnel login`\n") b.WriteString(bgHintStyle.Render(" (opens a browser for consent on your own account).") + "\n") } } if m.loginNote != "" { b.WriteString("\n" + bgWarnStyle.Render(m.loginNote) + "\n") } b.WriteString("\n") switch { case m.edgeReady(): b.WriteString(bgHintStyle.Render("enter continue · esc back") + "\n") case m.cloudflaredPath != "" && !m.certExists: b.WriteString(bgHintStyle.Render("l login · esc back") + "\n") default: b.WriteString(bgHintStyle.Render("esc back") + "\n") } case stepEdgeInput: b.WriteString(bgLabelStyle.Render("Cloudflare edge · credentials & scope") + "\n") b.WriteString(bgHintStyle.Render("API token: Account > Access Apps and Policies:Edit. Tunnel/DNS uses `cloudflared tunnel login`.") + "\n") b.WriteString(bgHintStyle.Render("Token template: "+cloudflareAccessTokenTemplateURL) + "\n\n") labels := []string{ "Cloudflare API token", "Cloudflare account ID", "Admit (your email, or @your-domain)", "Player console hostname", "Admin console hostname", "Tunnel name", "Tunnel config path", } for i, lbl := range labels { b.WriteString(bgLabelStyle.Render(lbl) + "\n") b.WriteString(m.inputs[i].View() + "\n\n") } if m.formErr != "" { b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") } b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter configure · esc back") + "\n") case stepWorking, stepEdgeWorking: msg := m.working if msg == "" { msg = "Working…" } b.WriteString(msg + "\n") case stepDone: b.WriteString(bgOKStyle.Render("✓ Owner provisioned · local-password login ENABLED") + "\n\n") box := bgLabelStyle.Render("username ") + m.ownerUsername if m.displayPassword != "" { box += "\n" + bgLabelStyle.Render("password ") + bgPwStyle.Render(m.displayPassword) } b.WriteString(bgBoxStyle.Render(box) + "\n\n") b.WriteString(bgHintStyle.Render("recorded as "+m.accountable+" · mode "+m.mode+" · os user "+m.osUser) + "\n\n") if m.displayPassword != "" { b.WriteString(bgErrStyle.Render("Record this password now — it is shown only once.") + "\n") } else { b.WriteString("Log in with the password you just entered.\n") } b.WriteString("You will be required to change it on first login.\n\n") if m.auditWarning != "" { b.WriteString(bgWarnStyle.Render("⚠ accountability record was NOT written: "+m.auditWarning) + "\n\n") } if url := adminLoginURL(m.rootDomain, m.adminHostname); url != "" { b.WriteString("Log in at " + bgLabelStyle.Render(url) + "\n\n") } b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") case stepEdgeDone: b.WriteString(bgOKStyle.Render("✓ Cloudflare Tunnel + Access edge configured") + "\n\n") var box string if m.edgeResult != nil { box = bgLabelStyle.Render("access_jwt_aud ") + bgPwStyle.Render(m.edgeResult.AccessAud) if len(m.edgeResult.RoutedHostnames) > 0 { box += "\n" + bgLabelStyle.Render("routed ") + strings.Join(m.edgeResult.RoutedHostnames, ", ") } if m.edgeResult.ConfigPath != "" { box += "\n" + bgLabelStyle.Render("tunnel config ") + m.edgeResult.ConfigPath } } b.WriteString(bgBoxStyle.Render(box) + "\n\n") b.WriteString(bgWarnStyle.Render("ACTION REQUIRED") + " — make felis-api trust the edge in felis.toml:\n") if m.edgePanelHostname != "" { b.WriteString("set " + bgLabelStyle.Render("[auth] panel_hostname") + " to " + bgLabelStyle.Render(m.edgePanelHostname) + "\n") } if m.edgeAdminHostname != "" { b.WriteString("set " + bgLabelStyle.Render("[auth] admin_hostname") + " to " + bgLabelStyle.Render(m.edgeAdminHostname) + "\n") } b.WriteString("set " + bgLabelStyle.Render("[auth] access_jwt_aud") + " to the value above, then start the\n") b.WriteString("tunnel with " + bgLabelStyle.Render("cloudflared tunnel run") + ".\n\n") b.WriteString(bgHintStyle.Render("Verify the Access app actually guards the admin face before relying on it.") + "\n\n") b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") case stepError, stepEdgeError: header := "✗ Break-glass failed" if m.step == stepEdgeError { header = "✗ Cloudflare edge setup failed — no usable edge was created" } b.WriteString(bgErrStyle.Render(header) + "\n\n") b.WriteString(m.err.Error() + "\n\n") b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") } return b.String() } // runBreakGlassTUI drives the emergency bubbletea program and projects the final // model onto a breakGlassResult. The owner/auth logic is unit-tested directly. func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass) } // runSetupTUI drives the normal first-run setup console. It shares the model with // breakGlass but starts it in setup mode, where Cloudflare edge setup is available // and emergency Owner reset is not. func runSetupTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup) } func runConsoleTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { final, err := tea.NewProgram(newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode), tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err } m, ok := final.(*bgModel) if !ok { return breakGlassResult{}, errors.New("unexpected final model") } // A terminal-error screen for either flow surfaces as a returned error. if m.step == stepError || m.step == stepEdgeError { return breakGlassResult{}, m.err } res := breakGlassResult{ provisioned: m.step == stepDone, mode: m.mode, accountable: m.accountable, osUser: m.osUser, username: m.ownerUsername, displayPassword: m.displayPassword, auditWarning: m.auditWarning, rootDomain: rootDomain, adminHostname: adminHostname, } if m.step == stepEdgeDone && m.edgeResult != nil { res.edgeConfigured = true res.edgeAud = m.edgeResult.AccessAud res.edgeRoutedHosts = m.edgeResult.RoutedHostnames res.edgeConfigPath = m.edgeResult.ConfigPath res.edgePanelHostname = m.edgePanelHostname res.edgeAdminHostname = m.edgeAdminHostname } return res, 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 }