refactor(deploy)!: TUI
This commit is contained in:
37 files changed
+3622
-1303
No files matched your search
+24
-3
@@ -13,6 +13,7 @@ import (
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/panel"
|
||||
"felis.lolicon.best/internal/restore"
|
||||
"felis.lolicon.best/internal/store"
|
||||
"felis.lolicon.best/internal/submit"
|
||||
@@ -34,9 +35,16 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
fs.SetOutput(stderr)
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
internalAddr := fs.String("internal-addr", ":8081", "internal-face listen address (service token, no Zero Trust)")
|
||||
httpsAddr := fs.String("https-addr", "", "external HTTPS listen address (disabled unless --tls-cert and --tls-key are also set)")
|
||||
tlsCert := fs.String("tls-cert", "", "TLS certificate path for --https-addr")
|
||||
tlsKey := fs.String("tls-key", "", "TLS private key path for --https-addr")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if (*httpsAddr == "") != (*tlsCert == "" || *tlsKey == "") {
|
||||
fmt.Fprintln(stderr, "felis api: --https-addr requires both --tls-cert and --tls-key")
|
||||
return 2
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
@@ -157,13 +165,23 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)")
|
||||
|
||||
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain)
|
||||
internalSrv := &http.Server{Addr: *internalAddr, Handler: a.InternalHandler()}
|
||||
externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: a.ExternalHandler()}
|
||||
externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: externalHandler}
|
||||
|
||||
errc := make(chan error, 2)
|
||||
errc := make(chan error, 3)
|
||||
go func() { errc <- internalSrv.ListenAndServe() }()
|
||||
go func() { errc <- externalSrv.ListenAndServe() }()
|
||||
fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen)
|
||||
var httpsSrv *http.Server
|
||||
if *httpsAddr != "" {
|
||||
httpsSrv = &http.Server{Addr: *httpsAddr, Handler: externalHandler}
|
||||
go func() { errc <- httpsSrv.ListenAndServeTLS(*tlsCert, *tlsKey) }()
|
||||
}
|
||||
if httpsSrv != nil {
|
||||
fmt.Fprintf(stdout, "felis api: internal=%s external=%s https=%s\n", *internalAddr, cfg.Server.Listen, *httpsAddr)
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen)
|
||||
}
|
||||
|
||||
// reconcileBuilds drives the scan-gate translation: poll unfinished builds
|
||||
// and advance any whose Job has reached a terminal phase. GET on a build also
|
||||
@@ -176,6 +194,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
defer cancel()
|
||||
_ = internalSrv.Shutdown(shutdownCtx)
|
||||
_ = externalSrv.Shutdown(shutdownCtx)
|
||||
if httpsSrv != nil {
|
||||
_ = httpsSrv.Shutdown(shutdownCtx)
|
||||
}
|
||||
return 0
|
||||
case err := <-errc:
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
felis "felis.lolicon.best"
|
||||
)
|
||||
|
||||
func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) != 1 || args[0] != "crd" {
|
||||
fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd")
|
||||
return 2
|
||||
}
|
||||
crd, err := felis.MinecraftServerCRD()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis bootstrap-assets: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if _, err := stdout.Write(crd); err != nil {
|
||||
fmt.Fprintf(stderr, "felis bootstrap-assets: write: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+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 ""
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -508,242 +507,3 @@ func TestAccountableOSUser(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// testRoot is the sanctioned placeholder root domain for tests (never a real host).
|
||||
const testRoot = "mc.example.net"
|
||||
|
||||
// advance feeds one message to the model and returns it re-typed as *bgModel, so the
|
||||
// state-machine assertions can read the resolved fields. The returned cmd is dropped:
|
||||
// these tests drive the gating transitions directly (authResultMsg / key presses)
|
||||
// rather than running the off-goroutine store commands.
|
||||
func advance(t *testing.T, m *bgModel, msg tea.Msg) *bgModel {
|
||||
t.Helper()
|
||||
next, _ := m.Update(msg)
|
||||
bm, ok := next.(*bgModel)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T, want *bgModel", next)
|
||||
}
|
||||
return bm
|
||||
}
|
||||
|
||||
// enterProvisionViaMenu drives the top-level router into the Owner provision/reset
|
||||
// flow the way an operator does on the emergency path: the menu opens with option 1
|
||||
// (provision) focused, so a single Enter selects it. The gating sub-tests use this to
|
||||
// reach stepAuth (recovery) or stepProvision (bootstrap) through the REAL entry path
|
||||
// before asserting the accountability transitions — not by reaching past the menu.
|
||||
func enterProvisionViaMenu(t *testing.T, m *bgModel) *bgModel {
|
||||
t.Helper()
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("expected the model to open on stepMenu, got %v", m.step)
|
||||
}
|
||||
return advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
}
|
||||
|
||||
// TestBGModelGating drives the break-glass state machine headlessly to lock in the
|
||||
// accountability gate: a credential never advances to provisioning without either a
|
||||
// verified admin (recovery) or a deliberate, explicit OVERRIDE (root override), and
|
||||
// the resolved actor matches the path taken. This is the "which SysAdmin" guarantee.
|
||||
func TestBGModelGating(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("recovery starts at auth; a non-matching credential offers override, never provision", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
if m.step != stepAuth || m.mode != "" {
|
||||
t.Fatalf("initial step/mode = %v/%q, want stepAuth and an unresolved mode", m.step, m.mode)
|
||||
}
|
||||
m = advance(t, m, authResultMsg{ok: false})
|
||||
if m.step != stepOverride {
|
||||
t.Errorf("after a non-matching credential step = %v, want stepOverride", m.step)
|
||||
}
|
||||
if m.mode == "recovery" {
|
||||
t.Error("mode must NOT become recovery on a failed credential — that would forge attribution")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recovery with a verified admin enters provision attributed to that admin", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{matched: "bob", ok: true})
|
||||
if m.step != stepProvision {
|
||||
t.Fatalf("step = %v, want stepProvision", m.step)
|
||||
}
|
||||
if m.mode != "recovery" || m.accountable != "bob" {
|
||||
t.Errorf("mode/accountable = %q/%q, want recovery/bob (the verified admin, not the OS user)", m.mode, m.accountable)
|
||||
}
|
||||
// Recovery generates the one-time password, so no password fields are shown.
|
||||
if len(m.inputs) != 2 {
|
||||
t.Errorf("recovery provision inputs = %d, want 2 (owner, email — no typed password)", len(m.inputs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an auth lookup error surfaces an error screen, not a silent override", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{err: errors.New("db unreachable")})
|
||||
if m.step != stepError || m.err == nil {
|
||||
t.Errorf("step/err = %v/%v, want stepError with a non-nil err", m.step, m.err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the root override requires the exact OVERRIDE token", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{ok: false}) // → stepOverride
|
||||
m.inputs[0].SetValue("override") // wrong case must not pass
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepOverride || m.formErr == "" {
|
||||
t.Errorf("wrong token: step/formErr = %v/%q, want stay on stepOverride with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.mode == "root_override" {
|
||||
t.Error("mode must not flip to root_override without the exact token")
|
||||
}
|
||||
m.inputs[0].SetValue(breakGlassOverrideToken)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepProvision || m.mode != "root_override" || m.accountable != "alice" {
|
||||
t.Errorf("after OVERRIDE: step/mode/accountable = %v/%q/%q, want stepProvision/root_override/alice (the OS user)", m.step, m.mode, m.accountable)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty admin credentials do not start a verification", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // both inputs blank
|
||||
if m.step != stepAuth || m.formErr == "" {
|
||||
t.Errorf("blank submit: step/formErr = %v/%q, want stay on stepAuth with an error", m.step, m.formErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bootstrap starts at provision as the OS user and requires a valid, matching password", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
m := newBGModel(ctx, f, testRoot, "", "", "deploybot", false)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
if m.step != stepProvision || m.mode != "bootstrap" || m.accountable != "deploybot" {
|
||||
t.Fatalf("initial step/mode/accountable = %v/%q/%q, want stepProvision/bootstrap/deploybot", m.step, m.mode, m.accountable)
|
||||
}
|
||||
if len(m.inputs) != 4 {
|
||||
t.Fatalf("bootstrap inputs = %d, want 4 (owner, email, password, confirm)", len(m.inputs))
|
||||
}
|
||||
// Too-short password is blocked, with no writes.
|
||||
m.inputs[2].SetValue("short")
|
||||
m.inputs[3].SetValue("short")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepProvision || m.formErr == "" {
|
||||
t.Errorf("weak password: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr)
|
||||
}
|
||||
// A mismatched confirmation is blocked.
|
||||
m.inputs[2].SetValue("valid-test-pw")
|
||||
m.inputs[3].SetValue("valid-test-XX")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepProvision || m.formErr == "" {
|
||||
t.Errorf("mismatch: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr)
|
||||
}
|
||||
if len(f.upserts) != 0 {
|
||||
t.Error("no owner should be provisioned while the form is invalid")
|
||||
}
|
||||
// Valid + matching advances to the working state (the write is dispatched).
|
||||
m.inputs[3].SetValue("valid-test-pw")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepWorking || m.ownerUsername != "owner" {
|
||||
t.Errorf("valid submit: step/owner = %v/%q, want stepWorking/owner", m.step, m.ownerUsername)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBGModelEdgeRouting locks in the setup-only Cloudflare edge flow WITHOUT
|
||||
// touching the operator's real Cloudflare account: setup option 2 reaches the edge
|
||||
// intro as an independent peer of Owner creation; breakGlass has no edge option;
|
||||
// hostnames are collected in the setup form; and invalid inputs are refused before
|
||||
// any cfsetup.Setup side effect. The real cloudflared/cert.pem detection and the
|
||||
// integration Setup (which shells out / calls the live API) are deliberately NOT exercised.
|
||||
func TestBGModelEdgeRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("setup menu option 2 enters the edge intro as a peer of provisioning, leaving the Owner credential untouched", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{admins: true}
|
||||
m := newSetupBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("initial step = %v, want stepMenu", m.step)
|
||||
}
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
if m.step != stepEdgeIntro {
|
||||
t.Fatalf("after selecting option 2 step = %v, want stepEdgeIntro", m.step)
|
||||
}
|
||||
// Reaching the edge must NOT have provisioned or reset an Owner.
|
||||
if len(f.upserts) != 0 {
|
||||
t.Error("the edge flow must not write any Owner record")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("esc from the edge intro returns to the setup router with the edge option highlighted", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if m.step != stepMenu || m.focus != 1 {
|
||||
t.Errorf("after esc step/focus = %v/%d, want stepMenu with the edge option (1) focused", m.step, m.focus)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("breakGlass has no edge option 2", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("breakGlass option 2 advanced to %v, want to stay on stepMenu", m.step)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge refuses empty credentials before any Cloudflare side effect", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
// Install the edge inputs directly: reaching them via the menu requires a real
|
||||
// cloudflared login (edgeReady()), which this unit test must not depend on.
|
||||
m.enterEdgeInput()
|
||||
if m.step != stepEdgeInput || len(m.inputs) != 7 {
|
||||
t.Fatalf("enterEdgeInput: step/inputs = %v/%d, want stepEdgeInput with 7 inputs", m.step, len(m.inputs))
|
||||
}
|
||||
// All inputs blank: submit (via the real key path) must report an error and stay
|
||||
// put — NOT reach stepEdgeWorking, which is what launches cfsetup.Setup against
|
||||
// the live cloudflared binary / Cloudflare API.
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("blank edge submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("empty credentials must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge rejects a bare @ identity that would scope Access to an empty domain", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m.enterEdgeInput()
|
||||
// Token + account present, but identity is a bare "@" (empty domain). This passes
|
||||
// the non-empty check yet must be refused before cfsetup.Setup, because an empty
|
||||
// EmailDomain admits no one — a silent lock-out the operator should fix.
|
||||
m.inputs[0].SetValue("token-value")
|
||||
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
|
||||
m.inputs[2].SetValue("@")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("bare @ submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("a bare @ identity must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge requires an admin hostname and rejects URLs", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m.enterEdgeInput()
|
||||
m.inputs[0].SetValue("token-value")
|
||||
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
|
||||
m.inputs[2].SetValue("[email protected]")
|
||||
m.inputs[3].SetValue("https://console." + testRoot)
|
||||
m.inputs[4].SetValue("")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("bad hostnames: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("invalid hostnames must never reach stepEdgeWorking")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
|
||||
buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in")
|
||||
registryNS := fs.String("registry-namespace", "", "namespace of the in-cluster registry (default: control namespace)")
|
||||
registryPort := fs.Int("registry-port", 5000, "port the in-cluster registry listens on")
|
||||
panelNodePort := fs.Int("panel-node-port", int(platform.DefaultPanelNodePort), "NodePort that exposes the built-in HTTPS panel/API origin")
|
||||
felisImage := fs.String("felis-image", "", "container image the felis-api/operator Deployments run, also passed through as FELIS_IMAGE (REQUIRED)")
|
||||
registryImage := fs.String("registry-image", "", "in-cluster registry image (default: registry:2)")
|
||||
backupPVC := fs.String("backup-pvc", "", "name of the backup PVC advertised to the restore executor via FELIS_BACKUP_PVC (default none = restore endpoint returns 503)")
|
||||
@@ -76,6 +77,10 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if *panelNodePort < 30000 || *panelNodePort > 32767 {
|
||||
fmt.Fprintf(stderr, "felis manifests: --panel-node-port must be in Kubernetes NodePort range 30000-32767 (got %d)\n", *panelNodePort)
|
||||
return 2
|
||||
}
|
||||
|
||||
// Retention/reaper rendering is opt-in and needs all three storage coordinates
|
||||
// together: where worlds live (to read+archive them), the backup PVC (to write
|
||||
@@ -114,6 +119,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
|
||||
BuildNamespace: *buildNS,
|
||||
RegistryNamespace: *registryNS,
|
||||
RegistryPort: int32(*registryPort),
|
||||
PanelNodePort: int32(*panelNodePort),
|
||||
FelisImage: *felisImage,
|
||||
RegistryImage: *registryImage,
|
||||
BackupPVC: *backupPVC,
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
)
|
||||
|
||||
// cmdRestore is the in-Pod entrypoint the restore Job runs (internal/restore
|
||||
// renders a Pod whose command is `felis restore`). It extracts a world archive
|
||||
// from the backup mount into the world mount and exits — it is NOT a
|
||||
// cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore
|
||||
// renders a Pod whose command is `/usr/local/bin/felis restore`. It extracts a
|
||||
// world archive from the backup mount into the world mount and exits — it is NOT a
|
||||
// user-facing command and is never invoked by hand.
|
||||
//
|
||||
// It deliberately holds NO database credentials and never calls config.Load:
|
||||
|
||||
+3
-1
@@ -18,7 +18,7 @@ Commands:
|
||||
restore Extract a world archive into a world volume (internal Job entrypoint)
|
||||
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
|
||||
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
|
||||
setup Open the first-run setup console (TUI; requires root/sudo)
|
||||
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
|
||||
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
|
||||
|
||||
Run "felis <command> -h" for command-specific flags.
|
||||
@@ -51,6 +51,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return cmdSetup(rest, stdout, stderr)
|
||||
case "breakGlass":
|
||||
return cmdBreakGlass(rest, stdout, stderr)
|
||||
case "bootstrap-assets":
|
||||
return cmdBootstrapAssets(rest, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
|
||||
+200
-36
@@ -8,60 +8,105 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
|
||||
const defaultSetupConfigPath = "/etc/felis/felis.toml"
|
||||
const hostSetupConfigPath = "/etc/felis/felis.host.toml"
|
||||
const hostBootstrapDonePath = "/etc/felis/bootstrap.done"
|
||||
const hostBootstrapBinPath = "/usr/local/bin/felis"
|
||||
const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"
|
||||
|
||||
var errHostBootstrapCancelled = errors.New("host bootstrap cancelled")
|
||||
|
||||
// cmdSetup is the normal first-run operator console. It is intentionally separate
|
||||
// from breakGlass: setup creates the initial Owner and optional web edge; breakGlass
|
||||
// is reserved for emergency local recovery/reset.
|
||||
func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("setup", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
configFlagSet := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "config" {
|
||||
configFlagSet = true
|
||||
}
|
||||
})
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(stderr, "felis setup: refused — the setup console must run as root (try: sudo felis setup)")
|
||||
return 1
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
drv, err := store.Open(ctx, cfg.Database.URL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: open database: %v\n", err)
|
||||
bootstrapped := false
|
||||
if shouldRunHostBootstrapBeforeConfig(configFlagSet) {
|
||||
if err := runHostBootstrapForSetup(ctx); err != nil {
|
||||
return reportHostBootstrapError(err, stdout, stderr)
|
||||
}
|
||||
bootstrapped = true
|
||||
}
|
||||
if err := repairDefaultSetupConfig(configFlagSet); err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: repair default config: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer drv.Close()
|
||||
|
||||
repo := api.NewPGRepo(drv.DB())
|
||||
adminExists, err := repo.AdminExists(ctx)
|
||||
effectiveCfgPath := setupConfigPath(*cfgPath, configFlagSet)
|
||||
setup, err := openConfiguredSetup(ctx, effectiveCfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: detect existing admin: %v\n", err)
|
||||
return 1
|
||||
if bootstrapped || !shouldRunHostBootstrap(effectiveCfgPath, configFlagSet, err) {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := runHostBootstrapForSetup(ctx); err != nil {
|
||||
return reportHostBootstrapError(err, stdout, stderr)
|
||||
}
|
||||
bootstrapped = true
|
||||
if err := repairDefaultSetupConfig(configFlagSet); err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: repair default config: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
effectiveCfgPath = setupConfigPath(*cfgPath, configFlagSet)
|
||||
setup, err = openConfiguredSetup(ctx, effectiveCfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: after bootstrap: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
defer setup.drv.Close()
|
||||
|
||||
res, err := runSetupTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists)
|
||||
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, accountableOSUser(), setup.adminExists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
panelURL := res.panelURL
|
||||
if panelURL == "" {
|
||||
panelURL = localPanelURL(setup.cfg.Server.RootDomain)
|
||||
}
|
||||
|
||||
if !res.provisioned && !res.edgeConfigured {
|
||||
if bootstrapped {
|
||||
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/edge setup skipped.")
|
||||
if panelURL != "" {
|
||||
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
||||
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintln(stdout, "felis setup: cancelled — no changes made.")
|
||||
if panelURL != "" {
|
||||
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -76,8 +121,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
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 panelURL != "" {
|
||||
fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", panelURL)
|
||||
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,27 +135,145 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
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:\n", *cfgPath)
|
||||
if res.edgePanelHostname != "" {
|
||||
fmt.Fprintf(stdout, " panel_hostname = %q\n", res.edgePanelHostname)
|
||||
}
|
||||
if res.edgeAdminHostname != "" {
|
||||
fmt.Fprintf(stdout, " admin_hostname = %q\n", res.edgeAdminHostname)
|
||||
}
|
||||
fmt.Fprintf(stdout, " access_jwt_aud = %q\n", 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.")
|
||||
fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func adminLoginURL(rootDomain, adminHostname string) string {
|
||||
if h := strings.TrimSpace(adminHostname); h != "" {
|
||||
return "https://" + h
|
||||
type configuredSetup struct {
|
||||
cfg *config.Config
|
||||
drv *store.PostgresDriver
|
||||
repo *api.PGRepo
|
||||
adminExists bool
|
||||
}
|
||||
|
||||
type setupOpenError struct {
|
||||
stage string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *setupOpenError) Error() string {
|
||||
return e.stage + ": " + e.err.Error()
|
||||
}
|
||||
|
||||
func (e *setupOpenError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func openConfiguredSetup(ctx context.Context, cfgPath string) (*configuredSetup, error) {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return nil, &setupOpenError{stage: "load config", err: err}
|
||||
}
|
||||
if rootDomain != "" {
|
||||
return "https://op.console." + rootDomain
|
||||
drv, err := store.Open(ctx, cfg.Database.URL)
|
||||
if err != nil {
|
||||
return nil, &setupOpenError{stage: "open database", err: err}
|
||||
}
|
||||
return ""
|
||||
repo := api.NewPGRepo(drv.DB())
|
||||
adminExists, err := repo.AdminExists(ctx)
|
||||
if err != nil {
|
||||
drv.Close()
|
||||
return nil, &setupOpenError{stage: "detect existing admin", err: err}
|
||||
}
|
||||
return &configuredSetup{cfg: cfg, drv: drv, repo: repo, adminExists: adminExists}, nil
|
||||
}
|
||||
|
||||
func setupConfigPath(requested string, configFlagSet bool) string {
|
||||
return setupConfigPathFor(requested, hostSetupConfigPath, configFlagSet)
|
||||
}
|
||||
|
||||
func setupConfigPathFor(requested, host string, configFlagSet bool) string {
|
||||
if configFlagSet {
|
||||
return requested
|
||||
}
|
||||
if _, err := os.Stat(host); err == nil {
|
||||
return host
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func repairDefaultSetupConfig(configFlagSet bool) error {
|
||||
if configFlagSet {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(hostSetupConfigPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ensureDefaultConfigLink(defaultSetupConfigPath, hostSetupConfigPath)
|
||||
}
|
||||
|
||||
func ensureDefaultConfigLink(target, host string) error {
|
||||
if link, err := os.Readlink(target); err == nil && link == host {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Lstat(target); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return os.Symlink(host, target)
|
||||
}
|
||||
return err
|
||||
}
|
||||
backup := fmt.Sprintf("%s.bak.%s.%d", target, time.Now().UTC().Format("20060102150405"), os.Getpid())
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(host, target)
|
||||
}
|
||||
|
||||
func shouldRunHostBootstrap(cfgPath string, configFlagSet bool, err error) bool {
|
||||
if configFlagSet {
|
||||
return false
|
||||
}
|
||||
var setupErr *setupOpenError
|
||||
if !errors.As(err, &setupErr) {
|
||||
return false
|
||||
}
|
||||
if setupErr.stage == "open database" {
|
||||
return true
|
||||
}
|
||||
if setupErr.stage != "load config" {
|
||||
return false
|
||||
}
|
||||
_, statErr := os.Stat(cfgPath)
|
||||
return errors.Is(statErr, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func shouldRunHostBootstrapBeforeConfig(configFlagSet bool) bool {
|
||||
if configFlagSet {
|
||||
return false
|
||||
}
|
||||
return !hostBootstrapReady(hostBootstrapDonePath, hostSetupConfigPath, hostBootstrapBinPath, hostBootstrapKubeconfigPath)
|
||||
}
|
||||
|
||||
func hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig string) bool {
|
||||
return fileExists(marker) && fileExists(hostConfig) && executableExists(hostBin) && fileExists(kubeconfig)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func executableExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir() && info.Mode()&0o111 != 0
|
||||
}
|
||||
|
||||
func runHostBootstrapForSetup(ctx context.Context) error {
|
||||
completed, err := runHostBootstrapTUI(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !completed {
|
||||
return errHostBootstrapCancelled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reportHostBootstrapError(err error, stdout, stderr io.Writer) int {
|
||||
if errors.Is(err, errHostBootstrapCancelled) {
|
||||
fmt.Fprintln(stdout, "felis setup: cancelled — bootstrap not run.")
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stderr, "felis setup: bootstrap: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultPanelNodePort = 30443
|
||||
|
||||
type panelAccessResult struct {
|
||||
url string
|
||||
err error
|
||||
}
|
||||
|
||||
func setupPanelNodePort() int {
|
||||
raw := strings.TrimSpace(os.Getenv("FELIS_PANEL_NODEPORT"))
|
||||
if raw == "" {
|
||||
return defaultPanelNodePort
|
||||
}
|
||||
port, err := strconv.Atoi(raw)
|
||||
if err != nil || port < 30000 || port > 32767 {
|
||||
return defaultPanelNodePort
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func localPanelURL(rootDomain string) string {
|
||||
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
|
||||
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
|
||||
}
|
||||
host := defaultAdminHostname(rootDomain, "")
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://%s:%d", host, setupPanelNodePort())
|
||||
}
|
||||
|
||||
func rootDomainEmbeddedIP(rootDomain string) string {
|
||||
domain := strings.TrimSpace(strings.TrimSuffix(rootDomain, "."))
|
||||
for _, suffix := range []string{".nip.io", ".sslip.io"} {
|
||||
base := strings.TrimSuffix(domain, suffix)
|
||||
if base == domain {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(base); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func localPanelOrigin() string {
|
||||
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
||||
}
|
||||
|
||||
func checkPanelAccess(rootDomain string) panelAccessResult {
|
||||
base := localPanelURL(rootDomain)
|
||||
if base == "" {
|
||||
return panelAccessResult{err: fmt.Errorf("root domain is empty")}
|
||||
}
|
||||
hostURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return panelAccessResult{url: base, err: err}
|
||||
}
|
||||
probeBase := fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
||||
client := &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec
|
||||
}
|
||||
for _, target := range []string{probeBase + "/healthz", probeBase + "/", probeBase + "/config.json"} {
|
||||
req, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return panelAccessResult{url: base, err: err}
|
||||
}
|
||||
req.Host = hostURL.Hostname()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return panelAccessResult{url: base, err: err}
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return panelAccessResult{url: base, err: fmt.Errorf("%s returned HTTP %d", target, resp.StatusCode)}
|
||||
}
|
||||
if strings.HasSuffix(target, "/config.json") {
|
||||
var cfg struct {
|
||||
APIBase string `json:"apiBase"`
|
||||
RootDomain string `json:"rootDomain"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
||||
return panelAccessResult{url: base, err: fmt.Errorf("decode config.json: %w", err)}
|
||||
}
|
||||
if cfg.APIBase != "/api/v1" || cfg.RootDomain == "" {
|
||||
return panelAccessResult{url: base, err: fmt.Errorf("config.json is incomplete")}
|
||||
}
|
||||
}
|
||||
}
|
||||
return panelAccessResult{url: base}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetupConfigPathPrefersGeneratedHostConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
requested := filepath.Join(dir, "felis.toml")
|
||||
host := filepath.Join(dir, "felis.host.toml")
|
||||
|
||||
if got := setupConfigPathFor(requested, host, false); got != requested {
|
||||
t.Fatalf("without host config: got %q, want requested %q", got, requested)
|
||||
}
|
||||
if err := os.WriteFile(host, []byte("host"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := setupConfigPathFor(requested, host, false); got != host {
|
||||
t.Fatalf("with host config: got %q, want host %q", got, host)
|
||||
}
|
||||
if got := setupConfigPathFor(requested, host, true); got != requested {
|
||||
t.Fatalf("explicit config: got %q, want requested %q", got, requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultConfigLinkBacksUpStaleDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "felis.toml")
|
||||
host := filepath.Join(dir, "felis.host.toml")
|
||||
if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(host, []byte("host"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureDefaultConfigLink(target, host); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := os.Readlink(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link != host {
|
||||
t.Fatalf("default config link = %q, want %q", link, host)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundBackup := false
|
||||
for _, e := range entries {
|
||||
foundBackup = foundBackup || strings.HasPrefix(e.Name(), "felis.toml.bak.")
|
||||
}
|
||||
if !foundBackup {
|
||||
t.Fatal("stale default config was not backed up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostBootstrapReadyRequiresMarkerAndArtifacts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "bootstrap.done")
|
||||
hostConfig := filepath.Join(dir, "felis.host.toml")
|
||||
hostBin := filepath.Join(dir, "felis")
|
||||
kubeconfig := filepath.Join(dir, "k3s.yaml")
|
||||
|
||||
if err := os.WriteFile(marker, []byte("done"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig) {
|
||||
t.Fatal("bootstrap should not be ready with marker only")
|
||||
}
|
||||
|
||||
for _, path := range []string{hostConfig, kubeconfig} {
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(hostBin, []byte("bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig) {
|
||||
t.Fatal("bootstrap should be ready when marker and host artifacts exist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
felis "felis.lolicon.best"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type hostBootstrapState int
|
||||
|
||||
const (
|
||||
hostBootstrapIntro hostBootstrapState = iota
|
||||
hostBootstrapRunning
|
||||
hostBootstrapDone
|
||||
hostBootstrapError
|
||||
)
|
||||
|
||||
type hostBootstrapDoneMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type hostBootstrapModel struct {
|
||||
ctx context.Context
|
||||
state hostBootstrapState
|
||||
completed bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newHostBootstrapModel(ctx context.Context) *hostBootstrapModel {
|
||||
return &hostBootstrapModel{ctx: ctx}
|
||||
}
|
||||
|
||||
func (m *hostBootstrapModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *hostBootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case hostBootstrapDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.state = hostBootstrapError
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.state = hostBootstrapDone
|
||||
m.completed = true
|
||||
m.err = nil
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case hostBootstrapIntro:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.state = hostBootstrapRunning
|
||||
m.err = nil
|
||||
return m, m.runBootstrap()
|
||||
}
|
||||
case hostBootstrapDone:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "enter":
|
||||
return m, tea.Quit
|
||||
}
|
||||
case hostBootstrapError:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.state = hostBootstrapRunning
|
||||
m.err = nil
|
||||
return m, m.runBootstrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *hostBootstrapModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Host Bootstrap"))
|
||||
|
||||
switch m.state {
|
||||
case hostBootstrapIntro:
|
||||
b.WriteString(tuiHint.Render("Host bootstrap has not been marked complete.") + "\n\n")
|
||||
b.WriteString(tuiInfo("The embedded installer will configure system packages, Docker, k3s, PostgreSQL, migrations, and the Felis control plane.") + "\n\n")
|
||||
b.WriteString(tuiWarn.Render("Run this on a disposable Linux host or VM. It changes system services.") + "\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "run bootstrap", "esc", "cancel"))
|
||||
case hostBootstrapRunning:
|
||||
b.WriteString(tuiHint.Render("Running host bootstrap...") + "\n")
|
||||
b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n")
|
||||
case hostBootstrapDone:
|
||||
b.WriteString(tuiSuccessBanner("Host bootstrap completed.") + "\n\n")
|
||||
b.WriteString(tuiInfo("Continue to create the Owner account and optional Cloudflare edge.") + "\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "continue", "esc", "continue"))
|
||||
case hostBootstrapError:
|
||||
b.WriteString(tuiErrorBanner("Host bootstrap failed.") + "\n\n")
|
||||
if m.err != nil {
|
||||
b.WriteString(tuiHint.Render(m.err.Error()) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "retry", "esc", "exit"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *hostBootstrapModel) runBootstrap() tea.Cmd {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return func() tea.Msg { return hostBootstrapDoneMsg{err: err} }
|
||||
}
|
||||
cmd := exec.CommandContext(m.ctx, "bash", "-s")
|
||||
cmd.Stdin = strings.NewReader(felis.BootstrapScript())
|
||||
cmd.Env = append(os.Environ(),
|
||||
"FELIS_BOOTSTRAP_FROM_TUI=1",
|
||||
"FELIS_BOOTSTRAP_BINARY="+exe,
|
||||
)
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
||||
return hostBootstrapDoneMsg{err: err}
|
||||
})
|
||||
}
|
||||
|
||||
func runHostBootstrapTUI(ctx context.Context) (bool, error) {
|
||||
final, err := tea.NewProgram(newHostBootstrapModel(ctx)).Run()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
m, ok := final.(*hostBootstrapModel)
|
||||
if !ok {
|
||||
return false, errors.New("unexpected bootstrap model type")
|
||||
}
|
||||
return m.completed, m.err
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type dashboardModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
rootDomain string
|
||||
adminHost string
|
||||
panelHost string
|
||||
osUser string
|
||||
dbURL string
|
||||
adminExists bool
|
||||
|
||||
focus int
|
||||
|
||||
pgStatus stepStatus
|
||||
pgDetail string
|
||||
|
||||
mgStatus stepStatus
|
||||
mgDetail string
|
||||
|
||||
owStatus stepStatus
|
||||
owDetail string
|
||||
|
||||
paStatus stepStatus
|
||||
paDetail string
|
||||
|
||||
egStatus stepStatus
|
||||
egDetail string
|
||||
}
|
||||
|
||||
func newDashboardModel(ctx context.Context, store ownerStore, dbURL, osUser, rootDomain, adminHost, panelHost string) *dashboardModel {
|
||||
return &dashboardModel{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
dbURL: dbURL,
|
||||
osUser: osUser,
|
||||
rootDomain: rootDomain,
|
||||
adminHost: adminHost,
|
||||
panelHost: panelHost,
|
||||
pgStatus: statusPending,
|
||||
mgStatus: statusPending,
|
||||
owStatus: statusOptional,
|
||||
paStatus: statusPending,
|
||||
egStatus: statusOptional,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *dashboardModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "up":
|
||||
if m.focus > 0 {
|
||||
m.focus--
|
||||
}
|
||||
return m, nil
|
||||
case "down":
|
||||
if m.focus < 4 {
|
||||
m.focus++
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
return m.enterStep()
|
||||
case "r", "R":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *dashboardModel) enterStep() (tea.Model, tea.Cmd) {
|
||||
switch m.focus {
|
||||
case 0:
|
||||
return newPostgresModel(m.dbURL, m.osUser), nil
|
||||
case 1:
|
||||
return newMigrationModel(m.dbURL, m.osUser), nil
|
||||
case 2:
|
||||
if m.adminExists {
|
||||
return m, nil
|
||||
}
|
||||
return newOwnerModel(m.ctx, m.store, m.osUser, false), nil
|
||||
case 3:
|
||||
return m, nil
|
||||
case 4:
|
||||
return newEdgeModel(m.rootDomain, m.adminHost, m.panelHost), nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *dashboardModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Setup"))
|
||||
|
||||
type step struct {
|
||||
title string
|
||||
status stepStatus
|
||||
detail string
|
||||
idx int
|
||||
}
|
||||
|
||||
steps := []step{
|
||||
{"Database & Migrations", m.pgStatus, m.pgDetail, 0},
|
||||
{"Database Migrations", m.mgStatus, m.mgDetail, 1},
|
||||
{"Owner Account", m.owStatus, m.owDetail, 2},
|
||||
{"Panel Access", m.paStatus, m.paDetail, 3},
|
||||
{"Cloudflare Edge", m.egStatus, m.egDetail, 4},
|
||||
}
|
||||
|
||||
for _, s := range steps {
|
||||
icon := tuiIcon(s.status)
|
||||
label := s.title
|
||||
if s.detail != "" {
|
||||
label += " " + tuiHint.Render(s.detail)
|
||||
}
|
||||
prefix := " "
|
||||
if m.focus == s.idx {
|
||||
prefix = tuiLabel.Render("▸ ")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s%s %s\n\n", prefix, icon, label))
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(tuiSeparator())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(tuiAction("enter", "configure", "r", "refresh", "↑↓", "navigate", "esc", "exit"))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type egStep int
|
||||
|
||||
const (
|
||||
egIntro egStep = iota
|
||||
egAuth
|
||||
egConfig
|
||||
egWorking
|
||||
egDone
|
||||
egError
|
||||
)
|
||||
|
||||
type egAuthDoneMsg struct {
|
||||
token string
|
||||
account string
|
||||
err error
|
||||
}
|
||||
|
||||
type egLoginDoneMsg struct{ err error }
|
||||
|
||||
type egInstallDoneMsg struct{ err error }
|
||||
|
||||
type egSetupDoneMsg struct {
|
||||
result *cfsetup.Result
|
||||
err error
|
||||
}
|
||||
|
||||
type edgeModel struct {
|
||||
step egStep
|
||||
|
||||
rootDomain string
|
||||
adminHostname string
|
||||
panelHostname string
|
||||
|
||||
// cloudflared detection
|
||||
cloudflaredPath string
|
||||
certExists bool
|
||||
installing bool
|
||||
loginNote string
|
||||
|
||||
// auth step inputs
|
||||
authInputs []textinput.Model
|
||||
authFocus int
|
||||
authErr string
|
||||
authToken string
|
||||
authAccount string
|
||||
|
||||
// config step inputs
|
||||
cfgInputs []textinput.Model
|
||||
cfgFocus int
|
||||
cfgErr string
|
||||
|
||||
// working / result
|
||||
working string
|
||||
lastErr error
|
||||
prog []string
|
||||
result *cfsetup.Result
|
||||
panelSet string
|
||||
adminSet string
|
||||
}
|
||||
|
||||
func newEdgeModel(rootDomain, adminHost, panelHost string) *edgeModel {
|
||||
m := &edgeModel{
|
||||
step: egIntro,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHost,
|
||||
panelHostname: panelHost,
|
||||
}
|
||||
m.detectCloudflared()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *edgeModel) detectCloudflared() {
|
||||
pre := cfsetup.DetectPreconditions("")
|
||||
m.cloudflaredPath = pre.CloudflaredPath
|
||||
m.certExists = pre.CertExists
|
||||
}
|
||||
|
||||
func (m *edgeModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case egLoginDoneMsg:
|
||||
m.detectCloudflared()
|
||||
if msg.err != nil && !m.certExists {
|
||||
m.loginNote = "cloudflared login did not complete: " + msg.err.Error()
|
||||
} else if !m.certExists {
|
||||
m.loginNote = "login finished but cert not found — try again"
|
||||
} else {
|
||||
m.loginNote = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case egInstallDoneMsg:
|
||||
m.installing = false
|
||||
if msg.err != nil {
|
||||
m.loginNote = "install failed: " + msg.err.Error()
|
||||
} else {
|
||||
m.detectCloudflared()
|
||||
m.loginNote = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case egAuthDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.authErr = msg.err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.authToken = msg.token
|
||||
m.authAccount = msg.account
|
||||
m.step = egConfig
|
||||
return m, m.enterConfig()
|
||||
|
||||
case egSetupDoneMsg:
|
||||
m.working = ""
|
||||
if msg.err != nil {
|
||||
m.step = egError
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.step = egDone
|
||||
m.result = msg.result
|
||||
if msg.result != nil {
|
||||
m.prog = msg.result.Progress
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Cloudflare Edge"))
|
||||
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
b.WriteString(tuiHint.Render("Publish web faces securely via Cloudflare Tunnel + Access.") + "\n\n")
|
||||
b.WriteString(tuiLabel.Render("Status") + "\n")
|
||||
if m.cloudflaredPath == "" {
|
||||
b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n")
|
||||
} else {
|
||||
b.WriteString(" " + tuiOK.Render("✓ cloudflared") + " " + tuiHint.Render(m.cloudflaredPath) + "\n")
|
||||
if m.certExists {
|
||||
b.WriteString(" " + tuiOK.Render("✓ logged in") + "\n\n")
|
||||
} else {
|
||||
b.WriteString(" " + tuiWarn.Render("⟳ not logged in") + " — press l for browser login\n\n")
|
||||
}
|
||||
}
|
||||
if m.loginNote != "" {
|
||||
b.WriteString(tuiHint.Render(m.loginNote) + "\n\n")
|
||||
}
|
||||
if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" {
|
||||
b.WriteString(tuiHint.Render("Player console: "+panel) + "\n")
|
||||
}
|
||||
if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" {
|
||||
b.WriteString(tuiHint.Render("Admin console: "+admin) + " (Access-guarded)\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
switch {
|
||||
case m.cloudflaredPath != "" && m.certExists:
|
||||
b.WriteString(tuiAction("enter", "continue", "esc", "back"))
|
||||
case m.cloudflaredPath == "":
|
||||
b.WriteString(tuiAction("i", "install cloudflared", "esc", "back"))
|
||||
default:
|
||||
b.WriteString(tuiAction("l", "login", "esc", "back"))
|
||||
}
|
||||
|
||||
case egAuth:
|
||||
b.WriteString(tuiHint.Render("Step 1/2: Enter your Cloudflare credentials.") + "\n\n")
|
||||
b.WriteString(tuiWizardCard("API Token & Account ID",
|
||||
"Create a Bearer token at: "+cloudflareAccessTokenTemplateURL,
|
||||
tuiFormField("API token", m.authInputs[0])+"\n\n"+
|
||||
tuiFormField("Account ID", m.authInputs[1])))
|
||||
b.WriteString("\n" + tuiInfo("Account ID is in the Cloudflare Dashboard URL: dash.cloudflare.com/<this-part>") + "\n")
|
||||
if m.authErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.authErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "continue", "esc", "back"))
|
||||
|
||||
case egConfig:
|
||||
b.WriteString(tuiHint.Render("Step 2/2: Choose hostnames and who gets access.") + "\n\n")
|
||||
labels := []string{"Admit (your email, or @your-domain)", "Player console hostname", "Admin console hostname", "Tunnel name", "Config path"}
|
||||
var fields string
|
||||
for i, lbl := range labels {
|
||||
if i > 0 {
|
||||
fields += "\n\n"
|
||||
}
|
||||
fields += tuiFormField(lbl, m.cfgInputs[i])
|
||||
}
|
||||
b.WriteString(tuiWizardCard("Hostnames & Identity", "", fields))
|
||||
if m.cfgErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.cfgErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "configure", "esc", "back"))
|
||||
|
||||
case egWorking:
|
||||
b.WriteString(tuiHint.Render("Configuring Cloudflare Tunnel + Access edge…") + "\n\n")
|
||||
for _, s := range m.prog {
|
||||
b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n")
|
||||
}
|
||||
if m.working != "" {
|
||||
b.WriteString(" " + tuiIconSpin + " " + m.working + "\n")
|
||||
}
|
||||
if len(m.prog) == 0 && m.working == "" {
|
||||
b.WriteString(tuiHint.Render("Starting…") + "\n")
|
||||
}
|
||||
|
||||
case egDone:
|
||||
b.WriteString(tuiSuccessBanner("Cloudflare edge configured.") + "\n\n")
|
||||
var box string
|
||||
if m.result != nil {
|
||||
box = tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n"
|
||||
if len(m.result.RoutedHostnames) > 0 {
|
||||
box += tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n"
|
||||
}
|
||||
if m.result.ConfigPath != "" {
|
||||
box += tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n"
|
||||
}
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(box) + "\n\n")
|
||||
b.WriteString(tuiOK.Render("✓") + " Felis config, Kubernetes Secret, API rollout and cloudflared service updated.\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter/esc", "back"))
|
||||
|
||||
case egError:
|
||||
b.WriteString(tuiErrorBanner("Edge setup failed.") + "\n\n")
|
||||
if len(m.prog) > 0 {
|
||||
b.WriteString(tuiHint.Render("Completed before failure:") + "\n")
|
||||
for _, s := range m.prog {
|
||||
b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "retry", "esc", "back"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
return m.handleIntroKey(msg)
|
||||
case egAuth:
|
||||
return m.handleAuthKey(msg)
|
||||
case egConfig:
|
||||
return m.handleCfgKey(msg)
|
||||
case egDone, egError:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
if m.step == egDone {
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "enter":
|
||||
if m.step == egDone {
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
if m.step == egError {
|
||||
m.step = egConfig
|
||||
m.lastErr = nil
|
||||
m.prog = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
default:
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "i", "I":
|
||||
if m.cloudflaredPath == "" {
|
||||
m.installing = true
|
||||
return m, m.installCloudflared()
|
||||
}
|
||||
case "l", "L":
|
||||
if m.cloudflaredPath != "" && !m.certExists {
|
||||
nm, cmd := m.startLogin()
|
||||
return nm, cmd
|
||||
}
|
||||
case "enter":
|
||||
if m.cloudflaredPath != "" && m.certExists {
|
||||
m.step = egAuth
|
||||
return m, m.enterAuth()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleAuthKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.step = egIntro
|
||||
m.authErr = ""
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.authFocus = (m.authFocus + 1) % 2
|
||||
return m, m.focusAuthInput(m.authFocus)
|
||||
case "shift+tab", "up":
|
||||
m.authFocus = (m.authFocus + 1) % 2
|
||||
return m, m.focusAuthInput(m.authFocus)
|
||||
case "enter":
|
||||
return m.submitAuth()
|
||||
}
|
||||
return m, m.updateAuthInputs(msg)
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleCfgKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.step = egAuth
|
||||
m.cfgErr = ""
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.cfgFocus = (m.cfgFocus + 1) % 5
|
||||
return m, m.focusCfgInput(m.cfgFocus)
|
||||
case "shift+tab", "up":
|
||||
m.cfgFocus = (m.cfgFocus + 4) % 5
|
||||
return m, m.focusCfgInput(m.cfgFocus)
|
||||
case "enter":
|
||||
return m.submitConfig()
|
||||
}
|
||||
return m, m.updateCfgInputs(msg)
|
||||
}
|
||||
|
||||
func (m *edgeModel) enterAuth() tea.Cmd {
|
||||
token := tuiInput("cfat_…", 200, true)
|
||||
account := tuiInput("32-char account ID", 64, false)
|
||||
m.authInputs = []textinput.Model{token, account}
|
||||
m.authFocus = 0
|
||||
return m.focusAuthInput(0)
|
||||
}
|
||||
|
||||
func (m *edgeModel) focusAuthInput(i int) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
for j := range m.authInputs {
|
||||
if j == i {
|
||||
cmd = m.authInputs[j].Focus()
|
||||
} else {
|
||||
m.authInputs[j].Blur()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *edgeModel) updateAuthInputs(msg tea.Msg) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.authInputs))
|
||||
for i := range m.authInputs {
|
||||
m.authInputs[i], cmds[i] = m.authInputs[i].Update(msg)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *edgeModel) submitAuth() (tea.Model, tea.Cmd) {
|
||||
token := strings.TrimSpace(m.authInputs[0].Value())
|
||||
account := strings.TrimSpace(m.authInputs[1].Value())
|
||||
if token == "" {
|
||||
m.authErr = "a Cloudflare API token is required"
|
||||
return m, nil
|
||||
}
|
||||
if account == "" {
|
||||
m.authErr = "the Cloudflare account ID is required"
|
||||
return m, nil
|
||||
}
|
||||
if !isHex32(account) {
|
||||
if strings.HasPrefix(account, "cfat_") {
|
||||
m.authErr = "that looks like an API token — the Account ID is a 32-char hex string"
|
||||
} else {
|
||||
m.authErr = "the Account ID must be 32 hex characters"
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.authErr = ""
|
||||
return m, func() tea.Msg { return egAuthDoneMsg{token: token, account: account} }
|
||||
}
|
||||
|
||||
func (m *edgeModel) enterConfig() tea.Cmd {
|
||||
identity := tuiInput("[email protected] or @your-domain", 254, false)
|
||||
panelHost := tuiInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false)
|
||||
panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname))
|
||||
adminHost := tuiInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false)
|
||||
adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname))
|
||||
tunnel := tuiInput(defaultTunnelName, 64, false)
|
||||
tunnel.SetValue(defaultTunnelName)
|
||||
cfgPath := tuiInput(defaultTunnelConfigPath, 256, false)
|
||||
cfgPath.SetValue(defaultTunnelConfigPath)
|
||||
m.cfgInputs = []textinput.Model{identity, panelHost, adminHost, tunnel, cfgPath}
|
||||
m.cfgFocus = 0
|
||||
return m.focusCfgInput(0)
|
||||
}
|
||||
|
||||
func (m *edgeModel) focusCfgInput(i int) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
for j := range m.cfgInputs {
|
||||
if j == i {
|
||||
cmd = m.cfgInputs[j].Focus()
|
||||
} else {
|
||||
m.cfgInputs[j].Blur()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *edgeModel) updateCfgInputs(msg tea.Msg) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.cfgInputs))
|
||||
for i := range m.cfgInputs {
|
||||
m.cfgInputs[i], cmds[i] = m.cfgInputs[i].Update(msg)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) {
|
||||
identity := strings.TrimSpace(m.cfgInputs[0].Value())
|
||||
panelHost := normalizeEdgeHostname(m.cfgInputs[1].Value())
|
||||
adminHost := normalizeEdgeHostname(m.cfgInputs[2].Value())
|
||||
tunnel := strings.TrimSpace(m.cfgInputs[3].Value())
|
||||
cfgPath := strings.TrimSpace(m.cfgInputs[4].Value())
|
||||
|
||||
if identity == "" {
|
||||
m.cfgErr = "enter who Access should admit"
|
||||
m.cfgFocus = 0
|
||||
return m, nil
|
||||
}
|
||||
if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" {
|
||||
m.cfgErr = "enter a domain after the @, e.g. @your-domain"
|
||||
m.cfgFocus = 0
|
||||
return m, nil
|
||||
}
|
||||
if err := validateEdgeHostname("player console", panelHost, false); err != nil {
|
||||
m.cfgErr = err.Error()
|
||||
m.cfgFocus = 1
|
||||
return m, nil
|
||||
}
|
||||
if err := validateEdgeHostname("admin console", adminHost, true); err != nil {
|
||||
m.cfgErr = err.Error()
|
||||
m.cfgFocus = 2
|
||||
return m, nil
|
||||
}
|
||||
if panelHost != "" && strings.EqualFold(panelHost, adminHost) {
|
||||
m.cfgErr = "player console and admin console hostnames must be different"
|
||||
m.cfgFocus = 2
|
||||
return m, nil
|
||||
}
|
||||
if tunnel == "" {
|
||||
tunnel = defaultTunnelName
|
||||
}
|
||||
if cfgPath == "" {
|
||||
cfgPath = defaultTunnelConfigPath
|
||||
}
|
||||
|
||||
m.panelSet = panelHost
|
||||
m.adminSet = adminHost
|
||||
m.cfgErr = ""
|
||||
m.step = egWorking
|
||||
m.working = "Starting…"
|
||||
|
||||
var id cfsetup.AccessIdentity
|
||||
if strings.HasPrefix(identity, "@") {
|
||||
id.EmailDomains = []string{strings.TrimPrefix(identity, "@")}
|
||||
} else {
|
||||
id.Emails = []string{identity}
|
||||
}
|
||||
|
||||
runner := &cfsetup.ExecRunner{
|
||||
Cloudflared: m.cloudflaredPath,
|
||||
APIToken: m.authToken,
|
||||
AccountID: m.authAccount,
|
||||
}
|
||||
p := cfsetup.Params{
|
||||
PanelHostname: panelHost,
|
||||
AdminHostname: adminHost,
|
||||
PanelOrigin: localPanelOrigin(),
|
||||
TunnelName: tunnel,
|
||||
ConfigPath: cfgPath,
|
||||
AccessIdentity: id,
|
||||
Pre: cfsetup.DetectPreconditions(m.authToken),
|
||||
}
|
||||
|
||||
return m, m.runEdgeSetup(runner, p)
|
||||
}
|
||||
|
||||
func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cmd {
|
||||
var progress []string
|
||||
p.OnProgress = func(step string) {
|
||||
progress = append(progress, step)
|
||||
}
|
||||
return func() tea.Msg {
|
||||
result, err := cfsetup.Setup(context.Background(), runner, p)
|
||||
if err != nil {
|
||||
return egSetupDoneMsg{err: err}
|
||||
}
|
||||
progress = append(progress, "Applying Felis config and starting cloudflared…")
|
||||
if err := applyCloudflareEdge(context.Background(), result, p.PanelHostname, p.AdminHostname, m.cloudflaredPath); err != nil {
|
||||
return egSetupDoneMsg{err: err}
|
||||
}
|
||||
progress = append(progress, "Updated Felis config and started cloudflared")
|
||||
result.Progress = progress
|
||||
return egSetupDoneMsg{result: result}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) sendEdgeResult() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return edgeResultMsg{
|
||||
result: m.result,
|
||||
panelHostname: m.panelSet,
|
||||
adminHostname: m.adminSet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) startLogin() (tea.Model, tea.Cmd) {
|
||||
c := exec.CommandContext(context.Background(), m.cloudflaredPath, "tunnel", "login")
|
||||
return m, tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
return egLoginDoneMsg{err: err}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *edgeModel) installCloudflared() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
arch := "amd64"
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
if strings.TrimSpace(string(out)) == "aarch64" {
|
||||
arch = "arm64"
|
||||
}
|
||||
}
|
||||
url := "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return egInstallDoneMsg{err: fmt.Errorf("download: %w", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return egInstallDoneMsg{err: fmt.Errorf("download returned status %d", resp.StatusCode)}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, resp.Body); err != nil {
|
||||
return egInstallDoneMsg{err: fmt.Errorf("read: %w", err)}
|
||||
}
|
||||
if err := os.WriteFile("/usr/local/bin/cloudflared", buf.Bytes(), 0o755); err != nil {
|
||||
return egInstallDoneMsg{err: fmt.Errorf("install: %w", err)}
|
||||
}
|
||||
return egInstallDoneMsg{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
"felis.lolicon.best/internal/config"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
const podSetupConfigPath = "/etc/felis/felis.pod.toml"
|
||||
const cloudflaredFelisUnit = "/etc/systemd/system/cloudflared-felis.service"
|
||||
|
||||
func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost, adminHost, cloudflaredBin string) error {
|
||||
if result == nil || result.AccessAud == "" {
|
||||
return fmt.Errorf("edge result did not include an Access audience")
|
||||
}
|
||||
if adminHost == "" {
|
||||
return fmt.Errorf("admin hostname is required")
|
||||
}
|
||||
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
|
||||
if err := updateAuthConfig(path, panelHost, adminHost, result.AccessAud); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyFelisConfigSecret(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installCloudflaredService(ctx, cloudflaredBin, result.ConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
||||
}
|
||||
|
||||
func updateAuthConfig(path, panelHost, adminHost, aud string) error {
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if panelHost != "" {
|
||||
cfg.Auth.PanelHostname = panelHost
|
||||
}
|
||||
cfg.Auth.AdminHostname = adminHost
|
||||
cfg.Auth.AccessJWTAud = aud
|
||||
return writeConfig(path, cfg)
|
||||
}
|
||||
|
||||
func writeConfig(path string, cfg *config.Config) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".felis-*.toml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := toml.NewEncoder(tmp).Encode(cfg); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func applyFelisConfigSecret(ctx context.Context) error {
|
||||
out, err := kubectlOutput(ctx,
|
||||
"-n", "felis", "create", "secret", "generic", "felis-config",
|
||||
"--from-file=felis.toml="+podSetupConfigPath,
|
||||
"--dry-run=client", "-o", "yaml",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return kubectlWithInput(ctx, out, "apply", "-f", "-")
|
||||
}
|
||||
|
||||
func installCloudflaredService(ctx context.Context, cloudflaredBin, configPath string) error {
|
||||
if cloudflaredBin == "" {
|
||||
return fmt.Errorf("cloudflared binary path is empty")
|
||||
}
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("cloudflared config path is empty")
|
||||
}
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=Felis Cloudflare Tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s --config %s tunnel run
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, cloudflaredBin, configPath)
|
||||
if err := os.WriteFile(cloudflaredFelisUnit, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
return systemctl(ctx, "enable", "--now", "cloudflared-felis.service")
|
||||
}
|
||||
|
||||
func kubectl(ctx context.Context, args ...string) error {
|
||||
_, err := kubectlOutput(ctx, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func kubectlWithInput(ctx context.Context, input []byte, args ...string) error {
|
||||
_, err := runK3sKubectl(ctx, input, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func kubectlOutput(ctx context.Context, args ...string) ([]byte, error) {
|
||||
return runK3sKubectl(ctx, nil, args...)
|
||||
}
|
||||
|
||||
func runK3sKubectl(ctx context.Context, input []byte, args ...string) ([]byte, error) {
|
||||
fullArgs := append([]string{"kubectl"}, args...)
|
||||
cmd := exec.CommandContext(ctx, "k3s", fullArgs...)
|
||||
cmd.Env = append(os.Environ(), "KUBECONFIG="+hostBootstrapKubeconfigPath)
|
||||
if input != nil {
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k3s %v: %w: %s", fullArgs, err, string(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func systemctl(ctx context.Context, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %v: %w: %s", args, err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/store"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type migRunMsg struct {
|
||||
n int
|
||||
total int
|
||||
err error
|
||||
}
|
||||
|
||||
type migrationModel struct {
|
||||
dbURL string
|
||||
osUser string
|
||||
|
||||
state string
|
||||
applied int
|
||||
total int
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func newMigrationModel(dbURL, osUser string) *migrationModel {
|
||||
return &migrationModel{
|
||||
dbURL: dbURL,
|
||||
osUser: osUser,
|
||||
state: "checking",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *migrationModel) Init() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
n, err := countMigrations(m.dbURL)
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
total, err := totalMigrations()
|
||||
return migRunMsg{n: n, total: total, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *migrationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case migRunMsg:
|
||||
if msg.err != nil {
|
||||
m.state = "error"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.applied = msg.n
|
||||
m.total = msg.total
|
||||
if m.applied < m.total {
|
||||
m.state = "pending"
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return migrationDoneMsg{n: msg.n} }
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "enter":
|
||||
if m.state == "pending" {
|
||||
m.state = "running"
|
||||
return m, runMigrationsCmd(m.dbURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *migrationModel) View() string {
|
||||
var b string
|
||||
b += tuiHeader("Database Migrations") + "\n"
|
||||
|
||||
switch m.state {
|
||||
case "checking":
|
||||
b += tuiHint.Render("Checking migration status…") + "\n"
|
||||
case "pending":
|
||||
b += tuiWarn.Render(fmt.Sprintf("%d of %d migrations applied. %d pending.", m.applied, m.total, m.total-m.applied)) + "\n\n"
|
||||
b += tuiInfo("Press enter to run pending migrations.") + "\n"
|
||||
case "running":
|
||||
b += tuiHint.Render("Running migrations…") + "\n"
|
||||
b += tuiInfo("This should take only a moment.") + "\n"
|
||||
case "error":
|
||||
b += tuiErr.Render("Migration check failed:") + "\n"
|
||||
b += tuiHint.Render(m.lastErr.Error()) + "\n"
|
||||
default:
|
||||
b += tuiOK.Render(fmt.Sprintf("✓ %d migrations applied.", m.applied)) + "\n"
|
||||
}
|
||||
|
||||
b += "\n"
|
||||
b += tuiSeparator() + "\n"
|
||||
switch m.state {
|
||||
case "pending":
|
||||
b += tuiAction("enter", "run", "esc", "back")
|
||||
case "running":
|
||||
b += tuiAction("esc", "cancel")
|
||||
default:
|
||||
b += tuiAction("esc", "back")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func runMigrationsCmd(dbURL string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
if err != nil {
|
||||
return migRunMsg{err: fmt.Errorf("open db: %w", err)}
|
||||
}
|
||||
defer drv.Close()
|
||||
migrations, err := store.LoadMigrations()
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
_, err = store.Up(ctx, drv, migrations)
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
applied, err := drv.AppliedVersions(ctx)
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
return migRunMsg{n: len(applied), total: len(migrations)}
|
||||
}
|
||||
}
|
||||
|
||||
func totalMigrations() (int, error) {
|
||||
migrations, err := store.LoadMigrations()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(migrations), nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type owAuthMsg struct {
|
||||
matched string
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
|
||||
type owProvisionMsg struct {
|
||||
outcome breakGlassOutcome
|
||||
err error
|
||||
}
|
||||
|
||||
type owStep int
|
||||
|
||||
const (
|
||||
owAuth owStep = iota
|
||||
owOverride
|
||||
owProvision
|
||||
owWorking
|
||||
owDone
|
||||
owError
|
||||
)
|
||||
|
||||
type ownerModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
osUser string
|
||||
adminExists bool
|
||||
mode string // "bootstrap", "recovery", "root_override"
|
||||
accountable string
|
||||
|
||||
step owStep
|
||||
inputs []textinput.Model
|
||||
focus int
|
||||
formErr string
|
||||
working string
|
||||
attempt string
|
||||
|
||||
username string
|
||||
displayPassword string
|
||||
auditWarning string
|
||||
}
|
||||
|
||||
func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel {
|
||||
m := &ownerModel{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
osUser: osUser,
|
||||
adminExists: adminExists,
|
||||
}
|
||||
if adminExists {
|
||||
m.step = owAuth
|
||||
} else {
|
||||
m.mode = "bootstrap"
|
||||
m.accountable = osUser
|
||||
m.step = owProvision
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *ownerModel) Init() tea.Cmd {
|
||||
if m.step == owAuth {
|
||||
return m.buildAuth()
|
||||
}
|
||||
return m.buildProvision(true)
|
||||
}
|
||||
|
||||
func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case owAuthMsg:
|
||||
m.working = ""
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return ownerResultMsg{err: msg.err} }
|
||||
}
|
||||
if msg.ok {
|
||||
m.mode = "recovery"
|
||||
m.accountable = msg.matched
|
||||
m.step = owProvision
|
||||
return m, m.buildProvision(false)
|
||||
}
|
||||
m.step = owOverride
|
||||
m.formErr = ""
|
||||
return m, m.buildOverride()
|
||||
|
||||
case owProvisionMsg:
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return ownerResultMsg{err: msg.err} }
|
||||
}
|
||||
m.step = owDone
|
||||
m.displayPassword = msg.outcome.displayPassword
|
||||
if msg.outcome.auditErr != nil {
|
||||
m.auditWarning = msg.outcome.auditErr.Error()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.step {
|
||||
case owDone, owError:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "enter":
|
||||
if m.step == owDone {
|
||||
return m, m.ownerResultCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case owWorking:
|
||||
return m, nil
|
||||
default:
|
||||
return m.handleFormKey(msg)
|
||||
}
|
||||
}
|
||||
// Forward to inputs.
|
||||
if m.step != owWorking && m.step != owDone && m.step != owError {
|
||||
return m, m.updateInputs(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *ownerModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Owner Account"))
|
||||
|
||||
switch m.step {
|
||||
case owAuth:
|
||||
b.WriteString(tuiHint.Render("A staff account exists. Identify yourself before proceeding.") + "\n\n")
|
||||
b.WriteString(tuiWizardCard("Admin Authentication", "",
|
||||
tuiFormField("Admin username", m.inputs[0])+"\n\n"+
|
||||
tuiFormField("Admin password", m.inputs[1])))
|
||||
if m.formErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "verify", "esc", "cancel"))
|
||||
|
||||
case owOverride:
|
||||
b.WriteString(tuiErrorBanner("That credential did not match.") + "\n\n")
|
||||
b.WriteString(tuiHint.Render(fmt.Sprintf("You can proceed as OS user %q with root authority.", m.osUser)) + "\n\n")
|
||||
b.WriteString(tuiWizardCard("Root Override", "",
|
||||
tuiFormField("Type "+breakGlassOverrideToken+" to confirm", m.inputs[0])))
|
||||
if m.formErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "confirm", "esc", "go back"))
|
||||
|
||||
case owProvision:
|
||||
if m.mode == "bootstrap" {
|
||||
b.WriteString(tuiHint.Render(fmt.Sprintf("Creating the first Owner. Recorded as OS user %q.", m.osUser)) + "\n\n")
|
||||
} else if m.mode == "root_override" {
|
||||
b.WriteString(tuiWarn.Render("Root override — a one-time password will be generated.") + "\n\n")
|
||||
} else {
|
||||
b.WriteString(tuiHint.Render(fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable)) + "\n\n")
|
||||
}
|
||||
var fields string
|
||||
fields = tuiFormField("Owner username", m.inputs[0]) + "\n\n"
|
||||
fields += tuiFormField("Owner email (optional)", m.inputs[1])
|
||||
if m.mode == "bootstrap" {
|
||||
fields += "\n\n" + tuiFormField("Owner password", m.inputs[2])
|
||||
fields += "\n\n" + tuiFormField("Confirm password", m.inputs[3])
|
||||
}
|
||||
b.WriteString(tuiWizardCard("Account Details", "", fields))
|
||||
if m.formErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "provision", "esc", "cancel"))
|
||||
|
||||
case owWorking:
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Working…"
|
||||
}
|
||||
b.WriteString(tuiHint.Render(msg) + "\n")
|
||||
|
||||
case owDone:
|
||||
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
|
||||
var box strings.Builder
|
||||
box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
|
||||
if m.displayPassword != "" {
|
||||
box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n")
|
||||
box.WriteString(tuiWarn.Render("Record this password — it is shown only once.") + "\n")
|
||||
} else {
|
||||
box.WriteString(tuiHint.Render("Log in with the password you entered.") + "\n")
|
||||
}
|
||||
if m.auditWarning != "" {
|
||||
box.WriteString("\n" + tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n")
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
|
||||
b.WriteString(tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter/esc", "back"))
|
||||
|
||||
case owError:
|
||||
b.WriteString(tuiErrorBanner("Owner provisioning failed.") + "\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("esc", "exit"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *ownerModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
if m.step == owOverride {
|
||||
m.step, m.formErr = owAuth, ""
|
||||
return m, m.buildAuth()
|
||||
}
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "tab", "down":
|
||||
m.focus = m.focus + 1
|
||||
if m.focus >= len(m.inputs) {
|
||||
m.focus = 0
|
||||
}
|
||||
return m, m.focusInput(m.focus)
|
||||
case "shift+tab", "up":
|
||||
m.focus = m.focus - 1
|
||||
if m.focus < 0 {
|
||||
m.focus = len(m.inputs) - 1
|
||||
}
|
||||
return m, m.focusInput(m.focus)
|
||||
case "enter":
|
||||
return m.submit()
|
||||
}
|
||||
return m, m.updateInputs(msg)
|
||||
}
|
||||
|
||||
func (m *ownerModel) focusInput(i int) tea.Cmd {
|
||||
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 *ownerModel) 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 *ownerModel) ownerResultCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return ownerResultMsg{
|
||||
username: m.username,
|
||||
displayPassword: m.displayPassword,
|
||||
mode: m.mode,
|
||||
accountable: m.accountable,
|
||||
auditWarning: m.auditWarning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) setInputs(ins []textinput.Model) tea.Cmd {
|
||||
m.inputs = ins
|
||||
m.focus = 0
|
||||
return m.focusInput(0)
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildAuth() tea.Cmd {
|
||||
user := tuiInput("admin username", 64, false)
|
||||
pass := tuiInput("admin password", 128, true)
|
||||
return m.setInputs([]textinput.Model{user, pass})
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildOverride() tea.Cmd {
|
||||
confirm := tuiInput("type "+breakGlassOverrideToken, 16, false)
|
||||
return m.setInputs([]textinput.Model{confirm})
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildProvision(withPassword bool) tea.Cmd {
|
||||
user := tuiInput("owner", 64, false)
|
||||
user.SetValue("owner")
|
||||
email := tuiInput("(optional)", 254, false)
|
||||
ins := []textinput.Model{user, email}
|
||||
if withPassword {
|
||||
ins = append(ins, tuiInput("at least 8 characters", 128, true))
|
||||
ins = append(ins, tuiInput("re-enter password", 128, true))
|
||||
}
|
||||
return m.setInputs(ins)
|
||||
}
|
||||
|
||||
func (m *ownerModel) submit() (tea.Model, tea.Cmd) {
|
||||
switch m.step {
|
||||
case owAuth:
|
||||
return m.submitAuth()
|
||||
case owOverride:
|
||||
return m.submitOverride()
|
||||
case owProvision:
|
||||
return m.submitProvision()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *ownerModel) 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.attempt = user
|
||||
m.formErr, m.working = "", "Verifying admin credential…"
|
||||
m.step = owWorking
|
||||
return m, func() tea.Msg {
|
||||
matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass)
|
||||
return owAuthMsg{matched: matched, ok: ok, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) submitOverride() (tea.Model, tea.Cmd) {
|
||||
if m.inputs[0].Value() != breakGlassOverrideToken {
|
||||
m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed"
|
||||
return m, nil
|
||||
}
|
||||
m.mode = "root_override"
|
||||
m.accountable = m.osUser
|
||||
m.step = owProvision
|
||||
return m, m.buildProvision(false)
|
||||
}
|
||||
|
||||
func (m *ownerModel) submitProvision() (tea.Model, tea.Cmd) {
|
||||
owner := strings.TrimSpace(m.inputs[0].Value())
|
||||
if owner == "" {
|
||||
m.formErr = "owner username is required"
|
||||
return m, m.focusForField(0)
|
||||
}
|
||||
email := m.inputs[1].Value()
|
||||
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.focusForField(2)
|
||||
}
|
||||
if pw != confirm {
|
||||
m.formErr = "the two passwords do not match"
|
||||
return m, m.focusForField(3)
|
||||
}
|
||||
password = pw
|
||||
}
|
||||
m.username = owner
|
||||
m.formErr, m.working = "", "Provisioning Owner account…"
|
||||
m.step = owWorking
|
||||
return m, func() tea.Msg {
|
||||
out, err := performBreakGlass(m.ctx, m.store, breakGlassOp{
|
||||
mode: m.mode,
|
||||
accountable: m.accountable,
|
||||
osUser: m.osUser,
|
||||
ownerUsername: owner,
|
||||
ownerEmail: email,
|
||||
ownerPassword: password,
|
||||
attemptedAdmin: m.attempt,
|
||||
})
|
||||
return owProvisionMsg{outcome: out, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) focusForField(i int) tea.Cmd {
|
||||
m.focus = i
|
||||
return m.focusInput(i)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/store"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type pgCheckMsg struct {
|
||||
running bool
|
||||
reachable bool
|
||||
err error
|
||||
}
|
||||
|
||||
type pgInstallMsg struct{ err error }
|
||||
|
||||
type pgCreateMsg struct{ err error }
|
||||
|
||||
type postgresModel struct {
|
||||
dbURL string
|
||||
osUser string
|
||||
|
||||
state string // "checking", "missing", "installing", "creating", "done", "error"
|
||||
lastErr error
|
||||
pgExists bool
|
||||
}
|
||||
|
||||
func newPostgresModel(dbURL, osUser string) *postgresModel {
|
||||
return &postgresModel{
|
||||
dbURL: dbURL,
|
||||
osUser: osUser,
|
||||
state: "checking",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *postgresModel) Init() tea.Cmd {
|
||||
return checkPostgresCmd(m.dbURL)
|
||||
}
|
||||
|
||||
func (m *postgresModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case pgCheckMsg:
|
||||
if msg.err != nil || !msg.reachable {
|
||||
m.state = "missing"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return pgDoneMsg{} }
|
||||
|
||||
case pgInstallMsg:
|
||||
if msg.err != nil {
|
||||
m.state = "error"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.state = "creating"
|
||||
return m, createDatabaseCmd(m.dbURL)
|
||||
|
||||
case pgCreateMsg:
|
||||
if msg.err != nil {
|
||||
m.state = "error"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return pgDoneMsg{} }
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "i", "I":
|
||||
if m.state == "missing" {
|
||||
m.state = "installing"
|
||||
return m, installPostgresCmd()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *postgresModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Database Setup"))
|
||||
|
||||
switch m.state {
|
||||
case "checking":
|
||||
b.WriteString(tuiHint.Render("Checking PostgreSQL status…") + "\n")
|
||||
case "missing":
|
||||
b.WriteString(tuiWarn.Render("PostgreSQL is not reachable.") + "\n\n")
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n\n")
|
||||
}
|
||||
b.WriteString(tuiInfo("Press i to install PostgreSQL, or esc to skip.") + "\n")
|
||||
case "installing":
|
||||
b.WriteString(tuiHint.Render("Installing PostgreSQL via apt…") + "\n")
|
||||
b.WriteString(tuiInfo("This may take up to a minute.") + "\n")
|
||||
case "creating":
|
||||
b.WriteString(tuiHint.Render("PostgreSQL installed. Creating database…") + "\n")
|
||||
case "done":
|
||||
b.WriteString(tuiOK.Render("✓ Database is ready.") + "\n")
|
||||
case "error":
|
||||
b.WriteString(tuiErr.Render("Failed to set up PostgreSQL:") + "\n")
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(tuiSeparator())
|
||||
b.WriteString("\n")
|
||||
switch m.state {
|
||||
case "missing":
|
||||
b.WriteString(tuiAction("i", "install", "esc", "back"))
|
||||
case "done", "error":
|
||||
b.WriteString(tuiAction("esc", "back"))
|
||||
default:
|
||||
b.WriteString(tuiAction("esc", "cancel"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func checkPostgresCmd(dbURL string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := checkPostgres(dbURL)
|
||||
if err != nil {
|
||||
return pgCheckMsg{reachable: false, err: err}
|
||||
}
|
||||
return pgCheckMsg{reachable: true}
|
||||
}
|
||||
}
|
||||
|
||||
func checkPostgres(dbURL string) error {
|
||||
cfg, err := parseDBURL(dbURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid database URL: %w", err)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", cfg.addr, 2*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach PostgreSQL at %s: %w", cfg.addr, err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to PostgreSQL: %w", err)
|
||||
}
|
||||
drv.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func installPostgresCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "apt-get", "install", "-y", "postgresql")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return pgInstallMsg{err: fmt.Errorf("%w: %s", err, string(out))}
|
||||
}
|
||||
// Start the service.
|
||||
start := exec.CommandContext(ctx, "systemctl", "restart", "postgresql")
|
||||
start.CombinedOutput()
|
||||
return pgInstallMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func createDatabaseCmd(dbURL string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cfg, err := parseDBURL(dbURL)
|
||||
if err != nil {
|
||||
return pgCreateMsg{err: err}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// Create user and database via PostgreSQL command line.
|
||||
cmds := [][]string{
|
||||
{"psql", "-c", fmt.Sprintf("CREATE USER %s WITH PASSWORD '%s';", cfg.user, cfg.pass)},
|
||||
{"psql", "-c", fmt.Sprintf("CREATE DATABASE %s OWNER %s;", cfg.db, cfg.user)},
|
||||
{"psql", "-c", fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s;", cfg.db, cfg.user)},
|
||||
}
|
||||
for _, args := range cmds {
|
||||
cmd := exec.CommandContext(ctx, "su", append([]string{"-", "postgres", "-c"}, strings.Join(args, " "))...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Ignore "already exists" errors.
|
||||
s := string(out)
|
||||
if strings.Contains(s, "already exists") {
|
||||
continue
|
||||
}
|
||||
return pgCreateMsg{err: fmt.Errorf("%w: %s", err, s)}
|
||||
}
|
||||
}
|
||||
return pgCreateMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
type dbCfg struct {
|
||||
addr string
|
||||
user string
|
||||
pass string
|
||||
db string
|
||||
}
|
||||
|
||||
func parseDBURL(url string) (dbCfg, error) {
|
||||
// Simple parser for postgres://user:pass@host:port/db?options
|
||||
s := strings.TrimPrefix(url, "postgres://")
|
||||
s = strings.TrimPrefix(s, "postgresql://")
|
||||
parts := strings.SplitN(s, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return dbCfg{}, fmt.Errorf("malformed URL")
|
||||
}
|
||||
auth := strings.SplitN(parts[0], ":", 2)
|
||||
rest := strings.SplitN(parts[1], "/", 2)
|
||||
if len(rest) < 2 {
|
||||
return dbCfg{}, fmt.Errorf("malformed URL: no database")
|
||||
}
|
||||
hostport := rest[0]
|
||||
dbname := strings.SplitN(rest[1], "?", 2)[0]
|
||||
if !strings.Contains(hostport, ":") {
|
||||
hostport += ":5432"
|
||||
}
|
||||
return dbCfg{
|
||||
addr: hostport,
|
||||
user: auth[0],
|
||||
pass: func() string {
|
||||
if len(auth) > 1 {
|
||||
return auth[1]
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
db: dbname,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func countMigrations(dbURL string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer drv.Close()
|
||||
if err := drv.EnsureVersionTable(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applied, err := drv.AppliedVersions(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(applied), nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ---- Messages: sub-model → root ----
|
||||
|
||||
type pgDoneMsg struct{ err error }
|
||||
|
||||
type migrationDoneMsg struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
type ownerResultMsg struct {
|
||||
username string
|
||||
displayPassword string
|
||||
mode string
|
||||
accountable string
|
||||
auditWarning string
|
||||
err error
|
||||
}
|
||||
|
||||
type edgeResultMsg struct {
|
||||
result *cfsetup.Result
|
||||
panelHostname string
|
||||
adminHostname string
|
||||
err error
|
||||
}
|
||||
|
||||
type panelCheckMsg struct{ result panelAccessResult }
|
||||
|
||||
// switchToDashboard tells the root to show the dashboard.
|
||||
type switchToDashboard struct{}
|
||||
|
||||
// ---- rootModel: top-level session ----
|
||||
|
||||
type rootModel struct {
|
||||
ctx context.Context
|
||||
|
||||
screen tea.Model // current active screen
|
||||
dashboard *dashboardModel // always preserved
|
||||
|
||||
result breakGlassResult
|
||||
err error
|
||||
|
||||
mode consoleMode
|
||||
dbURL string
|
||||
store ownerStore
|
||||
osUser string
|
||||
rootDomain string
|
||||
adminHost string
|
||||
panelHost string
|
||||
adminExists bool
|
||||
}
|
||||
|
||||
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *rootModel {
|
||||
rm := &rootModel{
|
||||
ctx: ctx,
|
||||
dbURL: dbURL,
|
||||
store: store,
|
||||
osUser: osUser,
|
||||
rootDomain: rootDomain,
|
||||
adminHost: adminHostname,
|
||||
panelHost: panelHostname,
|
||||
adminExists: adminExists,
|
||||
mode: mode,
|
||||
dashboard: newDashboardModel(ctx, store, dbURL, osUser, rootDomain, adminHostname, panelHostname),
|
||||
result: breakGlassResult{
|
||||
osUser: osUser,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHostname,
|
||||
},
|
||||
}
|
||||
rm.dashboard.adminExists = adminExists
|
||||
rm.refreshDashboard()
|
||||
if mode == consoleModeBreakGlass {
|
||||
rm.screen = newOwnerModel(ctx, store, osUser, adminExists)
|
||||
} else {
|
||||
rm.screen = rm.dashboard
|
||||
}
|
||||
return rm
|
||||
}
|
||||
|
||||
func (m *rootModel) Init() tea.Cmd {
|
||||
if m.mode == consoleModeSetup {
|
||||
return m.checkStatus()
|
||||
}
|
||||
return m.screen.Init()
|
||||
}
|
||||
|
||||
func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case pgDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.dashboard.pgStatus = statusFailed
|
||||
m.dashboard.pgDetail = msg.err.Error()
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
}
|
||||
m.dashboard.pgStatus = statusDone
|
||||
m.dashboard.pgDetail = "ready"
|
||||
m.showDashboard()
|
||||
return m, m.checkMigrations()
|
||||
|
||||
case migrationDoneMsg:
|
||||
if msg.err == nil {
|
||||
m.dashboard.mgStatus = statusDone
|
||||
m.dashboard.mgDetail = fmt.Sprintf("%d applied", msg.n)
|
||||
} else {
|
||||
m.dashboard.mgStatus = statusFailed
|
||||
m.dashboard.mgDetail = msg.err.Error()
|
||||
}
|
||||
m.showDashboard()
|
||||
if msg.err != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, m.checkPanel()
|
||||
|
||||
case ownerResultMsg:
|
||||
if msg.err != nil {
|
||||
if m.mode == consoleModeBreakGlass {
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.dashboard.owStatus = statusFailed
|
||||
m.dashboard.owDetail = msg.err.Error()
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
}
|
||||
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.dashboard.adminExists = true
|
||||
m.dashboard.owStatus = statusDone
|
||||
m.dashboard.owDetail = msg.username
|
||||
m.showDashboard()
|
||||
return m, m.checkPanel()
|
||||
|
||||
case panelCheckMsg:
|
||||
m.result.panelURL = msg.result.url
|
||||
if msg.result.err != nil {
|
||||
m.dashboard.paStatus = statusFailed
|
||||
m.dashboard.paDetail = msg.result.err.Error()
|
||||
} else {
|
||||
m.dashboard.paStatus = statusDone
|
||||
m.dashboard.paDetail = msg.result.url
|
||||
}
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
|
||||
case edgeResultMsg:
|
||||
if msg.err != nil {
|
||||
if m.mode == consoleModeBreakGlass {
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.dashboard.egStatus = statusFailed
|
||||
m.dashboard.egDetail = msg.err.Error()
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
}
|
||||
m.result.edgeConfigured = true
|
||||
m.result.edgeAud = msg.result.AccessAud
|
||||
m.result.edgeRoutedHosts = msg.result.RoutedHostnames
|
||||
m.result.edgeConfigPath = msg.result.ConfigPath
|
||||
m.result.edgePanelHostname = msg.panelHostname
|
||||
m.result.edgeAdminHostname = msg.adminHostname
|
||||
if m.mode == consoleModeBreakGlass {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.dashboard.egStatus = statusDone
|
||||
m.dashboard.egDetail = "configured"
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
|
||||
case switchToDashboard:
|
||||
m.refreshDashboard()
|
||||
return m, m.checkStatus()
|
||||
|
||||
}
|
||||
|
||||
if m.screen != nil {
|
||||
newScreen, cmd := m.screen.Update(msg)
|
||||
if newScreen != nil {
|
||||
if newScreen != m.screen {
|
||||
m.screen = newScreen
|
||||
return m, tea.Batch(cmd, m.screen.Init())
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *rootModel) View() string {
|
||||
if m.screen != nil {
|
||||
return m.screen.View()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *rootModel) showDashboard() {
|
||||
m.screen = m.dashboard
|
||||
}
|
||||
|
||||
func (m *rootModel) refreshDashboard() {
|
||||
d := m.dashboard
|
||||
d.pgStatus = statusPending
|
||||
d.mgStatus = statusPending
|
||||
d.owStatus = statusOptional
|
||||
d.paStatus = statusPending
|
||||
d.egStatus = statusOptional
|
||||
if m.adminExists {
|
||||
d.owStatus = statusDone
|
||||
d.owDetail = "already exists (use breakGlass to reset)"
|
||||
}
|
||||
if m.result.provisioned {
|
||||
d.owStatus = statusDone
|
||||
d.owDetail = m.result.username
|
||||
}
|
||||
if m.result.edgeConfigured {
|
||||
d.egStatus = statusDone
|
||||
d.egDetail = "configured"
|
||||
}
|
||||
if m.result.panelURL != "" {
|
||||
d.paStatus = statusDone
|
||||
d.paDetail = m.result.panelURL
|
||||
}
|
||||
}
|
||||
|
||||
func (m *rootModel) checkStatus() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := checkPostgres(m.dbURL); err != nil {
|
||||
return pgDoneMsg{err: err}
|
||||
}
|
||||
return pgDoneMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *rootModel) checkMigrations() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
n, err := countMigrations(m.dbURL)
|
||||
return migrationDoneMsg{n: n, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *rootModel) checkPanel() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return panelCheckMsg{result: checkPanelAccess(m.rootDomain)}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
// Color palette — semantic, terminal-safe
|
||||
cPrimary = lipgloss.Color("39") // bright cyan-blue
|
||||
cSuccess = lipgloss.Color("42") // green
|
||||
cWarning = lipgloss.Color("214") // orange
|
||||
cError = lipgloss.Color("196") // red
|
||||
cDim = lipgloss.Color("240") // gray
|
||||
cAccent = lipgloss.Color("99") // purple
|
||||
cBgDark = lipgloss.Color("236") // dark gray background
|
||||
cBgInput = lipgloss.Color("235") // input field bg
|
||||
cWhite = lipgloss.Color("15")
|
||||
|
||||
// Title bar — inverted primary
|
||||
tuiTitle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cWhite).
|
||||
Background(cPrimary).
|
||||
Padding(0, 2).
|
||||
Width(70)
|
||||
|
||||
// Section header
|
||||
tuiSection = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
// Card styles
|
||||
tuiCardStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(cDim).
|
||||
Padding(1, 2)
|
||||
|
||||
tuiCardFocusedStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(cPrimary).
|
||||
Padding(1, 2)
|
||||
|
||||
// Form label
|
||||
tuiLabel = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cPrimary)
|
||||
|
||||
// Hint / help text
|
||||
tuiHint = lipgloss.NewStyle().
|
||||
Foreground(cWhite)
|
||||
|
||||
// Success text
|
||||
tuiOK = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cSuccess)
|
||||
|
||||
// Warning text
|
||||
tuiWarn = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cWarning)
|
||||
|
||||
// Error text
|
||||
tuiErr = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cError)
|
||||
|
||||
// Password display — inverted highlight
|
||||
tuiPassword = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(cWarning).
|
||||
Padding(0, 1)
|
||||
|
||||
// Action bar — bottom stripe
|
||||
tuiActionBar = lipgloss.NewStyle().
|
||||
Foreground(cWhite).
|
||||
Padding(0, 1)
|
||||
|
||||
// Status icon styles
|
||||
tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓")
|
||||
tuiIconInPro = lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("→")
|
||||
tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○")
|
||||
tuiIconErr = lipgloss.NewStyle().Bold(true).Foreground(cError).Render("✗")
|
||||
tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳")
|
||||
|
||||
// Step card dimensions
|
||||
tuiStepWidth = 60
|
||||
tuiStepHeight = 5
|
||||
|
||||
// Info box — subtle background
|
||||
tuiInfoBox = lipgloss.NewStyle().
|
||||
Background(cBgDark).
|
||||
Padding(1, 2).
|
||||
Width(60)
|
||||
)
|
||||
|
||||
func tuiIcon(status stepStatus) string {
|
||||
switch status {
|
||||
case statusDone:
|
||||
return tuiIconOK
|
||||
case statusPending:
|
||||
return tuiIconInPro
|
||||
case statusOptional:
|
||||
return tuiIconOpt
|
||||
case statusFailed:
|
||||
return tuiIconErr
|
||||
case statusRunning:
|
||||
return tuiIconSpin
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type stepStatus int
|
||||
|
||||
const (
|
||||
statusDone stepStatus = iota
|
||||
statusPending
|
||||
statusOptional
|
||||
statusRunning
|
||||
statusFailed
|
||||
)
|
||||
|
||||
type dashboardStep struct {
|
||||
title string
|
||||
status stepStatus
|
||||
detail string
|
||||
}
|
||||
|
||||
func tuiHeader(title string) string {
|
||||
return tuiTitle.Render("🐾 " + title) + "\n\n"
|
||||
}
|
||||
|
||||
func tuiAction(pairs ...string) string {
|
||||
var parts []string
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
parts = append(parts, tuiLabel.Render(pairs[i])+" "+tuiHint.Render(pairs[i+1]))
|
||||
}
|
||||
return tuiActionBar.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
func tuiStatusLine(icon, label, detail string, focused bool) string {
|
||||
line := fmt.Sprintf(" %s %s", icon, tuiLabel.Render(label))
|
||||
if detail != "" {
|
||||
line += "\n " + tuiHint.Render(detail)
|
||||
}
|
||||
if focused {
|
||||
return tuiCardFocusedStyle.Width(tuiStepWidth).Render(line)
|
||||
}
|
||||
return tuiCardStyle.Width(tuiStepWidth).Render(line)
|
||||
}
|
||||
|
||||
func tuiFormField(label string, input textinput.Model) string {
|
||||
return fmt.Sprintf("%s\n%s",
|
||||
tuiLabel.Render(label),
|
||||
input.View())
|
||||
}
|
||||
|
||||
func tuiInfo(text string) string {
|
||||
return tuiInfoBox.Render(tuiHint.Render("ℹ " + text))
|
||||
}
|
||||
|
||||
type progressStep struct {
|
||||
label string
|
||||
done bool
|
||||
}
|
||||
|
||||
func tuiProgress(steps []progressStep) string {
|
||||
var b strings.Builder
|
||||
for _, s := range steps {
|
||||
if s.done {
|
||||
b.WriteString(" " + tuiIconOK + " " + s.label + "\n")
|
||||
} else if len(steps) > 0 && s == steps[0] {
|
||||
continue
|
||||
} else {
|
||||
b.WriteString(" " + tuiIconOpt + " " + s.label + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tuiWizardCard(title, desc, body string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiSection.Render(title))
|
||||
if desc != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(tuiHint.Render(desc))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(tuiCardStyle.Render(body))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tuiResultCard(title string, pairs ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiOK.Render(title) + "\n\n")
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
b.WriteString(tuiLabel.Render(pairs[i]) + " " + tuiPassword.Render(pairs[i+1]) + "\n")
|
||||
}
|
||||
return tuiCardStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func tuiErrorBanner(msg string) string {
|
||||
return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg))
|
||||
}
|
||||
|
||||
func tuiSuccessBanner(msg string) string {
|
||||
return tuiCardFocusedStyle.Render(tuiOK.Render("✓ " + msg))
|
||||
}
|
||||
|
||||
func tuiInput(placeholder string, charLimit int, password bool) textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.CharLimit = charLimit
|
||||
ti.Width = 44
|
||||
ti.Prompt = ""
|
||||
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(cWhite)
|
||||
if password {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
}
|
||||
return ti
|
||||
}
|
||||
|
||||
func tuiSeparator() string {
|
||||
return tuiHint.Render(strings.Repeat("─", 70))
|
||||
}
|
||||
Reference in new issue
Block a user