Loading cmd/felis/breakglass.go +10 −3 Changes for cmd/felis/breakglass.go: 10 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -569,6 +569,7 @@ 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 Loading Loading @@ -633,8 +634,11 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfi // runSetupTUI never reaches recovery: setup with a staff account present lands on // the status screen, so it has no relay to hand over. func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{}) // 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) } // newConsoleRoot is the console's root model as the host runs it: the summary Loading @@ -649,7 +653,10 @@ func newConsoleRoot(ctx context.Context, s ownerStore, db config.DatabaseConfig, } func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) { rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery) return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery)) } func runConsoleRoot(rm *rootModel) (breakGlassResult, error) { final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err Loading cmd/felis/setup.go +20 −6 Changes for cmd/felis/setup.go: 20 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -112,7 +112,8 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { return 1 } 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) 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) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 Loading @@ -121,6 +122,18 @@ 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) 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) { 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)) } if !res.provisioned && !res.connectConfigured { if bootstrapped { Loading @@ -129,19 +142,22 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { 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.") } return 0 return } // A re-run lands on the status screen, which changes nothing by design — // reporting that as "cancelled" reads as a failure the operator did not cause. msg := "felis setup: cancelled — no changes made." if res.alreadySetUp { 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 != "" { fmt.Fprintf(stdout, "Panel: %s\n", panelURL) } return 0 return } if res.provisioned { Loading Loading @@ -176,8 +192,6 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") } } return 0 } // provisionSystemServers ensures the login limbo and lobby system services exist, Loading cmd/felis/setup_panel.go +27 −0 Changes for cmd/felis/setup_panel.go: 27 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -57,6 +57,33 @@ func rootDomainEmbeddedIP(rootDomain string) string { return "" } // setupGameAddress is where the operator joins in Minecraft to bind the Owner: 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. func setupGameAddress(rootDomain string, gamePort int) string { host := rootDomainEmbeddedIP(rootDomain) if host == "" { host = strings.TrimSpace(strings.TrimSuffix(rootDomain, ".")) } if host == "" { return "" } if gamePort == 0 || gamePort == 25565 { return host } 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()) } Loading cmd/felis/tui_mc_bind.go +121 −18 Changes for cmd/felis/tui_mc_bind.go: 121 added lines, 18 removed lines. Original line number Diff line number Diff line Loading @@ -2,24 +2,37 @@ 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, runs /link to get a one-time code, and types it here. The // bound Minecraft account is promoted to the passwordless Owner, and a one-time // setup URL is minted for the first web login. It replaces the old ownerModel // bootstrap form in setup mode — no username/email/password is typed here, the // MC identity is the root of trust. // 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 Loading @@ -27,6 +40,8 @@ type mcBindModel struct { 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 Loading @@ -34,6 +49,9 @@ type mcBindModel struct { width, height int } // ownerSkippedMsg leaves the Owner step without binding one. type ownerSkippedMsg struct{} type mcBindStep int const ( Loading @@ -47,7 +65,7 @@ type mcBindMsg struct { err error } func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel { func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser, gameAddr string) *mcBindModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel Loading @@ -56,6 +74,7 @@ func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser str store: store, adminHost: adminHost, osUser: osUser, gameAddr: gameAddr, sp: sp, step: mcBindForm, } Loading @@ -63,17 +82,93 @@ func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser str return m } // buildForm starts the code entry afresh, with the last refusal (if any) on top. func (m *mcBindModel) buildForm() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( 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 your Minecraft account"). Description("Join the server and run /link to get a one-time code,\nthen type it here. Your bound account becomes the\npasswordless Owner."), Title("Bind the Owner's Minecraft account"). Description(desc), huh.NewInput(). Title("Link code"). Placeholder("ABCD12"). Description("Leave it empty and press enter to skip this step for now."). Placeholder("K7M2QX9P"). Value(&m.linkCode). Validate(requiredField("link code")), ))) 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 { Loading @@ -96,8 +191,12 @@ func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case mcBindMsg: if msg.err != nil { return m, m.failCmd(msg.err) m.step = 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 Loading Loading @@ -152,19 +251,23 @@ func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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…" code := strings.TrimSpace(strings.ToUpper(m.linkCode)) return m, tea.Batch(m.sp.Tick, func() tea.Msg { out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser) return mcBindMsg{outcome: out, err: err} }) } func (m *mcBindModel) failCmd(err error) tea.Cmd { return func() tea.Msg { return ownerResultMsg{err: err} } } func (m *mcBindModel) resultCmd() tea.Cmd { return func() tea.Msg { return ownerResultMsg{ Loading cmd/felis/tui_mc_bind_test.go 0 → 100644 +334 −0 Changes for cmd/felis/tui_mc_bind_test.go: 334 added lines, 0 removed lines. Original line number Diff line number Diff line 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()) } } Loading
cmd/felis/breakglass.go +10 −3 Changes for cmd/felis/breakglass.go: 10 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -569,6 +569,7 @@ 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 Loading Loading @@ -633,8 +634,11 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfi // runSetupTUI never reaches recovery: setup with a staff account present lands on // the status screen, so it has no relay to hand over. func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{}) // 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) } // newConsoleRoot is the console's root model as the host runs it: the summary Loading @@ -649,7 +653,10 @@ func newConsoleRoot(ctx context.Context, s ownerStore, db config.DatabaseConfig, } func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) { rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery) return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery)) } func runConsoleRoot(rm *rootModel) (breakGlassResult, error) { final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err Loading
cmd/felis/setup.go +20 −6 Changes for cmd/felis/setup.go: 20 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -112,7 +112,8 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { return 1 } 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) 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) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 Loading @@ -121,6 +122,18 @@ 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) 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) { 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)) } if !res.provisioned && !res.connectConfigured { if bootstrapped { Loading @@ -129,19 +142,22 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { 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.") } return 0 return } // A re-run lands on the status screen, which changes nothing by design — // reporting that as "cancelled" reads as a failure the operator did not cause. msg := "felis setup: cancelled — no changes made." if res.alreadySetUp { 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 != "" { fmt.Fprintf(stdout, "Panel: %s\n", panelURL) } return 0 return } if res.provisioned { Loading Loading @@ -176,8 +192,6 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") } } return 0 } // provisionSystemServers ensures the login limbo and lobby system services exist, Loading
cmd/felis/setup_panel.go +27 −0 Changes for cmd/felis/setup_panel.go: 27 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -57,6 +57,33 @@ func rootDomainEmbeddedIP(rootDomain string) string { return "" } // setupGameAddress is where the operator joins in Minecraft to bind the Owner: 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. func setupGameAddress(rootDomain string, gamePort int) string { host := rootDomainEmbeddedIP(rootDomain) if host == "" { host = strings.TrimSpace(strings.TrimSuffix(rootDomain, ".")) } if host == "" { return "" } if gamePort == 0 || gamePort == 25565 { return host } 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()) } Loading
cmd/felis/tui_mc_bind.go +121 −18 Changes for cmd/felis/tui_mc_bind.go: 121 added lines, 18 removed lines. Original line number Diff line number Diff line Loading @@ -2,24 +2,37 @@ 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, runs /link to get a one-time code, and types it here. The // bound Minecraft account is promoted to the passwordless Owner, and a one-time // setup URL is minted for the first web login. It replaces the old ownerModel // bootstrap form in setup mode — no username/email/password is typed here, the // MC identity is the root of trust. // 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 Loading @@ -27,6 +40,8 @@ type mcBindModel struct { 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 Loading @@ -34,6 +49,9 @@ type mcBindModel struct { width, height int } // ownerSkippedMsg leaves the Owner step without binding one. type ownerSkippedMsg struct{} type mcBindStep int const ( Loading @@ -47,7 +65,7 @@ type mcBindMsg struct { err error } func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel { func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser, gameAddr string) *mcBindModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel Loading @@ -56,6 +74,7 @@ func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser str store: store, adminHost: adminHost, osUser: osUser, gameAddr: gameAddr, sp: sp, step: mcBindForm, } Loading @@ -63,17 +82,93 @@ func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser str return m } // buildForm starts the code entry afresh, with the last refusal (if any) on top. func (m *mcBindModel) buildForm() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( 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 your Minecraft account"). Description("Join the server and run /link to get a one-time code,\nthen type it here. Your bound account becomes the\npasswordless Owner."), Title("Bind the Owner's Minecraft account"). Description(desc), huh.NewInput(). Title("Link code"). Placeholder("ABCD12"). Description("Leave it empty and press enter to skip this step for now."). Placeholder("K7M2QX9P"). Value(&m.linkCode). Validate(requiredField("link code")), ))) 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 { Loading @@ -96,8 +191,12 @@ func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case mcBindMsg: if msg.err != nil { return m, m.failCmd(msg.err) m.step = 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 Loading Loading @@ -152,19 +251,23 @@ func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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…" code := strings.TrimSpace(strings.ToUpper(m.linkCode)) return m, tea.Batch(m.sp.Tick, func() tea.Msg { out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser) return mcBindMsg{outcome: out, err: err} }) } func (m *mcBindModel) failCmd(err error) tea.Cmd { return func() tea.Msg { return ownerResultMsg{err: err} } } func (m *mcBindModel) resultCmd() tea.Cmd { return func() tea.Msg { return ownerResultMsg{ Loading
cmd/felis/tui_mc_bind_test.go 0 → 100644 +334 −0 Changes for cmd/felis/tui_mc_bind_test.go: 334 added lines, 0 removed lines. Original line number Diff line number Diff line 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()) } }