Files
Felis/cmd/felis/tui_root.go
T
flyemoji eb5875a699 feat(felis): add Operator break-glass op behind an operation menu
When a staff account already exists, the break-glass console now opens on a
thin top-level menu (menuModel) where account operations are peers rather than
tails of one wizard: provision/reset the Owner, or add an Operator. A fresh
machine with no Owner skips the menu and goes straight to Owner bootstrap, since
minting an Operator first would create a staff account the login gate rejects.

The Operator path reuses ownerModel via a bgOperation discriminator. It is
insert-only (performAddOperator -> InsertOperator), wraps a duplicate username as
api.ErrConflict and routes back to the provision form for a retry rather than
tearing down, and deliberately never flips the global local_auth toggle the way
the Owner thread does. The post-exit summary and audit trail distinguish the two
outcomes (isOperator); only the Owner provision claims local-password login was
enabled.

Tests cover the operator-model defaults, path selection (insert vs upsert and
the local-auth gate), conflict-retry versus generic teardown, isOperator
propagation, and the root menu routing for both fresh and admin-present
machines.
2026-06-30 15:40:24 +09:00

482 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"strings"
"felis.lolicon.best/internal/cfsetup"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// sizeable is implemented by screens that need to know the terminal area
// available to them (after the root reserves space for the step rail). huh-based
// screens forward this to form.WithWidth/WithHeight; custom screens use it to
// avoid overflowing the frame.
type sizeable interface {
setSize(width, height int)
}
// arrowNavigable is implemented by screens that don't need ←/→ for their own
// input (selects, summaries), so the root may repurpose those keys to walk back
// through completed steps. Text-input screens omit it and keep the arrows for
// cursor movement — that's the "don't fight the input fields" rule.
type arrowNavigable interface {
arrowNavOK() bool
}
// ---- Connection methods ----
type connectMethod int
const (
connectLocal connectMethod = iota
connectCloudflare
connectReverseProxy
)
func connectMethodLabel(m connectMethod) string {
switch m {
case connectCloudflare:
return "Cloudflare Tunnel + Access"
case connectReverseProxy:
return "Reverse proxy (your own front)"
default:
return "Local only (NodePort + self-signed TLS)"
}
}
// ---- Messages: sub-model → root ----
type preflightDoneMsg struct{}
type ownerResultMsg struct {
username string
displayPassword string
mode string
accountable string
auditWarning string
isOperator bool // true when an Operator was added rather than the Owner provisioned
err error
}
// connectResultMsg is emitted by every connection method (the chooser for
// local, the edge model for Cloudflare, the reverse-proxy model for BYO). It is
// the single, method-agnostic outcome the root advances on.
type connectResultMsg struct {
method connectMethod
panelHostname string
adminHostname string
edge *cfsetup.Result // Cloudflare only
guide string // reverse-proxy only
}
// goBackMsg returns from a connection sub-screen to the chooser.
type goBackMsg struct{}
func goBack() tea.Cmd { return func() tea.Msg { return goBackMsg{} } }
// reconfigureConnectMsg is sent from the re-run status screen to re-enter the
// connection chooser.
type reconfigureConnectMsg struct{}
// ---- rootModel: top-level session ----
type wizardStage int
const (
stagePreflight wizardStage = iota
stageOwner
stageConnect
stageSummary
// stageMenu is the break-glass operation menu. It is appended last so the
// setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed
// in break-glass mode, so this stage never reaches it.
stageMenu
)
// setupRailSteps is the one progress rail shared by the whole first-run flow,
// spanning both bubbletea programs: the host-bootstrap installer is rail cell 0,
// and the post-install wizard owns cells 1–4. Defining it once keeps the two
// programs' breadcrumbs identical so the rail reads as a single continuous bar
// rather than restarting when the wizard takes over.
var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Done"}
type rootModel struct {
ctx context.Context
screen tea.Model
stage wizardStage
// reviewing is the index of a completed step the operator is looking back at
// (read-only), or -1 when the live screen is in front. Driven by ←/→.
reviewing int
width int
height int
result breakGlassResult
err error
mode consoleMode
dbURL string
store ownerStore
osUser string
rootDomain string
adminHost string
panelHost string
accessAud string
adminExists bool
}
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) *rootModel {
rm := &rootModel{
ctx: ctx,
reviewing: -1,
dbURL: dbURL,
store: store,
osUser: osUser,
rootDomain: rootDomain,
adminHost: adminHostname,
panelHost: panelHostname,
accessAud: accessAud,
adminExists: adminExists,
mode: mode,
result: breakGlassResult{
osUser: osUser,
rootDomain: rootDomain,
adminHostname: adminHostname,
},
}
if mode == consoleModeBreakGlass {
if adminExists {
// A staff account exists, so account operations are peers: open on the menu
// (provision/reset Owner, or add Operator).
rm.stage = stageMenu
rm.screen = newMenuModel()
} else {
// Fresh machine: bootstrapping the first Owner is the only sensible op, so skip
// the menu and go straight to it (offering "add Operator" here would mint a
// staff account the login gate still rejects).
rm.stage = stageOwner
rm.screen = newOwnerModel(ctx, store, osUser, adminExists)
}
} else {
rm.stage = stagePreflight
rm.screen = newPreflightModel(dbURL, rootDomain)
}
return rm
}
func (m *rootModel) Init() tea.Cmd {
return m.screen.Init()
}
func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Rail navigation claims ←/→ before anything else sees them. While reviewing
// it owns every key so nothing leaks into the live screen underneath;
// otherwise it only takes ← (to enter review) and lets the rest fall through.
if key, ok := msg.(tea.KeyMsg); ok {
if handled, model, cmd := m.handleRailKey(key); handled {
return model, cmd
}
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.pushSize()
return m, nil
case preflightDoneMsg:
if m.adminExists {
// Re-run: setup already happened. Land on the status screen.
return m.showStatus()
}
m.stage = stageOwner
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
case menuChoiceMsg:
// The break-glass menu picked an account operation; build its screen. Both reuse
// stageOwner (the rail is suppressed in break-glass, so the stage is only a label).
m.stage = stageOwner
switch msg.op {
case bgAddOperator:
return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser))
default:
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists))
}
case ownerResultMsg:
if msg.err != nil {
m.err = msg.err
return m, tea.Quit
}
m.result.provisioned = true
m.result.isOperator = msg.isOperator
m.result.username = msg.username
m.result.displayPassword = msg.displayPassword
m.result.mode = msg.mode
m.result.accountable = msg.accountable
m.result.auditWarning = msg.auditWarning
if m.mode == consoleModeBreakGlass {
return m, tea.Quit
}
m.adminExists = true
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
case connectResultMsg:
m.applyConnectResult(msg)
return m.showSummary()
case goBackMsg:
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
case reconfigureConnectMsg:
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
}
if m.screen != nil {
newScreen, cmd := m.screen.Update(msg)
if newScreen != nil && newScreen != m.screen {
adopted, initCmd := m.adopt(newScreen)
return adopted, tea.Batch(cmd, initCmd)
}
return m, cmd
}
return m, nil
}
// adopt installs a new screen, hands it the current size, and returns its Init.
// Centralizing screen swaps here guarantees every screen is sized before its
// first View — the fix for the rail being clipped off the top of the frame.
func (m *rootModel) adopt(s tea.Model) (tea.Model, tea.Cmd) {
m.screen = s
m.reviewing = -1 // every stage change drops back to the live screen
m.pushSize()
return m, s.Init()
}
// handleRailKey implements ←/→ navigation of the step rail. While reviewing a
// completed step it owns every key (so nothing leaks into the live screen);
// otherwise it claims only ← to enter review, and only when the active screen
// doesn't need the arrows for its own text input.
func (m *rootModel) handleRailKey(k tea.KeyMsg) (bool, tea.Model, tea.Cmd) {
if m.reviewing >= 0 {
switch k.String() {
case "ctrl+c":
return true, m, tea.Quit
case "left":
if m.reviewing > 0 {
m.reviewing--
}
return true, m, nil
case "right":
m.reviewing++
if m.reviewing >= int(m.stage) {
m.reviewing = -1 // caught up to the live step
}
return true, m, nil
case "esc", "enter":
m.reviewing = -1
return true, m, nil
default:
return true, m, nil // swallow everything else while reviewing
}
}
if k.String() == "left" && m.canEnterReview() {
m.reviewing = int(m.stage) - 1
return true, m, nil
}
return false, m, nil
}
// canEnterReview reports whether the live screen will yield ←/→ to the rail.
func (m *rootModel) canEnterReview() bool {
if m.mode != consoleModeSetup || m.adminExistsAtStart() || m.stage == 0 {
return false
}
n, ok := m.screen.(arrowNavigable)
return ok && n.arrowNavOK()
}
// displayStage is the rail position currently shown — the reviewed step when
// looking back, otherwise the live stage.
func (m *rootModel) displayStage() int {
if m.reviewing >= 0 {
return m.reviewing
}
return int(m.stage)
}
// reviewBody renders a read-only recap of an already-completed step. Steps in
// this wizard commit as you finish them (the Owner account and its one-time
// password are created on submit), so review is deliberately look-only — there
// is no re-editing a step you've passed.
func (m *rootModel) reviewBody(stage int) string {
var b strings.Builder
switch wizardStage(stage) {
case stagePreflight:
b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
b.WriteString(tuiHint.Render("Control plane verified before configuration."))
case stageOwner:
b.WriteString(tuiOK.Render("✓ Owner account") + "\n")
if m.result.username != "" {
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
}
b.WriteString(tuiHint.Render("Created and recorded. The one-time password was shown on the Owner step."))
case stageConnect:
b.WriteString(tuiOK.Render("✓ Connection") + "\n")
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n")
if m.result.panelURL != "" {
b.WriteString(tuiLabel.Render("panel ") + m.result.panelURL)
}
}
b.WriteString("\n\n" + tuiHint.Render("read-only · ") + tuiLabel.Render("←/→") +
tuiHint.Render(" walk steps · ") + tuiLabel.Render("esc") + tuiHint.Render(" back"))
return b.String()
}
// pushSize gives the active screen the area left after the step rail.
func (m *rootModel) pushSize() {
if m.height == 0 {
return
}
if s, ok := m.screen.(sizeable); ok {
// Reserve one extra row: huh renders a hair taller than its WithHeight
// budget (the help line sits outside it). Clipping the rail off the top is
// the bug we're fixing, so we round the budget down — a blank bottom row is
// invisible, an overflowed top is not.
avail := m.height - m.chromeHeight() - 1
if avail < 1 {
avail = 1
}
s.setSize(m.width, avail)
}
}
// chromeHeight is the number of rows the root paints around the screen — the
// rail plus its blank separator, or 0 when the rail is hidden.
func (m *rootModel) chromeHeight() int {
if m.mode != consoleModeSetup || m.adminExistsAtStart() {
return 0
}
return lipgloss.Height(m.railWithHint()) + 1
}
func (m *rootModel) View() string {
if m.screen == nil {
return ""
}
if m.mode == consoleModeSetup && !m.adminExistsAtStart() {
body := m.screen.View()
if m.reviewing >= 0 {
body = m.reviewBody(m.reviewing)
}
return m.railWithHint() + "\n\n" + body
}
return m.screen.View()
}
// adminExistsAtStart reports whether this run began with an Owner already
// present (a re-run). The rail only makes sense for the first-run linear wizard.
func (m *rootModel) adminExistsAtStart() bool {
// adminExists flips true once we provision the Owner mid-run; the rail should
// keep showing through the connect/summary stages of that same first run. So
// only suppress the rail when the Owner pre-existed AND we never provisioned.
return m.adminExists && !m.result.provisioned
}
func (m *rootModel) rail() string {
// Bootstrap is rail cell 0 and is always done by the time the wizard runs (an
// open DB is the proof), so the wizard's own stages render starting at cell 1.
return tuiStepRail(setupRailSteps, m.displayStage()+1)
}
// railWithHint appends a discoverability hint when ←/→ can walk the rail — while
// reviewing, or on a live screen that yields the arrows.
func (m *rootModel) railWithHint() string {
r := m.rail()
switch {
case m.reviewing >= 0:
// Mid-review both directions move; → eventually returns to the live step.
r += tuiRailSep.Render(" ") + tuiRailTodo.Render("←/→ review steps")
case m.canEnterReview():
// At the live frontier only ← does anything — there's nothing ahead, so
// don't advertise → and have it silently no-op.
r += tuiRailSep.Render(" ") + tuiRailTodo.Render("← review steps")
}
return r
}
// applyConnectResult records the chosen connection outcome onto the result.
func (m *rootModel) applyConnectResult(msg connectResultMsg) {
m.result.connectMethod = msg.method
if msg.panelHostname != "" {
m.result.panelHostname = msg.panelHostname
}
if msg.adminHostname != "" {
m.result.adminHostname = msg.adminHostname
}
switch msg.method {
case connectCloudflare:
m.result.connectConfigured = true
m.result.edgeConfigured = true
if msg.edge != nil {
m.result.edgeAud = msg.edge.AccessAud
m.result.edgeRoutedHosts = msg.edge.RoutedHostnames
m.result.edgeConfigPath = msg.edge.ConfigPath
}
case connectReverseProxy:
m.result.connectConfigured = true
m.result.reverseProxyGuide = msg.guide
}
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain)
}
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
m.stage = stageSummary
routed := m.result.edgeRoutedHosts
if len(routed) == 0 && m.result.connectMethod == connectReverseProxy && m.result.panelHostname != "" {
routed = []string{m.result.panelHostname}
}
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
ownerUsername: m.result.username,
ownerPassword: m.result.displayPassword,
accessLabel: connectMethodLabel(m.result.connectMethod),
routedHosts: routed,
localHint: m.result.connectMethod == connectLocal,
})
}
// showStatus is the re-run landing: prove the backend is up, then point the
// operator at the panel without forcing any reconfiguration.
func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
m.stage = stageSummary
method := connectLocal
accessLabel := "configured (manage in panel)"
if m.accessAud != "" {
method = connectCloudflare
accessLabel = connectMethodLabel(connectCloudflare)
}
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain)
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
accessLabel: accessLabel,
alreadySetUp: true,
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
})
}
func panelURLFor(method connectMethod, panelHostname, rootDomain string) string {
if method != connectLocal && panelHostname != "" {
return "https://" + panelHostname
}
return localPanelURL(rootDomain)
}