From 9c466329292726c750528aca118a22be913388b7 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sun, 28 Jun 2026 16:39:40 +0900 Subject: [PATCH] feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. - 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 --- cmd/felis/api.go | 9 +- cmd/felis/breakglass.go | 285 +++++++++++++++---- cmd/felis/breakglass_test.go | 67 +++-- cmd/felis/run.go | 5 +- cmd/felis/run_test.go | 45 ++- cmd/felis/setup.go | 115 ++++++++ deploy/bootstrap.sh | 50 +++- docs/sequence-diagrams.md | 154 ++++++++++ internal/api/api_test.go | 44 +++ internal/api/handlers_player_reclaim.go | 25 ++ internal/api/handlers_player_reclaim_test.go | 110 +++++++ internal/api/pgrepo.go | 18 ++ internal/api/repo.go | 16 ++ internal/api/session.go | 34 +-- internal/cfsetup/runner.go | 106 ++++++- 15 files changed, 957 insertions(+), 126 deletions(-) create mode 100644 cmd/felis/setup.go create mode 100644 docs/sequence-diagrams.md diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 3c4754f..be15e94 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -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, diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index b2261d1..a9419cf 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -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("you@example.com 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 +} diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index 9190036..4958eee 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -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("ops@example.net") + 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") + } + }) } diff --git a/cmd/felis/run.go b/cmd/felis/run.go index 5e5415d..bbbf3bb 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -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 -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 } } - - diff --git a/cmd/felis/run_test.go b/cmd/felis/run_test.go index b3b9ad7..966b187 100644 --- a/cmd/felis/run_test.go +++ b/cmd/felis/run_test.go @@ -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 diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go new file mode 100644 index 0000000..9111819 --- /dev/null +++ b/cmd/felis/setup.go @@ -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 "" +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index e779eb1..9fac8ab 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -12,8 +12,8 @@ # Deployments + in-cluster registry). # # By design it stops short of serving the web panel. After it finishes you run -# `felis setup` on the host (a TUI) to create the Owner account; the SysAdmin web -# surface only unlocks once Web Zero-Trust is configured. See deploy/README.md. +# `felis setup` on the host (a TUI) to create the Owner account and optionally +# configure the Cloudflare edge. See deploy/README.md. # # The script is idempotent: re-running it converges rather than duplicating, and # generated secrets are persisted to /etc/felis/secrets.env so reruns reuse them. @@ -154,6 +154,28 @@ install_base() { ok "base tools present" } +install_cloudflared() { + if command -v cloudflared >/dev/null 2>&1; then + ok "cloudflared already installed" + return 0 + fi + local machine arch url tmp + machine="$(uname -m)" + case "$machine" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + armv7l|armv6l) arch="arm" ;; + *) die "unsupported architecture for cloudflared: ${machine}" ;; + esac + url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}" + tmp="$(mktemp)" + log "installing cloudflared (${arch})" + curl -fsSL "$url" -o "$tmp" + install -m 0755 "$tmp" /usr/local/bin/cloudflared + rm -f "$tmp" + ok "cloudflared installed ($(cloudflared --version | head -n 1))" +} + # --------------------------------------------------------------------------- # 3. Docker (used only to build & export the felis image; k3s uses containerd) # --------------------------------------------------------------------------- @@ -457,16 +479,31 @@ store = "tarLocal" local_path = "/var/lib/felis/archives" [auth] -admin_hostname = "admin.${FELIS_ROOT_DOMAIN}" -panel_hostname = "panel.${FELIS_ROOT_DOMAIN}" +admin_hostname = "op.console.${FELIS_ROOT_DOMAIN}" +panel_hostname = "console.${FELIS_ROOT_DOMAIN}" EOF } +ensure_default_config() { + local target="${STATE_DIR}/felis.toml" + if [ -L "$target" ] && [ "$(readlink "$target")" = "${STATE_DIR}/felis.host.toml" ]; then + ok "default host config already points at ${STATE_DIR}/felis.host.toml" + return 0 + fi + if [ -e "$target" ] || [ -L "$target" ]; then + warn "leaving existing ${target}; setup can use -config ${STATE_DIR}/felis.host.toml if needed" + return 0 + fi + ln -s "${STATE_DIR}/felis.host.toml" "$target" + ok "default host config: ${target} -> ${STATE_DIR}/felis.host.toml" +} + # --------------------------------------------------------------------------- # 8. Migrate + deploy bundle # --------------------------------------------------------------------------- run_migrations() { write_felis_toml "${STATE_DIR}/felis.host.toml" "127.0.0.1" + ensure_default_config log "running database migrations (host binary -> 127.0.0.1)" "$HOST_BIN" migrate up -config "${STATE_DIR}/felis.host.toml" ok "migrations applied" @@ -517,8 +554,8 @@ summary() { kube -n "$CONTROL_NS" get pods -o wide || true echo log "Web is intentionally NOT enabled yet." - log "Next: run 'sudo felis setup' on this host to create the Owner account." - log "The SysAdmin web surface unlocks only after Web Zero-Trust is configured." + log "Next: run 'sudo felis setup' on this host to create the Owner account and configure the web edge." + log "Use 'sudo felis breakGlass' only for emergency local Owner recovery/reset." echo } @@ -527,6 +564,7 @@ main() { detect_node_ip ensure_swap install_base + install_cloudflared load_or_make_secrets install_docker install_k3s diff --git a/docs/sequence-diagrams.md b/docs/sequence-diagrams.md new file mode 100644 index 0000000..acd3771 --- /dev/null +++ b/docs/sequence-diagrams.md @@ -0,0 +1,154 @@ +# Felis Sequence Diagrams + +This file carries the spec section 28 sequence-diagram deliverables that are not +covered by the OpenAPI artifact. + +## Section 28 #9: Ping To Join To Wake To Ready To Teleport + +```mermaid +sequenceDiagram + autonumber + actor Player + participant Velocity as Velocity proxy + participant Registry as Velocity server registry + participant API as felis-api internal face + participant Cluster as MinecraftServer CRD/status + participant Operator as felis operator + participant Backend as Minecraft backend + + Player->>Velocity: server-list ping for subdomain.root-domain + Velocity->>Registry: read cached lifecycle view + Registry-->>Velocity: phase-aware MOTD + Velocity-->>Player: ping response (read-only, no wake) + + Player->>Velocity: join subdomain.root-domain + Velocity->>Registry: resolve host to server + Registry-->>Velocity: ServerView(name, ready=false) + + alt backend already ready and registered + Velocity-->>Player: initial server = backend + Player->>Backend: connect + else backend not ready and lobby configured + Velocity-->>Player: initial server = lobby + Velocity->>API: POST /internal/servers/{name}/wake {mc_uuid} + API->>Cluster: GetServer(name) + API->>API: authorize autostartPolicy, cooldown, running cap + API->>Cluster: SetDesiredState(name, Running) + API-->>Velocity: 202 phase/ready + Velocity->>Velocity: enqueue waiter + Operator->>Cluster: reconcile DesiredState=Running + Operator->>Backend: start pod/service + Backend-->>Operator: RCON-ready / lifecycle ready + Operator-->>Cluster: status.ready=true + loop every waiting tick + Velocity->>API: GET /internal/servers/{name}/status + API->>Cluster: GetServer(name) + API-->>Velocity: ready flag + end + Velocity->>Registry: lookup registered backend + Velocity-->>Player: "ready - moving you in" + Velocity->>Player: Connect request to backend + Player->>Backend: connect + Velocity->>API: POST /internal/servers/{name}/join-event {mc_uuid} + API->>API: RecordJoin; refresh activity and allowlist UUID + API-->>Velocity: 204 + else backend not ready and no lobby configured + Velocity-->>Player: disconnect with reconnect-later message + Velocity->>API: POST /internal/servers/{name}/wake {mc_uuid} + API->>Cluster: SetDesiredState(name, Running) if authorized + API-->>Velocity: 202 or branchable error + end +``` + +## Section 28 #11: Claim Transaction + +```mermaid +sequenceDiagram + autonumber + actor Player + participant Panel as Web panel + participant API as felis-api external face + participant Repo as Repo / Postgres + participant Audit as Audit log + + Player->>Panel: click Claim on ownerless server + Panel->>API: POST /api/v1/servers/{name}/claim + API->>API: validate server name and principal + API->>Repo: IsLinked(user_id) + alt user has no verified account link + Repo-->>API: false + API-->>Panel: 412 not_linked + else linked + Repo-->>API: true + API->>Repo: QuotaAvailable(user_id) + alt quota exhausted + Repo-->>API: false + API-->>Panel: 403 quota_exceeded + else quota available + Repo-->>API: true + API->>Repo: ClaimServer(name, user_id) + Note over Repo: SELECT EXISTS(server); then atomic UPDATE servers SET owner_id=$2, claimed_at=now() WHERE name=$1 AND owner_id IS NULL AND deleted_at IS NULL + alt server missing + Repo-->>API: ErrNotFound + API-->>Panel: 404 not_found + else zero rows affected + Repo-->>API: claimed=false + API-->>Panel: 409 already_claimed + else one row affected + Repo-->>API: claimed=true + API->>Audit: external claim audit + API-->>Panel: 200 {"claimed":true} + end + end + end +``` + +## Section 28 #12: Account Binding /link Flow + +```mermaid +sequenceDiagram + autonumber + actor Player + participant Game as Minecraft server or Velocity + participant LinkClient as Felis LinkClient + participant APIInternal as felis-api internal face + participant Repo as Repo / Postgres + participant Panel as Web panel + participant APIExternal as felis-api external face + + Player->>Game: /link + Game->>Game: read verified online-mode UUID and auth_source + Game->>LinkClient: requestCode(mc_uuid) + LinkClient->>APIInternal: POST /api/v1/internal/account/link/code {mc_uuid, auth_source} + APIInternal->>APIInternal: validate UUID and auth_source; generate 8-symbol code + APIInternal->>Repo: CreateLinkCode(code, mc_uuid, auth_source, expires_at) + Repo-->>APIInternal: inserted account_link_codes row + APIInternal-->>LinkClient: 201 {code, expires_at} + LinkClient-->>Game: LinkCode + Game-->>Player: show one-time code in chat + + Player->>Panel: open Account link flow + Panel->>APIExternal: POST /api/v1/account/link/start + APIExternal->>Repo: IsLinked(user_id) + Repo-->>APIExternal: linked status + APIExternal-->>Panel: status and "run /link" instructions + + Player->>Panel: submit code + Panel->>APIExternal: POST /api/v1/account/link/verify {code} + APIExternal->>APIExternal: trim and uppercase code + APIExternal->>Repo: VerifyLinkCode(user_id, code, now) + Repo->>Repo: SELECT non-expired code + alt missing or expired code + Repo-->>APIExternal: ErrLinkCodeInvalid + APIExternal-->>Panel: 400 invalid_code + else UUID linked to another user + Repo-->>APIExternal: ErrConflict + APIExternal-->>Panel: 409 already_linked + else valid code + Repo->>Repo: INSERT account_links(user_id, mc_uuid, auth_source) ON CONFLICT (user_id, mc_uuid) DO UPDATE auth_source + Repo->>Repo: DELETE account_link_codes WHERE code=$1 + Repo-->>APIExternal: mc_uuid, auth_source + APIExternal->>Repo: Audit account.link + APIExternal-->>Panel: 200 {linked:true, mc_uuid, auth_source} + end +``` diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 433e357..e35624f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -247,6 +247,22 @@ func (f *fakeRepo) ReclaimUsername(_ context.Context, id, squatterUUID, username func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool, error) { return f.blacklist[mcUUID], nil } + +// IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked, +// auth_source 'thirdparty', and the linked user an admin — no password-hash test, so +// an SSO Operator (role='admin', empty PasswordHash) is protected like any other. +func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) { + userID, ok := f.links[mcUUID] + if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty { + return false, nil + } + for _, u := range f.staff { + if u.ID == userID && u.Role == "admin" { + return true, nil + } + } + return false, nil +} func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) { ok, present := f.claimOK[n] if !present { @@ -1015,6 +1031,34 @@ func TestErrorEnvelopeHasRequestID(t *testing.T) { // ---- real AccessVerifier (JWT aud) ---- +func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) { + repo := newFakeRepo() + repo.settings[LocalAuthEnabledKey] = []byte("true") + repo.staff["owner"] = &StaffUser{ID: "u1", Email: "owner@mc.example.net", Role: "admin"} + token := "session-token" + repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: time.Now().Add(time.Hour)} + auth := SessionAuth{Repo: repo, RootDomain: "old.example.net", AdminHostname: "op.console.mc.example.net"} + + r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + p, err := auth.Authenticate(r) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !p.ViaAdminAccess { + t.Fatalf("configured admin hostname should grant admin-path access, got %+v", p) + } + + r = httptest.NewRequest("GET", "https://op.console.old.example.net/api/v1/me", nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + p, err = auth.Authenticate(r) + if err != nil { + t.Fatalf("Authenticate fallback host: %v", err) + } + if p.ViaAdminAccess { + t.Fatalf("root-domain fallback host must not grant admin-path access when admin_hostname is configured") + } +} func TestAccessVerifier(t *testing.T) { key := []byte("test-signing-key") keyfunc := func(*jwt.Token) (any, error) { return key, nil } diff --git a/internal/api/handlers_player_reclaim.go b/internal/api/handlers_player_reclaim.go index 7bdd4e8..99bd3e9 100644 --- a/internal/api/handlers_player_reclaim.go +++ b/internal/api/handlers_player_reclaim.go @@ -75,6 +75,31 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username is required")) return } + // Admin-on-Yggdrasil exception (spec §B3). Before barring the holder, check + // whether the displaced UUID is a Linked Operator/SysAdmin authenticating through + // the third-party Yggdrasil. Such a holder is staff on the Login Server, not a + // Mojang squatter, so Mojang priority must NOT displace them: refuse the reclaim + // outright — no bar, no stash — so the protected admin never enters the blacklist + // and the login gate naturally passes them. The exception is scoped strictly to + // admins; an ordinary thirdparty player is still reclaimed (Mojang priority holds). + protected, err := a.Repo.IsProtectedAdminLink(r.Context(), req.SquatterUUID) + if err != nil { + writeError(w, r, err) + return + } + if protected { + // Distinct audit action so a refusal is never mistaken for a bar — the + // accountability record shows the reclaim was declined, and why. + payload, _ := json.Marshal(map[string]string{ + "username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"}) + _ = a.Repo.Audit(r.Context(), AuditEntry{ + Actor: "velocity", Source: "internal", Action: "player.reclaim.refused", + RequestID: requestIDFromContext(r.Context()), Payload: payload, + }) + writeError(w, r, newError(http.StatusConflict, "protected_admin", + "that username belongs to a linked administrator on the login server and cannot be reclaimed")) + return + } id, err := newHoldID() if err != nil { writeError(w, r, err) diff --git a/internal/api/handlers_player_reclaim_test.go b/internal/api/handlers_player_reclaim_test.go index 38988fc..ca6396a 100644 --- a/internal/api/handlers_player_reclaim_test.go +++ b/internal/api/handlers_player_reclaim_test.go @@ -88,6 +88,116 @@ func TestReclaimNeverCatchesGenuineMojangPlayer(t *testing.T) { } } +// TestReclaimProtectsAdminOnYggdrasil is the admin-on-Yggdrasil exception (spec §B3), +// the second safety property alongside the genuine-Mojang case: a Linked +// Operator/SysAdmin who authenticates through the third-party Yggdrasil is staff on the +// Login Server, not a Mojang squatter, so a Mojang-priority reclaim must REFUSE rather +// than bar them. The reclaim is declined (409 protected_admin), nothing is barred or +// stashed, the login gate consequently passes the admin's UUID end-to-end, and the +// refusal lands in the audit log under a DISTINCT action so it can never be mistaken +// for a bar. +func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) { + const adminUUID = "0a11dead-0000-0000-0000-00000000ad11" + repo := newFakeRepo() + // An Operator who linked in-game through the third-party Yggdrasil (auth_source). + repo.staff["operator1"] = &StaffUser{ID: "op-1", Username: "operator1", Role: "admin", PasswordHash: "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO"} + repo.links[adminUUID] = "op-1" + repo.linkAuthSource[adminUUID] = authSourceThirdParty + + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + body := `{"squatter_uuid":"` + adminUUID + `","username":"Operator"}` + w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil) + if w.Code != http.StatusConflict || decodeErr(t, w) != "protected_admin" { + t.Fatalf("reclaim of a protected admin: code = %d body %s, want 409 protected_admin", w.Code, w.Body.String()) + } + + // Nothing was barred and nothing was stashed — the reclaim was refused outright. + if len(repo.blacklist) != 0 || len(repo.holds) != 0 { + t.Fatalf("a refused reclaim must not bar or stash anything: blacklist=%v holds=%v", repo.blacklist, repo.holds) + } + // End-to-end: the login gate consequently passes the admin's UUID. + w = do(ih, "GET", "/api/v1/internal/player/blacklist/"+adminUUID, "", nil) + if w.Code != http.StatusOK { + t.Fatalf("gate check: code = %d (%s)", w.Code, w.Body.String()) + } + if got := acctBody(t, w)["blacklisted"]; got != false { + t.Fatalf("protected admin blacklisted = %v, want false", got) + } + // The refusal is audited under a distinct action, separable from a real bar. + if len(repo.audits) != 1 { + t.Fatalf("audits = %d, want 1 refusal row", len(repo.audits)) + } + a := repo.audits[0] + if a.Action != "player.reclaim.refused" || a.Actor != "velocity" || a.Source != "internal" { + t.Fatalf("audit = %+v, want player.reclaim.refused/velocity/internal", a) + } + var p map[string]string + if err := json.Unmarshal(a.Payload, &p); err != nil { + t.Fatalf("audit payload not JSON: %v (%s)", err, a.Payload) + } + if p["reason"] != "protected_admin" || p["squatter_uuid"] != adminUUID { + t.Errorf("audit payload = %v, want reason:protected_admin squatter_uuid:%s", p, adminUUID) + } +} + +// TestReclaimAdminProtectionScope pins the exact predicate the exception turns on so a +// future broadening or narrowing of it cannot pass silently. Protection holds for, and +// ONLY for, a linked holder that is BOTH authenticated via the third-party Yggdrasil +// AND an admin: +// - a thirdparty NON-admin player is still reclaimed (pins role='admin') — Mojang +// priority must keep displacing ordinary squatters; +// - a Mojang-authenticated admin is still reclaimed (pins auth_source='thirdparty') — +// an admin's Mojang identity has no Login-Server name to protect (and Mojang names +// are unique, so this is operationally moot, but it locks the conjunct); +// - an SSO Operator with NO local password is still protected (pins the deliberate +// ABSENCE of a password_hash test) — signing in via Cloudflare Access (§14) leaves +// role='admin' with a NULL hash, and that holder must be protected all the same. +func TestReclaimAdminProtectionScope(t *testing.T) { + const squatter = "0a11dead-0000-0000-0000-00000000ad11" + cases := []struct { + name string + role string + auth string + passHash string + protected bool // true: reclaim refused (409); false: reclaim succeeds (200, barred) + }{ + {"thirdparty non-admin is reclaimed", "user", authSourceThirdParty, "", false}, + {"mojang admin is reclaimed", "admin", authSourceMojang, "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO", false}, + {"sso admin without local password is protected", "admin", authSourceThirdParty, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := newFakeRepo() + repo.staff["holder"] = &StaffUser{ID: "h-1", Username: "holder", Role: tc.role, PasswordHash: tc.passHash} + repo.links[squatter] = "h-1" + repo.linkAuthSource[squatter] = tc.auth + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + body := `{"squatter_uuid":"` + squatter + `","username":"Holder"}` + w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil) + + if tc.protected { + if w.Code != http.StatusConflict || decodeErr(t, w) != "protected_admin" { + t.Fatalf("code = %d body %s, want 409 protected_admin", w.Code, w.Body.String()) + } + if len(repo.blacklist) != 0 || len(repo.holds) != 0 { + t.Fatal("a protected holder must not be barred or stashed") + } + return + } + if w.Code != http.StatusOK { + t.Fatalf("code = %d body %s, want 200 (reclaim should proceed)", w.Code, w.Body.String()) + } + if !repo.blacklist[squatter] { + t.Fatal("an unprotected squatter must be barred — Mojang priority still holds") + } + }) + } +} + // TestReclaimIsIdempotent proves a retried velocity callback is harmless: a repeat // reclaim of an already-barred UUID still answers 200 and does not disturb the // original hold (matching the ON CONFLICT DO NOTHING in both inserts). diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index cbe91f2..5cfb024 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -515,6 +515,24 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool return ok, err } +// IsProtectedAdminLink reports whether mc_uuid belongs to a Linked Operator/SysAdmin +// who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim +// exception (spec §B3). The EXISTS joins account_links to users on exactly three +// conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the +// linked user is an admin. It intentionally does not test password_hash: an Operator +// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash +// and must be protected just the same — the hash is orthogonal to "is staff" and "logs +// in via the Login Server". Keyed by UUID, the only identity velocity holds. +func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) { + var ok bool + err := p.db.QueryRowContext(ctx, + `SELECT EXISTS( + SELECT 1 FROM account_links al JOIN users u ON u.id = al.user_id + WHERE al.mc_uuid = $1 AND al.auth_source = 'thirdparty' AND u.role = 'admin')`, + mcUUID).Scan(&ok) + return ok, err +} + // ---- local-password auth (spec §B) ---- // UserByUsername loads a staff login projection by username, or ErrNotFound. A diff --git a/internal/api/repo.go b/internal/api/repo.go index 776b656..c91f634 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -218,6 +218,22 @@ type Repo interface { // reject a squatter while letting the genuine Mojang UUID — same username, // different UUID — through: the check is keyed by UUID, never by the name. IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool, error) + // IsProtectedAdminLink reports whether an in-game UUID belongs to a Linked + // Operator/SysAdmin who authenticates through the configured third-party + // Yggdrasil — the admin-on-Yggdrasil reclaim exception (spec §B3). Such a holder + // is staff logging in via the Login Server, not a Mojang squatter, so a + // Mojang-priority reclaim must never bar them. The predicate is exactly three + // conjuncts: the UUID is linked (account_links), that link authenticated via + // 'thirdparty' (auth_source), and the linked user is an admin (role='admin'). + // It deliberately does NOT require a local password hash: an Operator who signs + // in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin' + // with no password_hash, and must be protected all the same — a password hash is + // orthogonal to both "is staff" and "logs in via the Login Server". An unlinked + // UUID, a Mojang-sourced link, or a non-admin link all yield false, so the + // exception never broadens to ordinary thirdparty players (Mojang priority still + // displaces them) nor to Mojang-authenticated identities (who have no Login-Server + // name to protect). Keyed by UUID — the only identity velocity knows. + IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) // ---- local-password auth (spec §B) ---- diff --git a/internal/api/session.go b/internal/api/session.go index 5229b08..8b6fce1 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -84,22 +84,23 @@ func clearSessionCookie(w http.ResponseWriter) { }) } -// hostIsAdminConsole reports whether the request arrived on the operator console -// host, op.console.. The session cookie is host-only, so a session -// minted on op.console is structurally unable to reach the player console; this -// is the local-auth analogue of the admin Access path. The Host the API sees must -// be the real client Host (the ingress must forward it), which the VM check -// verifies. -func hostIsAdminConsole(r *http.Request, rootDomain string) bool { - if rootDomain == "" { - return false +// hostIsAdminConsole reports whether the request arrived on the configured +// operator console host. The session cookie is host-only, so a session minted on +// the admin host is structurally unable to reach the player console. If older +// configs omit [auth].admin_hostname, fall back to op.console.. +func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool { + want := strings.TrimSpace(adminHostname) + if want == "" { + if rootDomain == "" { + return false + } + want = "op.console." + rootDomain } host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } - want := "op.console." + rootDomain - return strings.EqualFold(strings.TrimSuffix(host, "."), want) + return strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(want, ".")) } // SessionAuth is the composite ExternalAuth for the web face. It prefers a @@ -113,10 +114,11 @@ func hostIsAdminConsole(r *http.Request, rootDomain string) bool { // rejected and does NOT fall through to the JWT delegate, so a stale or // forged cookie can never be laundered into a JWT attempt. type SessionAuth struct { - Repo Repo - Delegate ExternalAuth - RootDomain string - Now func() time.Time + Repo Repo + Delegate ExternalAuth + RootDomain string + AdminHostname string + Now func() time.Time } func (s SessionAuth) now() time.Time { @@ -152,7 +154,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) { UserID: u.ID, Email: u.Email, Role: u.Role, - ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain), + ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), MustChangePassword: u.MustChangePassword, }, nil } diff --git a/internal/cfsetup/runner.go b/internal/cfsetup/runner.go index 6f2ea44..42a3d2c 100644 --- a/internal/cfsetup/runner.go +++ b/internal/cfsetup/runner.go @@ -153,6 +153,12 @@ func (r *ExecRunner) CreateAccessApplication(ctx context.Context, app AccessAppl } `json:"result"` } if err := r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps", r.AccountID), app, &resp); err != nil { + // If application already exists, look it up instead of failing (idempotency) + if strings.Contains(err.Error(), "application_already_exists") || strings.Contains(err.Error(), "11010") { + if id, aud, lerr := r.lookupAccessApplication(ctx, app.Domain); lerr == nil && id != "" { + return id, aud, nil + } + } return "", "", err } return resp.Result.ID, resp.Result.AUD, nil @@ -160,7 +166,14 @@ func (r *ExecRunner) CreateAccessApplication(ctx context.Context, app AccessAppl // CreateAccessPolicy POSTs the policy onto the Access app. func (r *ExecRunner) CreateAccessPolicy(ctx context.Context, appID string, policy AccessPolicy) error { - return r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps/%s/policies", r.AccountID, appID), policy, nil) + if err := r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps/%s/policies", r.AccountID, appID), policy, nil); err != nil { + // If policy already exists, treat it as idempotent success + if strings.Contains(err.Error(), "policy_already_exists") || strings.Contains(err.Error(), "11015") || strings.Contains(err.Error(), "already_exists") { + return nil + } + return err + } + return nil } // runCloudflared executes the cloudflared binary with the given args, returning @@ -202,6 +215,25 @@ func (r *ExecRunner) apiPost(ctx context.Context, path string, body, out any) er defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("cfsetup: Cloudflare API authentication failed (status 401). Please verify that:\n"+ + " 1. The API Token is valid, active, and has not expired.\n"+ + " 2. You did not enter a Global API Key (a Bearer API Token is required).\n"+ + " 3. The token has the required permissions under the Account scope:\n"+ + " - Account > Access Apps and Policies: Edit\n"+ + " - Account > Cloudflare Tunnel: Edit\n"+ + " - Zone > DNS: Edit\n"+ + " Original error: %s", string(raw)) + } + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("cfsetup: Cloudflare API access forbidden (status 403). Please verify that:\n"+ + " 1. The API Token has permission to access Account ID %q.\n"+ + " 2. The token has the required permissions under the Account scope:\n"+ + " - Account > Access Apps and Policies: Edit\n"+ + " - Account > Cloudflare Tunnel: Edit\n"+ + " - Zone > DNS: Edit\n"+ + " Original error: %s", r.AccountID, string(raw)) + } return fmt.Errorf("cfsetup: Cloudflare API %s: status %d: %s", path, resp.StatusCode, string(raw)) } // Cloudflare wraps every response in {success, errors, result}; surface a @@ -220,3 +252,75 @@ func (r *ExecRunner) apiPost(ctx context.Context, path string, body, out any) er } return nil } + +// apiGet sends an authenticated JSON GET to the Cloudflare API and, on a +// non-2xx or success:false body, returns the error. out, when non-nil, receives +// the decoded response. +func (r *ExecRunner) apiGet(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.apiBase()+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+r.APIToken) + resp, err := r.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("cfsetup: Cloudflare API authentication failed (status 401). Please verify that:\n"+ + " 1. The API Token is valid, active, and has not expired.\n"+ + " 2. You did not enter a Global API Key (a Bearer API Token is required).\n"+ + " 3. The token has the required permissions under the Account scope:\n"+ + " - Account > Access Apps and Policies: Edit\n"+ + " - Account > Cloudflare Tunnel: Edit\n"+ + " - Zone > DNS: Edit\n"+ + " Original error: %s", string(raw)) + } + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("cfsetup: Cloudflare API access forbidden (status 403). Please verify that:\n"+ + " 1. The API Token has permission to access Account ID %q.\n"+ + " 2. The token has the required permissions under the Account scope:\n"+ + " - Account > Access Apps and Policies: Edit\n"+ + " - Account > Cloudflare Tunnel: Edit\n"+ + " - Zone > DNS: Edit\n"+ + " Original error: %s", r.AccountID, string(raw)) + } + return fmt.Errorf("cfsetup: Cloudflare API %s: status %d: %s", path, resp.StatusCode, string(raw)) + } + var envelope struct { + Success bool `json:"success"` + Errors []json.RawMessage `json:"errors"` + } + if err := json.Unmarshal(raw, &envelope); err == nil && !envelope.Success && len(envelope.Errors) > 0 { + return fmt.Errorf("cfsetup: Cloudflare API %s: %s", path, string(raw)) + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("cfsetup: decode Cloudflare API %s response: %w", path, err) + } + } + return nil +} + +// lookupAccessApplication finds an existing Access application's id and aud by domain. +func (r *ExecRunner) lookupAccessApplication(ctx context.Context, domain string) (string, string, error) { + var resp struct { + Result []struct { + ID string `json:"id"` + Domain string `json:"domain"` + AUD string `json:"aud"` + } `json:"result"` + } + if err := r.apiGet(ctx, fmt.Sprintf("/accounts/%s/access/apps?per_page=100", r.AccountID), &resp); err != nil { + return "", "", err + } + for _, app := range resp.Result { + if app.Domain == domain { + return app.ID, app.AUD, nil + } + } + return "", "", fmt.Errorf("cfsetup: access application for domain %q not found in list", domain) +}