feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency

- Add `felis setup` TUI for initial Owner provisioning and optional Cloudflare edge
- Refactor breakGlass to share console TUI model (runConsoleTUI) with setup mode
- Session auth respects configured [auth].admin_hostname; fallback to op.console.<root>
- Protect linked Yggdrasil admins from Mojang-priority reclaim (spec §B3)
- cfsetup: idempotent Access app/policy creation, better 401/403 errors, GET + lookup
- Bootstrap: auto-install cloudflared, symlink /etc/felis/felis.toml
- Add sequence diagrams for ping-to-join, claim, and link flows
This commit is contained in:
flyemoji committed 2026-06-28 16:41:37 +09:00
1 parent 94a3b7b5e8
commit 9c46632929
15 files changed
+957 -126

No files matched your search

+5 -4
View File
@@ -135,7 +135,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
Logs: api.NewK8sLogStreamer(clientset, cfg.K8s.Namespace),
// Build-log stream (spec §16) is scoped to the BUILD namespace — the same
// value the Builder renders Jobs into — so it follows where build Pods run.
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
Internal: api.BearerTokenAuth{Token: token},
Builder: builder,
Restorer: restorer,
@@ -147,9 +147,10 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// is wired (deployment integration point) — while the local-password path is
// live the moment `felis breakGlass` flips local_auth_enabled on.
External: api.SessionAuth{
Repo: repo,
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
RootDomain: cfg.Server.RootDomain,
Repo: repo,
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
RootDomain: cfg.Server.RootDomain,
AdminHostname: cfg.Auth.AdminHostname,
},
RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second,
+222 -63
View File
@@ -89,6 +89,9 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
@@ -156,8 +159,8 @@ func cmdBreakGlass(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 res.rootDomain != "" {
fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain)
if url := adminLoginURL(res.rootDomain, res.adminHostname); url != "" {
fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", url)
}
}
@@ -411,14 +414,24 @@ type breakGlassResult struct {
displayPassword string // empty when the operator typed their own bootstrap password
auditWarning string
rootDomain string
adminHostname string
// optional Cloudflare edge outcome (independent of provisioned)
edgeConfigured bool
edgeAud string
edgeRoutedHosts []string
edgeConfigPath string
edgeConfigured bool
edgeAud string
edgeRoutedHosts []string
edgeConfigPath string
edgePanelHostname string
edgeAdminHostname string
}
type consoleMode string
const (
consoleModeBreakGlass consoleMode = "breakGlass"
consoleModeSetup consoleMode = "setup"
)
type bgStep int
const (
@@ -437,10 +450,6 @@ const (
stepEdgeError
)
// menuOptionCount is the number of selectable top-level operations. Provision is
// index 0; the optional Cloudflare edge is index 1.
const menuOptionCount = 2
// Edge-flow defaults the operator can accept as-is.
const (
defaultTunnelName = "felis"
@@ -490,6 +499,7 @@ var (
type bgModel struct {
ctx context.Context
store ownerStore
consoleMode consoleMode
rootDomain string
osUser string
adminExists bool
@@ -518,19 +528,27 @@ type bgModel struct {
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
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 {
// The console opens on the top-level router; the bootstrap-vs-recovery branch is
// taken only when the operator chooses the provisioning op (enterProvisionFlow).
// The optional edge op is a peer, reachable without touching the Owner credential.
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,
@@ -841,6 +859,13 @@ func performCmd(ctx context.Context, s ownerStore, op breakGlassOp) tea.Cmd {
// ---- 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":
@@ -851,7 +876,7 @@ func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
case "down", "tab":
if m.focus < menuOptionCount-1 {
if m.focus < m.menuOptionCount()-1 {
m.focus++
}
return m, nil
@@ -859,8 +884,11 @@ func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focus = 0
return m.selectMenu()
case "2":
m.focus = 1
return m.selectMenu()
if m.menuOptionCount() > 1 {
m.focus = 1
return m.selectMenu()
}
return m, nil
case "enter":
return m.selectMenu()
}
@@ -868,9 +896,13 @@ func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m *bgModel) selectMenu() (tea.Model, tea.Cmd) {
if m.focus == 1 {
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()
}
@@ -889,10 +921,10 @@ func (m *bgModel) enterEdgeIntro() (tea.Model, tea.Cmd) {
}
// edgeReady reports whether the edge flow can proceed to credential entry: the
// admin hostname must be configured (it is what the Access app guards) and the
// operator must have cloudflared installed and be logged in.
// 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.adminHostname != "" && m.cloudflaredPath != "" && m.certExists
return m.cloudflaredPath != "" && m.certExists
}
func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -900,12 +932,12 @@ func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "ctrl+c":
return m, tea.Quit
case "esc":
// Back to the router with the edge option still highlighted.
// 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.adminHostname != "" && m.cloudflaredPath != "" && !m.certExists {
if m.cloudflaredPath != "" && !m.certExists {
return m.startCloudflaredLogin()
}
return m, nil
@@ -928,19 +960,24 @@ func (m *bgModel) startCloudflaredLogin() (tea.Model, tea.Cmd) {
})
}
// enterEdgeInput installs the credential/scope inputs, pre-filling the safe
// defaults. The hostnames are NOT collected here — they come from [auth] config.
// 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, tunnel, cfgPath})
return m.setInputs([]textinput.Model{token, account, identity, panelHost, adminHost, tunnel, cfgPath})
}
// submitEdge validates the edge inputs and launches cfsetup.Setup. The fail-closed
@@ -951,8 +988,10 @@ 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())
tunnel := strings.TrimSpace(m.inputs[3].Value())
cfgPath := strings.TrimSpace(m.inputs[4].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"
@@ -962,6 +1001,14 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
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)
@@ -973,9 +1020,24 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
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, "@") {
@@ -984,9 +1046,11 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
id.Emails = []string{identity}
}
m.edgePanelHostname = panelHost
m.edgeAdminHostname = adminHost
p := cfsetup.Params{
PanelHostname: m.panelHostname,
AdminHostname: m.adminHostname,
PanelHostname: panelHost,
AdminHostname: adminHost,
TunnelName: tunnel,
ConfigPath: cfgPath,
AccessIdentity: id,
@@ -1013,19 +1077,33 @@ func edgeSetupCmd(ctx context.Context, runner cfsetup.Runner, p cfsetup.Params)
func (m *bgModel) View() string {
var b strings.Builder
b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\n\n")
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:
b.WriteString("Choose a break-glass operation:\n\n")
provisionDesc := "No staff account yet — bootstrap the first Owner."
if m.adminExists {
provisionDesc = "An admin exists — authenticate, then reset the Owner credential."
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 := [menuOptionCount]struct{ title, desc string }{
{"Provision / reset the Owner account", provisionDesc},
{"Set up the Cloudflare edge", "Tunnel + fail-closed Access for the web faces — 锦上添花, optional."},
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 {
@@ -1034,7 +1112,14 @@ func (m *bgModel) View() string {
b.WriteString(fmt.Sprintf("%s%d. %s\n", cursor, i+1, title))
b.WriteString(" " + bgHintStyle.Render(o.desc) + "\n\n")
}
b.WriteString(bgHintStyle.Render("↑↓ move · 1/2 select · enter confirm · esc exit") + "\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")
@@ -1088,22 +1173,19 @@ func (m *bgModel) View() string {
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n")
case stepEdgeIntro:
b.WriteString(bgLabelStyle.Render("Optional · Cloudflare Tunnel + Access edge") + bgHintStyle.Render(" (锦上添花 — skippable)") + "\n")
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("felis stays domain- and IdP-agnostic; bring your own domain/SSO if you prefer.") + "\n\n")
b.WriteString(bgHintStyle.Render("Hostnames are editable on the next screen; the Minecraft game host is not tunneled.") + "\n\n")
if m.adminHostname == "" {
b.WriteString(bgErrStyle.Render("✗ [auth] admin_hostname is not set in felis.toml") + " — configure it first; it is\n")
b.WriteString(" the hostname the Access policy guards.\n\n")
} else {
b.WriteString(bgLabelStyle.Render("Will route to the local panel:") + "\n")
if m.panelHostname != "" {
b.WriteString(" • " + m.panelHostname + bgHintStyle.Render(" (Player console)") + "\n")
}
b.WriteString(" • " + m.adminHostname + bgHintStyle.Render(" (Operator + SysAdmin console — Access-guarded)") + "\n")
b.WriteString(bgHintStyle.Render(" The Minecraft game host is deliberately NOT tunneled.") + "\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")
@@ -1123,7 +1205,7 @@ func (m *bgModel) View() string {
switch {
case m.edgeReady():
b.WriteString(bgHintStyle.Render("enter continue · esc back") + "\n")
case m.adminHostname != "" && m.cloudflaredPath != "" && !m.certExists:
case m.cloudflaredPath != "" && !m.certExists:
b.WriteString(bgHintStyle.Render("l login · esc back") + "\n")
default:
b.WriteString(bgHintStyle.Render("esc back") + "\n")
@@ -1136,6 +1218,8 @@ func (m *bgModel) View() string {
"Cloudflare API token",
"Cloudflare account ID",
"Admit (your email, or @your-domain)",
"Player console hostname",
"Admin console hostname",
"Tunnel name",
"Tunnel config path",
}
@@ -1172,8 +1256,8 @@ func (m *bgModel) View() string {
if m.auditWarning != "" {
b.WriteString(bgWarnStyle.Render("⚠ accountability record was NOT written: "+m.auditWarning) + "\n\n")
}
if m.rootDomain != "" {
b.WriteString("Log in at " + bgLabelStyle.Render("https://op.console."+m.rootDomain) + "\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")
@@ -1190,8 +1274,14 @@ func (m *bgModel) View() string {
}
}
b.WriteString(bgBoxStyle.Render(box) + "\n\n")
b.WriteString(bgWarnStyle.Render("ACTION REQUIRED") + " — felis-api will reject the edge until you adopt the audience:\n")
b.WriteString("set " + bgLabelStyle.Render("[auth] access_jwt_aud") + " in felis.toml to the value above, then start the\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")
@@ -1208,11 +1298,21 @@ func (m *bgModel) View() string {
return b.String()
}
// runBreakGlassTUI drives the bubbletea program and projects the final model onto a
// breakGlassResult. It is the thin, untested shell; the logic it invokes
// (authenticateAdmin / performBreakGlass) is unit-tested directly.
// 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) {
final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists), tea.WithAltScreen()).Run()
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()
if err != nil {
return breakGlassResult{}, err
}
@@ -1233,12 +1333,71 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, adminHostna
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
}
func defaultPanelHostname(rootDomain, configured string) string {
if h := strings.TrimSpace(configured); h != "" {
return h
}
if rootDomain != "" {
return "console." + rootDomain
}
return ""
}
func defaultAdminHostname(rootDomain, configured string) string {
if h := strings.TrimSpace(configured); h != "" {
return h
}
if rootDomain != "" {
return "op.console." + rootDomain
}
return ""
}
func normalizeEdgeHostname(s string) string {
return strings.Trim(strings.TrimSpace(s), ".")
}
func validateEdgeHostname(label, host string, required bool) error {
if host == "" {
if required {
return fmt.Errorf("%s is required", label)
}
return nil
}
if strings.Contains(host, "://") || strings.ContainsAny(host, "/\\ \t\r\n") {
return fmt.Errorf("%s must be a hostname, not a URL", label)
}
if strings.Contains(host, ":") {
return fmt.Errorf("%s must not include a port", label)
}
if strings.HasPrefix(host, ".") {
return fmt.Errorf("%s must not start with a dot", label)
}
return nil
}
func isHex32(s string) bool {
if len(s) != 32 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
+36 -31
View File
@@ -650,20 +650,18 @@ func TestBGModelGating(t *testing.T) {
})
}
// TestBGModelEdgeRouting locks in the optional Cloudflare edge flow's routing and its
// load-bearing guards WITHOUT touching the operator's real Cloudflare account: the
// menu reaches the edge intro as an independent peer of provisioning (no Owner reset
// required to get there); an unconfigured admin hostname keeps edgeReady() false so the
// flow cannot proceed to credential entry; esc returns to the router; and submitEdge
// refuses empty inputs before any cfsetup.Setup side effect. Every assertion here is
// environment-independent — the real cloudflared/cert.pem detection and the integration
// Setup (which shells out / calls the live API) are deliberately NOT exercised.
// 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("menu option 2 enters the edge intro as a peer of provisioning, leaving the Owner credential untouched", func(t *testing.T) {
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 := newBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", 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)
}
@@ -677,8 +675,8 @@ func TestBGModelEdgeRouting(t *testing.T) {
}
})
t.Run("esc from the edge intro returns to the router with the edge option highlighted", func(t *testing.T) {
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
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 {
@@ -686,31 +684,21 @@ func TestBGModelEdgeRouting(t *testing.T) {
}
})
t.Run("an unconfigured admin hostname keeps the edge gated shut regardless of cloudflared/login", func(t *testing.T) {
// adminHostname == "" makes edgeReady() false by short-circuit, independent of
// whether this box happens to have cloudflared installed and a cert.pem present.
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
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 != stepEdgeIntro {
t.Fatalf("step = %v, want stepEdgeIntro", m.step)
}
if m.edgeReady() {
t.Fatal("edgeReady() must be false when no admin hostname is configured")
}
// Enter while not ready must NOT advance to credential entry.
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
if m.step != stepEdgeIntro {
t.Errorf("enter while not ready advanced to %v, want to stay on stepEdgeIntro", m.step)
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 := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
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) != 5 {
t.Fatalf("enterEdgeInput: step/inputs = %v/%d, want stepEdgeInput with 5 inputs", m.step, len(m.inputs))
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
@@ -725,13 +713,13 @@ func TestBGModelEdgeRouting(t *testing.T) {
})
t.Run("submitEdge rejects a bare @ identity that would scope Access to an empty domain", func(t *testing.T) {
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
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("account-id")
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
m.inputs[2].SetValue("@")
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
if m.step != stepEdgeInput || m.formErr == "" {
@@ -741,4 +729,21 @@ func TestBGModelEdgeRouting(t *testing.T) {
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")
}
})
}
+3 -2
View File
@@ -18,6 +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)
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
Run "felis <command> -h" for command-specific flags.
@@ -46,6 +47,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return cmdManifests(rest, stdout, stderr)
case "apply":
return cmdApply(rest, stdout, stderr)
case "setup":
return cmdSetup(rest, stdout, stderr)
case "breakGlass":
return cmdBreakGlass(rest, stdout, stderr)
case "-h", "--help", "help":
@@ -56,5 +59,3 @@ func run(args []string, stdout, stderr io.Writer) int {
return 2
}
}
+42 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"os"
"strings"
"testing"
)
@@ -52,10 +53,13 @@ func TestRunApplyRequiresFileFlag(t *testing.T) {
func TestRunApplyRejectsInvalidJSON(t *testing.T) {
// Sending garbage via a temp file must exit 1 (input error), not panic or hang.
empty := t.TempDir() + "/empty.json"
if err := os.WriteFile(empty, nil, 0o644); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
code := run([]string{"apply", "-f", "/dev/null"}, &out, &errBuf)
// /dev/null is empty → JSON parse fails or validation rejects the zero values;
// either way it must exit 1, not panic.
code := run([]string{"apply", "-f", empty}, &out, &errBuf)
// The empty file must fail JSON parsing or validation; either way it exits 1.
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
@@ -64,6 +68,41 @@ func TestRunApplyRejectsInvalidJSON(t *testing.T) {
}
}
func TestRunSetupAndBreakGlassCommands(t *testing.T) {
t.Run("setup help", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"setup", "-h"}, &out, &errBuf); code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(errBuf.String(), "Usage of setup") {
t.Errorf("expected setup help, got stderr=%q stdout=%q", errBuf.String(), out.String())
}
if strings.Contains(errBuf.String(), "Usage of breakGlass") {
t.Errorf("setup must not route to breakGlass help, got %q", errBuf.String())
}
})
t.Run("breakGlass help", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"breakGlass", "-h"}, &out, &errBuf); code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(errBuf.String(), "Usage of breakGlass") {
t.Errorf("expected breakGlass help, got stderr=%q stdout=%q", errBuf.String(), out.String())
}
})
t.Run("lowercase breakglass is intentionally rejected", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"breakglass", "-h"}, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "unknown command") {
t.Errorf("expected lowercase alias rejection, got stderr=%q stdout=%q", errBuf.String(), out.String())
}
})
}
func TestRunReaperValidatesConfigBeforeDialing(t *testing.T) {
var out, errBuf bytes.Buffer
// Like api, reaper must fail fast (exit 1) at config load, before any
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/store"
)
// 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")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
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)
return 1
}
defer drv.Close()
repo := api.NewPGRepo(drv.DB())
adminExists, err := repo.AdminExists(ctx)
if err != nil {
fmt.Fprintf(stderr, "felis setup: detect existing admin: %v\n", err)
return 1
}
res, err := runSetupTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists)
if err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err)
return 1
}
if !res.provisioned && !res.edgeConfigured {
fmt.Fprintln(stdout, "felis setup: cancelled — no changes made.")
return 0
}
if res.provisioned {
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.displayPassword != "" {
fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword)
} else {
fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).")
}
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 res.edgeConfigured {
fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access edge configured.\n")
if len(res.edgeRoutedHosts) > 0 {
fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", "))
}
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.")
}
return 0
}
func adminLoginURL(rootDomain, adminHostname string) string {
if h := strings.TrimSpace(adminHostname); h != "" {
return "https://" + h
}
if rootDomain != "" {
return "https://op.console." + rootDomain
}
return ""
}