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:
59 files changed
+1702
-1564
No files matched your search
+4
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user