feat(breakglass): optional Cloudflare Tunnel + Access setup in the TUI
Add an optional edge-setup flow to the `felis breakGlass` sudo TUI, reachable as an independent peer of Owner provisioning through a new top-level menu (so reaching it never forces an Owner password reset). The flow drives the operator's own Cloudflare consent (interactive `cloudflared tunnel login`, suspending the alt-screen, plus an API token) and then calls cfsetup to stand up a Tunnel routing the configured admin and panel hosts and a fail-closed Access application. It stays gated shut unless an admin hostname is configured and the operator is logged in (edgeReady), and refuses empty or bare-domain credentials before any side effect. On success the TUI surfaces the issued Access aud and an explicit ACTION REQUIRED note; it never edits felis.toml. The live cloudflared and Cloudflare API calls are integration-only and exercised against a real account.
This commit is contained in:
2 files changed
+579
-61
No files matched your search
+461
-55
@@ -10,9 +10,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/store"
|
||||
|
||||
@@ -41,9 +43,16 @@ import (
|
||||
// this is root and can edit Postgres directly — but it produces an honest trail
|
||||
// for an honest operator, which is the point.
|
||||
//
|
||||
// The richer break-glass operations (OP create, halt, sync, S3) land in a later
|
||||
// phase; this file is intentionally scoped to the one provisioning operation that
|
||||
// makes the local-auth thin thread reachable.
|
||||
// The console opens on a thin top-level router (stepMenu) so that operations
|
||||
// are peers, not tails of one wizard. Two are wired today: (1) provision/reset
|
||||
// the Owner — the thin thread above — and (2) an OPTIONAL Cloudflare Tunnel +
|
||||
// Access edge (internal/cfsetup), kept "锦上添花": it is reachable WITHOUT touching
|
||||
// the Owner credential, supported but never required, and gated entirely on the
|
||||
// operator's own Cloudflare account. The remaining ops (halt, sync, S3) land in a
|
||||
// later phase as further menu peers. The edge flow's verifiable logic lives in
|
||||
// cfsetup (fail-closed policy, ingress, gating, all unit-tested); what this file
|
||||
// adds for it is the untested bubbletea shell plus a `tea.ExecProcess` suspension
|
||||
// for the interactive `cloudflared tunnel login` browser consent.
|
||||
|
||||
// breakGlassOverrideToken is the literal an operator must type to proceed when no
|
||||
// admin credential could be verified. Requiring an explicit, deliberate word (not a
|
||||
@@ -117,13 +126,13 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain, accountableOSUser(), adminExists)
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if !res.provisioned {
|
||||
if !res.provisioned && !res.edgeConfigured {
|
||||
fmt.Fprintln(stdout, "felis breakGlass: cancelled — no changes made.")
|
||||
return 0
|
||||
}
|
||||
@@ -132,22 +141,42 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
// display with it. Re-print a durable summary to the normal screen so the
|
||||
// outcome — and any generated one-time password — survives in scrollback long
|
||||
// enough for the operator to log in.
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: 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 != "" {
|
||||
// A one-time password was generated (recovery / root override). It is shown,
|
||||
// never persisted: only the bcrypt hash reached the database.
|
||||
fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword)
|
||||
} else {
|
||||
// Bootstrap: the operator typed the password themselves, so we do NOT echo it
|
||||
// back into scrollback.
|
||||
fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).")
|
||||
if res.provisioned {
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: 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 != "" {
|
||||
// A one-time password was generated (recovery / root override). It is shown,
|
||||
// never persisted: only the bcrypt hash reached the database.
|
||||
fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword)
|
||||
} else {
|
||||
// Bootstrap: the operator typed the password themselves, so we do NOT echo it
|
||||
// back into scrollback.
|
||||
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 res.rootDomain != "" {
|
||||
fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain)
|
||||
}
|
||||
}
|
||||
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 res.edgeConfigured {
|
||||
// The Cloudflare edge was provisioned. The remaining steps are the operator's
|
||||
// own (set the audience, run the tunnel) — we do NOT edit felis.toml for them:
|
||||
// editing a config we did not write is a hard-to-reverse change, and the aud is
|
||||
// the one value felis-api must adopt to trust the new edge (spec §14).
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: 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: access_jwt_aud = %q\n", *cfgPath, 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
|
||||
}
|
||||
@@ -382,17 +411,40 @@ type breakGlassResult struct {
|
||||
displayPassword string // empty when the operator typed their own bootstrap password
|
||||
auditWarning string
|
||||
rootDomain string
|
||||
|
||||
// optional Cloudflare edge outcome (independent of provisioned)
|
||||
edgeConfigured bool
|
||||
edgeAud string
|
||||
edgeRoutedHosts []string
|
||||
edgeConfigPath string
|
||||
}
|
||||
|
||||
type bgStep int
|
||||
|
||||
const (
|
||||
stepAuth bgStep = iota // recovery: authenticate as an existing admin
|
||||
stepOverride // recovery: admin auth failed → deliberate root override
|
||||
stepProvision // collect the Owner target (and password, in bootstrap)
|
||||
stepMenu bgStep = iota // top-level router: provision vs. optional edge
|
||||
stepAuth // recovery: authenticate as an existing admin
|
||||
stepOverride // recovery: admin auth failed → deliberate root override
|
||||
stepProvision // collect the Owner target (and password, in bootstrap)
|
||||
stepWorking
|
||||
stepDone
|
||||
stepError
|
||||
// optional Cloudflare edge flow (锦上添花)
|
||||
stepEdgeIntro // preconditions + interactive `cloudflared tunnel login`
|
||||
stepEdgeInput // API token / account / who-to-admit / tunnel name / config path
|
||||
stepEdgeWorking // cfsetup.Setup runs against the operator's Cloudflare account
|
||||
stepEdgeDone
|
||||
stepEdgeError
|
||||
)
|
||||
|
||||
// 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"
|
||||
defaultTunnelConfigPath = "/etc/felis/cloudflared.yml"
|
||||
)
|
||||
|
||||
// authResultMsg carries the outcome of the off-goroutine admin credential check.
|
||||
@@ -408,6 +460,19 @@ type performedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// loginDoneMsg fires when the suspended `cloudflared tunnel login` returns. A
|
||||
// non-nil err (or a cancelled login) returns to the intro, never an error exit —
|
||||
// the operator may simply have closed the browser.
|
||||
type loginDoneMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// edgeDoneMsg carries the outcome of the off-goroutine cfsetup.Setup run.
|
||||
type edgeDoneMsg struct {
|
||||
result *cfsetup.Result
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
bgTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("88")).Padding(0, 1)
|
||||
bgLabelStyle = lipgloss.NewStyle().Bold(true)
|
||||
@@ -429,6 +494,12 @@ type bgModel struct {
|
||||
osUser string
|
||||
adminExists bool
|
||||
|
||||
// web hostnames sourced from [auth] config (never re-derived in the shell) —
|
||||
// what the optional edge flow routes. adminHostname is required for the edge;
|
||||
// panelHostname is optional.
|
||||
adminHostname string
|
||||
panelHostname string
|
||||
|
||||
step bgStep
|
||||
inputs []textinput.Model
|
||||
focus int
|
||||
@@ -445,30 +516,28 @@ type bgModel struct {
|
||||
displayPassword string
|
||||
auditWarning string
|
||||
err error
|
||||
|
||||
// optional Cloudflare edge flow
|
||||
cloudflaredPath string // detected; empty = not on PATH
|
||||
certExists bool // ~/.cloudflared/cert.pem present (logged in)
|
||||
loginNote string // soft note after a cancelled/failed login
|
||||
edgeResult *cfsetup.Result // populated on stepEdgeDone
|
||||
}
|
||||
|
||||
func newBGModel(ctx context.Context, s ownerStore, rootDomain, osUser string, adminExists bool) *bgModel {
|
||||
m := &bgModel{
|
||||
ctx: ctx,
|
||||
store: s,
|
||||
rootDomain: rootDomain,
|
||||
osUser: osUser,
|
||||
adminExists: adminExists,
|
||||
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 &bgModel{
|
||||
ctx: ctx,
|
||||
store: s,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHostname,
|
||||
panelHostname: panelHostname,
|
||||
osUser: osUser,
|
||||
adminExists: adminExists,
|
||||
step: stepMenu,
|
||||
}
|
||||
if adminExists {
|
||||
// Recovery: an admin already exists, so the operator must identify themselves
|
||||
// before the glass breaks.
|
||||
m.step = stepAuth
|
||||
m.buildAuth()
|
||||
} else {
|
||||
// Bootstrap: no staff account exists yet; the typed credential mints the first
|
||||
// Owner, attributed to the OS user.
|
||||
m.mode = "bootstrap"
|
||||
m.accountable = osUser
|
||||
m.step = stepProvision
|
||||
m.buildProvision(true)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *bgModel) Init() tea.Cmd { return textinput.Blink }
|
||||
@@ -535,6 +604,22 @@ func (m *bgModel) enterProvision() tea.Cmd {
|
||||
return m.buildProvision(m.mode == "bootstrap")
|
||||
}
|
||||
|
||||
// enterProvisionFlow is the menu entry into the Owner provision/reset op. It takes
|
||||
// the same bootstrap-vs-recovery branch newBGModel used to take at construction:
|
||||
// no admin → bootstrap the first Owner from a typed credential; an admin exists →
|
||||
// authenticate first so the recovery is attributable.
|
||||
func (m *bgModel) enterProvisionFlow() tea.Cmd {
|
||||
m.formErr = ""
|
||||
if m.adminExists {
|
||||
m.step = stepAuth
|
||||
return m.buildAuth()
|
||||
}
|
||||
m.mode = "bootstrap"
|
||||
m.accountable = m.osUser
|
||||
m.step = stepProvision
|
||||
return m.buildProvision(true)
|
||||
}
|
||||
|
||||
func (m *bgModel) focusInput(i int) tea.Cmd {
|
||||
if i < 0 {
|
||||
i = len(m.inputs) - 1
|
||||
@@ -593,13 +678,44 @@ func (m *bgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case loginDoneMsg:
|
||||
// `cloudflared tunnel login` returned. Re-detect to pick up a freshly written
|
||||
// cert.pem; a cancelled/failed login is NOT fatal — it just lands back on the
|
||||
// intro with a note, because the operator may have closed the browser.
|
||||
pre := cfsetup.DetectPreconditions("")
|
||||
m.cloudflaredPath = pre.CloudflaredPath
|
||||
m.certExists = pre.CertExists
|
||||
switch {
|
||||
case msg.err != nil && !m.certExists:
|
||||
m.loginNote = "cloudflared login did not complete: " + msg.err.Error()
|
||||
case !m.certExists:
|
||||
m.loginNote = "login finished but ~/.cloudflared/cert.pem still not found — try again"
|
||||
default:
|
||||
m.loginNote = ""
|
||||
}
|
||||
m.step = stepEdgeIntro
|
||||
return m, nil
|
||||
|
||||
case edgeDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.step, m.err = stepEdgeError, msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.step = stepEdgeDone
|
||||
m.edgeResult = msg.result
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.step {
|
||||
case stepDone, stepError:
|
||||
case stepMenu:
|
||||
return m.handleMenuKey(msg)
|
||||
case stepEdgeIntro:
|
||||
return m.handleEdgeIntroKey(msg)
|
||||
case stepDone, stepError, stepEdgeDone, stepEdgeError:
|
||||
// Any key dismisses the terminal screen.
|
||||
return m, tea.Quit
|
||||
case stepWorking:
|
||||
// Ignore input while a database write is in flight.
|
||||
case stepWorking, stepEdgeWorking:
|
||||
// Ignore input while a write / setup is in flight.
|
||||
return m, nil
|
||||
default:
|
||||
return m.handleFormKey(msg)
|
||||
@@ -619,6 +735,10 @@ func (m *bgModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.step, m.formErr = stepAuth, ""
|
||||
return m, m.buildAuth()
|
||||
}
|
||||
if m.step == stepEdgeInput {
|
||||
// Back to the edge intro (re-detects preconditions), not a hard exit.
|
||||
return m.enterEdgeIntro()
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "tab", "down":
|
||||
return m, m.focusInput(m.focus + 1)
|
||||
@@ -638,6 +758,8 @@ func (m *bgModel) submit() (tea.Model, tea.Cmd) {
|
||||
return m.submitOverride()
|
||||
case stepProvision:
|
||||
return m.submitProvision()
|
||||
case stepEdgeInput:
|
||||
return m.submitEdge()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -717,11 +839,203 @@ func performCmd(ctx context.Context, s ownerStore, op breakGlassOp) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- top-level router ----
|
||||
|
||||
func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "up", "shift+tab":
|
||||
if m.focus > 0 {
|
||||
m.focus--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "tab":
|
||||
if m.focus < menuOptionCount-1 {
|
||||
m.focus++
|
||||
}
|
||||
return m, nil
|
||||
case "1":
|
||||
m.focus = 0
|
||||
return m.selectMenu()
|
||||
case "2":
|
||||
m.focus = 1
|
||||
return m.selectMenu()
|
||||
case "enter":
|
||||
return m.selectMenu()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *bgModel) selectMenu() (tea.Model, tea.Cmd) {
|
||||
if m.focus == 1 {
|
||||
return m.enterEdgeIntro()
|
||||
}
|
||||
return m, m.enterProvisionFlow()
|
||||
}
|
||||
|
||||
// ---- optional Cloudflare edge flow (锦上添花) ----
|
||||
|
||||
// enterEdgeIntro detects the operator-only preconditions (cloudflared on PATH, a
|
||||
// completed `cloudflared tunnel login`) and shows the intro. Detection is READ-ONLY
|
||||
// and re-runs every time the screen is entered so a fresh login is picked up.
|
||||
func (m *bgModel) enterEdgeIntro() (tea.Model, tea.Cmd) {
|
||||
m.step = stepEdgeIntro
|
||||
m.formErr, m.loginNote = "", ""
|
||||
pre := cfsetup.DetectPreconditions("")
|
||||
m.cloudflaredPath = pre.CloudflaredPath
|
||||
m.certExists = pre.CertExists
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// edgeReady reports whether the edge flow can proceed to credential entry: the
|
||||
// admin hostname must be configured (it is what the Access app guards) and the
|
||||
// operator must have cloudflared installed and be logged in.
|
||||
func (m *bgModel) edgeReady() bool {
|
||||
return m.adminHostname != "" && m.cloudflaredPath != "" && m.certExists
|
||||
}
|
||||
|
||||
func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
// Back to the 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 {
|
||||
return m.startCloudflaredLogin()
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.edgeReady() {
|
||||
return m, m.enterEdgeInput()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startCloudflaredLogin suspends the TUI to run the interactive browser consent.
|
||||
// This is the one step cfsetup cannot perform or fake: it authenticates the
|
||||
// operator against their OWN Cloudflare account and writes ~/.cloudflared/cert.pem.
|
||||
func (m *bgModel) startCloudflaredLogin() (tea.Model, tea.Cmd) {
|
||||
c := exec.CommandContext(m.ctx, m.cloudflaredPath, "tunnel", "login")
|
||||
return m, tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
return loginDoneMsg{err: err}
|
||||
})
|
||||
}
|
||||
|
||||
// enterEdgeInput installs the credential/scope inputs, pre-filling the safe
|
||||
// defaults. The hostnames are NOT collected here — they come from [auth] config.
|
||||
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)
|
||||
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})
|
||||
}
|
||||
|
||||
// submitEdge validates the edge inputs and launches cfsetup.Setup. The fail-closed
|
||||
// guard and empty-identity refusal live in cfsetup — this only translates the typed
|
||||
// identity into an AccessIdentity (a leading "@" means an org email domain, else a
|
||||
// specific person) and surfaces cfsetup's errors as a clean stepEdgeError.
|
||||
func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
|
||||
token := strings.TrimSpace(m.inputs[0].Value())
|
||||
account := strings.TrimSpace(m.inputs[1].Value())
|
||||
identity := strings.TrimSpace(m.inputs[2].Value())
|
||||
tunnel := strings.TrimSpace(m.inputs[3].Value())
|
||||
cfgPath := strings.TrimSpace(m.inputs[4].Value())
|
||||
|
||||
if token == "" {
|
||||
m.formErr = "a Cloudflare API token is required"
|
||||
return m, m.focusInput(0)
|
||||
}
|
||||
if account == "" {
|
||||
m.formErr = "the Cloudflare account ID is required"
|
||||
return m, m.focusInput(1)
|
||||
}
|
||||
if identity == "" {
|
||||
m.formErr = "enter who Access should admit — your email, or @your-domain"
|
||||
return m, m.focusInput(2)
|
||||
}
|
||||
// A bare "@" would scope Access to an empty email domain. cfsetup is fail-closed
|
||||
// (an empty domain admits no one), but reject it here so the operator fixes the
|
||||
// typo rather than silently locking everyone out.
|
||||
if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" {
|
||||
m.formErr = "enter a domain after the @, e.g. @your-domain"
|
||||
return m, m.focusInput(2)
|
||||
}
|
||||
if tunnel == "" {
|
||||
tunnel = defaultTunnelName
|
||||
}
|
||||
|
||||
var id cfsetup.AccessIdentity
|
||||
if strings.HasPrefix(identity, "@") {
|
||||
id.EmailDomains = []string{strings.TrimPrefix(identity, "@")}
|
||||
} else {
|
||||
id.Emails = []string{identity}
|
||||
}
|
||||
|
||||
p := cfsetup.Params{
|
||||
PanelHostname: m.panelHostname,
|
||||
AdminHostname: m.adminHostname,
|
||||
TunnelName: tunnel,
|
||||
ConfigPath: cfgPath,
|
||||
AccessIdentity: id,
|
||||
// Re-detect with the token so a cert.pem written by the in-flow login is seen.
|
||||
Pre: cfsetup.DetectPreconditions(token),
|
||||
}
|
||||
runner := &cfsetup.ExecRunner{
|
||||
Cloudflared: m.cloudflaredPath,
|
||||
APIToken: token,
|
||||
AccountID: account,
|
||||
}
|
||||
m.formErr, m.working = "", "Configuring the Cloudflare Tunnel + Access edge…"
|
||||
m.step = stepEdgeWorking
|
||||
return m, edgeSetupCmd(m.ctx, runner, p)
|
||||
}
|
||||
|
||||
// edgeSetupCmd runs cfsetup.Setup off the UI goroutine.
|
||||
func edgeSetupCmd(ctx context.Context, runner cfsetup.Runner, p cfsetup.Params) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
res, err := cfsetup.Setup(ctx, runner, p)
|
||||
return edgeDoneMsg{result: res, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *bgModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\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."
|
||||
}
|
||||
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."},
|
||||
}
|
||||
for i, o := range opts {
|
||||
cursor, title := " ", o.title
|
||||
if i == m.focus {
|
||||
cursor, title = bgLabelStyle.Render(" ▸ "), bgLabelStyle.Render(o.title)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s%d. %s\n", cursor, i+1, title))
|
||||
b.WriteString(" " + bgHintStyle.Render(o.desc) + "\n\n")
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("↑↓ move · 1/2 select · enter confirm · esc exit") + "\n")
|
||||
|
||||
case stepAuth:
|
||||
b.WriteString("A staff account already exists. Identify yourself before breaking the glass.\n")
|
||||
b.WriteString("Authenticate as an existing admin — this records WHO performed the recovery.\n")
|
||||
@@ -773,7 +1087,68 @@ func (m *bgModel) View() string {
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n")
|
||||
|
||||
case stepWorking:
|
||||
case stepEdgeIntro:
|
||||
b.WriteString(bgLabelStyle.Render("Optional · Cloudflare Tunnel + Access edge") + bgHintStyle.Render(" (锦上添花 — skippable)") + "\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")
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if m.cloudflaredPath == "" {
|
||||
b.WriteString(bgErrStyle.Render("✗ cloudflared not found on PATH") + " — install it, then esc and re-enter.\n")
|
||||
} else {
|
||||
b.WriteString(bgOKStyle.Render("✓ cloudflared") + " " + bgHintStyle.Render(m.cloudflaredPath) + "\n")
|
||||
if m.certExists {
|
||||
b.WriteString(bgOKStyle.Render("✓ logged in") + bgHintStyle.Render(" (~/.cloudflared/cert.pem present)") + "\n")
|
||||
} else {
|
||||
b.WriteString(bgWarnStyle.Render("• not logged in to Cloudflare") + " — press " + bgLabelStyle.Render("l") + " to run `cloudflared tunnel login`\n")
|
||||
b.WriteString(bgHintStyle.Render(" (opens a browser for consent on your own account).") + "\n")
|
||||
}
|
||||
}
|
||||
if m.loginNote != "" {
|
||||
b.WriteString("\n" + bgWarnStyle.Render(m.loginNote) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
switch {
|
||||
case m.edgeReady():
|
||||
b.WriteString(bgHintStyle.Render("enter continue · esc back") + "\n")
|
||||
case m.adminHostname != "" && m.cloudflaredPath != "" && !m.certExists:
|
||||
b.WriteString(bgHintStyle.Render("l login · esc back") + "\n")
|
||||
default:
|
||||
b.WriteString(bgHintStyle.Render("esc back") + "\n")
|
||||
}
|
||||
|
||||
case stepEdgeInput:
|
||||
b.WriteString(bgLabelStyle.Render("Cloudflare edge · credentials & scope") + "\n")
|
||||
b.WriteString(bgHintStyle.Render("Token scopes: Account › Cloudflare Tunnel:Edit · Zone › DNS:Edit · Account › Access Apps and Policies:Edit") + "\n\n")
|
||||
labels := []string{
|
||||
"Cloudflare API token",
|
||||
"Cloudflare account ID",
|
||||
"Admit (your email, or @your-domain)",
|
||||
"Tunnel name",
|
||||
"Tunnel config path",
|
||||
}
|
||||
for i, lbl := range labels {
|
||||
b.WriteString(bgLabelStyle.Render(lbl) + "\n")
|
||||
b.WriteString(m.inputs[i].View() + "\n\n")
|
||||
}
|
||||
if m.formErr != "" {
|
||||
b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n")
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter configure · esc back") + "\n")
|
||||
|
||||
case stepWorking, stepEdgeWorking:
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Working…"
|
||||
@@ -802,8 +1177,31 @@ func (m *bgModel) View() string {
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
|
||||
case stepError:
|
||||
b.WriteString(bgErrStyle.Render("✗ Break-glass failed") + "\n\n")
|
||||
case stepEdgeDone:
|
||||
b.WriteString(bgOKStyle.Render("✓ Cloudflare Tunnel + Access edge configured") + "\n\n")
|
||||
var box string
|
||||
if m.edgeResult != nil {
|
||||
box = bgLabelStyle.Render("access_jwt_aud ") + bgPwStyle.Render(m.edgeResult.AccessAud)
|
||||
if len(m.edgeResult.RoutedHostnames) > 0 {
|
||||
box += "\n" + bgLabelStyle.Render("routed ") + strings.Join(m.edgeResult.RoutedHostnames, ", ")
|
||||
}
|
||||
if m.edgeResult.ConfigPath != "" {
|
||||
box += "\n" + bgLabelStyle.Render("tunnel config ") + m.edgeResult.ConfigPath
|
||||
}
|
||||
}
|
||||
b.WriteString(bgBoxStyle.Render(box) + "\n\n")
|
||||
b.WriteString(bgWarnStyle.Render("ACTION REQUIRED") + " — 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("tunnel with " + bgLabelStyle.Render("cloudflared tunnel run") + ".\n\n")
|
||||
b.WriteString(bgHintStyle.Render("Verify the Access app actually guards the admin face before relying on it.") + "\n\n")
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
|
||||
case stepError, stepEdgeError:
|
||||
header := "✗ Break-glass failed"
|
||||
if m.step == stepEdgeError {
|
||||
header = "✗ Cloudflare edge setup failed — no usable edge was created"
|
||||
}
|
||||
b.WriteString(bgErrStyle.Render(header) + "\n\n")
|
||||
b.WriteString(m.err.Error() + "\n\n")
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
}
|
||||
@@ -813,8 +1211,8 @@ func (m *bgModel) View() 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.
|
||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain, osUser, adminExists), tea.WithAltScreen()).Run()
|
||||
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()
|
||||
if err != nil {
|
||||
return breakGlassResult{}, err
|
||||
}
|
||||
@@ -822,10 +1220,11 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, osUser stri
|
||||
if !ok {
|
||||
return breakGlassResult{}, errors.New("unexpected final model")
|
||||
}
|
||||
if m.step == stepError {
|
||||
// A terminal-error screen for either flow surfaces as a returned error.
|
||||
if m.step == stepError || m.step == stepEdgeError {
|
||||
return breakGlassResult{}, m.err
|
||||
}
|
||||
return breakGlassResult{
|
||||
res := breakGlassResult{
|
||||
provisioned: m.step == stepDone,
|
||||
mode: m.mode,
|
||||
accountable: m.accountable,
|
||||
@@ -834,5 +1233,12 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, osUser stri
|
||||
displayPassword: m.displayPassword,
|
||||
auditWarning: m.auditWarning,
|
||||
rootDomain: rootDomain,
|
||||
}, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -526,6 +526,19 @@ func advance(t *testing.T, m *bgModel, msg tea.Msg) *bgModel {
|
||||
return bm
|
||||
}
|
||||
|
||||
// enterProvisionViaMenu drives the top-level router into the Owner provision/reset
|
||||
// flow the way an operator does on the emergency path: the menu opens with option 1
|
||||
// (provision) focused, so a single Enter selects it. The gating sub-tests use this to
|
||||
// reach stepAuth (recovery) or stepProvision (bootstrap) through the REAL entry path
|
||||
// before asserting the accountability transitions — not by reaching past the menu.
|
||||
func enterProvisionViaMenu(t *testing.T, m *bgModel) *bgModel {
|
||||
t.Helper()
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("expected the model to open on stepMenu, got %v", m.step)
|
||||
}
|
||||
return advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
}
|
||||
|
||||
// TestBGModelGating drives the break-glass state machine headlessly to lock in the
|
||||
// accountability gate: a credential never advances to provisioning without either a
|
||||
// verified admin (recovery) or a deliberate, explicit OVERRIDE (root override), and
|
||||
@@ -534,7 +547,8 @@ func TestBGModelGating(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("recovery starts at auth; a non-matching credential offers override, never provision", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true)
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
if m.step != stepAuth || m.mode != "" {
|
||||
t.Fatalf("initial step/mode = %v/%q, want stepAuth and an unresolved mode", m.step, m.mode)
|
||||
}
|
||||
@@ -548,7 +562,8 @@ func TestBGModelGating(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("recovery with a verified admin enters provision attributed to that admin", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true)
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{matched: "bob", ok: true})
|
||||
if m.step != stepProvision {
|
||||
t.Fatalf("step = %v, want stepProvision", m.step)
|
||||
@@ -563,7 +578,8 @@ func TestBGModelGating(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("an auth lookup error surfaces an error screen, not a silent override", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true)
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{err: errors.New("db unreachable")})
|
||||
if m.step != stepError || m.err == nil {
|
||||
t.Errorf("step/err = %v/%v, want stepError with a non-nil err", m.step, m.err)
|
||||
@@ -571,7 +587,8 @@ func TestBGModelGating(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("the root override requires the exact OVERRIDE token", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true)
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{ok: false}) // → stepOverride
|
||||
m.inputs[0].SetValue("override") // wrong case must not pass
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
@@ -589,7 +606,8 @@ func TestBGModelGating(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("empty admin credentials do not start a verification", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true)
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // both inputs blank
|
||||
if m.step != stepAuth || m.formErr == "" {
|
||||
t.Errorf("blank submit: step/formErr = %v/%q, want stay on stepAuth with an error", m.step, m.formErr)
|
||||
@@ -598,7 +616,8 @@ func TestBGModelGating(t *testing.T) {
|
||||
|
||||
t.Run("bootstrap starts at provision as the OS user and requires a valid, matching password", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
m := newBGModel(ctx, f, testRoot, "deploybot", false)
|
||||
m := newBGModel(ctx, f, testRoot, "", "", "deploybot", false)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
if m.step != stepProvision || m.mode != "bootstrap" || m.accountable != "deploybot" {
|
||||
t.Fatalf("initial step/mode/accountable = %v/%q/%q, want stepProvision/bootstrap/deploybot", m.step, m.mode, m.accountable)
|
||||
}
|
||||
@@ -630,3 +649,96 @@ 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.
|
||||
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) {
|
||||
f := &fakeOwnerStore{admins: true}
|
||||
m := newBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("initial step = %v, want stepMenu", m.step)
|
||||
}
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
if m.step != stepEdgeIntro {
|
||||
t.Fatalf("after selecting option 2 step = %v, want stepEdgeIntro", m.step)
|
||||
}
|
||||
// Reaching the edge must NOT have provisioned or reset an Owner.
|
||||
if len(f.upserts) != 0 {
|
||||
t.Error("the edge flow must not write any Owner record")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("esc from the edge intro returns to the router with the edge option highlighted", 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")})
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if m.step != stepMenu || m.focus != 1 {
|
||||
t.Errorf("after esc step/focus = %v/%d, want stepMenu with the edge option (1) focused", m.step, m.focus)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
// 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))
|
||||
}
|
||||
// All inputs blank: submit (via the real key path) must report an error and stay
|
||||
// put — NOT reach stepEdgeWorking, which is what launches cfsetup.Setup against
|
||||
// the live cloudflared binary / Cloudflare API.
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("blank edge submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("empty credentials must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge rejects a bare @ identity that would scope Access to an empty domain", func(t *testing.T) {
|
||||
m := newBGModel(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[2].SetValue("@")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("bare @ submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("a bare @ identity must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user