refactor(deploy)!: TUI

This commit is contained in:
Lemon-miaow committed 2026-06-29 20:17:17 +08:00
1 parent 318a724f98
commit e5f1682898
37 files changed
+3622 -1303

No files matched your search

+24 -3
View File
@@ -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 {
+25
View File
@@ -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
View File
@@ -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 ""
}
-240
View File
@@ -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")
}
})
}
+6
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+108
View File
@@ -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}
}
+88
View File
@@ -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")
}
}
+140
View File
@@ -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
}
+139
View File
@@ -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()
}
+575
View File
@@ -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{}
}
}
+152
View File
@@ -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
}
+142
View File
@@ -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
}
+380
View File
@@ -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)
}
+260
View File
@@ -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
}
+265
View File
@@ -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)}
}
}
+111
View File
@@ -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 " "
}
}
+124
View File
@@ -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))
}