diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index c162ac7..cfa1418 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -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 @@ -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 @@ -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 diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 19c1b37..5910c35 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -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 @@ -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 { @@ -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 { @@ -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, diff --git a/cmd/felis/setup_panel.go b/cmd/felis/setup_panel.go index 4fb2743..995b8eb 100644 --- a/cmd/felis/setup_panel.go +++ b/cmd/felis/setup_panel.go @@ -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()) } diff --git a/cmd/felis/tui_mc_bind.go b/cmd/felis/tui_mc_bind.go index 01c8079..26041ae 100644 --- a/cmd/felis/tui_mc_bind.go +++ b/cmd/felis/tui_mc_bind.go @@ -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 @@ -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 @@ -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 ( @@ -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 @@ -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, } @@ -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( - huh.NewNote(). - Title("Bind your Minecraft account"). - Description("Join the server and run /link to get a one-time code,\nthen type it here. Your bound account becomes the\npasswordless Owner."), - huh.NewInput(). - Title("Link code"). - Placeholder("ABCD12"). - Value(&m.linkCode). - Validate(requiredField("link code")), - ))) + 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 { @@ -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 @@ -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{ diff --git a/cmd/felis/tui_mc_bind_test.go b/cmd/felis/tui_mc_bind_test.go new file mode 100644 index 0000000..9ca9566 --- /dev/null +++ b/cmd/felis/tui_mc_bind_test.go @@ -0,0 +1,334 @@ +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()) + } +} diff --git a/cmd/felis/tui_menu_test.go b/cmd/felis/tui_menu_test.go index 32d0a15..98cb7b3 100644 --- a/cmd/felis/tui_menu_test.go +++ b/cmd/felis/tui_menu_test.go @@ -212,7 +212,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) { } func TestMCBindCarriesAuditWarning(t *testing.T) { - m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root") + 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", diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 1c01f5c..891b13f 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -142,6 +142,7 @@ 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 @@ -216,7 +217,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.stage = stageOwner if m.mode == consoleModeSetup { - return m.adopt(newMCBindModel(m.ctx, m.store, defaultAdminHostname(m.rootDomain, m.adminHost), m.osUser)) + 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)) @@ -292,6 +293,13 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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)) + case connectResultMsg: m.applyConnectResult(msg) if m.reconfiguringConnect { @@ -424,6 +432,11 @@ 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") @@ -553,6 +566,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, accessLabel: connectMethodLabel(m.result.connectMethod), storageLabel: m.result.storageDetail, diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go index 774a7a0..3a8a66e 100644 --- a/cmd/felis/tui_summary.go +++ b/cmd/felis/tui_summary.go @@ -16,6 +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 accessLabel string storageLabel string // build-context storage backend recap; empty to omit @@ -51,9 +53,12 @@ func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *summaryModel) View() string { var b strings.Builder - if m.alreadySetUp { + switch { + case m.alreadySetUp: b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n") - } else { + case m.ownerSkipped: + b.WriteString(tuiWarn.Render("⚠ Setup finished without an Owner.") + "\n\n") + default: b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n") } @@ -61,6 +66,9 @@ 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") @@ -85,7 +93,11 @@ func (m *summaryModel) View() string { } b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n") - b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\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") + } else { + b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n") + } if m.localHint { b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n") } diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go index e437c03..bea1e28 100644 --- a/internal/api/handlers_account.go +++ b/internal/api/handlers_account.go @@ -24,18 +24,18 @@ import ( // ownership operation (claim, §9.3), which otherwise dead-ends at a 412. const ( - // linkCodeTTL bounds how long a freshly minted code is accepted (spec §10: + // LinkCodeTTL bounds how long a freshly minted code is accepted (spec §10: // 短 TTL). Long enough to alt-tab from the game to the panel, short enough that // a leaked code is useless minutes later. - linkCodeTTL = 10 * time.Minute - // linkCodeAlphabet is a 32-symbol set with the visually ambiguous characters + LinkCodeTTL = 10 * time.Minute + // LinkCodeAlphabet is a 32-symbol set with the visually ambiguous characters // I, O, 0 and 1 removed, so a player can read a code off chat and type it on the // panel without confusion. 32 divides 256 evenly, so a uniform random byte // reduced mod 32 is itself uniform — no modulo bias, no rejection sampling. - linkCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - // linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute + LinkCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + // LinkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute // force inside the TTL. - linkCodeLen = 8 + LinkCodeLen = 8 // authSource records which Yggdrasil established the in-game UUID when a code // was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a @@ -97,12 +97,12 @@ func deriveAuthSource(mcUUID string) string { // newLinkCode returns a cryptographically random, unambiguous link code. func newLinkCode() (string, error) { - buf := make([]byte, linkCodeLen) + buf := make([]byte, LinkCodeLen) if _, err := rand.Read(buf); err != nil { return "", err } for i, b := range buf { - buf[i] = linkCodeAlphabet[int(b)%len(linkCodeAlphabet)] + buf[i] = LinkCodeAlphabet[int(b)%len(LinkCodeAlphabet)] } return string(buf), nil } @@ -151,7 +151,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - expiresAt := a.now().Add(linkCodeTTL) + expiresAt := a.now().Add(LinkCodeTTL) if err := a.Repo.CreateLinkCode(r.Context(), code, mcUUID, authSource, expiresAt); err != nil { writeError(w, r, err) return diff --git a/internal/api/handlers_account_test.go b/internal/api/handlers_account_test.go index 966202f..90764d5 100644 --- a/internal/api/handlers_account_test.go +++ b/internal/api/handlers_account_test.go @@ -50,8 +50,8 @@ func TestAccountLinkVertical(t *testing.T) { t.Fatalf("mint code: code = %d, want 201 (%s)", w.Code, w.Body.String()) } code, _ := acctBody(t, w)["code"].(string) - if len(code) != linkCodeLen { - t.Fatalf("minted code %q: len = %d, want %d", code, len(code), linkCodeLen) + if len(code) != LinkCodeLen { + t.Fatalf("minted code %q: len = %d, want %d", code, len(code), LinkCodeLen) } // 2) the player submits the code on the panel (external face). @@ -113,11 +113,11 @@ func TestCreateLinkCode(t *testing.T) { if rec.authSource != authSourceMojang { t.Errorf("default authSource = %q, want %q", rec.authSource, authSourceMojang) } - if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) { + if want := api.now().Add(LinkCodeTTL); !rec.expiresAt.Equal(want) { t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want) } for _, c := range code { - if !strings.ContainsRune(linkCodeAlphabet, c) { + if !strings.ContainsRune(LinkCodeAlphabet, c) { t.Errorf("code %q contains out-of-alphabet rune %q", code, c) } } diff --git a/internal/api/handlers_onboard_test.go b/internal/api/handlers_onboard_test.go index 3b98e76..8604ae6 100644 --- a/internal/api/handlers_onboard_test.go +++ b/internal/api/handlers_onboard_test.go @@ -34,7 +34,7 @@ func seedBindAPI(t *testing.T) (*API, *fakeRepo) { // mint (handleCreateLinkCode → CreateLinkCode). func mintBindCode(t *testing.T, api *API, repo *fakeRepo, code, uuid, authSource string) { t.Helper() - if err := repo.CreateLinkCode(t.Context(), code, uuid, authSource, api.now().Add(linkCodeTTL)); err != nil { + if err := repo.CreateLinkCode(t.Context(), code, uuid, authSource, api.now().Add(LinkCodeTTL)); err != nil { t.Fatalf("mint bind code: %v", err) } } diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java index cc8b2ec..8b2fafd 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java @@ -8,7 +8,7 @@ package best.lolicon.felis.limbo; final class GateConfig { static final int DEFAULT_HEALTH_PORT = 8080; - // The default window (10 min) matches the Bind Code TTL (linkCodeTTL in + // The default window (10 min) matches the Bind Code TTL (LinkCodeTTL in // internal/api): no point holding a player past code expiry, and no point cutting // them off while it is still valid. static final long DEFAULT_TIMEOUT_SECONDS = 600L;