feat: initialize panel access before linking Minecraft accounts

Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials.

Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
This commit is contained in:
Lemon-miaow committed 2026-10-04 03:05:05 +08:00
1 parent 75845d8f58
commit efbbe27629
59 files changed
+1702 -1564

No files matched your search

+4 -4
View File
@@ -61,9 +61,9 @@ func passkeyRelyingParty(cfg *config.Config) (string, []string) {
if rpID == "" {
return "", nil
}
origins := []string{"https://" + rpID}
origins := []string{"https://" + rpID, fmt.Sprintf("https://%s:%d", rpID, setupPanelNodePort())}
if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != rpID {
origins = append(origins, "https://"+admin)
origins = append(origins, "https://"+admin, fmt.Sprintf("https://%s:%d", admin, setupPanelNodePort()))
}
return rpID, origins
}
@@ -77,7 +77,7 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
sources := make([]api.AuthSource, 0, len(configured)+1)
sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true})
for _, s := range configured {
sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL})
sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL, APIURL: s.APIURL})
}
return sources
}
@@ -495,7 +495,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
cfg.Velocity.GamePort, resolvedVersion(), distribution != nil)
cfg.Velocity.GamePort, cfg.Velocity.GameVersion, resolvedVersion(), distribution != nil)
internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
+19 -4
View File
@@ -24,6 +24,7 @@ import (
// that names only its root domain still gets passkeys, on console.<root>, with the
// operator host as the second origin; only an install with no panel host goes without.
func TestPasskeyRelyingParty(t *testing.T) {
t.Setenv("FELIS_PANEL_NODEPORT", "")
for _, tc := range []struct {
name string
root, panel, admin string
@@ -43,14 +44,28 @@ func TestPasskeyRelyingParty(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname = tc.root, tc.panel, tc.admin
var wantOrigins []string
for _, origin := range tc.wantOrigins {
wantOrigins = append(wantOrigins, origin, fmt.Sprintf("%s:%d", origin, defaultPanelNodePort))
}
rp, origins := passkeyRelyingParty(cfg)
if rp != tc.wantRP || !slices.Equal(origins, tc.wantOrigins) {
t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, tc.wantOrigins)
if rp != tc.wantRP || !slices.Equal(origins, wantOrigins) {
t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, wantOrigins)
}
})
}
}
func TestPasskeyRelyingPartyIncludesConfiguredNodePort(t *testing.T) {
t.Setenv("FELIS_PANEL_NODEPORT", "30445")
cfg := &config.Config{}
cfg.Server.RootDomain = "example.com"
_, origins := passkeyRelyingParty(cfg)
if !slices.Contains(origins, "https://op.console.example.com:30445") {
t.Fatalf("configured NodePort origin missing: %v", origins)
}
}
// TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided:
// Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is.
// The empty case matters on its own — both `felis api` and `felis nano` call this with a
@@ -64,7 +79,7 @@ func TestAuthSourcesFromConfig(t *testing.T) {
{"no configured sources", nil},
{"configured sources", []config.AuthSourceConfig{
{Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.example/hasJoined"},
{Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined"},
{Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined", APIURL: "https://guild.example/api"},
}},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -80,7 +95,7 @@ func TestAuthSourcesFromConfig(t *testing.T) {
if s.Identity {
t.Errorf("configured source %q is marked Identity; only Mojang may be", c.Tag)
}
if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL {
if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL || s.APIURL != c.APIURL {
t.Errorf("source %d = %+v, want %+v in config order", i+1, s, c)
}
}
+24 -70
View File
@@ -11,6 +11,7 @@ import (
"flag"
"fmt"
"io"
"net/url"
"os"
"strings"
"time"
@@ -93,11 +94,9 @@ type ownerStore interface {
// role=owner identity (migration 0011 adds that role); the two are the only
// staff roles.
InsertOperator(ctx context.Context, id, username, email string) error
// CompleteOwnerSetup atomically consumes the in-game link code, creates or
// promotes the bound Owner, enables local auth, and stores the one-time setup
// token. A failure rolls all four writes back so setup is always retryable.
CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (userID, mcUUID, authSource string, err error)
// CompleteOwnerSetup creates or resumes the first panel login atomically.
CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (userID, username string, err 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
@@ -368,7 +367,7 @@ type breakGlassOp struct {
// breakGlassOutcome is what performBreakGlass reports back to the TUI.
type breakGlassOutcome struct {
setupTokenURL string // non-empty when setup minted a one-time first-login URL
ownerIdentity string // verified Minecraft UUID for the setup Owner-bind path
ownerUsername string // panel Owner created or resumed by setup
auditErr error // non-nil if the accountability row could not be written
}
@@ -408,25 +407,12 @@ func newSetupToken() (raw, hash string, err error) {
return raw, hex.EncodeToString(sum[:]), nil
}
// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
// binds their Minecraft account via a one-time link code the login gate handed
// them in-game, the bound user is promoted to role='owner' (passwordless Owner),
// local auth is enabled, and a one-time setup URL is minted for the first web
// login where the Owner verifies email / enrolls a passkey. adminHostname is the
// operator-console host the URL points at (op.console.<root>): the Owner is staff,
// so first-run onboarding belongs on the operator face, not the player panel. The
// passkey verifier's RP id is the panel host, but its permitted origins now include
// op.console (cmd/felis/api.go), so enrollment on op.console is a valid ceremony —
// one binding that works on both faces. osUser is recorded as the accountable actor.
//
// Local auth is as load-bearing here as it is in break-glass, and for a sharper
// reason: an MC-bound Owner has no password AND no email, so the setup token is
// their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth
// toggle, and token together; any failed write leaves the link code retryable.
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) {
code = strings.TrimSpace(strings.ToUpper(code))
if code == "" {
return breakGlassOutcome{}, errors.New("link code is required")
// performSetupOwner establishes panel access under the caller's host-root
// authority. Minecraft identity can be linked later from the authenticated panel.
func performSetupOwner(ctx context.Context, s ownerStore, panelURL, osUser string) (breakGlassOutcome, error) {
base, err := url.Parse(strings.TrimRight(panelURL, "/"))
if err != nil || base.Scheme != "https" || base.Host == "" {
return breakGlassOutcome{}, errors.New("a configured HTTPS operator console is required")
}
newID := newOwnerID()
if newID == "" {
@@ -437,48 +423,21 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname,
return breakGlassOutcome{}, err
}
now := time.Now()
_, mcUUID, authSource, err := s.CompleteOwnerSetup(
ctx, newID, code, now, hash, now.Add(setupTokenTTL))
userID, username, err := s.CompleteOwnerSetup(ctx, newID, now, hash, now.Add(setupTokenTTL))
out := breakGlassOutcome{ownerUsername: username}
if err != nil {
return breakGlassOutcome{}, fmt.Errorf("complete owner setup: %w", err)
return out, err
}
// The load-bearing writes committed together above. Accountability remains
// best-effort: an unhappy audit sink never costs the operator their install.
out := breakGlassOutcome{
ownerIdentity: mcUUID,
auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
}
host := strings.TrimSpace(adminHostname)
if host == "" {
host = "op.console.localhost"
}
out.setupTokenURL = "https://" + host + "/setup?token=" + raw
base.Path = "/setup"
base.RawQuery = url.Values{"token": {raw}}.Encode()
out.setupTokenURL = base.String()
blob, _ := json.Marshal(map[string]string{"os_user": osUser, "user_id": userID, "username": username})
out.auditErr = s.Audit(ctx, api.AuditEntry{
Actor: osUser, Source: "setup", Action: "setup.owner_login", Payload: blob,
})
return out, nil
}
// auditSetupMCBind records who claimed the Owner seat at setup. It carries the
// Minecraft identity rather than a username because that IS the evidence: the
// login gate only issues a link code to a player it authenticated, so mc_uuid +
// auth_source say which account was verified and by whom. Actor is the OS user who
// ran `felis setup` — honest attribution, not proof (root can edit the row).
func auditSetupMCBind(ctx context.Context, s ownerStore, osUser, mcUUID, authSource string) error {
blob, err := json.Marshal(map[string]any{
"mode": "setup",
"os_user": osUser,
"mc_uuid": mcUUID,
"auth_source": authSource,
})
if err != nil {
return err
}
return s.Audit(ctx, api.AuditEntry{
Actor: osUser,
Source: "setup",
Action: "setup.owner_bind",
Payload: blob,
})
}
// 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
@@ -569,7 +528,6 @@ type breakGlassResult struct {
// from a cancel and reports itself as one.
alreadySetUp bool
isOperator bool // an Operator was added rather than the Owner provisioned
ownerSkipped bool // setup's Owner step was skipped; no Owner is bound
mode string
accountable string
osUser string
@@ -632,13 +590,9 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfi
return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
}
// runSetupTUI never reaches recovery: setup with a staff account present lands on
// the status screen, so it has no relay to hand over.
// gameAddr is where the Owner step tells the operator to join (setupGameAddress).
func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, gameAddr string, adminExists bool) (breakGlassResult, error) {
rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
rm.gameAddr = gameAddr
return runConsoleRoot(rm)
// runSetupTUI configures deployment before issuing the first panel login link.
func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{}))
}
// newConsoleRoot is the console's root model as the host runs it: the summary
+77 -163
View File
@@ -24,16 +24,14 @@ type fakeOwnerStore struct {
settings map[string][]byte
audits []api.AuditEntry
tokens []setupTokenCall
redeems []redeemCall
setupIDs []string
users map[string]*api.StaffUser // keyed by username
admins bool // AdminExists answer
ownerSeat string // OwnerUsername answer: the occupied seat, "" when none
// CompleteOwnerSetup'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
setupUserID string
setupUsername string
onboarded bool
upsertErr error
insertErr error
@@ -59,12 +57,6 @@ type setupTokenCall struct {
expiresAt time.Time
}
// redeemCall records the inputs CompleteOwnerSetup was called with.
type redeemCall struct {
newUserID string
code string
}
func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) {
if f.adminErr != nil {
return false, f.adminErr
@@ -118,30 +110,31 @@ func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email s
return nil
}
// CompleteOwnerSetup models the real all-or-nothing transaction: injected failures
// record none of the redeem, auth-toggle, or setup-token writes.
func (f *fakeOwnerStore) CompleteOwnerSetup(_ context.Context, newUserID, code string, _ time.Time,
tokenHash string, expiresAt time.Time) (string, string, string, error) {
if f.redeemErr != nil {
return "", "", "", f.redeemErr
// CompleteOwnerSetup models the transaction without any game link code.
func (f *fakeOwnerStore) CompleteOwnerSetup(_ context.Context, newUserID string, _ time.Time,
tokenHash string, expiresAt time.Time) (string, string, error) {
for _, err := range []error{f.redeemErr, f.setErr, f.createTokenErr} {
if err != nil {
return "", "", err
}
}
if f.setErr != nil {
return "", "", "", f.setErr
}
if f.createTokenErr != nil {
return "", "", "", f.createTokenErr
}
f.redeems = append(f.redeems, redeemCall{newUserID, code})
userID := f.redeemUserID
userID, username := f.setupUserID, f.setupUsername
if userID == "" {
userID = newUserID // unlinked UUID → the fresh id becomes the Owner
userID = newUserID
}
if username == "" {
username = "owner"
}
if f.onboarded {
return userID, username, api.ErrConflict
}
f.setupIDs = append(f.setupIDs, newUserID)
if f.settings == nil {
f.settings = map[string][]byte{}
}
f.settings[api.LocalAuthEnabledKey] = []byte("true")
f.tokens = append(f.tokens, setupTokenCall{tokenHash, userID, expiresAt})
return userID, f.redeemMCUUID, f.redeemAuthSource, nil
return userID, username, nil
}
func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) error {
@@ -710,149 +703,70 @@ func TestPerformAddOperator(t *testing.T) {
})
}
func TestPerformSetupMCBind(t *testing.T) {
func TestPerformSetupOwner(t *testing.T) {
ctx := context.Background()
f := &fakeOwnerStore{setupUserID: "usr-owner-1"}
out, err := performSetupOwner(ctx, f, "https://op.console.example.com:30443", "deploybot")
if err != nil {
t.Fatal(err)
}
if out.ownerUsername != "owner" {
t.Fatalf("username = %q", out.ownerUsername)
}
const prefix = "https://op.console.example.com:30443/setup?token="
if !strings.HasPrefix(out.setupTokenURL, prefix) {
t.Fatalf("URL = %q", out.setupTokenURL)
}
if len(f.tokens) != 1 || f.tokens[0].userID != "usr-owner-1" {
t.Fatalf("tokens = %+v", f.tokens)
}
raw := strings.TrimPrefix(out.setupTokenURL, prefix)
sum := sha256.Sum256([]byte(raw))
if f.tokens[0].tokenHash != hex.EncodeToString(sum[:]) {
t.Fatal("stored token does not match URL")
}
if !f.tokens[0].expiresAt.After(time.Now()) {
t.Fatal("token already expired")
}
if string(f.settings[api.LocalAuthEnabledKey]) != "true" {
t.Fatal("local auth stayed disabled")
}
e, payload := auditOf(t, f)
if e.Actor != "deploybot" || e.Action != "setup.owner_login" || payload["user_id"] != "usr-owner-1" {
t.Fatalf("audit = %+v, %v", e, payload)
}
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", redeemMCUUID: "mc-uuid-1", redeemAuthSource: "mojang"}
out, err := performSetupMCBind(ctx, f, " abc-123 ", "console.example.com", "deploybot")
if err != nil {
t.Fatalf("performSetupMCBind: %v", err)
}
// The Owner this mints has no password and no email, so the setup token is the
// only door — and handleSetupRedeem is gated on local_auth_enabled. A bind that
// leaves the toggle off hands back a URL that answers 403.
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled — the setup URL would 403 local_auth_disabled")
}
// The bind is attributed by Minecraft identity, because that is what the login
// gate verified; a username would be the one thing nobody checked.
e, payload := auditOf(t, f)
if e.Actor != "deploybot" || e.Source != "setup" || e.Action != "setup.owner_bind" {
t.Errorf("audit = %+v, want actor=deploybot source=setup action=setup.owner_bind", e)
}
if payload["mc_uuid"] != "mc-uuid-1" || payload["auth_source"] != "mojang" {
t.Errorf("audit payload = %v, want the redeemed mc_uuid + auth_source", payload)
}
if out.ownerIdentity != "mc-uuid-1" {
t.Errorf("owner identity = %q, want the verified Minecraft UUID", out.ownerIdentity)
}
const prefix = "https://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("failed writes leave no partially initialized login", func(t *testing.T) {
for _, store := range []*fakeOwnerStore{
{redeemErr: errors.New("database unavailable")},
{setErr: errors.New("settings write failed")},
{createTokenErr: errors.New("token write failed")},
} {
if _, err := performSetupOwner(ctx, store, "https://op.console.example.com", "root"); err == nil {
t.Fatal("want failure")
}
if len(store.tokens) != 0 || len(store.setupIDs) != 0 || len(store.settings) != 0 {
t.Fatal("partial setup")
}
}
})
t.Run("an empty link code mints nothing", func(t *testing.T) {
f := &fakeOwnerStore{}
if _, err := performSetupMCBind(ctx, f, " ", "console.example.com", "root"); 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))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("local auth was enabled without an owner — the gate must not open on a failed bind")
t.Run("settled account is not reset", func(t *testing.T) {
store := &fakeOwnerStore{setupUserID: "existing", setupUsername: "alice", onboarded: true}
out, err := performSetupOwner(ctx, store, "https://op.console.example.com", "root")
if !errors.Is(err, api.ErrConflict) || out.ownerUsername != "alice" || len(store.tokens) != 0 {
t.Fatalf("out = %+v err = %v", out, err)
}
})
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", "console.example.com", "root"); 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))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("local auth was enabled without an owner — the gate must not open on a failed redeem")
t.Run("audit failure still returns the login link", func(t *testing.T) {
out, err := performSetupOwner(ctx, &fakeOwnerStore{auditErr: errors.New("audit down")}, "https://op.console.example.com", "root")
if err != nil || out.auditErr == nil || out.setupTokenURL == "" {
t.Fatalf("out = %+v err = %v", out, err)
}
})
t.Run("a local-auth failure fails the bind rather than minting an unredeemable URL", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", setErr: errors.New("db down")}
if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
t.Fatal("want error when local auth cannot be enabled")
}
if len(f.redeems) != 0 || len(f.tokens) != 0 {
t.Errorf("atomic setup was partially recorded: redeems=%d tokens=%d", len(f.redeems), len(f.tokens))
}
})
t.Run("a token-store failure rolls the bind back", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")}
if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
t.Fatal("want error when the setup token cannot be stored")
}
if len(f.redeems) != 0 {
t.Errorf("link code was consumed despite token failure, got %d redeems", len(f.redeems))
}
if len(f.tokens) != 0 {
t.Errorf("want no recorded token when the store fails, got %d", len(f.tokens))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("local auth stayed enabled despite transaction rollback")
}
})
t.Run("an audit failure does not cost the operator their install", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", auditErr: errors.New("audit sink down")}
out, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root")
if err != nil {
t.Fatalf("an audit failure must not fail the bind: %v", err)
}
if out.auditErr == nil {
t.Error("the audit failure was swallowed instead of surfaced on the outcome")
}
if out.setupTokenURL == "" {
t.Error("no setup URL minted despite a recoverable audit failure")
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled despite a recoverable audit failure")
}
})
t.Run("defaults to 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", " ", "root")
if err != nil {
t.Fatalf("performSetupMCBind: %v", err)
}
// The Owner is staff, so onboarding lands on the operator console, not the
// player panel — the empty-host fallback must reflect that.
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)
t.Run("missing HTTPS origin cannot create an account", func(t *testing.T) {
store := &fakeOwnerStore{}
if _, err := performSetupOwner(ctx, store, "", "root"); err == nil || len(store.tokens) != 0 {
t.Fatal("invalid origin accepted")
}
})
}
+16 -57
View File
@@ -11,7 +11,6 @@ import (
"time"
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/store"
@@ -100,20 +99,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
}
defer setup.drv.Close()
// The wizard's first screen asks the operator to join the server and run /link:
// the Owner IS the Minecraft account, so the login gate must be UP before we ask
// for a link code. This used to run after the wizard, which is why setup asked
// for a code from a server that had never been started. On a re-run the Owner
// already exists, so provisioning stays best-effort and never blocks the
// operator from reaching the status screen.
if err := provisionSystemServers(ctx, setup.cfg, stdout, !setup.adminExists); err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err)
fmt.Fprintln(stderr, "The Owner is bound by joining the login gate in-game, so setup cannot continue without it.")
return 1
}
gameAddr := setupGameAddress(setup.cfg.Server.RootDomain, setup.cfg.Velocity.GamePort)
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), gameAddr, setup.adminExists)
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists)
if err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err)
return 1
@@ -122,22 +108,25 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
if panelURL == "" {
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
}
reportSetupResult(stdout, res, bootstrapped, setup.adminExists, panelURL, gameAddr)
// Game services are provisioned independently. A failed login/lobby must not
// stop the host administrator from opening the panel to diagnose it.
if err := provisionSystemServers(ctx, setup.cfg, stdout); err != nil {
fmt.Fprintf(stdout, "felis setup: Minecraft services need attention: %v\n", err)
}
reportSetupResult(stdout, res, bootstrapped, setup.adminExists, panelURL)
return 0
}
// reportSetupResult prints what the console did, past the alt-screen teardown that
// wipes it. A run that ends with no Owner bound, skipped or quit, ends on how to bind
// one: nobody can sign in to the panel until then.
func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adminExisted bool, panelURL, gameAddr string) {
// reportSetupResult preserves the browser handoff after the TUI closes.
func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adminExisted bool, panelURL string) {
if !adminExisted && !res.provisioned {
defer fmt.Fprintf(stdout, "\nNo Owner is bound yet, so nobody can sign in to the panel. To bind one, run\n"+
" sudo felis setup\nand join %s in Minecraft when it asks.\n", ownerJoinTarget(gameAddr))
defer fmt.Fprintln(stdout, "\nOwner login is unfinished. Run sudo felis setup again to get a panel setup link; Minecraft is not required.")
}
if !res.provisioned && !res.connectConfigured {
if bootstrapped {
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection setup skipped.")
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; panel/connection setup unfinished.")
if panelURL != "" {
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
@@ -150,8 +139,6 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
switch {
case res.alreadySetUp:
msg = "felis setup: already set up — nothing to change."
case res.ownerSkipped:
msg = "felis setup: finished without an Owner."
}
fmt.Fprintln(stdout, msg)
if panelURL != "" {
@@ -164,7 +151,7 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
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.setupTokenURL != "" {
fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
fmt.Fprintf(stdout, "Open this URL to record your email and create a passkey (Minecraft is optional):\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)
@@ -198,20 +185,9 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
// then prints the login-first Velocity wiring. deploy/bootstrap.sh writes this
// configuration for its host proxy; operators only need to mirror it when they
// deliberately run Velocity elsewhere.
//
// required is set on a first run, where the next screen asks the operator to join
// the server and run /link. There a gate that never comes up is not a degraded
// install, it is an impossible one — so every soft landing below becomes a hard
// error and we block until the gate reports Ready. On a re-run the Owner already
// exists and nothing downstream needs the gate, so unconfigured images or an
// unreachable cluster degrade to printed guidance exactly as before.
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer, required bool) error {
// fail is the one place the two modes diverge: fatal on a first run, guidance
// on a re-run.
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer) error {
fail := func(format string, args ...any) error {
if required {
return fmt.Errorf(format, args...)
}
fmt.Fprintf(out, "\nfelis setup: "+format+"\n", args...)
return nil
}
@@ -219,10 +195,6 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
return fail("login/lobby system servers NOT provisioned — set [velocity] login_image " +
"and lobby_image in felis.toml (build them from deploy/limbo and deploy/lobby), then re-run `sudo felis setup`")
}
if required && cfg.Velocity.LoginImage == "" {
return errors.New("the Owner binds by joining the login gate, but [velocity] login_image is not set in felis.toml " +
"(build it from deploy/limbo), then re-run `sudo felis setup`")
}
cl, err := buildSystemServerClient()
if err != nil {
return fail("could not reach the cluster to provision the login/lobby system servers: %v\n"+
@@ -242,7 +214,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
// mount them are created: the login gate's token (login authenticates to
// felis-api with it), the Velocity forwarding secret (every backend verifies the proxy's
// signed handshake with it — without it the login gate would derive an OFFLINE
// UUID and the Owner would bind the wrong Minecraft identity), and felis-config
// UUID and players would bind the wrong Minecraft identity), and felis-config
// (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to
// self-record its world_backups row; without the replica the Job's volume
// mount fails and every backup request strands in the cluster).
@@ -268,19 +240,6 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
fmt.Fprintf(out, " - %s: skipped (%s)\n", o.name, o.skipped)
}
}
if required {
if err := requiredProvisioningError(outcomes); err != nil {
return fmt.Errorf("required Minecraft provisioning failed: %w", err)
}
fmt.Fprintln(out, "\nfelis setup: waiting for the login gate to accept players…")
err := awaitLoginGateReady(ctx, cl, cfg.K8s.Namespace, loginGateReadyTimeout, loginGatePollInterval, func(p v1alpha1.Phase) {
fmt.Fprintf(out, " login: %s\n", phaseOrPending(p))
})
if err != nil {
return err
}
fmt.Fprintln(out, " login: Ready")
}
printVelocityWiringGuidance(out, cfg.Server.RootDomain)
return nil
}
+1 -13
View File
@@ -33,9 +33,6 @@ func setupPanelNodePort() int {
}
func localPanelURL(rootDomain, adminHostname string) string {
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
}
host := defaultAdminHostname(rootDomain, adminHostname)
if host == "" {
return ""
@@ -57,7 +54,7 @@ func rootDomainEmbeddedIP(rootDomain string) string {
return ""
}
// setupGameAddress is where the operator joins in Minecraft to bind the Owner: the
// setupGameAddress is where players join Minecraft: the
// IP a nip.io or sslip.io root domain spells out (nothing to resolve), otherwise the
// root domain, with the port when it is not Minecraft's default. The proxy lands
// every fresh connection on the login server whatever name it was dialled by.
@@ -75,15 +72,6 @@ func setupGameAddress(rootDomain string, gamePort int) string {
return net.JoinHostPort(host, strconv.Itoa(gamePort))
}
// gameAddrIsIP reports whether addr, a host or host:port, names its host by IP.
func gameAddrIsIP(addr string) bool {
host := addr
if h, _, err := net.SplitHostPort(addr); err == nil {
host = h
}
return net.ParseIP(host) != nil
}
func localPanelOrigin() string {
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
}
-92
View File
@@ -4,13 +4,11 @@ import (
"bytes"
"context"
"fmt"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -560,75 +558,6 @@ func convergeDerivedEnv(existing, desired *v1alpha1.MinecraftServer) []string {
return changes
}
// The login gate is a hard prerequisite of the Owner bind, so setup waits for it
// rather than racing it. The ceiling covers a cold image pull on a fresh node;
// the poll is fast enough that a warm start feels immediate.
const (
loginGateReadyTimeout = 5 * time.Minute
loginGatePollInterval = 3 * time.Second
)
// awaitLoginGateReady blocks until the login system server reports status.ready.
//
// The Owner claims their seat by JOINING the game and running /link, so the gate
// being up is not a nicety — it is the precondition for the very next thing setup
// asks of the operator. progress is called on each phase change so the caller can
// show movement during a cold image pull; it may be nil.
func awaitLoginGateReady(ctx context.Context, cl client.Client, namespace string, timeout, poll time.Duration, progress func(v1alpha1.Phase)) error {
key := client.ObjectKey{Namespace: namespace, Name: naming.SystemLoginServer}
deadline := time.Now().Add(timeout)
last := v1alpha1.Phase("")
for {
var ms v1alpha1.MinecraftServer
switch err := cl.Get(ctx, key, &ms); {
case err == nil:
if ms.Status.Ready {
return nil
}
if ms.Status.Phase != last {
last = ms.Status.Phase
if progress != nil {
progress(last)
}
}
// The operator only marks Failed once its OWN startup deadline has already
// elapsed, so Failed is a settled verdict rather than a transient — sitting
// out the rest of our timeout on top of it would only hide the reason.
if ms.Status.Phase == v1alpha1.PhaseFailed {
return fmt.Errorf("the login gate failed to start: %s", readyConditionMessage(&ms))
}
case !apierrors.IsNotFound(err):
return err
}
if !time.Now().Before(deadline) {
return fmt.Errorf("timed out after %s waiting for the login gate to become ready (last phase: %s)", timeout, phaseOrPending(last))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// readyConditionMessage is the operator's own account of why the gate is not
// ready — far more useful to an operator than "phase: Failed".
func readyConditionMessage(ms *v1alpha1.MinecraftServer) string {
if c := meta.FindStatusCondition(ms.Status.Conditions, v1alpha1.ConditionReady); c != nil && c.Message != "" {
return c.Message
}
return "no Ready condition was reported"
}
// phaseOrPending names the empty phase, which means the operator has not
// reconciled the server yet (commonly: the operator itself is not running).
func phaseOrPending(p v1alpha1.Phase) string {
if p == "" {
return "not yet reconciled — is the felis operator running?"
}
return string(p)
}
// provisionSecretReplicas copies the Secrets workload pods mount from the control
// namespace into the namespaces those pods run in. The proxy's felis-service-token
// is not among them: it lives in the control namespace and on the host, and a copy
@@ -794,24 +723,3 @@ func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace
}
return systemServerOutcome{name: name, created: true, available: true}
}
func requiredProvisioningError(outcomes []systemServerOutcome) error {
required := map[string]struct{}{
"limbo-token (minecraft ns)": {},
"forwarding-secret (minecraft ns)": {},
naming.SystemLoginServer: {},
}
for _, o := range outcomes {
if o.err != nil {
return fmt.Errorf("%s: %w", o.name, o.err)
}
if _, ok := required[o.name]; ok && !o.available {
reason := o.skipped
if reason == "" {
reason = "object was not created"
}
return fmt.Errorf("%s unavailable: %s", o.name, reason)
}
}
return nil
}
-93
View File
@@ -4,7 +4,6 @@ import (
"context"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
@@ -332,98 +331,6 @@ func TestEnsureSecretReplicaRefresh(t *testing.T) {
})
}
func TestRequiredProvisioningError(t *testing.T) {
ready := []systemServerOutcome{
{name: "limbo-token (minecraft ns)", available: true},
{name: "forwarding-secret (minecraft ns)", available: true},
{name: naming.SystemLoginServer, available: true},
{name: naming.SystemLobbyServer, skipped: "image not configured"},
}
if err := requiredProvisioningError(ready); err != nil {
t.Fatalf("ready outcomes: %v", err)
}
missing := append([]systemServerOutcome(nil), ready...)
missing[1] = systemServerOutcome{name: "forwarding-secret (minecraft ns)", skipped: "source missing"}
if err := requiredProvisioningError(missing); err == nil || !strings.Contains(err.Error(), "forwarding-secret") {
t.Fatalf("missing forwarding secret = %v, want named error", err)
}
// The login gate cannot reach felis-api without its token, so setup must not
// report success while that replica is missing.
noToken := append([]systemServerOutcome(nil), ready...)
noToken[0] = systemServerOutcome{name: "limbo-token (minecraft ns)", skipped: "source missing"}
if err := requiredProvisioningError(noToken); err == nil || !strings.Contains(err.Error(), "limbo-token") {
t.Fatalf("missing limbo token = %v, want named error", err)
}
failed := append([]systemServerOutcome(nil), ready...)
failed[3] = systemServerOutcome{name: naming.SystemLobbyServer, err: context.DeadlineExceeded}
if err := requiredProvisioningError(failed); err == nil || !strings.Contains(err.Error(), naming.SystemLobbyServer) {
t.Fatalf("lobby create failure = %v, want immediate named error", err)
}
}
// The Owner binds by joining the game, so setup blocks on the login gate rather
// than racing it. What matters is that each ending is distinguishable: Ready
// proceeds, Failed reports the operator's own reason instead of waiting out the
// clock, and a gate that never appears (no operator reconciling it) times out
// saying so rather than dropping the operator on a bind screen that cannot work.
func TestAwaitLoginGateReady(t *testing.T) {
scheme := newSystemServerScheme(t)
ctx := context.Background()
gate := func(mut func(*v1alpha1.MinecraftServer)) *v1alpha1.MinecraftServer {
ms := &v1alpha1.MinecraftServer{
ObjectMeta: metav1.ObjectMeta{Name: naming.SystemLoginServer, Namespace: "minecraft"},
}
mut(ms)
return ms
}
t.Run("returns once the gate is ready", func(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gate(func(ms *v1alpha1.MinecraftServer) {
ms.Status.Phase = v1alpha1.PhaseRunning
ms.Status.Ready = true
})).Build()
if err := awaitLoginGateReady(ctx, cl, "minecraft", time.Second, 10*time.Millisecond, nil); err != nil {
t.Fatalf("await: %v", err)
}
})
t.Run("fails fast on Failed, carrying the operator's reason", func(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gate(func(ms *v1alpha1.MinecraftServer) {
ms.Status.Phase = v1alpha1.PhaseFailed
ms.Status.Conditions = []metav1.Condition{{
Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: "StartupTimeout",
Message: "pod never became ready: ImagePullBackOff",
LastTransitionTime: metav1.Now(),
}}
})).Build()
start := time.Now()
err := awaitLoginGateReady(ctx, cl, "minecraft", time.Minute, 10*time.Millisecond, nil)
if err == nil {
t.Fatal("await: nil error, want failure")
}
if !strings.Contains(err.Error(), "ImagePullBackOff") {
t.Errorf("error = %q, want the operator's Ready-condition message", err)
}
if time.Since(start) > 5*time.Second {
t.Error("await sat out the full timeout on a settled Failed verdict")
}
})
t.Run("times out when nothing ever reconciles the gate", func(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
err := awaitLoginGateReady(ctx, cl, "minecraft", 30*time.Millisecond, 10*time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("await = %v, want a timeout", err)
}
})
}
func newSystemServerScheme(t *testing.T) *runtime.Scheme {
t.Helper()
scheme := runtime.NewScheme()
+1
View File
@@ -104,6 +104,7 @@ func TestRootSummaryReadsAlertRoute(t *testing.T) {
return route
}
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
sum, ok := m.screen.(*summaryModel)
if !ok || sum.alerts == nil || sum.alerts.relay != "" {
t.Fatalf("summary after storage: %T %+v", m.screen, sum)
-326
View File
@@ -1,326 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"felis.lolicon.best/internal/api"
)
// mcBindModel is the `felis setup` Owner-establishment screen: the operator
// joins the server, reads the one-time code the login server shows, 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.
//
// The screen says where to join and what the code looks like, because the
// operator arrives here straight from the installer with nothing else to go on.
// A refused code returns to the form with the reason: CompleteOwnerSetup rolls
// back on every failure, so another try is always safe. An empty code offers to
// skip, and setup goes on to the connection and storage steps without an Owner.
type mcBindModel struct {
ctx context.Context
store ownerStore
adminHost string
osUser string
gameAddr string // where to join in Minecraft; "" names no address
step mcBindStep
form *huh.Form
sp spinner.Model
working string
linkCode string
skip bool // the skip confirmation's answer
note string // why the last code was refused, shown above the rebuilt form
ownerIdentity string
setupTokenURL string
auditWarning string
width, height int
}
// ownerSkippedMsg leaves the Owner step without binding one.
type ownerSkippedMsg struct{}
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, gameAddr string) *mcBindModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
m := &mcBindModel{
ctx: ctx,
store: store,
adminHost: adminHost,
osUser: osUser,
gameAddr: gameAddr,
sp: sp,
step: mcBindForm,
}
m.form = m.buildForm()
return m
}
// buildForm starts the code entry afresh, with the last refusal (if any) on top.
func (m *mcBindModel) buildForm() *huh.Form {
m.linkCode, m.skip = "", false
desc := m.instructions()
if m.note != "" {
desc = m.note + "\n\n" + desc
}
return m.sized(newFelisForm(
huh.NewGroup(
huh.NewNote().
Title("Bind the Owner's Minecraft account").
Description(desc),
huh.NewInput().
Title("Link code").
Description("Leave it empty and press enter to skip this step for now.").
Placeholder("K7M2QX9P").
Value(&m.linkCode).
Validate(validLinkCode),
),
// Asked only for an empty code, so an enter pressed too early cannot skip.
huh.NewGroup(
huh.NewConfirm().
Title("Skip the Owner for now?").
Description("Setup goes on to the connection and storage steps. Nobody can sign in\n"+
"to the panel until an Owner is bound: run sudo felis setup again to bind one.").
Affirmative("Skip for now").
Negative("Enter a code").
Value(&m.skip),
).WithHideFunc(func() bool { return normalizeLinkCode(m.linkCode) != "" }),
))
}
// instructions is the way to a code, for an operator who has only this screen.
func (m *mcBindModel) instructions() string {
join := ownerJoinTarget(m.gameAddr)
if m.gameAddr != "" && !gameAddrIsIP(m.gameAddr) {
join += "\n (or this host's IP address while that name does not point here yet)"
}
return fmt.Sprintf("The Minecraft account you bind becomes the Owner and signs in to the\n"+
"panel without a password.\n\n"+
"1. In Minecraft (Java Edition), join %s\n"+
"2. The login server opens a book with your link code; chat shows it too.\n"+
" It is %d characters and works for %d minutes. /link prints a new one.\n"+
"3. Type the code below. The web link in the book is for players: the\n"+
" Owner's code goes here.",
join, api.LinkCodeLen, int(api.LinkCodeTTL/time.Minute))
}
// ownerJoinTarget names where to join in Minecraft to bind the Owner.
func ownerJoinTarget(gameAddr string) string {
if gameAddr == "" {
return "this server"
}
return gameAddr
}
// normalizeLinkCode is a code as the store keeps it: upper case, without the
// spaces or dashes an operator may type to group it.
func normalizeLinkCode(s string) string {
return strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(s)))
}
// validLinkCode catches a mistyped code before it costs a round trip. Empty is
// valid: it asks to skip.
func validLinkCode(s string) error {
code := normalizeLinkCode(s)
if code == "" {
return nil
}
if n := utf8.RuneCountInString(code); n != api.LinkCodeLen {
return fmt.Errorf("a link code is %d characters; this is %d", api.LinkCodeLen, n)
}
for _, c := range code {
if !strings.ContainsRune(api.LinkCodeAlphabet, c) {
return fmt.Errorf("a link code never contains %q (codes leave out I, O, 0 and 1)", c)
}
}
return nil
}
// bindFailureNote says why a code was refused and what to do next.
func bindFailureNote(err error) string {
if errors.Is(err, api.ErrLinkCodeInvalid) {
return fmt.Sprintf("✗ That code was not accepted: it is mistyped, older than %d minutes, or\n"+
" already used. Type /link in Minecraft for a new one.", int(api.LinkCodeTTL/time.Minute))
}
return "✗ Binding failed: " + err.Error() + "\n Nothing was changed; try again."
}
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 {
m.step = mcBindForm
m.note = bindFailureNote(msg.err)
m.form = m.buildForm()
return m, m.form.Init()
}
m.note = ""
m.step = mcBindDone
m.ownerIdentity = msg.outcome.ownerIdentity
m.setupTokenURL = msg.outcome.setupTokenURL
if msg.outcome.auditErr != nil {
m.auditWarning = msg.outcome.auditErr.Error()
}
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) {
code := normalizeLinkCode(m.linkCode)
if code == "" {
if m.skip {
return m, func() tea.Msg { return ownerSkippedMsg{} }
}
// "Enter a code": back to the entry, keeping any refusal on screen.
m.form = m.buildForm()
return m, m.form.Init()
}
m.step = mcBindWorking
m.working = "Binding Minecraft account…"
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser)
return mcBindMsg{outcome: out, err: err}
})
}
func (m *mcBindModel) resultCmd() tea.Cmd {
return func() tea.Msg {
return ownerResultMsg{
username: m.ownerIdentity,
setupTokenURL: m.setupTokenURL,
mode: "setup",
accountable: m.osUser,
auditWarning: m.auditWarning,
}
}
}
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.ownerIdentity != "" {
box.WriteString(tuiLabel.Render("minecraft ") + m.ownerIdentity + "\n")
}
if m.setupTokenURL != "" {
if box.Len() > 0 {
box.WriteString("\n")
}
box.WriteString(tuiLabel.Render("setup URL ") + "\n")
for _, line := range wrapDisplayURL(m.setupTokenURL, 70) {
box.WriteString(tuiPassword.Render(line) + "\n")
}
box.WriteString("\n")
box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup.\nIt is shown only once."))
}
if m.auditWarning != "" {
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
}
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
b.WriteString(tuiAction("enter", "continue"))
return b.String()
}
-334
View File
@@ -1,334 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"felis.lolicon.best/internal/api"
)
// cmdMsgs runs cmd and the commands of any batch it returns, and collects the
// messages.
func cmdMsgs(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if batch, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range batch {
out = append(out, cmdMsgs(c)...)
}
return out
}
return []tea.Msg{msg}
}
// settle hands m the messages cmd produces, as the program would, and returns
// them. A huh form draws its fields only once it has handled a message.
func settle(m *mcBindModel, cmd tea.Cmd) []tea.Msg {
msgs := cmdMsgs(cmd)
for _, msg := range msgs {
m.Update(msg)
}
return msgs
}
// openBind is the bind screen as the wizard first shows it.
func openBind(store ownerStore, gameAddr string) *mcBindModel {
m := newMCBindModel(context.Background(), store, "console.example.com", "root", gameAddr)
m.setSize(100, 60)
settle(m, m.Init())
return m
}
// leavesBindScreen reports whether any of msgs ends the Owner step.
func leavesBindScreen(msgs []tea.Msg) bool {
for _, msg := range msgs {
switch msg.(type) {
case ownerResultMsg, ownerSkippedMsg, tea.QuitMsg:
return true
}
}
return false
}
func TestMCBindSaysWhereToJoinAndWhatTheCodeIs(t *testing.T) {
view := openBind(&fakeOwnerStore{}, "10.0.0.5").View()
for _, want := range []string{"join 10.0.0.5", "8 characters", "10 minutes", "/link", "Leave it empty"} {
if !strings.Contains(view, want) {
t.Errorf("bind screen does not say %q:\n%s", want, view)
}
}
if strings.Contains(view, "IP address while") {
t.Errorf("an IP address needs no IP fallback:\n%s", view)
}
named := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "play.example.net:25570")
if got := named.instructions(); !strings.Contains(got, "join play.example.net:25570\n (or this host's IP address") {
t.Errorf("a hostname should come with the IP fallback:\n%s", got)
}
unnamed := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "")
if got := unnamed.instructions(); !strings.Contains(got, "join this server\n") {
t.Errorf("no address should still say where to join:\n%s", got)
}
}
func TestValidLinkCode(t *testing.T) {
for _, tc := range []struct {
in, wantErr string
}{
{"", ""}, // skip
{" ", ""},
{"K7M2QX9P", ""},
{"k7m2qx9p", ""},
{" K7M2-QX9P ", ""},
{"K7M2 QX9P", ""},
{"ABCD12", "a link code is 8 characters; this is 6"},
{"K7M2QX9PZ", "a link code is 8 characters; this is 9"},
{"K7M2QX9O", `never contains 'O'`},
{"K7M2QX90", `never contains '0'`},
{"K7M2QX9É", `never contains 'É'`},
} {
err := validLinkCode(tc.in)
switch {
case tc.wantErr == "" && err != nil:
t.Errorf("validLinkCode(%q) = %v, want nil", tc.in, err)
case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)):
t.Errorf("validLinkCode(%q) = %v, want an error with %q", tc.in, err, tc.wantErr)
}
}
}
// A refused code is the operator's most likely mistake; it must leave them on the
// form with the reason, never end setup.
func TestMCBindRefusedCodeStaysOnTheForm(t *testing.T) {
store := &fakeOwnerStore{redeemErr: api.ErrLinkCodeInvalid}
m := openBind(store, "10.0.0.5")
m.linkCode = "k7m2-qx9p"
_, cmd := m.onFormComplete()
if m.step != mcBindWorking {
t.Fatalf("step = %v after submit, want mcBindWorking", m.step)
}
var result tea.Msg
for _, msg := range cmdMsgs(cmd) {
if r, ok := msg.(mcBindMsg); ok {
result = r
}
}
if result == nil {
t.Fatal("submitting a code did not try to bind it")
}
next, cmd := m.Update(result)
if next != m || m.step != mcBindForm {
t.Fatalf("after a refused code: model %T step %v, want the bind form", next, m.step)
}
if leavesBindScreen(settle(m, cmd)) {
t.Fatal("a refused code ended the Owner step")
}
view := m.View()
for _, want := range []string{"That code was not accepted", "older than 10 minutes", "Type /link", "join 10.0.0.5"} {
if !strings.Contains(view, want) {
t.Errorf("refused-code form does not say %q:\n%s", want, view)
}
}
if m.linkCode != "" {
t.Errorf("the refused code %q is still in the field", m.linkCode)
}
// The next code goes through, and the refusal leaves with it.
store.redeemErr = nil
m.linkCode = "K7M2QX9P"
_, cmd = m.onFormComplete()
for _, msg := range cmdMsgs(cmd) {
if r, ok := msg.(mcBindMsg); ok {
m.Update(r)
}
}
if m.step != mcBindDone {
t.Fatalf("step = %v after a good code, want mcBindDone", m.step)
}
if got := store.redeems[len(store.redeems)-1].code; got != "K7M2QX9P" {
t.Errorf("bound code %q, want K7M2QX9P", got)
}
}
func TestMCBindOtherFailureStaysOnTheForm(t *testing.T) {
m := openBind(&fakeOwnerStore{}, "10.0.0.5")
_, cmd := m.Update(mcBindMsg{err: fmt.Errorf("complete owner setup: %w", errors.New("connection refused"))})
if m.step != mcBindForm || leavesBindScreen(settle(m, cmd)) {
t.Fatalf("a failed bind left the form: step %v", m.step)
}
view := m.View()
for _, want := range []string{"Binding failed: complete owner setup: connection refused", "Nothing was changed"} {
if !strings.Contains(view, want) {
t.Errorf("failed-bind form does not say %q:\n%s", want, view)
}
}
}
// An empty code skips only once the operator confirms; "Enter a code" goes back.
func TestMCBindEmptyCodeSkipsOnlyWhenConfirmed(t *testing.T) {
m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "10.0.0.5")
m.linkCode, m.skip = " ", false
_, cmd := m.onFormComplete()
if m.step != mcBindForm || leavesBindScreen(cmdMsgs(cmd)) {
t.Fatalf("declining the skip left the form: step %v", m.step)
}
m.linkCode, m.skip = "", true
_, cmd = m.onFormComplete()
skipped := false
for _, msg := range cmdMsgs(cmd) {
if _, ok := msg.(ownerSkippedMsg); ok {
skipped = true
}
}
if !skipped {
t.Fatal("a confirmed skip did not leave the Owner step")
}
}
func TestRootGoesOnWhenTheOwnerIsSkipped(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m.gameAddr = "10.0.0.5"
m = drive(t, m, preflightDoneMsg{})
bind, ok := m.screen.(*mcBindModel)
if !ok || bind.gameAddr != "10.0.0.5" {
t.Fatalf("bind screen = %T without the game address", m.screen)
}
m = drive(t, m, ownerSkippedMsg{})
if m.stage != stageConnect {
t.Fatalf("after skipping, stage = %v, want stageConnect", m.stage)
}
if _, ok := m.screen.(*connectChooserModel); !ok {
t.Fatalf("after skipping, screen = %T, want *connectChooserModel", m.screen)
}
if !m.result.ownerSkipped || m.result.provisioned {
t.Fatalf("skip not recorded: %+v", m.result)
}
if got := m.reviewBody(int(stageOwner)); !strings.Contains(got, "Owner account skipped") {
t.Errorf("review of the Owner step = %q", got)
}
m = drive(t, m, connectResultMsg{method: connectLocal})
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"})
sum, ok := m.screen.(*summaryModel)
if !ok {
t.Fatalf("screen = %T, want *summaryModel", m.screen)
}
view := sum.View()
for _, want := range []string{"Setup finished without an Owner", "not bound", "run sudo felis setup again and join 10.0.0.5"} {
if !strings.Contains(view, want) {
t.Errorf("summary does not say %q:\n%s", want, view)
}
}
for _, unwanted := range []string{"Setup complete", "You won't need this console again"} {
if strings.Contains(view, unwanted) {
t.Errorf("summary without an Owner says %q:\n%s", unwanted, view)
}
}
}
func TestReportSetupResultWithoutAnOwner(t *testing.T) {
const hint = "No Owner is bound yet, so nobody can sign in to the panel."
const bindLast = hint + " To bind one, run\n sudo felis setup\nand join 10.0.0.5 in Minecraft when it asks.\n"
report := func(res breakGlassResult, adminExisted bool) string {
var b bytes.Buffer
reportSetupResult(&b, res, false, adminExisted, "https://10.0.0.5:30443", "10.0.0.5")
return b.String()
}
out := report(breakGlassResult{ownerSkipped: true, connectMethod: connectLocal}, false)
if !strings.Contains(out, "finished without an Owner") || strings.Contains(out, "cancelled") {
t.Errorf("a skipped Owner reads as:\n%s", out)
}
if !strings.HasSuffix(out, bindLast) {
t.Errorf("a skipped Owner does not end on how to bind one:\n%s", out)
}
out = report(breakGlassResult{}, false)
if !strings.Contains(out, "cancelled — no changes made.") || !strings.HasSuffix(out, bindLast) {
t.Errorf("quitting before an Owner is bound reads as:\n%s", out)
}
out = report(breakGlassResult{ownerSkipped: true, connectMethod: connectReverseProxy, connectConfigured: true, reverseProxyGuide: "proxy guide"}, false)
if !strings.Contains(out, "proxy guide") || !strings.HasSuffix(out, bindLast) {
t.Errorf("a skipped Owner with a configured front reads as:\n%s", out)
}
for name, res := range map[string]breakGlassResult{
"re-run": {alreadySetUp: true},
"provisioned": {provisioned: true, username: "mc-uuid-1"},
} {
adminExisted := name == "re-run"
if out := report(res, adminExisted); strings.Contains(out, hint) {
t.Errorf("%s: says no Owner is bound:\n%s", name, out)
}
}
}
func TestSetupGameAddress(t *testing.T) {
for _, tc := range []struct {
root string
port int
want string
}{
{"10.211.55.6.nip.io", 0, "10.211.55.6"},
{"10.211.55.6.nip.io", 25565, "10.211.55.6"},
{"10.211.55.6.sslip.io.", 25570, "10.211.55.6:25570"},
{"play.example.net", 0, "play.example.net"},
{"play.example.net", 25570, "play.example.net:25570"},
{"", 25570, ""},
} {
if got := setupGameAddress(tc.root, tc.port); got != tc.want {
t.Errorf("setupGameAddress(%q, %d) = %q, want %q", tc.root, tc.port, got, tc.want)
}
}
for addr, want := range map[string]bool{
"10.0.0.5": true, "10.0.0.5:25570": true, "play.example.net": false, "play.example.net:25570": false,
} {
if got := gameAddrIsIP(addr); got != want {
t.Errorf("gameAddrIsIP(%q) = %v, want %v", addr, got, want)
}
}
}
// press sends key to m and runs a few rounds of the commands that follow, as the
// program would, so huh can move between fields and groups.
func press(m *mcBindModel, key tea.KeyMsg) {
_, cmd := m.Update(key)
for round := 0; round < 4 && cmd != nil; round++ {
var next []tea.Cmd
for _, msg := range cmdMsgs(cmd) {
if _, c := m.Update(msg); c != nil {
next = append(next, c)
}
}
cmd = tea.Batch(next...)
}
}
// The skip question comes only for an empty code: a typed code goes straight to
// the bind.
func TestMCBindFormAsksBeforeSkipping(t *testing.T) {
m := openBind(&fakeOwnerStore{}, "10.0.0.5")
press(m, tea.KeyMsg{Type: tea.KeyEnter})
if view := m.View(); m.step != mcBindForm || !strings.Contains(view, "Skip the Owner for now?") {
t.Fatalf("enter on an empty code should ask before skipping: step %v\n%s", m.step, view)
}
m = openBind(&fakeOwnerStore{}, "10.0.0.5")
press(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("K7M2QX9P")})
press(m, tea.KeyMsg{Type: tea.KeyEnter})
if m.step == mcBindForm {
t.Fatalf("a typed code did not go to the bind:\n%s", m.View())
}
}
+7 -16
View File
@@ -211,23 +211,14 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
}
}
func TestMCBindCarriesAuditWarning(t *testing.T) {
m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "10.0.0.5")
next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{
ownerIdentity: "mc-uuid-1",
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
auditErr: errors.New("audit insert failed"),
func TestSetupOwnerCarriesAuditWarning(t *testing.T) {
m := newSetupOwnerModel(context.Background(), &fakeOwnerStore{}, "https://op.console.example.com", "root")
_, cmd := m.Update(setupOwnerMsg{outcome: breakGlassOutcome{
ownerUsername: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", auditErr: errors.New("audit insert failed"),
}})
bound := next.(*mcBindModel)
if !strings.Contains(bound.doneView(), "audit insert failed") {
t.Fatalf("done view did not surface audit warning:\n%s", bound.doneView())
}
res := bound.resultCmd()().(ownerResultMsg)
if res.username != "mc-uuid-1" {
t.Fatalf("result username = %q, want verified Minecraft UUID", res.username)
}
if res.auditWarning != "audit insert failed" {
t.Fatalf("result audit warning = %q", res.auditWarning)
res := cmd().(ownerResultMsg)
if res.username != "owner" || res.auditWarning != "audit insert failed" {
t.Fatalf("result = %+v", res)
}
}
+61 -46
View File
@@ -94,9 +94,9 @@ type wizardStage int
const (
stagePreflight wizardStage = iota
stageOwner
stageConnect
stageStorage
stageOwner
stageSummary
// stageMenu is the break-glass operation menu. It is appended last so the
// setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed
@@ -109,7 +109,7 @@ const (
// and the post-install wizard owns cells 1–4. Defining it once keeps the two
// programs' breadcrumbs identical so the rail reads as a single continuous bar
// rather than restarting when the wizard takes over.
var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Storage", "Done"}
var setupRailSteps = []string{"Bootstrap", "Preflight", "Connection", "Storage", "Panel login", "Done"}
type rootModel struct {
ctx context.Context
@@ -142,7 +142,6 @@ type rootModel struct {
panelHost string
accessAud string
namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op
gameAddr string // where to join in Minecraft to bind the Owner (setupGameAddress)
adminExists bool
recovery recoveryConfig // how the account operations mail a recovery code
// alertRoute reads where the watchdog's alerts go for the summary; nil
@@ -212,14 +211,14 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case preflightDoneMsg:
if m.adminExists {
// Re-run: setup already happened. Land on the status screen.
return m.showStatus()
return m.startOwnerSetup()
}
m.stage = stageOwner
if m.mode == consoleModeSetup {
return m.adopt(newMCBindModel(m.ctx, m.store, defaultAdminHostname(m.rootDomain, m.adminHost), m.osUser, m.gameAddr))
}
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
case setupReadyMsg:
m.result.username = msg.username
return m.showStatus()
case menuChoiceMsg:
// The break-glass menu picked an account operation; build its screen. Both reuse
@@ -290,21 +289,19 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
m.adminExists = true
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
case ownerSkippedMsg:
// The rest of the wizard needs no Owner; the summary and the exit message say
// how to come back and bind one.
m.result.ownerSkipped = true
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
if m.result.alreadySetUp {
return m.showStatus()
}
return m.showSummary()
case connectResultMsg:
m.applyConnectResult(msg)
if m.reconfiguringConnect {
// Changing only the connection — storage is already set, so skip it.
m.reconfiguringConnect = false
if m.result.setupTokenURL != "" {
return m.startOwnerSetup()
}
return m.showSummary()
}
m.stage = stageStorage
@@ -313,6 +310,9 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case storageResultMsg:
m.result.storageMethod = msg.method
m.result.storageDetail = msg.detail
if !m.adminExists {
return m.startOwnerSetup()
}
return m.showSummary()
case storageBackMsg:
@@ -423,7 +423,7 @@ func (m *rootModel) displayStage() int {
// reviewBody renders a read-only recap of an already-completed step. Steps in
// this wizard commit as you finish them (the Owner account and its one-time
// password are created on submit), so review is deliberately look-only — there
// login link are created on submit), so review is deliberately look-only — there
// is no re-editing a step you've passed.
func (m *rootModel) reviewBody(stage int) string {
var b strings.Builder
@@ -432,16 +432,8 @@ func (m *rootModel) reviewBody(stage int) string {
b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
b.WriteString(tuiHint.Render("Control plane verified before configuration."))
case stageOwner:
if m.result.ownerSkipped {
b.WriteString(tuiWarn.Render("– Owner account skipped") + "\n")
b.WriteString(tuiHint.Render("Run sudo felis setup again to bind it."))
break
}
b.WriteString(tuiOK.Render("✓ Owner account") + "\n")
if m.result.username != "" {
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
}
b.WriteString(tuiHint.Render("Created and recorded. The one-time setup URL was shown on the Owner step."))
b.WriteString(tuiOK.Render("✓ Panel login") + "\n")
b.WriteString(tuiHint.Render("Open the one-time setup link in the summary to create your passkey."))
case stageConnect:
b.WriteString(tuiOK.Render("✓ Connection") + "\n")
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n")
@@ -504,10 +496,9 @@ func (m *rootModel) View() string {
// adminExistsAtStart reports whether this run began with an Owner already
// present (a re-run). The rail only makes sense for the first-run linear wizard.
func (m *rootModel) adminExistsAtStart() bool {
// adminExists flips true once we provision the Owner mid-run; the rail should
// keep showing through the connect/summary stages of that same first run. So
// only suppress the rail when the Owner pre-existed AND we never provisioned.
return m.adminExists && !m.result.provisioned
// First-run creation flips adminExists, but must keep its rail. Resuming an
// unfinished browser login is still a re-run even though it renews a token.
return m.result.alreadySetUp || (m.adminExists && !m.result.provisioned)
}
func (m *rootModel) rail() string {
@@ -554,7 +545,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) {
m.result.connectConfigured = true
m.result.reverseProxyGuide = msg.guide
}
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain, m.adminHost)
m.result.panelURL = panelURLFor(msg.method, m.rootDomain, m.result.adminHostname)
}
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
@@ -566,9 +557,8 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
ownerUsername: m.result.username,
ownerSkipped: m.result.ownerSkipped,
gameAddr: m.gameAddr,
setupTokenURL: m.result.setupTokenURL,
auditWarning: m.result.auditWarning,
accessLabel: connectMethodLabel(m.result.connectMethod),
storageLabel: m.result.storageDetail,
routedHosts: routed,
@@ -578,6 +568,28 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
})
}
func (m *rootModel) startOwnerSetup() (tea.Model, tea.Cmd) {
m.stage = stageOwner
method := m.result.connectMethod
adminHost := defaultAdminHostname(m.rootDomain, m.adminHost)
if m.result.adminHostname != "" {
adminHost = m.result.adminHostname
}
if m.adminExists && !m.result.provisioned {
m.result.alreadySetUp = true
if m.accessAud != "" {
method = connectCloudflare
}
}
base := "https://" + adminHost
if method == connectLocal {
base = localPanelURL(m.rootDomain, adminHost)
}
model := newSetupOwnerModel(m.ctx, m.store, base, m.osUser)
model.probe = func() error { return checkPanelAccess(m.rootDomain, adminHost).err }
return m.adopt(model)
}
// showStatus is the re-run landing: prove the backend is up, then point the
// operator at the panel without forcing any reconfiguration.
func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
@@ -589,13 +601,16 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
method = connectCloudflare
accessLabel = connectMethodLabel(connectCloudflare)
}
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain, m.adminHost)
m.result.panelURL = panelURLFor(method, m.rootDomain, m.result.adminHostname)
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
accessLabel: accessLabel,
alreadySetUp: true,
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
alerts: m.readAlertRoute(),
panelURL: m.result.panelURL,
ownerUsername: m.result.username,
setupTokenURL: m.result.setupTokenURL,
auditWarning: m.result.auditWarning,
accessLabel: accessLabel,
alreadySetUp: true,
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
alerts: m.readAlertRoute(),
})
}
@@ -609,9 +624,9 @@ func (m *rootModel) readAlertRoute() *alertRoute {
return &r
}
func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string {
if method != connectLocal && panelHostname != "" {
return "https://" + panelHostname
func panelURLFor(method connectMethod, rootDomain, adminHostname string) string {
if method != connectLocal {
return "https://" + defaultAdminHostname(rootDomain, adminHostname)
}
return localPanelURL(rootDomain, adminHostname)
}
+46 -132
View File
@@ -46,94 +46,38 @@ func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootMode
func TestRootSetupHappyPath(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
// First-run setup begins at preflight.
if m.stage != stagePreflight {
t.Fatalf("initial stage = %v, want stagePreflight", m.stage)
}
if _, ok := m.screen.(*preflightModel); !ok {
t.Fatalf("initial screen = %T, want *preflightModel", m.screen)
}
// 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.(*mcBindModel); !ok {
t.Fatalf("after preflight, screen = %T, want *mcBindModel", m.screen)
}
// Owner provisioned → Connection chooser.
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)
t.Fatalf("stage = %v, want Connection", 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.setupTokenURL != "https://op.console.example.com/setup?token=t0ken" {
t.Fatalf("owner result not recorded: %+v", m.result)
}
// Reverse-proxy chosen → Storage chooser, with the connection recorded.
guide := "caddy config…"
m = drive(t, m, connectResultMsg{
method: connectReverseProxy,
panelHostname: "panel.felis.example.com",
adminHostname: "admin.felis.example.com",
guide: guide,
})
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", adminHostname: "new-admin.felis.example.com", guide: "caddy…"})
if m.stage != stageStorage {
t.Fatalf("after connect, stage = %v, want stageStorage", m.stage)
t.Fatalf("stage = %v, want Storage", m.stage)
}
if _, ok := m.screen.(*storageChooserModel); !ok {
t.Fatalf("after connect, screen = %T, want *storageChooserModel", m.screen)
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
owner, ok := m.screen.(*setupOwnerModel)
if !ok || owner.panelURL != "https://new-admin.felis.example.com" {
t.Fatalf("owner setup = %#v", m.screen)
}
if !m.result.connectConfigured {
t.Fatalf("connectConfigured not set")
}
if m.result.connectMethod != connectReverseProxy {
t.Fatalf("connectMethod = %v, want connectReverseProxy", m.result.connectMethod)
}
if m.result.reverseProxyGuide != guide {
t.Fatalf("reverseProxyGuide = %q, want %q", m.result.reverseProxyGuide, guide)
}
// Storage chosen → Summary, with both the connection and storage recorded.
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket · minio:9000"})
if m.stage != stageSummary {
t.Fatalf("after storage, stage = %v, want stageSummary", m.stage)
if m.result.provisioned {
t.Fatal("Owner must not be minted before configuration")
}
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://new-admin.felis.example.com/setup?token=t0ken"})
sum, ok := m.screen.(*summaryModel)
if !ok {
t.Fatalf("after storage, screen = %T, want *summaryModel", m.screen)
if !ok || sum.setupTokenURL != m.result.setupTokenURL || sum.panelURL != "https://new-admin.felis.example.com" || sum.storageLabel != "s3://bucket" {
t.Fatalf("summary = %#v", m.screen)
}
if m.result.storageMethod != storageS3 || m.result.storageDetail == "" {
t.Fatalf("storage result not recorded: %+v", m.result)
}
if sum.storageLabel != m.result.storageDetail {
t.Fatalf("summary storageLabel = %q, want %q", sum.storageLabel, m.result.storageDetail)
}
if want := "https://panel.felis.example.com"; sum.panelURL != want {
t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want)
}
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")
if !strings.Contains(sum.View(), "Finish Owner login") || !strings.Contains(sum.View(), "Minecraft can be linked later") {
t.Fatal(sum.View())
}
}
func TestRootSetupLocalSummary(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner"})
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
m = drive(t, m, ownerResultMsg{username: "owner"})
sum, ok := m.screen.(*summaryModel)
if !ok {
@@ -155,9 +99,9 @@ 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", 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"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
}
@@ -174,6 +118,10 @@ func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
// Completing it returns straight to the summary — NOT the storage chooser —
// with the original storage recap intact.
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", guide: "caddy…"})
if _, ok := m.screen.(*setupOwnerModel); !ok {
t.Fatalf("pending link should refresh, screen = %T", m.screen)
}
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com/setup?token=fresh"})
if m.stage != stageSummary {
t.Fatalf("after reconfigure connect, stage = %v, want stageSummary", m.stage)
}
@@ -195,9 +143,9 @@ func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner"})
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
m = drive(t, m, ownerResultMsg{username: "owner"})
if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
}
@@ -232,6 +180,7 @@ func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
func TestRootReconfigureSMTP(t *testing.T) {
m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, setupReadyMsg{username: "owner"})
if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
}
@@ -257,6 +206,7 @@ func TestRootReconfigureSMTP(t *testing.T) {
func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, setupReadyMsg{username: "owner"})
if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
}
@@ -280,23 +230,27 @@ func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
}
func TestRootRerunLandsOnStatus(t *testing.T) {
// adminExists at start of a setup run = re-run: preflight should skip straight
// to the "manage in panel" status screen, never touching owner/connect.
m := newTestRoot(true, consoleModeSetup, "")
if _, ok := m.screen.(*preflightModel); !ok {
t.Fatalf("re-run initial screen = %T, want *preflightModel", m.screen)
}
m = drive(t, m, preflightDoneMsg{})
sum, ok := m.screen.(*summaryModel)
if !ok {
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
if _, ok := m.screen.(*setupOwnerModel); !ok {
t.Fatalf("screen = %T", m.screen)
}
if !sum.alreadySetUp {
t.Fatalf("re-run summary should be marked alreadySetUp")
m = drive(t, m, setupReadyMsg{username: "owner"})
if m.stage != stageSummary || !m.result.alreadySetUp || m.result.provisioned {
t.Fatalf("result = %+v", m.result)
}
if m.result.provisioned {
t.Fatalf("re-run must not provision an owner")
}
func TestRootRerunResumesUnfinishedLogin(t *testing.T) {
m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com:30443/setup?token=new"})
sum := m.screen.(*summaryModel)
if sum.setupTokenURL == "" || !sum.alreadySetUp {
t.Fatalf("summary = %+v", sum)
}
if strings.Contains(m.View(), "Bootstrap") {
t.Fatal("renewing a login link restarted the deployment rail")
}
}
@@ -342,58 +296,18 @@ func key(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
func TestRootRailReviewNavigation(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{})
// On the Owner screen (text inputs) ← must NOT hijack the arrow: it stays
// with the field, so we remain on the live screen.
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != -1 {
t.Fatalf("← on the owner (text-input) screen entered review (%d); arrows belong to the field", m.reviewing)
}
// Advance to the Connection chooser (a select — it yields ←/→).
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)
}
// ← walks back to Owner (read-only recap), then Preflight, then clamps.
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stageOwner) {
t.Fatalf("first ← = stage %d, want stageOwner %d", m.reviewing, stageOwner)
}
if v := m.View(); !strings.Contains(v, "Owner account") || !strings.Contains(v, "username") {
t.Fatalf("owner review body missing recap, got:\n%s", v)
}
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stagePreflight) {
t.Fatalf("second ← = stage %d, want stagePreflight %d", m.reviewing, stagePreflight)
}
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stagePreflight) {
t.Fatalf("← past the first step should clamp, got %d", m.reviewing)
}
// → walks forward; stepping past the last completed step returns to live.
m = drive(t, m, key(tea.KeyRight))
if m.reviewing != int(stageOwner) {
t.Fatalf("→ = stage %d, want stageOwner %d", m.reviewing, stageOwner)
if m.reviewing != int(stagePreflight) || !strings.Contains(m.View(), "Control plane verified") {
t.Fatal("connection review should return to preflight")
}
m = drive(t, m, key(tea.KeyRight))
if m.reviewing != -1 {
t.Fatalf("→ past the last completed step should return live, reviewing = %d", m.reviewing)
t.Fatal("right should return to live connection chooser")
}
if v := m.View(); !strings.Contains(v, "reach the panel") {
t.Fatalf("returning live should show the chooser, got:\n%s", v)
}
// esc is an immediate escape hatch back to the live screen.
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing < 0 {
t.Fatalf("← should re-enter review")
}
m = drive(t, m, key(tea.KeyEsc))
if m.reviewing != -1 {
t.Fatalf("esc should return to the live screen, reviewing = %d", m.reviewing)
t.Fatal("escape should return to live screen")
}
}
@@ -411,8 +325,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.(*mcBindModel); !ok {
t.Fatalf("expected MC-bind screen after preflight, got %T", m.screen)
if _, ok := m.screen.(*connectChooserModel); !ok {
t.Fatalf("expected connection 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)
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"context"
"errors"
"felis.lolicon.best/internal/api"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
type setupOwnerMsg struct {
outcome breakGlassOutcome
err error
}
type setupReadyMsg struct{ username string }
// setupOwnerModel waits for the panel, then issues a host-authorized login link.
// It never needs a running Minecraft client or login server.
type setupOwnerModel struct {
ctx context.Context
store ownerStore
panelURL, osUser string
probe func() error
sp spinner.Model
err error
}
func newSetupOwnerModel(ctx context.Context, store ownerStore, panelURL, osUser string) *setupOwnerModel {
sp := spinner.New()
sp.Spinner, sp.Style = spinner.Dot, tuiLabel
return &setupOwnerModel{ctx: ctx, store: store, panelURL: panelURL, osUser: osUser, sp: sp}
}
func (m *setupOwnerModel) Init() tea.Cmd {
return tea.Batch(m.sp.Tick, func() tea.Msg {
if m.probe != nil {
if err := m.probe(); err != nil {
return setupOwnerMsg{err: err}
}
}
out, err := performSetupOwner(m.ctx, m.store, m.panelURL, m.osUser)
return setupOwnerMsg{outcome: out, err: err}
})
}
func (m *setupOwnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case setupOwnerMsg:
if errors.Is(msg.err, api.ErrConflict) {
return m, func() tea.Msg { return setupReadyMsg{username: msg.outcome.ownerUsername} }
}
if msg.err != nil {
m.err = msg.err
return m, nil
}
return m, func() tea.Msg {
res := ownerResultMsg{username: msg.outcome.ownerUsername,
setupTokenURL: msg.outcome.setupTokenURL, mode: "setup", accountable: m.osUser}
if msg.outcome.auditErr != nil {
res.auditWarning = msg.outcome.auditErr.Error()
}
return res
}
case spinner.TickMsg:
if m.err == nil {
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "r", "R", "enter":
if m.err != nil {
m.err = nil
return m, m.Init()
}
}
}
return m, nil
}
func (m *setupOwnerModel) View() string {
if m.err != nil {
return tuiWarn.Render("Panel login setup could not finish: "+m.err.Error()) + "\n\n" +
tuiHint.Render("Minecraft is not required. Fix the reported service, then retry.") + "\n\n" +
tuiAction("r/enter", "retry", "esc", "exit")
}
return " " + m.sp.View() + " " + tuiHint.Render("Preparing your first panel login…") + "\n"
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestSetupOwnerWaitsForPanelBeforeMinting(t *testing.T) {
store := &fakeOwnerStore{}
m := newSetupOwnerModel(context.Background(), store, "https://op.console.example.com:30443", "root")
m.probe = func() error { return errors.New("panel not ready") }
batch := m.Init()().(tea.BatchMsg)
_, cmd := m.Update(batch[1]())
if cmd != nil || len(store.tokens) != 0 || !strings.Contains(m.View(), "panel not ready") {
t.Fatal("unready panel issued login")
}
m.probe = func() error { return nil }
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
batch = cmd().(tea.BatchMsg)
_, cmd = m.Update(batch[1]())
res := cmd().(ownerResultMsg)
if res.setupTokenURL == "" || res.username != "owner" || len(store.tokens) != 1 {
t.Fatalf("result = %+v", res)
}
}
func TestReportSetupWithoutOwnerPointsAtBrowserSetup(t *testing.T) {
var b bytes.Buffer
reportSetupResult(&b, breakGlassResult{}, false, false, "https://op.console.example.com")
if !strings.Contains(b.String(), "sudo felis setup") || strings.Contains(b.String(), "join ") {
t.Fatalf("output = %s", b.String())
}
}
func TestLocalPanelSetupUsesPasskeyHostname(t *testing.T) {
t.Setenv("FELIS_PANEL_NODEPORT", "30445")
if got := localPanelURL("10.211.55.6.nip.io", ""); got != "https://op.console.10.211.55.6.nip.io:30445" {
t.Fatalf("local setup URL = %q; a bare IP cannot enroll the panel passkey", got)
}
}
func TestSetupGameAddress(t *testing.T) {
for _, tc := range []struct {
root string
port int
want string
}{
{"10.211.55.6.nip.io", 0, "10.211.55.6"},
{"10.211.55.6.nip.io", 25565, "10.211.55.6"},
{"10.211.55.6.sslip.io.", 25570, "10.211.55.6:25570"},
{"play.example.net", 0, "play.example.net"},
{"play.example.net", 25570, "play.example.net:25570"},
{"", 25570, ""},
} {
if got := setupGameAddress(tc.root, tc.port); got != tc.want {
t.Errorf("setupGameAddress(%q, %d) = %q, want %q", tc.root, tc.port, got, tc.want)
}
}
}
+9 -10
View File
@@ -16,9 +16,8 @@ import (
type summaryModel struct {
panelURL string
ownerUsername string
ownerSkipped bool // the Owner step was skipped: say how to bind one
gameAddr string // where to join in Minecraft to bind the Owner
setupTokenURL string // one-time first-login URL; shown once
auditWarning string
accessLabel string
storageLabel string // build-context storage backend recap; empty to omit
routedHosts []string
@@ -54,10 +53,10 @@ func (m *summaryModel) View() string {
var b strings.Builder
switch {
case m.setupTokenURL != "":
b.WriteString(tuiOK.Render("✓ Deployment ready. Finish Owner login in your browser.") + "\n\n")
case m.alreadySetUp:
b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n")
case m.ownerSkipped:
b.WriteString(tuiWarn.Render("⚠ Setup finished without an Owner.") + "\n\n")
default:
b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n")
}
@@ -66,9 +65,6 @@ func (m *summaryModel) View() string {
if m.ownerUsername != "" {
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
}
if m.ownerSkipped {
card.WriteString(routeRow("owner ", "not bound: nobody can sign in to the panel yet", false))
}
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")
@@ -93,10 +89,13 @@ func (m *summaryModel) View() string {
}
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
if m.ownerSkipped {
b.WriteString(tuiWarn.Render("To bind the Owner, run sudo felis setup again and join "+ownerJoinTarget(m.gameAddr)+" in Minecraft.") + "\n")
if m.setupTokenURL != "" {
b.WriteString(tuiHint.Render("Open the setup link, record your email, and create a passkey.\nMinecraft can be linked later from Account; it is not required for panel access.") + "\n")
} else {
b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n")
b.WriteString(tuiHint.Render("Manage servers, users, and Minecraft identities in the panel.") + "\n")
}
if m.auditWarning != "" {
b.WriteString(tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n")
}
if m.localHint {
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")