fix(setup): 绑定码可重试并说明连接方式
This commit is contained in:
12 files changed
+558
-46
No files matched your search
+10
-3
@@ -569,6 +569,7 @@ type breakGlassResult struct {
|
|||||||
// from a cancel and reports itself as one.
|
// from a cancel and reports itself as one.
|
||||||
alreadySetUp bool
|
alreadySetUp bool
|
||||||
isOperator bool // an Operator was added rather than the Owner provisioned
|
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
|
mode string
|
||||||
accountable string
|
accountable string
|
||||||
osUser 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
|
// runSetupTUI never reaches recovery: setup with a staff account present lands on
|
||||||
// the status screen, so it has no relay to hand over.
|
// 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) {
|
// gameAddr is where the Owner step tells the operator to join (setupGameAddress).
|
||||||
return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
|
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
|
// 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) {
|
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()
|
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return breakGlassResult{}, err
|
return breakGlassResult{}, err
|
||||||
|
|||||||
+20
-6
@@ -112,7 +112,8 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -121,6 +122,18 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
|||||||
if panelURL == "" {
|
if panelURL == "" {
|
||||||
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
|
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 !res.provisioned && !res.connectConfigured {
|
||||||
if bootstrapped {
|
if bootstrapped {
|
||||||
@@ -129,19 +142,22 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
||||||
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
|
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 —
|
// 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.
|
// reporting that as "cancelled" reads as a failure the operator did not cause.
|
||||||
msg := "felis setup: cancelled — no changes made."
|
msg := "felis setup: cancelled — no changes made."
|
||||||
if res.alreadySetUp {
|
switch {
|
||||||
|
case res.alreadySetUp:
|
||||||
msg = "felis setup: already set up — nothing to change."
|
msg = "felis setup: already set up — nothing to change."
|
||||||
|
case res.ownerSkipped:
|
||||||
|
msg = "felis setup: finished without an Owner."
|
||||||
}
|
}
|
||||||
fmt.Fprintln(stdout, msg)
|
fmt.Fprintln(stdout, msg)
|
||||||
if panelURL != "" {
|
if panelURL != "" {
|
||||||
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
||||||
}
|
}
|
||||||
return 0
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.provisioned {
|
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.")
|
fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionSystemServers ensures the login limbo and lobby system services exist,
|
// provisionSystemServers ensures the login limbo and lobby system services exist,
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ func rootDomainEmbeddedIP(rootDomain string) string {
|
|||||||
return ""
|
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 {
|
func localPanelOrigin() string {
|
||||||
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-18
@@ -2,24 +2,37 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mcBindModel is the `felis setup` Owner-establishment screen: the operator
|
// 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
|
// joins the server, reads the one-time code the login server shows, and types it
|
||||||
// bound Minecraft account is promoted to the passwordless Owner, and a one-time
|
// here. The bound Minecraft account is promoted to the passwordless Owner, and a
|
||||||
// setup URL is minted for the first web login. It replaces the old ownerModel
|
// one-time setup URL is minted for the first web login. It replaces the old
|
||||||
// bootstrap form in setup mode — no username/email/password is typed here, the
|
// ownerModel bootstrap form in setup mode — no username/email/password is typed
|
||||||
// MC identity is the root of trust.
|
// 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 {
|
type mcBindModel struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
store ownerStore
|
store ownerStore
|
||||||
adminHost string
|
adminHost string
|
||||||
osUser string
|
osUser string
|
||||||
|
gameAddr string // where to join in Minecraft; "" names no address
|
||||||
|
|
||||||
step mcBindStep
|
step mcBindStep
|
||||||
form *huh.Form
|
form *huh.Form
|
||||||
@@ -27,6 +40,8 @@ type mcBindModel struct {
|
|||||||
working string
|
working string
|
||||||
|
|
||||||
linkCode 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
|
ownerIdentity string
|
||||||
setupTokenURL string
|
setupTokenURL string
|
||||||
auditWarning string
|
auditWarning string
|
||||||
@@ -34,6 +49,9 @@ type mcBindModel struct {
|
|||||||
width, height int
|
width, height int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerSkippedMsg leaves the Owner step without binding one.
|
||||||
|
type ownerSkippedMsg struct{}
|
||||||
|
|
||||||
type mcBindStep int
|
type mcBindStep int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -47,7 +65,7 @@ type mcBindMsg struct {
|
|||||||
err error
|
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.New()
|
||||||
sp.Spinner = spinner.Dot
|
sp.Spinner = spinner.Dot
|
||||||
sp.Style = tuiLabel
|
sp.Style = tuiLabel
|
||||||
@@ -56,6 +74,7 @@ func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser str
|
|||||||
store: store,
|
store: store,
|
||||||
adminHost: adminHost,
|
adminHost: adminHost,
|
||||||
osUser: osUser,
|
osUser: osUser,
|
||||||
|
gameAddr: gameAddr,
|
||||||
sp: sp,
|
sp: sp,
|
||||||
step: mcBindForm,
|
step: mcBindForm,
|
||||||
}
|
}
|
||||||
@@ -63,17 +82,93 @@ func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser str
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildForm starts the code entry afresh, with the last refusal (if any) on top.
|
||||||
func (m *mcBindModel) buildForm() *huh.Form {
|
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().
|
huh.NewNote().
|
||||||
Title("Bind your Minecraft account").
|
Title("Bind the Owner's 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."),
|
Description(desc),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Link code").
|
Title("Link code").
|
||||||
Placeholder("ABCD12").
|
Description("Leave it empty and press enter to skip this step for now.").
|
||||||
|
Placeholder("K7M2QX9P").
|
||||||
Value(&m.linkCode).
|
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 {
|
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) {
|
switch msg := msg.(type) {
|
||||||
case mcBindMsg:
|
case mcBindMsg:
|
||||||
if msg.err != nil {
|
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.step = mcBindDone
|
||||||
m.ownerIdentity = msg.outcome.ownerIdentity
|
m.ownerIdentity = msg.outcome.ownerIdentity
|
||||||
m.setupTokenURL = msg.outcome.setupTokenURL
|
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) {
|
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.step = mcBindWorking
|
||||||
m.working = "Binding Minecraft account…"
|
m.working = "Binding Minecraft account…"
|
||||||
code := strings.TrimSpace(strings.ToUpper(m.linkCode))
|
|
||||||
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
||||||
out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser)
|
out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser)
|
||||||
return mcBindMsg{outcome: out, err: err}
|
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 {
|
func (m *mcBindModel) resultCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return ownerResultMsg{
|
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) {
|
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{
|
next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{
|
||||||
ownerIdentity: "mc-uuid-1",
|
ownerIdentity: "mc-uuid-1",
|
||||||
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
|
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
|
||||||
|
|||||||
+16
-1
@@ -142,6 +142,7 @@ type rootModel struct {
|
|||||||
panelHost string
|
panelHost string
|
||||||
accessAud string
|
accessAud string
|
||||||
namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op
|
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
|
adminExists bool
|
||||||
recovery recoveryConfig // how the account operations mail a recovery code
|
recovery recoveryConfig // how the account operations mail a recovery code
|
||||||
// alertRoute reads where the watchdog's alerts go for the summary; nil
|
// 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
|
m.stage = stageOwner
|
||||||
if m.mode == consoleModeSetup {
|
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))
|
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
|
m.stage = stageConnect
|
||||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
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:
|
case connectResultMsg:
|
||||||
m.applyConnectResult(msg)
|
m.applyConnectResult(msg)
|
||||||
if m.reconfiguringConnect {
|
if m.reconfiguringConnect {
|
||||||
@@ -424,6 +432,11 @@ func (m *rootModel) reviewBody(stage int) string {
|
|||||||
b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
|
b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
|
||||||
b.WriteString(tuiHint.Render("Control plane verified before configuration."))
|
b.WriteString(tuiHint.Render("Control plane verified before configuration."))
|
||||||
case stageOwner:
|
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")
|
b.WriteString(tuiOK.Render("✓ Owner account") + "\n")
|
||||||
if m.result.username != "" {
|
if m.result.username != "" {
|
||||||
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
|
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{
|
return m.adopt(&summaryModel{
|
||||||
panelURL: m.result.panelURL,
|
panelURL: m.result.panelURL,
|
||||||
ownerUsername: m.result.username,
|
ownerUsername: m.result.username,
|
||||||
|
ownerSkipped: m.result.ownerSkipped,
|
||||||
|
gameAddr: m.gameAddr,
|
||||||
setupTokenURL: m.result.setupTokenURL,
|
setupTokenURL: m.result.setupTokenURL,
|
||||||
accessLabel: connectMethodLabel(m.result.connectMethod),
|
accessLabel: connectMethodLabel(m.result.connectMethod),
|
||||||
storageLabel: m.result.storageDetail,
|
storageLabel: m.result.storageDetail,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
type summaryModel struct {
|
type summaryModel struct {
|
||||||
panelURL string
|
panelURL string
|
||||||
ownerUsername 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
|
setupTokenURL string // one-time first-login URL; shown once
|
||||||
accessLabel string
|
accessLabel string
|
||||||
storageLabel string // build-context storage backend recap; empty to omit
|
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 {
|
func (m *summaryModel) View() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
if m.alreadySetUp {
|
switch {
|
||||||
|
case m.alreadySetUp:
|
||||||
b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n")
|
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")
|
b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,9 @@ func (m *summaryModel) View() string {
|
|||||||
if m.ownerUsername != "" {
|
if m.ownerUsername != "" {
|
||||||
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
|
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 != "" {
|
if m.setupTokenURL != "" {
|
||||||
card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n")
|
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")
|
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(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
|
||||||
|
|
||||||
|
if m.ownerSkipped {
|
||||||
|
b.WriteString(tuiWarn.Render("To bind the Owner, run sudo felis setup again and join "+ownerJoinTarget(m.gameAddr)+" in Minecraft.") + "\n")
|
||||||
|
} else {
|
||||||
b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n")
|
b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n")
|
||||||
|
}
|
||||||
if m.localHint {
|
if m.localHint {
|
||||||
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
|
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,18 @@ import (
|
|||||||
// ownership operation (claim, §9.3), which otherwise dead-ends at a 412.
|
// ownership operation (claim, §9.3), which otherwise dead-ends at a 412.
|
||||||
|
|
||||||
const (
|
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
|
// 短 TTL). Long enough to alt-tab from the game to the panel, short enough that
|
||||||
// a leaked code is useless minutes later.
|
// a leaked code is useless minutes later.
|
||||||
linkCodeTTL = 10 * time.Minute
|
LinkCodeTTL = 10 * time.Minute
|
||||||
// linkCodeAlphabet is a 32-symbol set with the visually ambiguous characters
|
// 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
|
// 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
|
// panel without confusion. 32 divides 256 evenly, so a uniform random byte
|
||||||
// reduced mod 32 is itself uniform — no modulo bias, no rejection sampling.
|
// reduced mod 32 is itself uniform — no modulo bias, no rejection sampling.
|
||||||
linkCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
LinkCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
|
// LinkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
|
||||||
// force inside the TTL.
|
// force inside the TTL.
|
||||||
linkCodeLen = 8
|
LinkCodeLen = 8
|
||||||
|
|
||||||
// authSource records which Yggdrasil established the in-game UUID when a code
|
// authSource records which Yggdrasil established the in-game UUID when a code
|
||||||
// was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a
|
// 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.
|
// newLinkCode returns a cryptographically random, unambiguous link code.
|
||||||
func newLinkCode() (string, error) {
|
func newLinkCode() (string, error) {
|
||||||
buf := make([]byte, linkCodeLen)
|
buf := make([]byte, LinkCodeLen)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for i, b := range buf {
|
for i, b := range buf {
|
||||||
buf[i] = linkCodeAlphabet[int(b)%len(linkCodeAlphabet)]
|
buf[i] = LinkCodeAlphabet[int(b)%len(LinkCodeAlphabet)]
|
||||||
}
|
}
|
||||||
return string(buf), nil
|
return string(buf), nil
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expiresAt := a.now().Add(linkCodeTTL)
|
expiresAt := a.now().Add(LinkCodeTTL)
|
||||||
if err := a.Repo.CreateLinkCode(r.Context(), code, mcUUID, authSource, expiresAt); err != nil {
|
if err := a.Repo.CreateLinkCode(r.Context(), code, mcUUID, authSource, expiresAt); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ func TestAccountLinkVertical(t *testing.T) {
|
|||||||
t.Fatalf("mint code: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
t.Fatalf("mint code: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
code, _ := acctBody(t, w)["code"].(string)
|
code, _ := acctBody(t, w)["code"].(string)
|
||||||
if len(code) != linkCodeLen {
|
if len(code) != LinkCodeLen {
|
||||||
t.Fatalf("minted code %q: len = %d, want %d", code, 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).
|
// 2) the player submits the code on the panel (external face).
|
||||||
@@ -113,11 +113,11 @@ func TestCreateLinkCode(t *testing.T) {
|
|||||||
if rec.authSource != authSourceMojang {
|
if rec.authSource != authSourceMojang {
|
||||||
t.Errorf("default authSource = %q, want %q", 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)
|
t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want)
|
||||||
}
|
}
|
||||||
for _, c := range code {
|
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)
|
t.Errorf("code %q contains out-of-alphabet rune %q", code, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func seedBindAPI(t *testing.T) (*API, *fakeRepo) {
|
|||||||
// mint (handleCreateLinkCode → CreateLinkCode).
|
// mint (handleCreateLinkCode → CreateLinkCode).
|
||||||
func mintBindCode(t *testing.T, api *API, repo *fakeRepo, code, uuid, authSource string) {
|
func mintBindCode(t *testing.T, api *API, repo *fakeRepo, code, uuid, authSource string) {
|
||||||
t.Helper()
|
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)
|
t.Fatalf("mint bind code: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ package best.lolicon.felis.limbo;
|
|||||||
final class GateConfig {
|
final class GateConfig {
|
||||||
|
|
||||||
static final int DEFAULT_HEALTH_PORT = 8080;
|
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
|
// internal/api): no point holding a player past code expiry, and no point cutting
|
||||||
// them off while it is still valid.
|
// them off while it is still valid.
|
||||||
static final long DEFAULT_TIMEOUT_SECONDS = 600L;
|
static final long DEFAULT_TIMEOUT_SECONDS = 600L;
|
||||||
|
|||||||
Reference in new issue
Block a user