feat(cli): break-glass emergency console TUI
Add `felis breakGlass`, a root-only interactive TUI that provisions or resets the Owner account directly against Postgres and enables local password login. It is the local-root recovery path that bypasses web Zero Trust by design - used to bootstrap the first Owner credential and to recover when the web login is unreachable. - Bare `felis` prints CLI usage only; breakGlass is the sole subcommand that enters a TUI rather than running as a CLI. - Refuses to run unless euid is 0 (try: sudo felis breakGlass); on non-Unix platforms the euid check also refuses. - Generates a one-time Owner password, sets must_change_password, and prints a durable summary (username, one-time password, op.console login URL derived from the configured root domain) after the alt-screen TUI is torn down. Covered by Go unit tests over a fake owner store.
This commit is contained in:
3 files changed
+635
No files matched your search
@@ -0,0 +1,425 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/store"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// `felis breakGlass` is the local break-glass emergency console (spec §B). Its
|
||||
// authority is local root, so it legitimately BYPASSES the web Zero-Trust + Passkey
|
||||
// path: critical recovery runs direct-to-Postgres. The thin-thread operation it
|
||||
// ships here is the one that bootstraps everything else — provision (or reset) the
|
||||
// single Owner account and turn local-password login on — so that even with the web
|
||||
// auth path unconfigured an operator can get into op.console. It is a genuine
|
||||
// interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass`
|
||||
// opens this full-screen console. It refuses to run unless euid is 0 (sudo/root).
|
||||
//
|
||||
// The richer break-glass operations (OP create, halt, sync, S3) land in a later
|
||||
// phase; this file is intentionally scoped to the one provisioning operation that
|
||||
// makes the local-auth thin thread reachable.
|
||||
|
||||
// localAuthEnabledSettingKey is the platform_settings key the live API reads to
|
||||
// decide whether local-password sessions are honored. It MUST match the
|
||||
// (unexported) localAuthEnabledKey the api package consults per-request; the VM
|
||||
// smoke test (login after break-glass) is what guarantees they stay in sync.
|
||||
const localAuthEnabledSettingKey = "local_auth_enabled"
|
||||
|
||||
// bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a
|
||||
// human can transcribe the one-time password off a terminal without error.
|
||||
const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
|
||||
// ownerStore is the minimal repo surface break-glass provisioning needs.
|
||||
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core provisioning
|
||||
// logic is exercised without a database or a terminal.
|
||||
type ownerStore interface {
|
||||
UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
|
||||
SetSetting(ctx context.Context, key string, value []byte) error
|
||||
}
|
||||
|
||||
// cmdBreakGlass is the `felis breakGlass` entrypoint: the root gate, config load,
|
||||
// database open, and the interactive TUI. Everything below runBreakGlassTUI is
|
||||
// I/O at the edge; the provisioning logic itself is plain functions over
|
||||
// ownerStore so it stays testable off a terminal.
|
||||
func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("breakGlass", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Root gate. The break-glass console's whole authority model is "you already
|
||||
// have local root", so anything less is refused rather than degraded. On
|
||||
// non-Unix os.Geteuid() returns -1, which also refuses — correct, since this is
|
||||
// a Linux-VM recovery tool.
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(stderr, "felis breakGlass: refused — the break-glass emergency console must run as root (try: sudo felis breakGlass)")
|
||||
return 1
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
drv, err := store.Open(ctx, cfg.Database.URL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: open database: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer drv.Close()
|
||||
|
||||
repo := api.NewPGRepo(drv.DB())
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if !res.provisioned {
|
||||
fmt.Fprintln(stdout, "felis breakGlass: cancelled — no changes made.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// The TUI runs on the alternate screen, which is torn down on exit and takes the
|
||||
// in-console password display with it. Re-print a durable summary to the normal
|
||||
// screen so the one-time bootstrap password survives in scrollback long enough
|
||||
// for the operator to log in. It is shown, never persisted: only the bcrypt hash
|
||||
// reached the database.
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
|
||||
fmt.Fprintf(stdout, "One-time bootstrap password (you MUST change it on first login):\n\n %s\n\n", res.password)
|
||||
if res.rootDomain != "" {
|
||||
fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// newOwnerID returns a fresh, unguessable id for the Owner row. It mirrors the
|
||||
// crypto/rand hex idiom used elsewhere (internal/submit, internal/build): 16 bytes
|
||||
// give uuid-equivalent entropy and the value is path/argv-safe lowercase hex. A
|
||||
// fresh id per run is fine because UpsertOwner preserves the existing id on a
|
||||
// username conflict, so a reset keeps live sessions valid.
|
||||
func newOwnerID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand only fails on a broken entropy source. Refuse rather than mint a
|
||||
// predictable id for a privileged account.
|
||||
return ""
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// provisionOwner mints or resets the single Owner account direct-to-Postgres and
|
||||
// returns the one-time bootstrap password to display. 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. The returned plaintext exists ONLY to be shown once on this terminal — it
|
||||
// is never logged or persisted; only its bcrypt hash reaches the database.
|
||||
func provisionOwner(ctx context.Context, s ownerStore, username, email string) (string, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return "", errors.New("username is required")
|
||||
}
|
||||
id := newOwnerID()
|
||||
if id == "" {
|
||||
return "", errors.New("generate owner id: entropy source failed")
|
||||
}
|
||||
password, err := generateBootstrapPassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash bootstrap password: %w", err)
|
||||
}
|
||||
if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil {
|
||||
return "", fmt.Errorf("write owner: %w", err)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// enableLocalAuth flips the runtime local_auth_enabled toggle on
|
||||
// direct-to-Postgres. It is the second load-bearing write of break-glass: without
|
||||
// it handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a
|
||||
// successful provisionOwner with local auth off is not a usable thin thread.
|
||||
func enableLocalAuth(ctx context.Context, s ownerStore) error {
|
||||
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
|
||||
// value must be the literal true.
|
||||
if err := s.SetSetting(ctx, localAuthEnabledSettingKey, []byte("true")); err != nil {
|
||||
return fmt.Errorf("enable local auth: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- interactive TUI (the untested shell over the tested core) ----
|
||||
|
||||
// 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
|
||||
username string
|
||||
password string
|
||||
rootDomain string
|
||||
}
|
||||
|
||||
type bgState int
|
||||
|
||||
const (
|
||||
stateForm bgState = iota
|
||||
stateWorking
|
||||
stateDone
|
||||
stateError
|
||||
)
|
||||
|
||||
// provisionedMsg is delivered to the model when the background provisioning
|
||||
// command finishes.
|
||||
type provisionedMsg struct {
|
||||
password string
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
bgTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("88")).Padding(0, 1)
|
||||
bgLabelStyle = lipgloss.NewStyle().Bold(true)
|
||||
bgHintStyle = lipgloss.NewStyle().Faint(true)
|
||||
bgErrStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
|
||||
bgOKStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
|
||||
bgPwStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(lipgloss.Color("11")).Padding(0, 1)
|
||||
bgBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3)
|
||||
)
|
||||
|
||||
// bgModel is the bubbletea model for the provisioning console. It is a pointer
|
||||
// model so Update can mutate in place; the only field touched from the background
|
||||
// command is read after the command returns via provisionedMsg.
|
||||
type bgModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
rootDomain string
|
||||
|
||||
inputs []textinput.Model
|
||||
focus int
|
||||
state bgState
|
||||
formErr string
|
||||
|
||||
username string
|
||||
password string
|
||||
err error
|
||||
}
|
||||
|
||||
func newBGModel(ctx context.Context, s ownerStore, rootDomain string) *bgModel {
|
||||
user := textinput.New()
|
||||
user.Placeholder = "owner"
|
||||
user.SetValue("owner")
|
||||
user.CharLimit = 64
|
||||
user.Width = 40
|
||||
user.Prompt = ""
|
||||
user.Focus()
|
||||
|
||||
email := textinput.New()
|
||||
email.Placeholder = "(optional)"
|
||||
email.CharLimit = 254
|
||||
email.Width = 40
|
||||
email.Prompt = ""
|
||||
|
||||
return &bgModel{
|
||||
ctx: ctx,
|
||||
store: s,
|
||||
rootDomain: rootDomain,
|
||||
inputs: []textinput.Model{user, email},
|
||||
focus: 0,
|
||||
state: stateForm,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *bgModel) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (m *bgModel) focusInput(i int) tea.Cmd {
|
||||
if i < 0 {
|
||||
i = len(m.inputs) - 1
|
||||
}
|
||||
if i >= len(m.inputs) {
|
||||
i = 0
|
||||
}
|
||||
m.focus = i
|
||||
var cmd tea.Cmd
|
||||
for j := range m.inputs {
|
||||
if j == i {
|
||||
cmd = m.inputs[j].Focus()
|
||||
} else {
|
||||
m.inputs[j].Blur()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *bgModel) updateInputs(msg tea.Msg) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.inputs))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *bgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case provisionedMsg:
|
||||
if msg.err != nil {
|
||||
m.state, m.err = stateError, msg.err
|
||||
} else {
|
||||
m.state, m.password = stateDone, msg.password
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case stateDone, stateError:
|
||||
// Any key dismisses the terminal screen.
|
||||
return m, tea.Quit
|
||||
case stateWorking:
|
||||
// Ignore input while the database write is in flight.
|
||||
return m, nil
|
||||
case stateForm:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "tab", "down":
|
||||
return m, m.focusInput(m.focus + 1)
|
||||
case "shift+tab", "up":
|
||||
return m, m.focusInput(m.focus - 1)
|
||||
case "enter":
|
||||
if strings.TrimSpace(m.inputs[0].Value()) == "" {
|
||||
m.formErr = "username is required"
|
||||
return m, m.focusInput(0)
|
||||
}
|
||||
m.username = strings.TrimSpace(m.inputs[0].Value())
|
||||
m.state, m.formErr = stateWorking, ""
|
||||
return m, provisionCmd(m.ctx, m.store, m.username, m.inputs[1].Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := m.updateInputs(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *bgModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\n\n")
|
||||
|
||||
switch m.state {
|
||||
case stateForm:
|
||||
b.WriteString("Provision (or reset) the Owner account and enable local-password login.\n")
|
||||
b.WriteString("This writes directly to Postgres, bypassing the web Zero-Trust path.\n\n")
|
||||
b.WriteString(bgLabelStyle.Render("Owner username") + "\n")
|
||||
b.WriteString(m.inputs[0].View() + "\n\n")
|
||||
b.WriteString(bgLabelStyle.Render("Owner email (optional)") + "\n")
|
||||
b.WriteString(m.inputs[1].View() + "\n\n")
|
||||
if m.formErr != "" {
|
||||
b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n")
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n")
|
||||
|
||||
case stateWorking:
|
||||
b.WriteString("Provisioning the Owner account…\n")
|
||||
|
||||
case stateDone:
|
||||
b.WriteString(bgOKStyle.Render("✓ Owner provisioned · local-password login ENABLED") + "\n\n")
|
||||
box := bgLabelStyle.Render("username ") + m.username + "\n" +
|
||||
bgLabelStyle.Render("password ") + bgPwStyle.Render(m.password)
|
||||
b.WriteString(bgBoxStyle.Render(box) + "\n\n")
|
||||
b.WriteString(bgErrStyle.Render("Record this password now — it is shown only once.") + "\n")
|
||||
b.WriteString("You will be required to change it on first login.\n\n")
|
||||
if m.rootDomain != "" {
|
||||
b.WriteString("Log in at " + bgLabelStyle.Render("https://op.console."+m.rootDomain) + "\n\n")
|
||||
}
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
|
||||
case stateError:
|
||||
b.WriteString(bgErrStyle.Render("✗ Provisioning failed") + "\n\n")
|
||||
b.WriteString(m.err.Error() + "\n\n")
|
||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// provisionCmd runs the two load-bearing writes off the UI goroutine and reports
|
||||
// the outcome back as a provisionedMsg. The Owner row is written first, then local
|
||||
// auth is enabled. If enabling local auth fails, the message carries the error and
|
||||
// Update routes to stateError: the generated plaintext in msg.password is dropped —
|
||||
// never displayed and never stored anywhere — so a partial run leaks nothing. This
|
||||
// is safe because the Owner row is unreachable for login while local_auth_enabled is
|
||||
// unset, and re-running break-glass is idempotent (UpsertOwner is ON CONFLICT, so it
|
||||
// overwrites the hash and re-issues a fresh password) and converges the toggle.
|
||||
func provisionCmd(ctx context.Context, s ownerStore, username, email string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
pw, err := provisionOwner(ctx, s, username, email)
|
||||
if err == nil {
|
||||
err = enableLocalAuth(ctx, s)
|
||||
}
|
||||
return provisionedMsg{password: pw, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// runBreakGlassTUI drives the bubbletea program and projects the final model onto a
|
||||
// breakGlassResult. It is the thin, untested shell; the logic it invokes
|
||||
// (provisionOwner / enableLocalAuth) is unit-tested directly.
|
||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain string) (breakGlassResult, error) {
|
||||
final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain), tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
return breakGlassResult{}, err
|
||||
}
|
||||
m, ok := final.(*bgModel)
|
||||
if !ok {
|
||||
return breakGlassResult{}, errors.New("unexpected final model")
|
||||
}
|
||||
if m.state == stateError {
|
||||
return breakGlassResult{}, m.err
|
||||
}
|
||||
return breakGlassResult{
|
||||
provisioned: m.state == stateDone,
|
||||
username: m.username,
|
||||
password: m.password,
|
||||
rootDomain: rootDomain,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// fakeOwnerStore records what break-glass provisioning writes, so the core logic
|
||||
// is exercised without a database or a terminal.
|
||||
type fakeOwnerStore struct {
|
||||
upserts []upsertCall
|
||||
settings map[string][]byte
|
||||
upsertErr error
|
||||
setErr error
|
||||
}
|
||||
|
||||
type upsertCall struct {
|
||||
id, username, email, passwordHash string
|
||||
mustChange bool
|
||||
}
|
||||
|
||||
func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
||||
if f.upsertErr != nil {
|
||||
return f.upsertErr
|
||||
}
|
||||
f.upserts = append(f.upserts, upsertCall{id, username, email, passwordHash, mustChange})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) error {
|
||||
if f.setErr != nil {
|
||||
return f.setErr
|
||||
}
|
||||
if f.settings == nil {
|
||||
f.settings = map[string][]byte{}
|
||||
}
|
||||
f.settings[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
f := &fakeOwnerStore{}
|
||||
pw, err := provisionOwner(ctx, f, "owner", "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatalf("provisionOwner: %v", err)
|
||||
}
|
||||
if len(f.upserts) != 1 {
|
||||
t.Fatalf("want 1 upsert, got %d", len(f.upserts))
|
||||
}
|
||||
got := f.upserts[0]
|
||||
if got.username != "owner" {
|
||||
t.Errorf("username = %q, want owner", got.username)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// The plaintext is returned only to be shown; only the hash is stored. The
|
||||
// returned password must verify against the stored hash.
|
||||
if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil {
|
||||
t.Error("returned password does not verify against the stored hash")
|
||||
}
|
||||
if pw == got.passwordHash {
|
||||
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] "); err != nil {
|
||||
t.Fatalf("provisionOwner: %v", err)
|
||||
}
|
||||
if f.upserts[0].username != "owner" || f.upserts[0].email != "[email protected]" {
|
||||
t.Errorf("got username=%q email=%q, want trimmed", f.upserts[0].username, f.upserts[0].email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects an empty username before any write", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
if _, err := provisionOwner(ctx, f, " ", ""); err == nil {
|
||||
t.Fatal("want error for empty username")
|
||||
}
|
||||
if len(f.upserts) != 0 {
|
||||
t.Errorf("want no upsert on validation failure, 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", ""); err == nil {
|
||||
t.Fatal("want error when the store fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two runs mint distinct passwords", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
a, err := provisionOwner(ctx, f, "owner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := provisionOwner(ctx, f, "owner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Error("two provisions produced the same bootstrap password")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnableLocalAuth(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
if err := enableLocalAuth(context.Background(), f); err != nil {
|
||||
t.Fatalf("enableLocalAuth: %v", err)
|
||||
}
|
||||
raw, ok := f.settings[localAuthEnabledSettingKey]
|
||||
if !ok {
|
||||
t.Fatalf("setting %q not written", localAuthEnabledSettingKey)
|
||||
}
|
||||
// Mirror how the live API parses the toggle (session.go localAuthEnabled): the
|
||||
// stored jsonb must round-trip to the bool true, or the gate fails closed and the
|
||||
// Owner can never log in.
|
||||
var enabled bool
|
||||
if err := json.Unmarshal(raw, &enabled); err != nil {
|
||||
t.Fatalf("setting value %q is not valid JSON: %v", raw, err)
|
||||
}
|
||||
if !enabled {
|
||||
t.Errorf("local_auth_enabled = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// bcrypt's hard limit is 72 bytes; a bootstrap password must stay well under it.
|
||||
if len(pw) > 72 {
|
||||
t.Errorf("length %d exceeds bcrypt's 72-byte limit", len(pw))
|
||||
}
|
||||
other, err := generateBootstrapPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pw == other {
|
||||
t.Error("two calls produced the same password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionCmd(t *testing.T) {
|
||||
t.Run("performs both load-bearing writes", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
msg := provisionCmd(context.Background(), f, "owner", "")()
|
||||
pm, ok := msg.(provisionedMsg)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want provisionedMsg", msg)
|
||||
}
|
||||
if pm.err != nil {
|
||||
t.Fatalf("provisionCmd error: %v", pm.err)
|
||||
}
|
||||
if pm.password == "" {
|
||||
t.Error("empty password in result")
|
||||
}
|
||||
if len(f.upserts) != 1 {
|
||||
t.Errorf("want 1 owner upsert, got %d", len(f.upserts))
|
||||
}
|
||||
if _, ok := f.settings[localAuthEnabledSettingKey]; !ok {
|
||||
t.Error("local auth was not enabled — login would 403")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not enable local auth if the owner write fails", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
|
||||
msg := provisionCmd(context.Background(), f, "owner", "")()
|
||||
pm := msg.(provisionedMsg)
|
||||
if pm.err == nil {
|
||||
t.Fatal("want error when the owner write fails")
|
||||
}
|
||||
if len(f.settings) != 0 {
|
||||
t.Error("local auth should not be enabled when provisioning failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ Commands:
|
||||
restore Extract a world archive into a world volume (internal Job entrypoint)
|
||||
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
|
||||
apply Apply a MinecraftServer manifest
|
||||
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
|
||||
|
||||
Run "felis <command> -h" for command-specific flags.
|
||||
`
|
||||
@@ -45,6 +46,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return cmdManifests(rest, stdout, stderr)
|
||||
case "apply":
|
||||
return notImplemented("apply", "MinecraftServer manifest apply", stderr)
|
||||
case "breakGlass":
|
||||
return cmdBreakGlass(rest, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
|
||||
Reference in new issue
Block a user