feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency
- Add `felis setup` TUI for initial Owner provisioning and optional Cloudflare edge - Refactor breakGlass to share console TUI model (runConsoleTUI) with setup mode - Session auth respects configured [auth].admin_hostname; fallback to op.console.<root> - Protect linked Yggdrasil admins from Mojang-priority reclaim (spec §B3) - cfsetup: idempotent Access app/policy creation, better 401/403 errors, GET + lookup - Bootstrap: auto-install cloudflared, symlink /etc/felis/felis.toml - Add sequence diagrams for ping-to-join, claim, and link flows
This commit is contained in:
15 files changed
+957
-126
No files matched your search
+5
-4
@@ -135,7 +135,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
Logs: api.NewK8sLogStreamer(clientset, cfg.K8s.Namespace),
|
||||
// Build-log stream (spec §16) is scoped to the BUILD namespace — the same
|
||||
// value the Builder renders Jobs into — so it follows where build Pods run.
|
||||
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
|
||||
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
|
||||
Internal: api.BearerTokenAuth{Token: token},
|
||||
Builder: builder,
|
||||
Restorer: restorer,
|
||||
@@ -147,9 +147,10 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
// is wired (deployment integration point) — while the local-password path is
|
||||
// live the moment `felis breakGlass` flips local_auth_enabled on.
|
||||
External: api.SessionAuth{
|
||||
Repo: repo,
|
||||
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
Repo: repo,
|
||||
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
AdminHostname: cfg.Auth.AdminHostname,
|
||||
},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
WakeCooldown: 30 * time.Second,
|
||||
|
||||
+222
-63
@@ -89,6 +89,9 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
fs.SetOutput(stderr)
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -156,8 +159,8 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
if res.auditWarning != "" {
|
||||
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
|
||||
}
|
||||
if res.rootDomain != "" {
|
||||
fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain)
|
||||
if url := adminLoginURL(res.rootDomain, res.adminHostname); url != "" {
|
||||
fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,14 +414,24 @@ type breakGlassResult struct {
|
||||
displayPassword string // empty when the operator typed their own bootstrap password
|
||||
auditWarning string
|
||||
rootDomain string
|
||||
adminHostname string
|
||||
|
||||
// optional Cloudflare edge outcome (independent of provisioned)
|
||||
edgeConfigured bool
|
||||
edgeAud string
|
||||
edgeRoutedHosts []string
|
||||
edgeConfigPath string
|
||||
edgeConfigured bool
|
||||
edgeAud string
|
||||
edgeRoutedHosts []string
|
||||
edgeConfigPath string
|
||||
edgePanelHostname string
|
||||
edgeAdminHostname string
|
||||
}
|
||||
|
||||
type consoleMode string
|
||||
|
||||
const (
|
||||
consoleModeBreakGlass consoleMode = "breakGlass"
|
||||
consoleModeSetup consoleMode = "setup"
|
||||
)
|
||||
|
||||
type bgStep int
|
||||
|
||||
const (
|
||||
@@ -437,10 +450,6 @@ const (
|
||||
stepEdgeError
|
||||
)
|
||||
|
||||
// menuOptionCount is the number of selectable top-level operations. Provision is
|
||||
// index 0; the optional Cloudflare edge is index 1.
|
||||
const menuOptionCount = 2
|
||||
|
||||
// Edge-flow defaults the operator can accept as-is.
|
||||
const (
|
||||
defaultTunnelName = "felis"
|
||||
@@ -490,6 +499,7 @@ var (
|
||||
type bgModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
consoleMode consoleMode
|
||||
rootDomain string
|
||||
osUser string
|
||||
adminExists bool
|
||||
@@ -518,19 +528,27 @@ type bgModel struct {
|
||||
err error
|
||||
|
||||
// optional Cloudflare edge flow
|
||||
cloudflaredPath string // detected; empty = not on PATH
|
||||
certExists bool // ~/.cloudflared/cert.pem present (logged in)
|
||||
loginNote string // soft note after a cancelled/failed login
|
||||
edgeResult *cfsetup.Result // populated on stepEdgeDone
|
||||
cloudflaredPath string // detected; empty = not on PATH
|
||||
certExists bool // ~/.cloudflared/cert.pem present (logged in)
|
||||
loginNote string // soft note after a cancelled/failed login
|
||||
edgeResult *cfsetup.Result // populated on stepEdgeDone
|
||||
edgePanelHostname string
|
||||
edgeAdminHostname string
|
||||
}
|
||||
|
||||
func newBGModel(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) *bgModel {
|
||||
// The console opens on the top-level router; the bootstrap-vs-recovery branch is
|
||||
// taken only when the operator chooses the provisioning op (enterProvisionFlow).
|
||||
// The optional edge op is a peer, reachable without touching the Owner credential.
|
||||
return newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass)
|
||||
}
|
||||
|
||||
func newSetupBGModel(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) *bgModel {
|
||||
return newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup)
|
||||
}
|
||||
|
||||
func newBGModelForMode(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *bgModel {
|
||||
return &bgModel{
|
||||
ctx: ctx,
|
||||
store: s,
|
||||
consoleMode: mode,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHostname,
|
||||
panelHostname: panelHostname,
|
||||
@@ -841,6 +859,13 @@ func performCmd(ctx context.Context, s ownerStore, op breakGlassOp) tea.Cmd {
|
||||
|
||||
// ---- top-level router ----
|
||||
|
||||
func (m *bgModel) menuOptionCount() int {
|
||||
if m.consoleMode == consoleModeSetup {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
@@ -851,7 +876,7 @@ func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case "down", "tab":
|
||||
if m.focus < menuOptionCount-1 {
|
||||
if m.focus < m.menuOptionCount()-1 {
|
||||
m.focus++
|
||||
}
|
||||
return m, nil
|
||||
@@ -859,8 +884,11 @@ func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.focus = 0
|
||||
return m.selectMenu()
|
||||
case "2":
|
||||
m.focus = 1
|
||||
return m.selectMenu()
|
||||
if m.menuOptionCount() > 1 {
|
||||
m.focus = 1
|
||||
return m.selectMenu()
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
return m.selectMenu()
|
||||
}
|
||||
@@ -868,9 +896,13 @@ func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *bgModel) selectMenu() (tea.Model, tea.Cmd) {
|
||||
if m.focus == 1 {
|
||||
if m.consoleMode == consoleModeSetup && m.focus == 1 {
|
||||
return m.enterEdgeIntro()
|
||||
}
|
||||
if m.consoleMode == consoleModeSetup && m.adminExists {
|
||||
m.formErr = "an Owner/admin already exists; use breakGlass for emergency reset, or choose Cloudflare edge"
|
||||
return m, nil
|
||||
}
|
||||
return m, m.enterProvisionFlow()
|
||||
}
|
||||
|
||||
@@ -889,10 +921,10 @@ func (m *bgModel) enterEdgeIntro() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// edgeReady reports whether the edge flow can proceed to credential entry: the
|
||||
// admin hostname must be configured (it is what the Access app guards) and the
|
||||
// operator must have cloudflared installed and be logged in.
|
||||
// operator must have cloudflared installed and be logged in. Hostnames are chosen
|
||||
// on the next screen, so an empty [auth] hostname no longer blocks setup.
|
||||
func (m *bgModel) edgeReady() bool {
|
||||
return m.adminHostname != "" && m.cloudflaredPath != "" && m.certExists
|
||||
return m.cloudflaredPath != "" && m.certExists
|
||||
}
|
||||
|
||||
func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
@@ -900,12 +932,12 @@ func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
// Back to the router with the edge option still highlighted.
|
||||
// Back to the setup router with the edge option still highlighted.
|
||||
m.step, m.focus, m.loginNote = stepMenu, 1, ""
|
||||
return m, nil
|
||||
case "l", "L":
|
||||
// Offer the interactive login only when it is the actual blocker.
|
||||
if m.adminHostname != "" && m.cloudflaredPath != "" && !m.certExists {
|
||||
if m.cloudflaredPath != "" && !m.certExists {
|
||||
return m.startCloudflaredLogin()
|
||||
}
|
||||
return m, nil
|
||||
@@ -928,19 +960,24 @@ func (m *bgModel) startCloudflaredLogin() (tea.Model, tea.Cmd) {
|
||||
})
|
||||
}
|
||||
|
||||
// enterEdgeInput installs the credential/scope inputs, pre-filling the safe
|
||||
// defaults. The hostnames are NOT collected here — they come from [auth] config.
|
||||
// enterEdgeInput installs the credential/scope inputs, pre-filling safe defaults.
|
||||
// Hostnames are explicit setup inputs so an operator can choose console.mc and
|
||||
// op.console.mc instead of accepting whatever the bootstrap config guessed.
|
||||
func (m *bgModel) enterEdgeInput() tea.Cmd {
|
||||
m.step = stepEdgeInput
|
||||
m.formErr = ""
|
||||
token := bgInput("Cloudflare API token", 200, true)
|
||||
account := bgInput("Cloudflare account ID", 64, false)
|
||||
identity := bgInput("[email protected] or @your-domain", 254, false)
|
||||
panelHost := bgInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false)
|
||||
panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname))
|
||||
adminHost := bgInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false)
|
||||
adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname))
|
||||
tunnel := bgInput(defaultTunnelName, 64, false)
|
||||
tunnel.SetValue(defaultTunnelName)
|
||||
cfgPath := bgInput(defaultTunnelConfigPath, 256, false)
|
||||
cfgPath.SetValue(defaultTunnelConfigPath)
|
||||
return m.setInputs([]textinput.Model{token, account, identity, tunnel, cfgPath})
|
||||
return m.setInputs([]textinput.Model{token, account, identity, panelHost, adminHost, tunnel, cfgPath})
|
||||
}
|
||||
|
||||
// submitEdge validates the edge inputs and launches cfsetup.Setup. The fail-closed
|
||||
@@ -951,8 +988,10 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
|
||||
token := strings.TrimSpace(m.inputs[0].Value())
|
||||
account := strings.TrimSpace(m.inputs[1].Value())
|
||||
identity := strings.TrimSpace(m.inputs[2].Value())
|
||||
tunnel := strings.TrimSpace(m.inputs[3].Value())
|
||||
cfgPath := strings.TrimSpace(m.inputs[4].Value())
|
||||
panelHost := normalizeEdgeHostname(m.inputs[3].Value())
|
||||
adminHost := normalizeEdgeHostname(m.inputs[4].Value())
|
||||
tunnel := strings.TrimSpace(m.inputs[5].Value())
|
||||
cfgPath := strings.TrimSpace(m.inputs[6].Value())
|
||||
|
||||
if token == "" {
|
||||
m.formErr = "a Cloudflare API token is required"
|
||||
@@ -962,6 +1001,14 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
|
||||
m.formErr = "the Cloudflare account ID is required"
|
||||
return m, m.focusInput(1)
|
||||
}
|
||||
if !isHex32(account) {
|
||||
if strings.HasPrefix(account, "cfat_") {
|
||||
m.formErr = "you entered an API token (starting with cfat_) instead of the Cloudflare Account ID"
|
||||
} else {
|
||||
m.formErr = "the Cloudflare Account ID must be a 32-character hexadecimal string"
|
||||
}
|
||||
return m, m.focusInput(1)
|
||||
}
|
||||
if identity == "" {
|
||||
m.formErr = "enter who Access should admit — your email, or @your-domain"
|
||||
return m, m.focusInput(2)
|
||||
@@ -973,9 +1020,24 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
|
||||
m.formErr = "enter a domain after the @, e.g. @your-domain"
|
||||
return m, m.focusInput(2)
|
||||
}
|
||||
if err := validateEdgeHostname("player console hostname", panelHost, false); err != nil {
|
||||
m.formErr = err.Error()
|
||||
return m, m.focusInput(3)
|
||||
}
|
||||
if err := validateEdgeHostname("admin console hostname", adminHost, true); err != nil {
|
||||
m.formErr = err.Error()
|
||||
return m, m.focusInput(4)
|
||||
}
|
||||
if panelHost != "" && strings.EqualFold(panelHost, adminHost) {
|
||||
m.formErr = "player console and admin console hostnames must be different"
|
||||
return m, m.focusInput(4)
|
||||
}
|
||||
if tunnel == "" {
|
||||
tunnel = defaultTunnelName
|
||||
}
|
||||
if cfgPath == "" {
|
||||
cfgPath = defaultTunnelConfigPath
|
||||
}
|
||||
|
||||
var id cfsetup.AccessIdentity
|
||||
if strings.HasPrefix(identity, "@") {
|
||||
@@ -984,9 +1046,11 @@ func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) {
|
||||
id.Emails = []string{identity}
|
||||
}
|
||||
|
||||
m.edgePanelHostname = panelHost
|
||||
m.edgeAdminHostname = adminHost
|
||||
p := cfsetup.Params{
|
||||
PanelHostname: m.panelHostname,
|
||||
AdminHostname: m.adminHostname,
|
||||
PanelHostname: panelHost,
|
||||
AdminHostname: adminHost,
|
||||
TunnelName: tunnel,
|
||||
ConfigPath: cfgPath,
|
||||
AccessIdentity: id,
|
||||
@@ -1013,19 +1077,33 @@ func edgeSetupCmd(ctx context.Context, runner cfsetup.Runner, p cfsetup.Params)
|
||||
|
||||
func (m *bgModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\n\n")
|
||||
title := "FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE"
|
||||
if m.consoleMode == consoleModeSetup {
|
||||
title = "FELIS SETUP — LOCAL SETUP CONSOLE"
|
||||
}
|
||||
b.WriteString(bgTitleStyle.Render("⚠ "+title) + "\n\n")
|
||||
|
||||
switch m.step {
|
||||
case stepMenu:
|
||||
b.WriteString("Choose a break-glass operation:\n\n")
|
||||
provisionDesc := "No staff account yet — bootstrap the first Owner."
|
||||
if m.adminExists {
|
||||
provisionDesc = "An admin exists — authenticate, then reset the Owner credential."
|
||||
prompt := "Choose a break-glass operation:"
|
||||
provisionTitle := "Emergency reset the Owner account"
|
||||
provisionDesc := "Authenticate as an existing admin, or use a deliberate root override."
|
||||
if !m.adminExists {
|
||||
provisionTitle = "Create the first Owner account"
|
||||
provisionDesc = "No staff account exists yet — bootstrap the first Owner."
|
||||
}
|
||||
opts := [menuOptionCount]struct{ title, desc string }{
|
||||
{"Provision / reset the Owner account", provisionDesc},
|
||||
{"Set up the Cloudflare edge", "Tunnel + fail-closed Access for the web faces — 锦上添花, optional."},
|
||||
opts := []struct{ title, desc string }{{provisionTitle, provisionDesc}}
|
||||
if m.consoleMode == consoleModeSetup {
|
||||
prompt = "Choose a setup operation:"
|
||||
provisionTitle = "Create the Owner account"
|
||||
provisionDesc = "No staff account exists yet — bootstrap the first Owner."
|
||||
if m.adminExists {
|
||||
provisionDesc = "Already exists — use breakGlass only for emergency reset."
|
||||
}
|
||||
opts[0] = struct{ title, desc string }{provisionTitle, provisionDesc}
|
||||
opts = append(opts, struct{ title, desc string }{"Set up the Cloudflare edge", "Choose web hostnames, create Tunnel DNS, and guard the admin face with Access."})
|
||||
}
|
||||
b.WriteString(prompt + "\n\n")
|
||||
for i, o := range opts {
|
||||
cursor, title := " ", o.title
|
||||
if i == m.focus {
|
||||
@@ -1034,7 +1112,14 @@ func (m *bgModel) View() string {
|
||||
b.WriteString(fmt.Sprintf("%s%d. %s\n", cursor, i+1, title))
|
||||
b.WriteString(" " + bgHintStyle.Render(o.desc) + "\n\n")
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("↑↓ move · 1/2 select · enter confirm · esc exit") + "\n")
|
||||
if m.formErr != "" {
|
||||
b.WriteString(bgWarnStyle.Render(m.formErr) + "\n\n")
|
||||
}
|
||||
hint := "↑↓ move · 1 select · enter confirm · esc exit"
|
||||
if m.menuOptionCount() > 1 {
|
||||
hint = "↑↓ move · 1/2 select · enter confirm · esc exit"
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render(hint) + "\n")
|
||||
|
||||
case stepAuth:
|
||||
b.WriteString("A staff account already exists. Identify yourself before breaking the glass.\n")
|
||||
@@ -1088,22 +1173,19 @@ func (m *bgModel) View() string {
|
||||
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n")
|
||||
|
||||
case stepEdgeIntro:
|
||||
b.WriteString(bgLabelStyle.Render("Optional · Cloudflare Tunnel + Access edge") + bgHintStyle.Render(" (锦上添花 — skippable)") + "\n")
|
||||
b.WriteString(bgLabelStyle.Render("Cloudflare Tunnel + Access edge") + bgHintStyle.Render(" (optional)") + "\n")
|
||||
b.WriteString("Publishes the web faces over a Cloudflare Tunnel and fronts the SysAdmin\n")
|
||||
b.WriteString("console with a fail-closed Access policy, using YOUR own Cloudflare account.\n")
|
||||
b.WriteString(bgHintStyle.Render("felis stays domain- and IdP-agnostic; bring your own domain/SSO if you prefer.") + "\n\n")
|
||||
b.WriteString(bgHintStyle.Render("Hostnames are editable on the next screen; the Minecraft game host is not tunneled.") + "\n\n")
|
||||
|
||||
if m.adminHostname == "" {
|
||||
b.WriteString(bgErrStyle.Render("✗ [auth] admin_hostname is not set in felis.toml") + " — configure it first; it is\n")
|
||||
b.WriteString(" the hostname the Access policy guards.\n\n")
|
||||
} else {
|
||||
b.WriteString(bgLabelStyle.Render("Will route to the local panel:") + "\n")
|
||||
if m.panelHostname != "" {
|
||||
b.WriteString(" • " + m.panelHostname + bgHintStyle.Render(" (Player console)") + "\n")
|
||||
}
|
||||
b.WriteString(" • " + m.adminHostname + bgHintStyle.Render(" (Operator + SysAdmin console — Access-guarded)") + "\n")
|
||||
b.WriteString(bgHintStyle.Render(" The Minecraft game host is deliberately NOT tunneled.") + "\n\n")
|
||||
b.WriteString(bgLabelStyle.Render("Default web hostnames:") + "\n")
|
||||
if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" {
|
||||
b.WriteString(" • " + panel + bgHintStyle.Render(" (Player console)") + "\n")
|
||||
}
|
||||
if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" {
|
||||
b.WriteString(" • " + admin + bgHintStyle.Render(" (Operator + SysAdmin console — Access-guarded)") + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if m.cloudflaredPath == "" {
|
||||
b.WriteString(bgErrStyle.Render("✗ cloudflared not found on PATH") + " — install it, then esc and re-enter.\n")
|
||||
@@ -1123,7 +1205,7 @@ func (m *bgModel) View() string {
|
||||
switch {
|
||||
case m.edgeReady():
|
||||
b.WriteString(bgHintStyle.Render("enter continue · esc back") + "\n")
|
||||
case m.adminHostname != "" && m.cloudflaredPath != "" && !m.certExists:
|
||||
case m.cloudflaredPath != "" && !m.certExists:
|
||||
b.WriteString(bgHintStyle.Render("l login · esc back") + "\n")
|
||||
default:
|
||||
b.WriteString(bgHintStyle.Render("esc back") + "\n")
|
||||
@@ -1136,6 +1218,8 @@ func (m *bgModel) View() string {
|
||||
"Cloudflare API token",
|
||||
"Cloudflare account ID",
|
||||
"Admit (your email, or @your-domain)",
|
||||
"Player console hostname",
|
||||
"Admin console hostname",
|
||||
"Tunnel name",
|
||||
"Tunnel config path",
|
||||
}
|
||||
@@ -1172,8 +1256,8 @@ func (m *bgModel) View() string {
|
||||
if m.auditWarning != "" {
|
||||
b.WriteString(bgWarnStyle.Render("⚠ accountability record was NOT written: "+m.auditWarning) + "\n\n")
|
||||
}
|
||||
if m.rootDomain != "" {
|
||||
b.WriteString("Log in at " + bgLabelStyle.Render("https://op.console."+m.rootDomain) + "\n\n")
|
||||
if url := adminLoginURL(m.rootDomain, m.adminHostname); url != "" {
|
||||
b.WriteString("Log in at " + bgLabelStyle.Render(url) + "\n\n")
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
|
||||
@@ -1190,8 +1274,14 @@ func (m *bgModel) View() string {
|
||||
}
|
||||
}
|
||||
b.WriteString(bgBoxStyle.Render(box) + "\n\n")
|
||||
b.WriteString(bgWarnStyle.Render("ACTION REQUIRED") + " — felis-api will reject the edge until you adopt the audience:\n")
|
||||
b.WriteString("set " + bgLabelStyle.Render("[auth] access_jwt_aud") + " in felis.toml to the value above, then start the\n")
|
||||
b.WriteString(bgWarnStyle.Render("ACTION REQUIRED") + " — make felis-api trust the edge in felis.toml:\n")
|
||||
if m.edgePanelHostname != "" {
|
||||
b.WriteString("set " + bgLabelStyle.Render("[auth] panel_hostname") + " to " + bgLabelStyle.Render(m.edgePanelHostname) + "\n")
|
||||
}
|
||||
if m.edgeAdminHostname != "" {
|
||||
b.WriteString("set " + bgLabelStyle.Render("[auth] admin_hostname") + " to " + bgLabelStyle.Render(m.edgeAdminHostname) + "\n")
|
||||
}
|
||||
b.WriteString("set " + bgLabelStyle.Render("[auth] access_jwt_aud") + " to the value above, then start the\n")
|
||||
b.WriteString("tunnel with " + bgLabelStyle.Render("cloudflared tunnel run") + ".\n\n")
|
||||
b.WriteString(bgHintStyle.Render("Verify the Access app actually guards the admin face before relying on it.") + "\n\n")
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
@@ -1208,11 +1298,21 @@ func (m *bgModel) View() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runBreakGlassTUI drives the bubbletea program and projects the final model onto a
|
||||
// breakGlassResult. It is the thin, untested shell; the logic it invokes
|
||||
// (authenticateAdmin / performBreakGlass) is unit-tested directly.
|
||||
// runBreakGlassTUI drives the emergency bubbletea program and projects the final
|
||||
// model onto a breakGlassResult. The owner/auth logic is unit-tested directly.
|
||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists), tea.WithAltScreen()).Run()
|
||||
return runConsoleTUI(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass)
|
||||
}
|
||||
|
||||
// runSetupTUI drives the normal first-run setup console. It shares the model with
|
||||
// breakGlass but starts it in setup mode, where Cloudflare edge setup is available
|
||||
// and emergency Owner reset is not.
|
||||
func runSetupTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup)
|
||||
}
|
||||
|
||||
func runConsoleTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) {
|
||||
final, err := tea.NewProgram(newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode), tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
return breakGlassResult{}, err
|
||||
}
|
||||
@@ -1233,12 +1333,71 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, adminHostna
|
||||
displayPassword: m.displayPassword,
|
||||
auditWarning: m.auditWarning,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHostname,
|
||||
}
|
||||
if m.step == stepEdgeDone && m.edgeResult != nil {
|
||||
res.edgeConfigured = true
|
||||
res.edgeAud = m.edgeResult.AccessAud
|
||||
res.edgeRoutedHosts = m.edgeResult.RoutedHostnames
|
||||
res.edgeConfigPath = m.edgeResult.ConfigPath
|
||||
res.edgePanelHostname = m.edgePanelHostname
|
||||
res.edgeAdminHostname = m.edgeAdminHostname
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func defaultPanelHostname(rootDomain, configured string) string {
|
||||
if h := strings.TrimSpace(configured); h != "" {
|
||||
return h
|
||||
}
|
||||
if rootDomain != "" {
|
||||
return "console." + rootDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultAdminHostname(rootDomain, configured string) string {
|
||||
if h := strings.TrimSpace(configured); h != "" {
|
||||
return h
|
||||
}
|
||||
if rootDomain != "" {
|
||||
return "op.console." + rootDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeEdgeHostname(s string) string {
|
||||
return strings.Trim(strings.TrimSpace(s), ".")
|
||||
}
|
||||
|
||||
func validateEdgeHostname(label, host string, required bool) error {
|
||||
if host == "" {
|
||||
if required {
|
||||
return fmt.Errorf("%s is required", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(host, "://") || strings.ContainsAny(host, "/\\ \t\r\n") {
|
||||
return fmt.Errorf("%s must be a hostname, not a URL", label)
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
return fmt.Errorf("%s must not include a port", label)
|
||||
}
|
||||
if strings.HasPrefix(host, ".") {
|
||||
return fmt.Errorf("%s must not start with a dot", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isHex32(s string) bool {
|
||||
if len(s) != 32 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -650,20 +650,18 @@ func TestBGModelGating(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBGModelEdgeRouting locks in the optional Cloudflare edge flow's routing and its
|
||||
// load-bearing guards WITHOUT touching the operator's real Cloudflare account: the
|
||||
// menu reaches the edge intro as an independent peer of provisioning (no Owner reset
|
||||
// required to get there); an unconfigured admin hostname keeps edgeReady() false so the
|
||||
// flow cannot proceed to credential entry; esc returns to the router; and submitEdge
|
||||
// refuses empty inputs before any cfsetup.Setup side effect. Every assertion here is
|
||||
// environment-independent — the real cloudflared/cert.pem detection and the integration
|
||||
// Setup (which shells out / calls the live API) are deliberately NOT exercised.
|
||||
// TestBGModelEdgeRouting locks in the setup-only Cloudflare edge flow WITHOUT
|
||||
// touching the operator's real Cloudflare account: setup option 2 reaches the edge
|
||||
// intro as an independent peer of Owner creation; breakGlass has no edge option;
|
||||
// hostnames are collected in the setup form; and invalid inputs are refused before
|
||||
// any cfsetup.Setup side effect. The real cloudflared/cert.pem detection and the
|
||||
// integration Setup (which shells out / calls the live API) are deliberately NOT exercised.
|
||||
func TestBGModelEdgeRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("menu option 2 enters the edge intro as a peer of provisioning, leaving the Owner credential untouched", func(t *testing.T) {
|
||||
t.Run("setup menu option 2 enters the edge intro as a peer of provisioning, leaving the Owner credential untouched", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{admins: true}
|
||||
m := newBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m := newSetupBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("initial step = %v, want stepMenu", m.step)
|
||||
}
|
||||
@@ -677,8 +675,8 @@ func TestBGModelEdgeRouting(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("esc from the edge intro returns to the router with the edge option highlighted", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
t.Run("esc from the edge intro returns to the setup router with the edge option highlighted", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if m.step != stepMenu || m.focus != 1 {
|
||||
@@ -686,31 +684,21 @@ func TestBGModelEdgeRouting(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unconfigured admin hostname keeps the edge gated shut regardless of cloudflared/login", func(t *testing.T) {
|
||||
// adminHostname == "" makes edgeReady() false by short-circuit, independent of
|
||||
// whether this box happens to have cloudflared installed and a cert.pem present.
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
t.Run("breakGlass has no edge option 2", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
if m.step != stepEdgeIntro {
|
||||
t.Fatalf("step = %v, want stepEdgeIntro", m.step)
|
||||
}
|
||||
if m.edgeReady() {
|
||||
t.Fatal("edgeReady() must be false when no admin hostname is configured")
|
||||
}
|
||||
// Enter while not ready must NOT advance to credential entry.
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeIntro {
|
||||
t.Errorf("enter while not ready advanced to %v, want to stay on stepEdgeIntro", m.step)
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("breakGlass option 2 advanced to %v, want to stay on stepMenu", m.step)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge refuses empty credentials before any Cloudflare side effect", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
// Install the edge inputs directly: reaching them via the menu requires a real
|
||||
// cloudflared login (edgeReady()), which this unit test must not depend on.
|
||||
m.enterEdgeInput()
|
||||
if m.step != stepEdgeInput || len(m.inputs) != 5 {
|
||||
t.Fatalf("enterEdgeInput: step/inputs = %v/%d, want stepEdgeInput with 5 inputs", m.step, len(m.inputs))
|
||||
if m.step != stepEdgeInput || len(m.inputs) != 7 {
|
||||
t.Fatalf("enterEdgeInput: step/inputs = %v/%d, want stepEdgeInput with 7 inputs", m.step, len(m.inputs))
|
||||
}
|
||||
// All inputs blank: submit (via the real key path) must report an error and stay
|
||||
// put — NOT reach stepEdgeWorking, which is what launches cfsetup.Setup against
|
||||
@@ -725,13 +713,13 @@ func TestBGModelEdgeRouting(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("submitEdge rejects a bare @ identity that would scope Access to an empty domain", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m.enterEdgeInput()
|
||||
// Token + account present, but identity is a bare "@" (empty domain). This passes
|
||||
// the non-empty check yet must be refused before cfsetup.Setup, because an empty
|
||||
// EmailDomain admits no one — a silent lock-out the operator should fix.
|
||||
m.inputs[0].SetValue("token-value")
|
||||
m.inputs[1].SetValue("account-id")
|
||||
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
|
||||
m.inputs[2].SetValue("@")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
@@ -741,4 +729,21 @@ func TestBGModelEdgeRouting(t *testing.T) {
|
||||
t.Error("a bare @ identity must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge requires an admin hostname and rejects URLs", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m.enterEdgeInput()
|
||||
m.inputs[0].SetValue("token-value")
|
||||
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
|
||||
m.inputs[2].SetValue("[email protected]")
|
||||
m.inputs[3].SetValue("https://console." + testRoot)
|
||||
m.inputs[4].SetValue("")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("bad hostnames: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("invalid hostnames must never reach stepEdgeWorking")
|
||||
}
|
||||
})
|
||||
}
|
||||
+3
-2
@@ -18,6 +18,7 @@ Commands:
|
||||
restore Extract a world archive into a world volume (internal Job entrypoint)
|
||||
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
|
||||
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
|
||||
setup Open the first-run setup console (TUI; requires root/sudo)
|
||||
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
|
||||
|
||||
Run "felis <command> -h" for command-specific flags.
|
||||
@@ -46,6 +47,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return cmdManifests(rest, stdout, stderr)
|
||||
case "apply":
|
||||
return cmdApply(rest, stdout, stderr)
|
||||
case "setup":
|
||||
return cmdSetup(rest, stdout, stderr)
|
||||
case "breakGlass":
|
||||
return cmdBreakGlass(rest, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
@@ -56,5 +59,3 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-3
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
+44
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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: "[email protected]", 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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
|
||||
+18
-16
@@ -84,22 +84,23 @@ func clearSessionCookie(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
// hostIsAdminConsole reports whether the request arrived on the operator console
|
||||
// host, op.console.<root_domain>. 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.<root_domain>.
|
||||
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
|
||||
}
|
||||
|
||||
+105
-1
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user