feat(auth): migrate console login to passwordless

Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
This commit is contained in:
flyemoji committed 2026-07-04 21:47:12 +09:00
1 parent 627883e89a
commit 0c1cc598c1
46 files changed
+5554 -1651

No files matched your search

+3 -13
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"regexp"
goruntime "runtime"
"strings"
"time"
@@ -169,14 +168,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// agree on what local auth knows.
repo := api.NewPGRepo(drv.DB())
// Bound concurrent login bcrypt to roughly the core count (floored so even a 1–2
// vCPU demo box tolerates a handful of simultaneous staff logins). bcrypt is
// CPU-costly and the public login route runs a full compare on every request, so
// this caps the work a login flood can pile on the scheduler; the excess is shed
// as a cheap 429. Staff password logins are rare (players never use this path), so
// the cap never bites legitimate use.
loginBcryptCap := max(goruntime.NumCPU(), 4)
a := &api.API{
Repo: repo,
Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
@@ -201,9 +192,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
RootDomain: cfg.Server.RootDomain,
AdminHostname: cfg.Auth.AdminHostname,
},
RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second,
MaxConcurrentLogins: loginBcryptCap,
RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second,
// Bound concurrent console/build-log SSE streams per principal. Generous enough
// for legitimate multi-tab / multi-server watching, while capping how many
// upstream follow connections a single caller can tie up if their streams stall.
@@ -232,7 +222,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503")
}
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain)
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname, resolvedVersion())
internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
+112 -157
View File
@@ -3,6 +3,8 @@ package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -11,13 +13,13 @@ import (
"io"
"os"
"strings"
"time"
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/store"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/crypto/bcrypt"
)
// `felis breakGlass` is the local break-glass emergency console (spec §B). Its
@@ -64,27 +66,31 @@ import (
// bare Enter) keeps the unverified root override from happening by reflex.
const breakGlassOverrideToken = "OVERRIDE"
// bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a
// human can transcribe a generated one-time password off a terminal without error.
const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
// ownerStore is the minimal repo surface the break-glass console needs.
// ownerStore is the minimal repo surface the break-glass / setup console needs.
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic
// (authentication, provisioning, accountability audit) is exercised without a
// (recovery, provisioning, accountability audit) is exercised without a
// database or a terminal.
type ownerStore interface {
// AdminExists reports whether any authenticatable staff account already exists.
// AdminExists reports whether any admin account already exists.
// It is the bootstrap-vs-recovery switch.
AdminExists(ctx context.Context) (bool, error)
// UserByUsername loads a staff login projection for credential verification.
// UserByUsername loads a staff login projection.
UserByUsername(ctx context.Context, username string) (*api.StaffUser, error)
UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
UpsertOwner(ctx context.Context, id, username, email string) error
// InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is
// insert-only: a username already taken is a conflict (api.ErrConflict), never a
// silent reset, so adding an Operator can never clobber the Owner or an existing
// Operator. The row is role=admin, identical in shape to the Owner — Felis has no
// separate operator DB role (migration 0003: staff = role=admin WITH a hash).
InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
// separate operator DB role (migration 0003: staff = role=admin).
InsertOperator(ctx context.Context, id, username, email string) error
// RedeemLinkCodeForOwner consumes an in-game link code and creates-or-promotes
// the bound user to role='admin' (Owner). It is the `felis setup` MC-bind path:
// the operator enters limbo, runs /link, types the code here, and the bound
// account becomes the passwordless Owner. Unlike RedeemPlayerBindCode it does NOT
// refuse staff — setup deliberately elevates the bound account.
RedeemLinkCodeForOwner(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error)
// CreateSetupToken mints a one-time setup token for first-web-login bootstrap.
CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error
SetSetting(ctx context.Context, key string, value []byte) error
// Audit records the break-glass accountability row.
Audit(ctx context.Context, e api.AuditEntry) error
@@ -164,20 +170,14 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
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.setupTokenURL != "" {
fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
}
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)
fmt.Fprintf(stdout, "Admin console: %s\n", url)
}
}
@@ -229,54 +229,12 @@ func newOwnerID() string {
return "usr-" + hex.EncodeToString(b[:])
}
// generateBootstrapPassword returns a fresh one-time password from the unambiguous
// alphabet. It rejection-samples to avoid modulo bias, so every position is uniform
// over the alphabet. 20 chars over a 57-symbol alphabet is ~116 bits — far more than
// the must-change credential needs, and it is rotated on first login regardless.
func generateBootstrapPassword() (string, error) {
const n = 20
// Largest multiple of the alphabet size that fits in a byte; bytes at or above it
// are discarded so the surviving values map uniformly (no modulo bias).
limit := byte(256 - (256 % len(bootstrapPasswordAlphabet)))
out := make([]byte, 0, n)
var b [1]byte
for len(out) < n {
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate bootstrap password: %w", err)
}
if b[0] >= limit {
continue
}
out = append(out, bootstrapPasswordAlphabet[int(b[0])%len(bootstrapPasswordAlphabet)])
}
return string(out), nil
}
// validateOwnerPassword mirrors api.validateNewPassword (handlers_auth.go): a
// break-glass credential must satisfy the SAME 8–72-byte rule the panel's own
// change-password enforces, so an operator can never set a password here that the
// web change-password flow would later reject. 72 is bcrypt's hard input limit.
func validateOwnerPassword(pw string) error {
if len(pw) < 8 {
return errors.New("password must be at least 8 characters")
}
if len(pw) > 72 {
return errors.New("password must be at most 72 bytes")
}
return nil
}
// authenticateAdmin verifies a typed credential against an existing admin account
// for recovery-mode attribution. matched is the stored username on success.
//
// ok==false with err==nil is NOT a failure to surface — it means the credential did
// not match any admin password. The caller offers an explicit root override instead
// of refusing, because break-glass must still recover when no admin credential can
// be produced (a forgotten password is the canonical reason the web login is
// unreachable in the first place). Only a real datastore fault returns err.
func authenticateAdmin(ctx context.Context, s ownerStore, username, password string) (matched string, ok bool, err error) {
// authenticateAdmin resolves a typed admin username for recovery-mode attribution.
// Password verification is gone (passwordless design); Phase 3 replaces this with
// email-OTP recovery. For now it confirms the named admin exists.
func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matched string, ok bool, err error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
if username == "" {
return "", false, nil
}
u, err := s.UserByUsername(ctx, username)
@@ -286,70 +244,47 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username, password str
if err != nil {
return "", false, err
}
// Only an admin row carrying a bcrypt hash is an authenticatable staff identity;
// a player row (role=user, hash NULL → empty PasswordHash) can never attribute a
// break-glass action.
if u.Role != "admin" || u.PasswordHash == "" {
return "", false, nil
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
if u.Role != "admin" {
return "", false, nil
}
return u.Username, true, nil
}
// provisionOwner mints or resets the single Owner account direct-to-Postgres with
// the given (already-validated-by-the-caller) password. The account is created with
// must_change_password=true, which is load-bearing: it is what arms the API's
// lockdown middleware so the Owner can do nothing but change the password on first
// login. Only the bcrypt hash reaches the database; the plaintext never does.
func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error {
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
// passwordless. The account is role=admin with no password — the Owner completes
// passwordless login setup via the web setup-token flow after `felis setup`.
func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
username = strings.TrimSpace(username)
if username == "" {
return errors.New("owner username is required")
}
if err := validateOwnerPassword(password); err != nil {
return err
}
id := newOwnerID()
if id == "" {
return errors.New("generate owner id: entropy source failed")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash owner password: %w", err)
}
if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil {
if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email)); err != nil {
return fmt.Errorf("write owner: %w", err)
}
return nil
}
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the
// Owner it requires must_change_password=true but carries role='admin' (the single
// above-admin 'owner' role was added in migration 0011 and is exclusive to the first
// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which
// username conflict, this is insert-only: a username already taken returns
// api.ErrConflict rather than overwriting a live account, so adding an Operator can
// never silently clobber the Owner's or another Operator's credential. Only the
// bcrypt hash reaches the database; the plaintext never does.
func provisionOperator(ctx context.Context, s ownerStore, username, email, password string) error {
// Owner it is role=admin and passwordless — Felis has no separate operator DB role,
// so an Operator is simply an additional staff admin (migration 0003). UNLIKE
// provisionOwner, which upserts the single Owner and resets it on a username
// conflict, this is insert-only: a username already taken returns api.ErrConflict
// rather than overwriting a live account, so adding an Operator can never silently
// clobber the Owner's or another Operator's account.
func provisionOperator(ctx context.Context, s ownerStore, username, email string) error {
username = strings.TrimSpace(username)
if username == "" {
return errors.New("operator username is required")
}
if err := validateOwnerPassword(password); err != nil {
return err
}
id := newOwnerID()
if id == "" {
return errors.New("generate operator id: entropy source failed")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash operator password: %w", err)
}
if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil {
if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email)); err != nil {
if errors.Is(err, api.ErrConflict) {
// Wrap %w so errors.Is(err, api.ErrConflict) still holds — the TUI can render
// a "name already taken" message — while keeping a clear human string.
@@ -381,50 +316,84 @@ type breakGlassOp struct {
osUser string // $SUDO_USER (or "root"); recorded in the payload
ownerUsername string
ownerEmail string
ownerPassword string // typed (bootstrap); "" => generate a one-time password
attemptedAdmin string // recovery / override: the admin username the operator typed
}
// breakGlassOutcome is what performBreakGlass reports back to the TUI.
type breakGlassOutcome struct {
displayPassword string // non-empty only when a one-time password was generated
auditErr error // non-nil if the accountability row could not be written
setupTokenURL string // non-empty when setup minted a one-time first-login URL
auditErr error // non-nil if the accountability row could not be written
}
// performBreakGlass executes a resolved break-glass operation: provision (or reset)
// the Owner, enable local-password login, then record a best-effort accountability
// audit row. A typed ownerPassword (bootstrap) is used as-is; an empty one (recovery
// / root override) is replaced with a generated one-time password returned for
// one-time display. The audit write is best-effort: a logging failure is reported
// via auditErr but does NOT fail the recovery — break-glass must still work when the
// audit sink is unhappy.
// audit row. The Owner is passwordless — the setup-token flow handles first-login
// setup. The audit write is best-effort: a logging failure is reported via auditErr
// but does NOT fail the recovery — break-glass must still work when the audit sink
// is unhappy.
func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
password := op.ownerPassword
generated := false
if password == "" {
p, err := generateBootstrapPassword()
if err != nil {
return breakGlassOutcome{}, err
}
password, generated = p, true
}
if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil {
if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
return breakGlassOutcome{}, err
}
// Record accountability the instant the credential changes — BEFORE enabling
// local auth, which can still fail. Auditing only after both writes would let a
// failed enableLocalAuth leave a just-reset credential with no "who did it" row;
// the audit is best-effort, so doing it first never blocks the recovery.
// Record accountability the instant the account is written — BEFORE enabling
// local auth, which can still fail. The audit is best-effort, so doing it first
// never blocks the recovery.
out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)}
if generated {
out.displayPassword = password
}
if err := enableLocalAuth(ctx, s); err != nil {
return breakGlassOutcome{}, err
}
return out, nil
}
// setupTokenTTL bounds how long a one-time setup URL is valid. The operator opens
// it right after setup completes, so a generous-but-bounded window is enough.
const setupTokenTTL = 30 * time.Minute
// newSetupToken returns a fresh opaque setup token (256 bits, URL-safe) and its
// sha-256 hex hash. Only the hash is persisted; the raw value rides in the URL.
func newSetupToken() (raw, hash string, err error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", fmt.Errorf("generate setup token: %w", err)
}
raw = base64.RawURLEncoding.EncodeToString(b[:])
sum := sha256.Sum256([]byte(raw))
return raw, hex.EncodeToString(sum[:]), nil
}
// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
// binds their Minecraft account via an in-game /link code, the bound user is
// promoted to role='admin' (passwordless Owner), and a one-time setup URL is
// minted for the first web login where the Owner verifies email / enrolls a
// passkey. adminHostname is the op.console host the URL points at.
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname string) (breakGlassOutcome, error) {
code = strings.TrimSpace(strings.ToUpper(code))
if code == "" {
return breakGlassOutcome{}, errors.New("link code is required")
}
newID := newOwnerID()
if newID == "" {
return breakGlassOutcome{}, errors.New("generate owner id: entropy source failed")
}
userID, _, _, err := s.RedeemLinkCodeForOwner(ctx, newID, code, time.Now())
if err != nil {
return breakGlassOutcome{}, fmt.Errorf("bind minecraft account: %w", err)
}
raw, hash, err := newSetupToken()
if err != nil {
return breakGlassOutcome{}, err
}
if err := s.CreateSetupToken(ctx, hash, userID, time.Now().Add(setupTokenTTL)); err != nil {
return breakGlassOutcome{}, fmt.Errorf("mint setup token: %w", err)
}
host := strings.TrimSpace(adminHostname)
if host == "" {
host = "op.console.localhost"
}
url := "https://" + host + "/setup?token=" + raw
return breakGlassOutcome{setupTokenURL: url}, nil
}
// auditBreakGlass writes the break-glass accountability row. The actor is the
// resolved human identity (a verified admin in recovery, the OS user otherwise);
// the payload carries the full who/what/how so an after-the-fact reader can tell a
@@ -454,9 +423,7 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
}
// performAddOperator mints a NEW Operator account and records a best-effort
// accountability row. It mirrors performBreakGlass — a typed password is used as-is,
// an empty one is replaced with a generated one-time password returned for one-time
// display (the common case: hand a fresh credential to the new operator) — with two
// accountability row. It mirrors performBreakGlass — passwordless — with two
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it
// can never reset an existing account the way the Owner upsert does. (2) It does NOT
// touch local_auth_enabled: adding an Operator presupposes an already-configured,
@@ -465,22 +432,10 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
// the Owner break-glass thread alone. The audit is best-effort and written only after
// a successful provision; a conflict mints nothing, so there is nothing to attribute.
func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
password := op.ownerPassword
generated := false
if password == "" {
p, err := generateBootstrapPassword()
if err != nil {
return breakGlassOutcome{}, err
}
password, generated = p, true
}
if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil {
if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
return breakGlassOutcome{}, err
}
out := breakGlassOutcome{auditErr: auditAddOperator(ctx, s, op)}
if generated {
out.displayPassword = password
}
return out, nil
}
@@ -514,17 +469,17 @@ func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error
// breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable
// post-exit summary. provisioned is false on cancel.
type breakGlassResult struct {
provisioned bool
isOperator bool // an Operator was added rather than the Owner provisioned
mode string
accountable string
osUser string
username string
displayPassword string // empty when the operator typed their own bootstrap password
auditWarning string
rootDomain string
adminHostname string
panelURL string
provisioned bool
isOperator bool // an Operator was added rather than the Owner provisioned
mode string
accountable string
osUser string
username string
setupTokenURL string // non-empty when setup minted a one-time first-login URL
auditWarning string
rootDomain string
adminHostname string
panelURL string
// connection outcome (independent of provisioned)
connectMethod connectMethod
+223 -222
View File
@@ -2,38 +2,65 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/api"
"golang.org/x/crypto/bcrypt"
)
// fakeOwnerStore records what break-glass provisioning writes and answers the
// identity lookups, so the core logic (authentication, provisioning, accountability
// audit) is exercised without a database or a terminal.
// fakeOwnerStore records what break-glass / setup provisioning writes and answers
// the identity lookups, so the core logic (admin resolution, provisioning,
// accountability audit, setup-token mint) is exercised without a database or a
// terminal. The design is passwordless: accounts carry no credential, and the
// Owner completes first-login through the setup-token web flow.
type fakeOwnerStore struct {
upserts []upsertCall
inserts []upsertCall
settings map[string][]byte
audits []api.AuditEntry
tokens []setupTokenCall
redeems []redeemCall
users map[string]*api.StaffUser // keyed by username
admins bool // AdminExists answer
upsertErr error
insertErr error
setErr error
auditErr error
userErr error // non-not-found error from UserByUsername
adminErr error
// RedeemLinkCodeForOwner's success result. redeemUserID defaults to the fresh id
// the caller passes (the unlinked-UUID case) when left empty.
redeemUserID string
redeemMCUUID string
redeemAuthSource string
upsertErr error
insertErr error
setErr error
auditErr error
userErr error // non-not-found error from UserByUsername
adminErr error
redeemErr error
createTokenErr error
}
// upsertCall is a recorded owner/operator provision. Passwordless: the row is pure
// identity (id, username, email) with an implied role=admin.
type upsertCall struct {
id, username, email, passwordHash string
mustChange bool
id, username, email string
}
// setupTokenCall is a recorded CreateSetupToken write. Only the hash is persisted.
type setupTokenCall struct {
tokenHash string
userID string
expiresAt time.Time
}
// redeemCall records the inputs RedeemLinkCodeForOwner was called with.
type redeemCall struct {
newUserID string
code string
}
func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) {
@@ -53,33 +80,55 @@ func (f *fakeOwnerStore) UserByUsername(_ context.Context, username string) (*ap
return nil, api.ErrNotFound
}
func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email string) error {
if f.upsertErr != nil {
return f.upsertErr
}
f.upserts = append(f.upserts, upsertCall{id, username, email, passwordHash, mustChange})
f.upserts = append(f.upserts, upsertCall{id, username, email})
return nil
}
// InsertOperator records an insert-only Operator provision. A username already in
// the users map is a conflict (api.ErrConflict), mirroring the PGRepo ON CONFLICT
// DO NOTHING + zero-RowsAffected contract; a fresh one is recorded and reflected
// into users so a later lookup — or a second insert of the same name — sees it.
func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
// the users map is a conflict (api.ErrConflict), mirroring the PGRepo insert-only
// contract; a fresh one is recorded and reflected into users so a later lookup — or
// a second insert of the same name — sees it. The row is passwordless (role=admin).
func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email string) error {
if f.insertErr != nil {
return f.insertErr
}
if _, taken := f.users[username]; taken {
return api.ErrConflict
}
f.inserts = append(f.inserts, upsertCall{id, username, email, passwordHash, mustChange})
f.inserts = append(f.inserts, upsertCall{id, username, email})
if f.users == nil {
f.users = map[string]*api.StaffUser{}
}
f.users[username] = &api.StaffUser{
ID: id, Username: username, Email: email,
Role: "admin", PasswordHash: passwordHash, MustChangePassword: mustChange,
f.users[username] = &api.StaffUser{ID: id, Username: username, Email: email, Role: "admin"}
return nil
}
// RedeemLinkCodeForOwner records the call and returns the configured Owner identity
// (or the injected error). The real method consumes a link code and promotes the
// bound account; the fake models only its inputs and outputs.
func (f *fakeOwnerStore) RedeemLinkCodeForOwner(_ context.Context, newUserID, code string, _ time.Time) (string, string, string, error) {
if f.redeemErr != nil {
return "", "", "", f.redeemErr
}
f.redeems = append(f.redeems, redeemCall{newUserID, code})
userID := f.redeemUserID
if userID == "" {
userID = newUserID // unlinked UUID → the fresh id becomes the Owner
}
return userID, f.redeemMCUUID, f.redeemAuthSource, nil
}
// CreateSetupToken records a minted setup token (hash only), or fails with the
// injected error without recording it.
func (f *fakeOwnerStore) CreateSetupToken(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
if f.createTokenErr != nil {
return f.createTokenErr
}
f.tokens = append(f.tokens, setupTokenCall{tokenHash, userID, expiresAt})
return nil
}
@@ -102,49 +151,18 @@ func (f *fakeOwnerStore) Audit(_ context.Context, e api.AuditEntry) error {
return nil
}
// mkAdmin builds an authenticatable admin row (role=admin, real bcrypt hash) for the
// fake. MinCost keeps the hash fast — these tests are about wiring, not bcrypt.
func mkAdmin(t *testing.T, username, password string) *api.StaffUser {
t.Helper()
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash: %v", err)
}
return &api.StaffUser{ID: "usr-admin", Username: username, Role: "admin", PasswordHash: string(h)}
}
func TestValidateOwnerPassword(t *testing.T) {
cases := []struct {
name string
pw string
ok bool
}{
{"too short", "1234567", false},
{"minimum", "12345678", true},
{"comfortable", "Mid-Range-1", true},
{"at the bcrypt limit", strings.Repeat("a", 72), true},
{"past the bcrypt limit", strings.Repeat("a", 73), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateOwnerPassword(tc.pw)
if tc.ok && err != nil {
t.Errorf("validateOwnerPassword(%d bytes) = %v, want nil", len(tc.pw), err)
}
if !tc.ok && err == nil {
t.Errorf("validateOwnerPassword(%d bytes) = nil, want error", len(tc.pw))
}
})
}
// mkAdmin builds a resolvable staff row (role=admin). The design is passwordless,
// so a staff account is identity + role — there is no credential to attach.
func mkAdmin(username string) *api.StaffUser {
return &api.StaffUser{ID: "usr-admin", Username: username, Role: "admin"}
}
func TestProvisionOwner(t *testing.T) {
ctx := context.Background()
t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) {
t.Run("mints a passwordless owner row", func(t *testing.T) {
f := &fakeOwnerStore{}
const pw = "valid-test-pw"
if err := provisionOwner(ctx, f, "owner", "[email protected]", pw); err != nil {
if err := provisionOwner(ctx, f, "owner", "[email protected]"); err != nil {
t.Fatalf("provisionOwner: %v", err)
}
if len(f.upserts) != 1 {
@@ -157,26 +175,14 @@ func TestProvisionOwner(t *testing.T) {
if got.email != "[email protected]" {
t.Errorf("email = %q, want [email protected]", got.email)
}
// must_change_password=true is load-bearing: it arms the API lockdown so the
// Owner can do nothing but change the password on first login.
if !got.mustChange {
t.Error("mustChange = false, want true (forced first-login change)")
}
if !strings.HasPrefix(got.id, "usr-") {
t.Errorf("id = %q, want usr- prefix", got.id)
}
// Only the hash is stored; the typed plaintext must verify against it.
if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil {
t.Error("typed password does not verify against the stored hash")
}
if got.passwordHash == pw {
t.Error("stored hash equals plaintext — password was not hashed")
}
})
t.Run("trims surrounding whitespace", func(t *testing.T) {
f := &fakeOwnerStore{}
if err := provisionOwner(ctx, f, " owner ", " [email protected] ", "valid-test-pw"); err != nil {
if err := provisionOwner(ctx, f, " owner ", " [email protected] "); err != nil {
t.Fatalf("provisionOwner: %v", err)
}
if f.upserts[0].username != "owner" || f.upserts[0].email != "[email protected]" {
@@ -186,7 +192,7 @@ func TestProvisionOwner(t *testing.T) {
t.Run("rejects an empty username before any write", func(t *testing.T) {
f := &fakeOwnerStore{}
if err := provisionOwner(ctx, f, " ", "", "valid-test-pw"); err == nil {
if err := provisionOwner(ctx, f, " ", ""); err == nil {
t.Fatal("want error for empty username")
}
if len(f.upserts) != 0 {
@@ -194,19 +200,9 @@ func TestProvisionOwner(t *testing.T) {
}
})
t.Run("rejects a weak password before any write", func(t *testing.T) {
f := &fakeOwnerStore{}
if err := provisionOwner(ctx, f, "owner", "", "short"); err == nil {
t.Fatal("want error for a sub-8-byte password")
}
if len(f.upserts) != 0 {
t.Errorf("want no upsert on weak password, got %d", len(f.upserts))
}
})
t.Run("propagates a store error", func(t *testing.T) {
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
if err := provisionOwner(ctx, f, "owner", "", "valid-test-pw"); err == nil {
if err := provisionOwner(ctx, f, "owner", ""); err == nil {
t.Fatal("want error when the store fails")
}
})
@@ -233,66 +229,32 @@ func TestEnableLocalAuth(t *testing.T) {
}
}
func TestGenerateBootstrapPassword(t *testing.T) {
const want = 20
pw, err := generateBootstrapPassword()
if err != nil {
t.Fatalf("generateBootstrapPassword: %v", err)
}
if len(pw) != want {
t.Errorf("length = %d, want %d", len(pw), want)
}
for _, c := range pw {
if !strings.ContainsRune(bootstrapPasswordAlphabet, c) {
t.Errorf("password contains out-of-alphabet rune %q", c)
}
}
// A generated password must satisfy the same rule provisionOwner enforces.
if err := validateOwnerPassword(pw); err != nil {
t.Errorf("generated password fails validateOwnerPassword: %v", err)
}
other, err := generateBootstrapPassword()
if err != nil {
t.Fatal(err)
}
if pw == other {
t.Error("two calls produced the same password")
}
}
func TestAuthenticateAdmin(t *testing.T) {
ctx := context.Background()
t.Run("verifies a matching admin credential", func(t *testing.T) {
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}}
matched, ok, err := authenticateAdmin(ctx, f, "root", "correct horse")
// Password verification is gone (passwordless design): authenticateAdmin now only
// resolves the named admin so recovery can attribute the audit to a real identity.
// The security boundary is the break-glass root gate, not a typed secret.
t.Run("resolves an existing admin for attribution", func(t *testing.T) {
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin("root")}}
matched, ok, err := authenticateAdmin(ctx, f, "root")
if err != nil {
t.Fatalf("authenticateAdmin: %v", err)
}
if !ok {
t.Fatal("ok = false, want true for the correct password")
t.Fatal("ok = false, want true for an existing admin")
}
if matched != "root" {
t.Errorf("matched = %q, want root", matched)
}
})
t.Run("a wrong password is a non-match, not an error", func(t *testing.T) {
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}}
_, ok, err := authenticateAdmin(ctx, f, "root", "wrong")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Error("ok = true, want false for a wrong password")
}
})
t.Run("a non-admin role can never attribute a break-glass", func(t *testing.T) {
player := mkAdmin(t, "alice", "correct horse")
player.Role = "user" // a player row, even with a hash, is not staff
player := mkAdmin("alice")
player.Role = "user" // a player row is not staff
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"alice": player}}
_, ok, err := authenticateAdmin(ctx, f, "alice", "correct horse")
_, ok, err := authenticateAdmin(ctx, f, "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -301,22 +263,9 @@ func TestAuthenticateAdmin(t *testing.T) {
}
})
t.Run("a hashless admin row is a non-match", func(t *testing.T) {
f := &fakeOwnerStore{users: map[string]*api.StaffUser{
"ghost": {Username: "ghost", Role: "admin", PasswordHash: ""},
}}
_, ok, err := authenticateAdmin(ctx, f, "ghost", "anything")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Error("ok = true, want false when no hash is set")
}
})
t.Run("an unknown user is a non-match, not an error", func(t *testing.T) {
f := &fakeOwnerStore{}
_, ok, err := authenticateAdmin(ctx, f, "nobody", "pw")
_, ok, err := authenticateAdmin(ctx, f, "nobody")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -325,19 +274,16 @@ func TestAuthenticateAdmin(t *testing.T) {
}
})
t.Run("empty input is a non-match with no store call", func(t *testing.T) {
t.Run("an empty username is a non-match with no store call", func(t *testing.T) {
f := &fakeOwnerStore{userErr: errors.New("must not be called")}
if _, ok, err := authenticateAdmin(ctx, f, "", "pw"); ok || err != nil {
if _, ok, err := authenticateAdmin(ctx, f, ""); ok || err != nil {
t.Errorf("empty username: ok=%v err=%v, want false,nil", ok, err)
}
if _, ok, err := authenticateAdmin(ctx, f, "root", ""); ok || err != nil {
t.Errorf("empty password: ok=%v err=%v, want false,nil", ok, err)
}
})
t.Run("a datastore fault is surfaced", func(t *testing.T) {
f := &fakeOwnerStore{userErr: errors.New("db down")}
if _, _, err := authenticateAdmin(ctx, f, "root", "pw"); err == nil {
if _, _, err := authenticateAdmin(ctx, f, "root"); err == nil {
t.Fatal("want error when the store fails")
}
})
@@ -360,7 +306,7 @@ func auditOf(t *testing.T, f *fakeOwnerStore) (api.AuditEntry, map[string]any) {
func TestPerformBreakGlass(t *testing.T) {
ctx := context.Background()
t.Run("bootstrap uses the typed password and never echoes it", func(t *testing.T) {
t.Run("bootstrap provisions the owner, enables local auth, and audits", func(t *testing.T) {
f := &fakeOwnerStore{}
op := breakGlassOp{
mode: "bootstrap",
@@ -368,21 +314,16 @@ func TestPerformBreakGlass(t *testing.T) {
osUser: "deploybot",
ownerUsername: "owner",
ownerEmail: "[email protected]",
ownerPassword: "valid-test-pw",
}
out, err := performBreakGlass(ctx, f, op)
if err != nil {
t.Fatalf("performBreakGlass: %v", err)
}
// The operator typed their own password, so it must NOT be surfaced for display.
if out.displayPassword != "" {
t.Errorf("displayPassword = %q, want empty for a typed bootstrap password", out.displayPassword)
}
if out.auditErr != nil {
t.Errorf("auditErr = %v, want nil", out.auditErr)
}
if len(f.upserts) != 1 || bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte("valid-test-pw")) != nil {
t.Error("owner was not provisioned with the typed password")
if len(f.upserts) != 1 || f.upserts[0].username != "owner" {
t.Errorf("owner was not provisioned: %+v", f.upserts)
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled — login would 403")
@@ -402,7 +343,7 @@ func TestPerformBreakGlass(t *testing.T) {
}
})
t.Run("recovery generates a one-time password and records a verified row", func(t *testing.T) {
t.Run("recovery provisions the owner and records a verified row", func(t *testing.T) {
f := &fakeOwnerStore{}
op := breakGlassOp{
mode: "recovery",
@@ -415,12 +356,14 @@ func TestPerformBreakGlass(t *testing.T) {
if err != nil {
t.Fatalf("performBreakGlass: %v", err)
}
if out.displayPassword == "" {
t.Fatal("displayPassword empty, want a generated one-time password")
if out.auditErr != nil {
t.Errorf("auditErr = %v, want nil", out.auditErr)
}
// The shown password must be the one actually stored (as a hash).
if bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte(out.displayPassword)) != nil {
t.Error("displayed password does not match the stored hash")
if len(f.upserts) != 1 {
t.Fatalf("want 1 upsert, got %d", len(f.upserts))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled")
}
e, payload := auditOf(t, f)
if e.Actor != "root" || e.Action != "break_glass.recovery" {
@@ -443,13 +386,9 @@ func TestPerformBreakGlass(t *testing.T) {
ownerUsername: "owner",
attemptedAdmin: "typo-admin",
}
out, err := performBreakGlass(ctx, f, op)
if err != nil {
if _, err := performBreakGlass(ctx, f, op); err != nil {
t.Fatalf("performBreakGlass: %v", err)
}
if out.displayPassword == "" {
t.Error("displayPassword empty, want a generated one-time password")
}
e, payload := auditOf(t, f)
if e.Actor != "alice" || e.Action != "break_glass.root_override" {
t.Errorf("audit envelope = %+v, want actor=alice action=break_glass.root_override", e)
@@ -484,7 +423,7 @@ func TestPerformBreakGlass(t *testing.T) {
t.Run("does not enable local auth or audit if the owner write fails", func(t *testing.T) {
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
op := breakGlassOp{mode: "bootstrap", accountable: "root", osUser: "root", ownerUsername: "owner", ownerPassword: "valid-test-pw"}
op := breakGlassOp{mode: "bootstrap", accountable: "root", osUser: "root", ownerUsername: "owner"}
if _, err := performBreakGlass(ctx, f, op); err == nil {
t.Fatal("want error when the owner write fails")
}
@@ -497,9 +436,9 @@ func TestPerformBreakGlass(t *testing.T) {
})
t.Run("records accountability before enabling local auth, surviving an enableLocalAuth failure", func(t *testing.T) {
// The credential is reset by provisionOwner; if the audit were written only
// after enableLocalAuth, a failed toggle write would leave that reset with no
// "who did it" row. Order guarantees the accountability row lands first.
// The Owner is written by provisionOwner; if the audit were written only after
// enableLocalAuth, a failed toggle write would leave that write with no "who did
// it" row. Order guarantees the accountability row lands first.
f := &fakeOwnerStore{setErr: errors.New("settings write down")}
op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"}
if _, err := performBreakGlass(ctx, f, op); err == nil {
@@ -535,10 +474,9 @@ func TestAccountableOSUser(t *testing.T) {
func TestProvisionOperator(t *testing.T) {
ctx := context.Background()
t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) {
t.Run("mints a passwordless operator row (insert-only)", func(t *testing.T) {
f := &fakeOwnerStore{}
const pw = "valid-test-pw"
if err := provisionOperator(ctx, f, "ops-jordan", "[email protected]", pw); err != nil {
if err := provisionOperator(ctx, f, "ops-jordan", "[email protected]"); err != nil {
t.Fatalf("provisionOperator: %v", err)
}
// Insert-only: it records an insert and never touches the Owner upsert path.
@@ -555,29 +493,18 @@ func TestProvisionOperator(t *testing.T) {
if got.email != "[email protected]" {
t.Errorf("email = %q, want [email protected]", got.email)
}
// must_change_password=true arms the API lockdown for the new operator too.
if !got.mustChange {
t.Error("mustChange = false, want true (forced first-login change)")
}
if !strings.HasPrefix(got.id, "usr-") {
t.Errorf("id = %q, want usr- prefix", got.id)
}
// Only the hash is stored; the typed plaintext must verify against it.
if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil {
t.Error("typed password does not verify against the stored hash")
}
if got.passwordHash == pw {
t.Error("stored hash equals plaintext — password was not hashed")
}
})
t.Run("a taken username is a conflict, not a silent reset", func(t *testing.T) {
// The Owner already holds this username. Operator-add must refuse rather than
// overwrite it the way UpsertOwner would.
f := &fakeOwnerStore{users: map[string]*api.StaffUser{
"owner": {ID: "usr-owner", Username: "owner", Role: "admin", PasswordHash: "x"},
"owner": {ID: "usr-owner", Username: "owner", Role: "admin"},
}}
err := provisionOperator(ctx, f, "owner", "", "valid-test-pw")
err := provisionOperator(ctx, f, "owner", "")
if err == nil {
t.Fatal("want error when the username is already taken")
}
@@ -589,14 +516,14 @@ func TestProvisionOperator(t *testing.T) {
t.Errorf("want no insert on conflict, got %d", len(f.inserts))
}
// The pre-existing account must be untouched.
if f.users["owner"].PasswordHash != "x" {
t.Error("conflicting insert clobbered the existing account's hash")
if f.users["owner"].ID != "usr-owner" {
t.Error("conflicting insert clobbered the existing account")
}
})
t.Run("trims surrounding whitespace", func(t *testing.T) {
f := &fakeOwnerStore{}
if err := provisionOperator(ctx, f, " ops ", " [email protected] ", "valid-test-pw"); err != nil {
if err := provisionOperator(ctx, f, " ops ", " [email protected] "); err != nil {
t.Fatalf("provisionOperator: %v", err)
}
if f.inserts[0].username != "ops" || f.inserts[0].email != "[email protected]" {
@@ -606,7 +533,7 @@ func TestProvisionOperator(t *testing.T) {
t.Run("rejects an empty username before any write", func(t *testing.T) {
f := &fakeOwnerStore{}
if err := provisionOperator(ctx, f, " ", "", "valid-test-pw"); err == nil {
if err := provisionOperator(ctx, f, " ", ""); err == nil {
t.Fatal("want error for empty username")
}
if len(f.inserts) != 0 {
@@ -614,19 +541,9 @@ func TestProvisionOperator(t *testing.T) {
}
})
t.Run("rejects a weak password before any write", func(t *testing.T) {
f := &fakeOwnerStore{}
if err := provisionOperator(ctx, f, "ops", "", "short"); err == nil {
t.Fatal("want error for a sub-8-byte password")
}
if len(f.inserts) != 0 {
t.Errorf("want no insert on weak password, got %d", len(f.inserts))
}
})
t.Run("propagates a non-conflict store error without mislabeling it", func(t *testing.T) {
f := &fakeOwnerStore{insertErr: errors.New("boom")}
err := provisionOperator(ctx, f, "ops", "", "valid-test-pw")
err := provisionOperator(ctx, f, "ops", "")
if err == nil {
t.Fatal("want error when the store fails")
}
@@ -640,7 +557,7 @@ func TestProvisionOperator(t *testing.T) {
func TestPerformAddOperator(t *testing.T) {
ctx := context.Background()
t.Run("typed password is used as-is, never echoed, and never flips local auth", func(t *testing.T) {
t.Run("provisions an operator, audits, and never flips local auth", func(t *testing.T) {
f := &fakeOwnerStore{}
op := breakGlassOp{
mode: "recovery",
@@ -648,19 +565,17 @@ func TestPerformAddOperator(t *testing.T) {
osUser: "alice",
ownerUsername: "ops-jordan",
ownerEmail: "[email protected]",
ownerPassword: "valid-test-pw",
attemptedAdmin: "root",
}
out, err := performAddOperator(ctx, f, op)
if err != nil {
t.Fatalf("performAddOperator: %v", err)
}
// The operator's password was typed, so it must NOT be surfaced for display.
if out.displayPassword != "" {
t.Errorf("displayPassword = %q, want empty for a typed password", out.displayPassword)
if out.auditErr != nil {
t.Errorf("auditErr = %v, want nil", out.auditErr)
}
if len(f.inserts) != 1 || bcrypt.CompareHashAndPassword([]byte(f.inserts[0].passwordHash), []byte("valid-test-pw")) != nil {
t.Error("operator was not provisioned with the typed password")
if len(f.inserts) != 1 || f.inserts[0].username != "ops-jordan" {
t.Errorf("operator was not provisioned: %+v", f.inserts)
}
// Adding an Operator must NOT flip the global local-auth gate (Owner-only).
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
@@ -682,19 +597,14 @@ func TestPerformAddOperator(t *testing.T) {
}
})
t.Run("an empty password generates a one-time credential matching the stored hash", func(t *testing.T) {
t.Run("root override records an unverified operator row", func(t *testing.T) {
f := &fakeOwnerStore{}
op := breakGlassOp{mode: "root_override", accountable: "alice", osUser: "alice", ownerUsername: "ops", attemptedAdmin: "typo-admin"}
out, err := performAddOperator(ctx, f, op)
if err != nil {
if _, err := performAddOperator(ctx, f, op); err != nil {
t.Fatalf("performAddOperator: %v", err)
}
if out.displayPassword == "" {
t.Fatal("displayPassword empty, want a generated one-time password to hand off")
}
// The shown password must be the one actually stored (as a hash).
if bcrypt.CompareHashAndPassword([]byte(f.inserts[0].passwordHash), []byte(out.displayPassword)) != nil {
t.Error("displayed password does not match the stored hash")
if len(f.inserts) != 1 {
t.Fatalf("want 1 insert, got %d", len(f.inserts))
}
_, payload := auditOf(t, f)
if payload["verified"] != false {
@@ -719,9 +629,9 @@ func TestPerformAddOperator(t *testing.T) {
t.Run("a conflict mints nothing and writes no audit row", func(t *testing.T) {
f := &fakeOwnerStore{users: map[string]*api.StaffUser{
"owner": {ID: "usr-owner", Username: "owner", Role: "admin", PasswordHash: "x"},
"owner": {ID: "usr-owner", Username: "owner", Role: "admin"},
}}
op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", ownerPassword: "valid-test-pw", attemptedAdmin: "root"}
op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"}
if _, err := performAddOperator(ctx, f, op); err == nil {
t.Fatal("want error when the operator username is already taken")
}
@@ -733,3 +643,94 @@ func TestPerformAddOperator(t *testing.T) {
}
})
}
func TestPerformSetupMCBind(t *testing.T) {
ctx := context.Background()
t.Run("binds the owner and mints a setup URL whose token hash is what is stored", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
out, err := performSetupMCBind(ctx, f, " abc-123 ", "op.console.example.com")
if err != nil {
t.Fatalf("performSetupMCBind: %v", err)
}
const prefix = "https://op.console.example.com/setup?token="
if !strings.HasPrefix(out.setupTokenURL, prefix) {
t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix)
}
// The link code is trimmed and upper-cased before redemption.
if len(f.redeems) != 1 {
t.Fatalf("want 1 redeem, got %d", len(f.redeems))
}
if f.redeems[0].code != "ABC-123" {
t.Errorf("redeemed code = %q, want ABC-123 (trimmed + upper-cased)", f.redeems[0].code)
}
if !strings.HasPrefix(f.redeems[0].newUserID, "usr-") {
t.Errorf("redeem newUserID = %q, want usr- prefix", f.redeems[0].newUserID)
}
// Exactly one token minted, for the redeemed user, and only its hash stored —
// the stored hash must be sha-256 of the raw token carried in the URL.
if len(f.tokens) != 1 {
t.Fatalf("want 1 setup token, got %d", len(f.tokens))
}
tok := f.tokens[0]
if tok.userID != "usr-owner-1" {
t.Errorf("token userID = %q, want usr-owner-1 (the redeemed owner)", tok.userID)
}
raw := strings.TrimPrefix(out.setupTokenURL, prefix)
sum := sha256.Sum256([]byte(raw))
if tok.tokenHash != hex.EncodeToString(sum[:]) {
t.Error("stored token hash is not sha-256 of the raw token in the URL")
}
if tok.tokenHash == raw || tok.tokenHash == "" {
t.Error("the raw token (or nothing) was stored instead of its hash")
}
// The token is short-lived and in the future.
if !tok.expiresAt.After(time.Now()) {
t.Errorf("token expiresAt = %v, want a future time", tok.expiresAt)
}
})
t.Run("an empty link code mints nothing", func(t *testing.T) {
f := &fakeOwnerStore{}
if _, err := performSetupMCBind(ctx, f, " ", "op.console.example.com"); err == nil {
t.Fatal("want error for an empty link code")
}
if len(f.redeems) != 0 || len(f.tokens) != 0 {
t.Errorf("want no redeem/token on an empty code, got redeems=%d tokens=%d", len(f.redeems), len(f.tokens))
}
})
t.Run("a link-code redemption failure mints no token", func(t *testing.T) {
f := &fakeOwnerStore{redeemErr: errors.New("code expired")}
if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com"); err == nil {
t.Fatal("want error when the link code cannot be redeemed")
}
if len(f.tokens) != 0 {
t.Errorf("want no token minted on a redeem failure, got %d", len(f.tokens))
}
})
t.Run("a token-store failure surfaces after the bind", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")}
if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com"); err == nil {
t.Fatal("want error when the setup token cannot be stored")
}
if len(f.redeems) != 1 {
t.Errorf("want the redeem to have happened before the token write, got %d", len(f.redeems))
}
if len(f.tokens) != 0 {
t.Errorf("want no recorded token when the store fails, got %d", len(f.tokens))
}
})
t.Run("defaults the op.console host when adminHostname is empty", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
out, err := performSetupMCBind(ctx, f, "abc-123", " ")
if err != nil {
t.Fatalf("performSetupMCBind: %v", err)
}
if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") {
t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL)
}
})
}
+21 -6
View File
@@ -24,6 +24,15 @@ const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"
var errHostBootstrapCancelled = errors.New("host bootstrap cancelled")
// channelName maps the --dev flag to the release channel deploy/bootstrap.sh
// understands. Release is the default so a bare `felis setup` is production.
func channelName(dev bool) string {
if dev {
return "dev"
}
return "release"
}
// 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.
@@ -31,12 +40,20 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("setup", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml")
dev := fs.Bool("dev", false, "install the dev channel (felis:dev, main HEAD) instead of the default release channel (felis:release, newest tag)")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
// The channel governs which image tag/source ref the host bootstrap builds.
// runBootstrap forwards the whole environment, so exporting it here is enough
// to reach deploy/bootstrap.sh without threading a parameter through the TUI.
if err := os.Setenv("FELIS_CHANNEL", channelName(*dev)); err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err)
return 1
}
configFlagSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "config" {
@@ -112,18 +129,16 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
}
if res.provisioned {
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\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.setupTokenURL != "" {
fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
}
if res.auditWarning != "" {
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
}
if panelURL != "" {
fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", panelURL)
fmt.Fprintf(stdout, "Admin console: %s\n", panelURL)
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ func TestWizardViewsFitTerminal(t *testing.T) {
msg tea.Msg
}{
{"owner", preflightDoneMsg{}},
{"connect", ownerResultMsg{username: "owner", displayPassword: "hunter2pw"}},
{"connect", ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"}},
{"summary", connectResultMsg{method: connectLocal, panelHostname: "panel.example.com"}},
}
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"context"
"strings"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
// mcBindModel is the `felis setup` Owner-establishment screen: the operator
// joins the server, runs /link to get a one-time code, and types it here. The
// bound Minecraft account is promoted to the passwordless Owner, and a one-time
// setup URL is minted for the first web login. It replaces the old ownerModel
// bootstrap form in setup mode — no username/email/password is typed here, the
// MC identity is the root of trust.
type mcBindModel struct {
ctx context.Context
store ownerStore
adminHost string
osUser string
step mcBindStep
form *huh.Form
sp spinner.Model
working string
linkCode string
setupTokenURL string
width, height int
}
type mcBindStep int
const (
mcBindForm mcBindStep = iota
mcBindWorking
mcBindDone
)
type mcBindMsg struct {
outcome breakGlassOutcome
err error
}
func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
m := &mcBindModel{
ctx: ctx,
store: store,
adminHost: adminHost,
osUser: osUser,
sp: sp,
step: mcBindForm,
}
m.form = m.buildForm()
return m
}
func (m *mcBindModel) buildForm() *huh.Form {
return m.sized(newFelisForm(huh.NewGroup(
huh.NewNote().
Title("Bind your Minecraft account").
Description("Join the server and run /link to get a one-time code,\nthen type it here. Your bound account becomes the\npasswordless Owner."),
huh.NewInput().
Title("Link code").
Placeholder("ABCD12").
Value(&m.linkCode).
Validate(requiredField("link code")),
)))
}
func (m *mcBindModel) sized(f *huh.Form) *huh.Form {
if m.width > 0 {
return f.WithWidth(m.width).WithHeight(m.height)
}
return f
}
func (m *mcBindModel) setSize(w, h int) {
m.width, m.height = w, h
if m.form != nil {
m.form = m.form.WithWidth(w).WithHeight(h)
}
}
func (m *mcBindModel) Init() tea.Cmd { return m.form.Init() }
func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case mcBindMsg:
if msg.err != nil {
return m, m.failCmd(msg.err)
}
m.step = mcBindDone
m.setupTokenURL = msg.outcome.setupTokenURL
return m, nil
case spinner.TickMsg:
if m.step == mcBindWorking {
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
}
return m, nil
case tea.KeyMsg:
switch m.step {
case mcBindDone:
switch msg.String() {
case "ctrl+c", "esc", "enter":
return m, m.resultCmd()
}
return m, nil
case mcBindWorking:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
default:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
}
}
}
if m.step == mcBindForm && m.form != nil {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
switch m.form.State {
case huh.StateCompleted:
return m.onFormComplete()
case huh.StateAborted:
return m, tea.Quit
}
return m, cmd
}
return m, nil
}
func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) {
m.step = mcBindWorking
m.working = "Binding Minecraft account…"
code := strings.TrimSpace(strings.ToUpper(m.linkCode))
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost)
return mcBindMsg{outcome: out, err: err}
})
}
func (m *mcBindModel) failCmd(err error) tea.Cmd {
return func() tea.Msg { return ownerResultMsg{err: err} }
}
func (m *mcBindModel) resultCmd() tea.Cmd {
return func() tea.Msg {
return ownerResultMsg{
setupTokenURL: m.setupTokenURL,
mode: "setup",
accountable: m.osUser,
}
}
}
func (m *mcBindModel) View() string {
switch m.step {
case mcBindWorking:
msg := m.working
if msg == "" {
msg = "Working…"
}
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n"
case mcBindDone:
return m.doneView()
default:
if m.form == nil {
return ""
}
return m.form.View()
}
}
func (m *mcBindModel) doneView() string {
var b strings.Builder
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
var box strings.Builder
if m.setupTokenURL != "" {
box.WriteString(tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n")
box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup.\nIt is shown only once."))
}
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
b.WriteString(tuiAction("enter", "continue"))
return b.String()
}
+4 -4
View File
@@ -76,9 +76,9 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) {
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("the operator path flipped local auth — only the Owner thread may")
}
// No password was typed, so a one-time credential is surfaced to hand off.
if msg.outcome.displayPassword == "" {
t.Error("want a generated one-time password to hand to the new operator")
// The provision is fully done: the audit row landed (no recoverable audit error).
if msg.outcome.auditErr != nil {
t.Errorf("operator provision recorded an audit error: %v", msg.outcome.auditErr)
}
})
@@ -171,7 +171,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
ctx := context.Background()
op := newOperatorModel(ctx, &fakeOwnerStore{}, "root")
op.username, op.displayPassword, op.mode, op.accountable = "ops", "pw", "recovery", "root"
op.username, op.mode, op.accountable = "ops", "recovery", "root"
if res := op.ownerResultCmd()().(ownerResultMsg); !res.isOperator {
t.Error("operator result.isOperator = false, want true")
}
+24 -61
View File
@@ -65,17 +65,14 @@ type ownerModel struct {
width, height int
// huh-bound form values
authUser string
authPass string
overrideTok string
ownerUser string
ownerEmail string
ownerPass string
ownerConfirm string
authUser string
overrideTok string
ownerUser string
ownerEmail string
username string
displayPassword string
auditWarning string
username string
setupTokenURL string
auditWarning string
}
func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel {
@@ -191,7 +188,7 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.failCmd(msg.err)
}
m.step = owDone
m.displayPassword = msg.outcome.displayPassword
m.setupTokenURL = msg.outcome.setupTokenURL
if msg.outcome.auditErr != nil {
m.auditWarning = msg.outcome.auditErr.Error()
}
@@ -255,10 +252,10 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
case owAuth:
m.attempt = strings.TrimSpace(m.authUser)
m.step = owWorking
m.working = "Verifying admin credential…"
user, pass := m.authUser, m.authPass
m.working = "Verifying admin…"
user := m.authUser
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass)
matched, ok, err := authenticateAdmin(m.ctx, m.store, user)
return owAuthMsg{matched: matched, ok: ok, err: err}
})
case owOverride:
@@ -277,17 +274,12 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
}
func (m *ownerModel) provisionCmd() tea.Cmd {
password := ""
if m.mode == "bootstrap" {
password = m.ownerPass
}
op := breakGlassOp{
mode: m.mode,
accountable: m.accountable,
osUser: m.osUser,
ownerUsername: m.username,
ownerEmail: m.ownerEmail,
ownerPassword: password,
attemptedAdmin: m.attempt,
}
// performAddOperator and performBreakGlass share a signature; the operation
@@ -311,12 +303,12 @@ func (m *ownerModel) failCmd(err error) tea.Cmd {
func (m *ownerModel) ownerResultCmd() tea.Cmd {
return func() tea.Msg {
return ownerResultMsg{
username: m.username,
displayPassword: m.displayPassword,
mode: m.mode,
accountable: m.accountable,
auditWarning: m.auditWarning,
isOperator: m.operation == bgAddOperator,
username: m.username,
setupTokenURL: m.setupTokenURL,
mode: m.mode,
accountable: m.accountable,
auditWarning: m.auditWarning,
isOperator: m.operation == bgAddOperator,
}
}
}
@@ -332,11 +324,6 @@ func (m *ownerModel) buildAuthForm() *huh.Form {
Title("Admin username").
Value(&m.authUser).
Validate(requiredField("admin username")),
huh.NewInput().
Title("Admin password").
EchoMode(huh.EchoModePassword).
Value(&m.authPass).
Validate(requiredField("admin password")),
)))
}
@@ -364,18 +351,16 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
switch m.mode {
case "recovery":
desc = fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable)
desc = fmt.Sprintf("Authenticated as %q.", m.accountable)
case "root_override":
desc = "Root override — a one-time password will be generated."
desc = "Root override — the Owner will be reset."
}
if m.operation == bgAddOperator {
// Operator-add never bootstraps (an admin is already present to authorize it),
// so it is always one of the generated-password modes.
switch m.mode {
case "recovery":
desc = fmt.Sprintf("Add an Operator — authenticated as %q; a one-time password will be generated.", m.accountable)
desc = fmt.Sprintf("Add an Operator — authenticated as %q.", m.accountable)
case "root_override":
desc = "Add an Operator (root override) — a one-time password will be generated."
desc = "Add an Operator (root override)."
}
}
if m.provisionErr != nil {
@@ -396,26 +381,6 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
Placeholder("[email protected]").
Value(&m.ownerEmail),
}
if m.mode == "bootstrap" {
fields = append(fields,
huh.NewInput().
Title("Owner password").
Description("at least 8 characters").
EchoMode(huh.EchoModePassword).
Value(&m.ownerPass).
Validate(validateOwnerPassword),
huh.NewInput().
Title("Confirm password").
EchoMode(huh.EchoModePassword).
Value(&m.ownerConfirm).
Validate(func(s string) error {
if s != m.ownerPass {
return errors.New("the two passwords do not match")
}
return nil
}),
)
}
return m.sized(newFelisForm(huh.NewGroup(fields...)))
}
@@ -454,11 +419,9 @@ func (m *ownerModel) doneView() string {
var box strings.Builder
box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
if m.displayPassword != "" {
box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n")
box.WriteString(tuiWarn.Render("Record this password — it is shown only once."))
} else {
box.WriteString(tuiHint.Render("Log in with the password you entered."))
if m.setupTokenURL != "" {
box.WriteString("\n" + tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n")
box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup. It is shown only once."))
}
if m.auditWarning != "" {
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
+13 -10
View File
@@ -52,13 +52,13 @@ func connectMethodLabel(m connectMethod) string {
type preflightDoneMsg struct{}
type ownerResultMsg struct {
username string
displayPassword string
mode string
accountable string
auditWarning string
isOperator bool // true when an Operator was added rather than the Owner provisioned
err error
username string
setupTokenURL string
mode string
accountable string
auditWarning string
isOperator bool // true when an Operator was added rather than the Owner provisioned
err error
}
// connectResultMsg is emitted by every connection method (the chooser for
@@ -207,6 +207,9 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.showStatus()
}
m.stage = stageOwner
if m.mode == consoleModeSetup {
return m.adopt(newMCBindModel(m.ctx, m.store, m.adminHost, m.osUser))
}
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
case menuChoiceMsg:
@@ -228,7 +231,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.result.provisioned = true
m.result.isOperator = msg.isOperator
m.result.username = msg.username
m.result.displayPassword = msg.displayPassword
m.result.setupTokenURL = msg.setupTokenURL
m.result.mode = msg.mode
m.result.accountable = msg.accountable
m.result.auditWarning = msg.auditWarning
@@ -363,7 +366,7 @@ func (m *rootModel) reviewBody(stage int) string {
if m.result.username != "" {
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
}
b.WriteString(tuiHint.Render("Created and recorded. The one-time password was shown on the Owner step."))
b.WriteString(tuiHint.Render("Created and recorded. The one-time setup URL was shown on the Owner step."))
case stageConnect:
b.WriteString(tuiOK.Render("✓ Connection") + "\n")
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n")
@@ -488,7 +491,7 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
ownerUsername: m.result.username,
ownerPassword: m.result.displayPassword,
setupTokenURL: m.result.setupTokenURL,
accessLabel: connectMethodLabel(m.result.connectMethod),
storageLabel: m.result.storageDetail,
routedHosts: routed,
+14 -12
View File
@@ -52,24 +52,26 @@ func TestRootSetupHappyPath(t *testing.T) {
t.Fatalf("initial screen = %T, want *preflightModel", m.screen)
}
// Preflight done → Owner.
// Preflight done → MC-bind (setup mode establishes the Owner by binding a
// Minecraft account, not by typing a username/password). The stage label is
// still stageOwner; only the screen differs by mode.
m = drive(t, m, preflightDoneMsg{})
if m.stage != stageOwner {
t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage)
}
if _, ok := m.screen.(*ownerModel); !ok {
t.Fatalf("after preflight, screen = %T, want *ownerModel", m.screen)
if _, ok := m.screen.(*mcBindModel); !ok {
t.Fatalf("after preflight, screen = %T, want *mcBindModel", m.screen)
}
// Owner provisioned → Connection chooser.
m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
if m.stage != stageConnect {
t.Fatalf("after owner, stage = %v, want stageConnect", m.stage)
}
if _, ok := m.screen.(*connectChooserModel); !ok {
t.Fatalf("after owner, screen = %T, want *connectChooserModel", m.screen)
}
if !m.result.provisioned || m.result.username != "owner" || m.result.displayPassword != "hunter2" {
if !m.result.provisioned || m.result.username != "owner" || m.result.setupTokenURL != "https://op.console.example.com/setup?token=t0ken" {
t.Fatalf("owner result not recorded: %+v", m.result)
}
@@ -115,8 +117,8 @@ func TestRootSetupHappyPath(t *testing.T) {
if want := "https://panel.felis.example.com"; sum.panelURL != want {
t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want)
}
if sum.ownerPassword != "hunter2" {
t.Fatalf("summary ownerPassword = %q, want %q", sum.ownerPassword, "hunter2")
if want := "https://op.console.example.com/setup?token=t0ken"; sum.setupTokenURL != want {
t.Fatalf("summary setupTokenURL = %q, want %q", sum.setupTokenURL, want)
}
if sum.alreadySetUp {
t.Fatalf("first-run summary should not be marked alreadySetUp")
@@ -150,7 +152,7 @@ func TestRootSetupLocalSummary(t *testing.T) {
func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
if _, ok := m.screen.(*summaryModel); !ok {
@@ -257,7 +259,7 @@ func TestRootBreakGlassQuitsAfterOwner(t *testing.T) {
t.Fatalf("after the menu choice, screen = %T, want *ownerModel", m.screen)
}
next, cmd := m.Update(ownerResultMsg{username: "owner", displayPassword: "pw", mode: "recovery"})
next, cmd := m.Update(ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", mode: "recovery"})
rm := next.(*rootModel)
if _, ok := rm.screen.(*connectChooserModel); ok {
t.Fatalf("break-glass must not enter the connection chooser")
@@ -292,7 +294,7 @@ func TestRootRailReviewNavigation(t *testing.T) {
}
// Advance to the Connection chooser (a select — it yields ←/→).
m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
if m.reviewing != -1 {
t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing)
}
@@ -352,8 +354,8 @@ func TestSetupRailSpansBootstrap(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30})
m = drive(t, m, preflightDoneMsg{})
if _, ok := m.screen.(*ownerModel); !ok {
t.Fatalf("expected owner screen after preflight, got %T", m.screen)
if _, ok := m.screen.(*mcBindModel); !ok {
t.Fatalf("expected MC-bind screen after preflight, got %T", m.screen)
}
if v := m.View(); !strings.Contains(v, "✓ Bootstrap") {
t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v)
+4 -4
View File
@@ -14,7 +14,7 @@ import (
type summaryModel struct {
panelURL string
ownerUsername string
ownerPassword string // one-time; shown once
setupTokenURL string // one-time first-login URL; shown once
accessLabel string
storageLabel string // build-context storage backend recap; empty to omit
routedHosts []string
@@ -55,9 +55,9 @@ func (m *summaryModel) View() string {
if m.ownerUsername != "" {
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
}
if m.ownerPassword != "" {
card.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.ownerPassword) + "\n")
card.WriteString(" " + tuiWarn.Render("shown only once — record it now") + "\n")
if m.setupTokenURL != "" {
card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n")
card.WriteString(" " + tuiWarn.Render("one-time link — open it to finish login setup") + "\n")
}
if m.accessLabel != "" {
card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n")
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"fmt"
"io"
"runtime"
"runtime/debug"
)
// version is the build stamp injected at link time via
//
// -ldflags "-X main.version=<git describe>"
//
// deploy/bootstrap.sh computes it from the checked-out source with
// `git describe --tags --always --dirty`: the release channel builds the newest
// vX.Y.Z tag (a clean name like v1.0.0-earlyAccess), the dev channel builds main
// HEAD (a tag+distance+gSHA string). It stays "dev" for an un-stamped local
// `go build`, where ReadBuildInfo below still surfaces the vcs revision.
var version = "dev"
// cmdVersion prints the build stamp. It takes no flags and never touches the
// cluster, so it is safe to run as any user (unlike setup/breakGlass).
func cmdVersion(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "felis %s\n", resolvedVersion())
fmt.Fprintf(stdout, " go: %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
if rev, ok := vcsRevision(); ok {
fmt.Fprintf(stdout, " revision: %s\n", rev)
}
return 0
}
// resolvedVersion prefers the ldflag stamp, then the module version recorded by
// `go install`, and only reports "unknown" when neither is present.
func resolvedVersion() string {
if version != "" {
return version
}
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
return bi.Main.Version
}
return "unknown"
}
// vcsRevision returns the git commit the binary was built from when the build
// carried VCS stamping (local `go build` in a checkout; the docker build strips
// .git, so there the ldflag version carries the identity instead).
func vcsRevision() (string, bool) {
bi, ok := debug.ReadBuildInfo()
if !ok {
return "", false
}
for _, s := range bi.Settings {
if s.Key == "vcs.revision" && s.Value != "" {
return s.Value, true
}
}
return "", false
}