322 lines
8.8 KiB
Go
322 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
|
|
"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)
|
|
}
|
|
|
|
// ---- 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
|
|
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
|
|
)
|
|
|
|
type rootModel struct {
|
|
ctx context.Context
|
|
|
|
screen tea.Model
|
|
stage wizardStage
|
|
|
|
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,
|
|
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 {
|
|
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) {
|
|
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 ownerResultMsg:
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
return m, tea.Quit
|
|
}
|
|
m.result.provisioned = true
|
|
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.pushSize()
|
|
return m, s.Init()
|
|
}
|
|
|
|
// 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.rail()) + 1
|
|
}
|
|
|
|
func (m *rootModel) View() string {
|
|
if m.screen == nil {
|
|
return ""
|
|
}
|
|
if m.mode == consoleModeSetup && !m.adminExistsAtStart() {
|
|
return m.rail() + "\n\n" + m.screen.View()
|
|
}
|
|
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 {
|
|
return tuiStepRail([]string{"Preflight", "Owner", "Connection", "Done"}, int(m.stage))
|
|
}
|
|
|
|
// 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)
|
|
}
|