fix(setup): 绑定码可重试并说明连接方式
This commit is contained in:
12 files changed
+563
-51
No files matched your search
+10
-3
@@ -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
|
||||
|
||||
+20
-6
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
+125
-22
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+16
-1
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user