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:
46 files changed
+5554
-1651
No files matched your search
+3
-13
@@ -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
@@ -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
@@ -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
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user