refactor(deploy)!: TUI
This commit is contained in:
37 files changed
+3622
-1303
No files matched your search
+24
-911
@@ -10,17 +10,13 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -129,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists)
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||
return 1
|
||||
@@ -401,8 +397,6 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
@@ -415,6 +409,7 @@ type breakGlassResult struct {
|
||||
auditWarning string
|
||||
rootDomain string
|
||||
adminHostname string
|
||||
panelURL string
|
||||
|
||||
// optional Cloudflare edge outcome (independent of provisioned)
|
||||
edgeConfigured bool
|
||||
@@ -432,25 +427,6 @@ const (
|
||||
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"
|
||||
@@ -462,901 +438,28 @@ const (
|
||||
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
|
||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass)
|
||||
}
|
||||
|
||||
// performedMsg carries the outcome of the off-goroutine break-glass writes.
|
||||
type performedMsg struct {
|
||||
outcome breakGlassOutcome
|
||||
err error
|
||||
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup)
|
||||
}
|
||||
|
||||
// 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("[email protected] 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()
|
||||
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) {
|
||||
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode)
|
||||
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
return breakGlassResult{}, err
|
||||
}
|
||||
m, ok := final.(*bgModel)
|
||||
root, ok := final.(*rootModel)
|
||||
if !ok {
|
||||
return breakGlassResult{}, errors.New("unexpected final model")
|
||||
return breakGlassResult{}, errors.New("unexpected final model type")
|
||||
}
|
||||
// A terminal-error screen for either flow surfaces as a returned error.
|
||||
if m.step == stepError || m.step == stepEdgeError {
|
||||
return breakGlassResult{}, m.err
|
||||
if root.err != nil {
|
||||
return breakGlassResult{}, root.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
|
||||
return root.result, nil
|
||||
}
|
||||
|
||||
func defaultPanelHostname(rootDomain, configured string) string {
|
||||
@@ -1414,3 +517,13 @@ func isHex32(s string) bool {
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
Reference in new issue
Block a user