feat: initialize panel access before linking Minecraft accounts
Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials. Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
This commit is contained in:
59 files changed
+1702
-1564
No files matched your search
@@ -72,7 +72,7 @@ curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap
|
||||
|
||||
脚本将安装 K3s,在 K3s 中部署 PostgreSQL 与控制平面,随后启动设置向导。设置完成后,通过浏览器访问所配置的域名即可进入控制面板。
|
||||
|
||||
* **设置向导**:向导首先绑定平台所有者:以 Minecraft Java 版加入向导所示的地址,登录服务器会给出 8 位绑定码(10 分钟内有效),将其输入向导即可。该步骤可以跳过,之后再次执行 `sudo felis setup` 补做;绑定所有者之前,任何人均无法登录控制面板,登录页届时会说明原因并列出绑定步骤及连接地址。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。
|
||||
* **设置向导**:先完成面板访问方式与存储配置,再由主机管理员创建首位 Owner,终端会给出一次性网页设置链接(30 分钟内有效)。用浏览器打开链接、登记邮箱并创建通行密钥,即可进入面板,无需启动 Minecraft。角色关联可稍后在“账户”页选择认证源、输入角色名或 UUID 并确认;普通玩家继续通过游戏绑定码验证身份。未完成网页登录或链接过期时,再次执行 `sudo felis setup` 会提供新链接;已设置登录凭证的账号不会被重置。登录服或大厅故障不会阻止面板初始化。“账户”页会解释世界树(Yggdrasil)认证、显示进服地址与登录服/大厅所需的 Java 版客户端版本;安装器会把实际构建版本写入 `[velocity].game_version`,自定义镜像需自行填写,未配置时不会猜测版本。标准世界树接口可直接查询角色;非标准 `hasJoined` 地址可通过 `[[auth_source]].api_url` 指定认证站 API 根地址,游戏绑定码仍可作为替代方式。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。
|
||||
|
||||
* **支持的系统**:CentOS Stream 9(aarch64)已在实机上验证;Ubuntu 24.04(x86_64)在每次推送时由 CI 执行全新安装、重复安装、升级及上述安装命令(参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap
|
||||
|
||||
The script installs K3s, deploys PostgreSQL and the control plane inside it, and launches a setup wizard. When setup completes, open the configured domain in a browser to reach the control panel.
|
||||
|
||||
* **Setup wizard**: The wizard first binds the platform Owner: join the address it shows in Minecraft Java Edition, then enter the 8-character link code that the login server displays (valid for 10 minutes). The step can be skipped and completed later by running `sudo felis setup` again; until an Owner is bound, nobody can sign in to the control panel, and the sign-in page states this together with the binding steps and the address to join. The installer launches the wizard automatically only on an interactive terminal; when output is redirected to a log or the install runs under cloud-init, run `sudo felis setup` after it finishes. Setting `FELIS_NO_SETUP=1` makes the installer end at its summary.
|
||||
* **Setup wizard**: Configure panel access and storage first. The host administrator then initializes the first Owner and receives a one-time browser setup link (valid for 30 minutes). Open it, record an email, and create a passkey to enter the panel; Minecraft is not required. Later, link a game role from Account by selecting an authentication source, entering a role name or UUID, and confirming it. Players retain the in-game bind-code flow. Rerun `sudo felis setup` if login setup is unfinished or the link expires; accounts with an established login factor are never reset. Login/lobby failures do not block panel initialization. Account explains Yggdrasil authentication and shows the join address and Java client version for the login/lobby servers. Bootstrap records the built protocol in `[velocity].game_version`; set it yourself for custom images, otherwise the panel reports it as unknown. Standard Yggdrasil endpoints support role lookup directly; sources with a nonstandard `hasJoined` path can set `[[auth_source]].api_url` to their API root, with game-code linking still available as a fallback. The installer launches the wizard automatically only on an interactive terminal; when output is redirected to a log or the install runs under cloud-init, run `sudo felis setup` after it finishes. Setting `FELIS_NO_SETUP=1` makes the installer end at its summary.
|
||||
|
||||
* **Supported hosts**: CentOS Stream 9 (aarch64) is verified on physical hardware; Ubuntu 24.04 (x86_64) is tested in CI on every push with a fresh install, a rerun, an upgrade and the install command above (see [operations §1](docs/operations.md#1-supported-hosts)).
|
||||
|
||||
|
||||
+4
-4
@@ -61,9 +61,9 @@ func passkeyRelyingParty(cfg *config.Config) (string, []string) {
|
||||
if rpID == "" {
|
||||
return "", nil
|
||||
}
|
||||
origins := []string{"https://" + rpID}
|
||||
origins := []string{"https://" + rpID, fmt.Sprintf("https://%s:%d", rpID, setupPanelNodePort())}
|
||||
if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != rpID {
|
||||
origins = append(origins, "https://"+admin)
|
||||
origins = append(origins, "https://"+admin, fmt.Sprintf("https://%s:%d", admin, setupPanelNodePort()))
|
||||
}
|
||||
return rpID, origins
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
|
||||
sources := make([]api.AuthSource, 0, len(configured)+1)
|
||||
sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true})
|
||||
for _, s := range configured {
|
||||
sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL})
|
||||
sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL, APIURL: s.APIURL})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
@@ -495,7 +495,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
|
||||
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
|
||||
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
|
||||
cfg.Velocity.GamePort, resolvedVersion(), distribution != nil)
|
||||
cfg.Velocity.GamePort, cfg.Velocity.GameVersion, resolvedVersion(), distribution != nil)
|
||||
internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
|
||||
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
|
||||
|
||||
|
||||
+19
-4
@@ -24,6 +24,7 @@ import (
|
||||
// that names only its root domain still gets passkeys, on console.<root>, with the
|
||||
// operator host as the second origin; only an install with no panel host goes without.
|
||||
func TestPasskeyRelyingParty(t *testing.T) {
|
||||
t.Setenv("FELIS_PANEL_NODEPORT", "")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
root, panel, admin string
|
||||
@@ -43,14 +44,28 @@ func TestPasskeyRelyingParty(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname = tc.root, tc.panel, tc.admin
|
||||
var wantOrigins []string
|
||||
for _, origin := range tc.wantOrigins {
|
||||
wantOrigins = append(wantOrigins, origin, fmt.Sprintf("%s:%d", origin, defaultPanelNodePort))
|
||||
}
|
||||
rp, origins := passkeyRelyingParty(cfg)
|
||||
if rp != tc.wantRP || !slices.Equal(origins, tc.wantOrigins) {
|
||||
t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, tc.wantOrigins)
|
||||
if rp != tc.wantRP || !slices.Equal(origins, wantOrigins) {
|
||||
t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, wantOrigins)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyRelyingPartyIncludesConfiguredNodePort(t *testing.T) {
|
||||
t.Setenv("FELIS_PANEL_NODEPORT", "30445")
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.RootDomain = "example.com"
|
||||
_, origins := passkeyRelyingParty(cfg)
|
||||
if !slices.Contains(origins, "https://op.console.example.com:30445") {
|
||||
t.Fatalf("configured NodePort origin missing: %v", origins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided:
|
||||
// Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is.
|
||||
// The empty case matters on its own — both `felis api` and `felis nano` call this with a
|
||||
@@ -64,7 +79,7 @@ func TestAuthSourcesFromConfig(t *testing.T) {
|
||||
{"no configured sources", nil},
|
||||
{"configured sources", []config.AuthSourceConfig{
|
||||
{Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.example/hasJoined"},
|
||||
{Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined"},
|
||||
{Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined", APIURL: "https://guild.example/api"},
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -80,7 +95,7 @@ func TestAuthSourcesFromConfig(t *testing.T) {
|
||||
if s.Identity {
|
||||
t.Errorf("configured source %q is marked Identity; only Mojang may be", c.Tag)
|
||||
}
|
||||
if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL {
|
||||
if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL || s.APIURL != c.APIURL {
|
||||
t.Errorf("source %d = %+v, want %+v in config order", i+1, s, c)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-70
@@ -11,6 +11,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -93,11 +94,9 @@ type ownerStore interface {
|
||||
// role=owner identity (migration 0011 adds that role); the two are the only
|
||||
// staff roles.
|
||||
InsertOperator(ctx context.Context, id, username, email string) error
|
||||
// CompleteOwnerSetup atomically consumes the in-game link code, creates or
|
||||
// promotes the bound Owner, enables local auth, and stores the one-time setup
|
||||
// token. A failure rolls all four writes back so setup is always retryable.
|
||||
CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
|
||||
tokenHash string, tokenExpiresAt time.Time) (userID, mcUUID, authSource string, err error)
|
||||
// CompleteOwnerSetup creates or resumes the first panel login atomically.
|
||||
CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time,
|
||||
tokenHash string, tokenExpiresAt time.Time) (userID, username string, err error)
|
||||
SetSetting(ctx context.Context, key string, value []byte) error
|
||||
// Audit records the break-glass accountability row.
|
||||
Audit(ctx context.Context, e api.AuditEntry) error
|
||||
@@ -368,7 +367,7 @@ type breakGlassOp struct {
|
||||
// breakGlassOutcome is what performBreakGlass reports back to the TUI.
|
||||
type breakGlassOutcome struct {
|
||||
setupTokenURL string // non-empty when setup minted a one-time first-login URL
|
||||
ownerIdentity string // verified Minecraft UUID for the setup Owner-bind path
|
||||
ownerUsername string // panel Owner created or resumed by setup
|
||||
auditErr error // non-nil if the accountability row could not be written
|
||||
}
|
||||
|
||||
@@ -408,25 +407,12 @@ func newSetupToken() (raw, hash string, err error) {
|
||||
return raw, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
|
||||
// binds their Minecraft account via a one-time link code the login gate handed
|
||||
// them in-game, the bound user is promoted to role='owner' (passwordless Owner),
|
||||
// local auth is enabled, and a one-time setup URL is minted for the first web
|
||||
// login where the Owner verifies email / enrolls a passkey. adminHostname is the
|
||||
// operator-console host the URL points at (op.console.<root>): the Owner is staff,
|
||||
// so first-run onboarding belongs on the operator face, not the player panel. The
|
||||
// passkey verifier's RP id is the panel host, but its permitted origins now include
|
||||
// op.console (cmd/felis/api.go), so enrollment on op.console is a valid ceremony —
|
||||
// one binding that works on both faces. osUser is recorded as the accountable actor.
|
||||
//
|
||||
// Local auth is as load-bearing here as it is in break-glass, and for a sharper
|
||||
// reason: an MC-bound Owner has no password AND no email, so the setup token is
|
||||
// their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth
|
||||
// toggle, and token together; any failed write leaves the link code retryable.
|
||||
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) {
|
||||
code = strings.TrimSpace(strings.ToUpper(code))
|
||||
if code == "" {
|
||||
return breakGlassOutcome{}, errors.New("link code is required")
|
||||
// performSetupOwner establishes panel access under the caller's host-root
|
||||
// authority. Minecraft identity can be linked later from the authenticated panel.
|
||||
func performSetupOwner(ctx context.Context, s ownerStore, panelURL, osUser string) (breakGlassOutcome, error) {
|
||||
base, err := url.Parse(strings.TrimRight(panelURL, "/"))
|
||||
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||
return breakGlassOutcome{}, errors.New("a configured HTTPS operator console is required")
|
||||
}
|
||||
newID := newOwnerID()
|
||||
if newID == "" {
|
||||
@@ -437,48 +423,21 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname,
|
||||
return breakGlassOutcome{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
_, mcUUID, authSource, err := s.CompleteOwnerSetup(
|
||||
ctx, newID, code, now, hash, now.Add(setupTokenTTL))
|
||||
userID, username, err := s.CompleteOwnerSetup(ctx, newID, now, hash, now.Add(setupTokenTTL))
|
||||
out := breakGlassOutcome{ownerUsername: username}
|
||||
if err != nil {
|
||||
return breakGlassOutcome{}, fmt.Errorf("complete owner setup: %w", err)
|
||||
return out, err
|
||||
}
|
||||
// The load-bearing writes committed together above. Accountability remains
|
||||
// best-effort: an unhappy audit sink never costs the operator their install.
|
||||
out := breakGlassOutcome{
|
||||
ownerIdentity: mcUUID,
|
||||
auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
|
||||
}
|
||||
host := strings.TrimSpace(adminHostname)
|
||||
if host == "" {
|
||||
host = "op.console.localhost"
|
||||
}
|
||||
out.setupTokenURL = "https://" + host + "/setup?token=" + raw
|
||||
base.Path = "/setup"
|
||||
base.RawQuery = url.Values{"token": {raw}}.Encode()
|
||||
out.setupTokenURL = base.String()
|
||||
blob, _ := json.Marshal(map[string]string{"os_user": osUser, "user_id": userID, "username": username})
|
||||
out.auditErr = s.Audit(ctx, api.AuditEntry{
|
||||
Actor: osUser, Source: "setup", Action: "setup.owner_login", Payload: blob,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// auditSetupMCBind records who claimed the Owner seat at setup. It carries the
|
||||
// Minecraft identity rather than a username because that IS the evidence: the
|
||||
// login gate only issues a link code to a player it authenticated, so mc_uuid +
|
||||
// auth_source say which account was verified and by whom. Actor is the OS user who
|
||||
// ran `felis setup` — honest attribution, not proof (root can edit the row).
|
||||
func auditSetupMCBind(ctx context.Context, s ownerStore, osUser, mcUUID, authSource string) error {
|
||||
blob, err := json.Marshal(map[string]any{
|
||||
"mode": "setup",
|
||||
"os_user": osUser,
|
||||
"mc_uuid": mcUUID,
|
||||
"auth_source": authSource,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Audit(ctx, api.AuditEntry{
|
||||
Actor: osUser,
|
||||
Source: "setup",
|
||||
Action: "setup.owner_bind",
|
||||
Payload: blob,
|
||||
})
|
||||
}
|
||||
|
||||
// auditBreakGlass writes the break-glass accountability row. The actor is the
|
||||
// resolved human identity (a verified admin in recovery, the OS user otherwise);
|
||||
// the payload carries the full who/what/how so an after-the-fact reader can tell a
|
||||
@@ -569,7 +528,6 @@ type breakGlassResult struct {
|
||||
// from a cancel and reports itself as one.
|
||||
alreadySetUp bool
|
||||
isOperator bool // an Operator was added rather than the Owner provisioned
|
||||
ownerSkipped bool // setup's Owner step was skipped; no Owner is bound
|
||||
mode string
|
||||
accountable string
|
||||
osUser string
|
||||
@@ -632,13 +590,9 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfi
|
||||
return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
|
||||
}
|
||||
|
||||
// runSetupTUI never reaches recovery: setup with a staff account present lands on
|
||||
// the status screen, so it has no relay to hand over.
|
||||
// gameAddr is where the Owner step tells the operator to join (setupGameAddress).
|
||||
func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, gameAddr string, adminExists bool) (breakGlassResult, error) {
|
||||
rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
|
||||
rm.gameAddr = gameAddr
|
||||
return runConsoleRoot(rm)
|
||||
// runSetupTUI configures deployment before issuing the first panel login link.
|
||||
func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{}))
|
||||
}
|
||||
|
||||
// newConsoleRoot is the console's root model as the host runs it: the summary
|
||||
|
||||
+77
-163
@@ -24,16 +24,14 @@ type fakeOwnerStore struct {
|
||||
settings map[string][]byte
|
||||
audits []api.AuditEntry
|
||||
tokens []setupTokenCall
|
||||
redeems []redeemCall
|
||||
setupIDs []string
|
||||
users map[string]*api.StaffUser // keyed by username
|
||||
admins bool // AdminExists answer
|
||||
ownerSeat string // OwnerUsername answer: the occupied seat, "" when none
|
||||
|
||||
// CompleteOwnerSetup's success result. redeemUserID defaults to the fresh id
|
||||
// the caller passes (the unlinked-UUID case) when left empty.
|
||||
redeemUserID string
|
||||
redeemMCUUID string
|
||||
redeemAuthSource string
|
||||
setupUserID string
|
||||
setupUsername string
|
||||
onboarded bool
|
||||
|
||||
upsertErr error
|
||||
insertErr error
|
||||
@@ -59,12 +57,6 @@ type setupTokenCall struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// redeemCall records the inputs CompleteOwnerSetup was called with.
|
||||
type redeemCall struct {
|
||||
newUserID string
|
||||
code string
|
||||
}
|
||||
|
||||
func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) {
|
||||
if f.adminErr != nil {
|
||||
return false, f.adminErr
|
||||
@@ -118,30 +110,31 @@ func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email s
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteOwnerSetup models the real all-or-nothing transaction: injected failures
|
||||
// record none of the redeem, auth-toggle, or setup-token writes.
|
||||
func (f *fakeOwnerStore) CompleteOwnerSetup(_ context.Context, newUserID, code string, _ time.Time,
|
||||
tokenHash string, expiresAt time.Time) (string, string, string, error) {
|
||||
if f.redeemErr != nil {
|
||||
return "", "", "", f.redeemErr
|
||||
// CompleteOwnerSetup models the transaction without any game link code.
|
||||
func (f *fakeOwnerStore) CompleteOwnerSetup(_ context.Context, newUserID string, _ time.Time,
|
||||
tokenHash string, expiresAt time.Time) (string, string, error) {
|
||||
for _, err := range []error{f.redeemErr, f.setErr, f.createTokenErr} {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if f.setErr != nil {
|
||||
return "", "", "", f.setErr
|
||||
}
|
||||
if f.createTokenErr != nil {
|
||||
return "", "", "", f.createTokenErr
|
||||
}
|
||||
f.redeems = append(f.redeems, redeemCall{newUserID, code})
|
||||
userID := f.redeemUserID
|
||||
userID, username := f.setupUserID, f.setupUsername
|
||||
if userID == "" {
|
||||
userID = newUserID // unlinked UUID → the fresh id becomes the Owner
|
||||
userID = newUserID
|
||||
}
|
||||
if username == "" {
|
||||
username = "owner"
|
||||
}
|
||||
if f.onboarded {
|
||||
return userID, username, api.ErrConflict
|
||||
}
|
||||
f.setupIDs = append(f.setupIDs, newUserID)
|
||||
if f.settings == nil {
|
||||
f.settings = map[string][]byte{}
|
||||
}
|
||||
f.settings[api.LocalAuthEnabledKey] = []byte("true")
|
||||
f.tokens = append(f.tokens, setupTokenCall{tokenHash, userID, expiresAt})
|
||||
return userID, f.redeemMCUUID, f.redeemAuthSource, nil
|
||||
return userID, username, nil
|
||||
}
|
||||
|
||||
func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) error {
|
||||
@@ -710,149 +703,70 @@ func TestPerformAddOperator(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPerformSetupMCBind(t *testing.T) {
|
||||
func TestPerformSetupOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := &fakeOwnerStore{setupUserID: "usr-owner-1"}
|
||||
out, err := performSetupOwner(ctx, f, "https://op.console.example.com:30443", "deploybot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.ownerUsername != "owner" {
|
||||
t.Fatalf("username = %q", out.ownerUsername)
|
||||
}
|
||||
const prefix = "https://op.console.example.com:30443/setup?token="
|
||||
if !strings.HasPrefix(out.setupTokenURL, prefix) {
|
||||
t.Fatalf("URL = %q", out.setupTokenURL)
|
||||
}
|
||||
if len(f.tokens) != 1 || f.tokens[0].userID != "usr-owner-1" {
|
||||
t.Fatalf("tokens = %+v", f.tokens)
|
||||
}
|
||||
raw := strings.TrimPrefix(out.setupTokenURL, prefix)
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
if f.tokens[0].tokenHash != hex.EncodeToString(sum[:]) {
|
||||
t.Fatal("stored token does not match URL")
|
||||
}
|
||||
if !f.tokens[0].expiresAt.After(time.Now()) {
|
||||
t.Fatal("token already expired")
|
||||
}
|
||||
if string(f.settings[api.LocalAuthEnabledKey]) != "true" {
|
||||
t.Fatal("local auth stayed disabled")
|
||||
}
|
||||
e, payload := auditOf(t, f)
|
||||
if e.Actor != "deploybot" || e.Action != "setup.owner_login" || payload["user_id"] != "usr-owner-1" {
|
||||
t.Fatalf("audit = %+v, %v", e, payload)
|
||||
}
|
||||
|
||||
t.Run("binds the owner and mints a setup URL whose token hash is what is stored", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", redeemMCUUID: "mc-uuid-1", redeemAuthSource: "mojang"}
|
||||
out, err := performSetupMCBind(ctx, f, " abc-123 ", "console.example.com", "deploybot")
|
||||
if err != nil {
|
||||
t.Fatalf("performSetupMCBind: %v", err)
|
||||
}
|
||||
// The Owner this mints has no password and no email, so the setup token is the
|
||||
// only door — and handleSetupRedeem is gated on local_auth_enabled. A bind that
|
||||
// leaves the toggle off hands back a URL that answers 403.
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
|
||||
t.Error("local auth was not enabled — the setup URL would 403 local_auth_disabled")
|
||||
}
|
||||
// The bind is attributed by Minecraft identity, because that is what the login
|
||||
// gate verified; a username would be the one thing nobody checked.
|
||||
e, payload := auditOf(t, f)
|
||||
if e.Actor != "deploybot" || e.Source != "setup" || e.Action != "setup.owner_bind" {
|
||||
t.Errorf("audit = %+v, want actor=deploybot source=setup action=setup.owner_bind", e)
|
||||
}
|
||||
if payload["mc_uuid"] != "mc-uuid-1" || payload["auth_source"] != "mojang" {
|
||||
t.Errorf("audit payload = %v, want the redeemed mc_uuid + auth_source", payload)
|
||||
}
|
||||
if out.ownerIdentity != "mc-uuid-1" {
|
||||
t.Errorf("owner identity = %q, want the verified Minecraft UUID", out.ownerIdentity)
|
||||
}
|
||||
const prefix = "https://console.example.com/setup?token="
|
||||
if !strings.HasPrefix(out.setupTokenURL, prefix) {
|
||||
t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix)
|
||||
}
|
||||
// The link code is trimmed and upper-cased before redemption.
|
||||
if len(f.redeems) != 1 {
|
||||
t.Fatalf("want 1 redeem, got %d", len(f.redeems))
|
||||
}
|
||||
if f.redeems[0].code != "ABC-123" {
|
||||
t.Errorf("redeemed code = %q, want ABC-123 (trimmed + upper-cased)", f.redeems[0].code)
|
||||
}
|
||||
if !strings.HasPrefix(f.redeems[0].newUserID, "usr-") {
|
||||
t.Errorf("redeem newUserID = %q, want usr- prefix", f.redeems[0].newUserID)
|
||||
}
|
||||
// Exactly one token minted, for the redeemed user, and only its hash stored —
|
||||
// the stored hash must be sha-256 of the raw token carried in the URL.
|
||||
if len(f.tokens) != 1 {
|
||||
t.Fatalf("want 1 setup token, got %d", len(f.tokens))
|
||||
}
|
||||
tok := f.tokens[0]
|
||||
if tok.userID != "usr-owner-1" {
|
||||
t.Errorf("token userID = %q, want usr-owner-1 (the redeemed owner)", tok.userID)
|
||||
}
|
||||
raw := strings.TrimPrefix(out.setupTokenURL, prefix)
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
if tok.tokenHash != hex.EncodeToString(sum[:]) {
|
||||
t.Error("stored token hash is not sha-256 of the raw token in the URL")
|
||||
}
|
||||
if tok.tokenHash == raw || tok.tokenHash == "" {
|
||||
t.Error("the raw token (or nothing) was stored instead of its hash")
|
||||
}
|
||||
// The token is short-lived and in the future.
|
||||
if !tok.expiresAt.After(time.Now()) {
|
||||
t.Errorf("token expiresAt = %v, want a future time", tok.expiresAt)
|
||||
t.Run("failed writes leave no partially initialized login", func(t *testing.T) {
|
||||
for _, store := range []*fakeOwnerStore{
|
||||
{redeemErr: errors.New("database unavailable")},
|
||||
{setErr: errors.New("settings write failed")},
|
||||
{createTokenErr: errors.New("token write failed")},
|
||||
} {
|
||||
if _, err := performSetupOwner(ctx, store, "https://op.console.example.com", "root"); err == nil {
|
||||
t.Fatal("want failure")
|
||||
}
|
||||
if len(store.tokens) != 0 || len(store.setupIDs) != 0 || len(store.settings) != 0 {
|
||||
t.Fatal("partial setup")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty link code mints nothing", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
if _, err := performSetupMCBind(ctx, f, " ", "console.example.com", "root"); err == nil {
|
||||
t.Fatal("want error for an empty link code")
|
||||
}
|
||||
if len(f.redeems) != 0 || len(f.tokens) != 0 {
|
||||
t.Errorf("want no redeem/token on an empty code, got redeems=%d tokens=%d", len(f.redeems), len(f.tokens))
|
||||
}
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
|
||||
t.Error("local auth was enabled without an owner — the gate must not open on a failed bind")
|
||||
t.Run("settled account is not reset", func(t *testing.T) {
|
||||
store := &fakeOwnerStore{setupUserID: "existing", setupUsername: "alice", onboarded: true}
|
||||
out, err := performSetupOwner(ctx, store, "https://op.console.example.com", "root")
|
||||
if !errors.Is(err, api.ErrConflict) || out.ownerUsername != "alice" || len(store.tokens) != 0 {
|
||||
t.Fatalf("out = %+v err = %v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a link-code redemption failure mints no token", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{redeemErr: errors.New("code expired")}
|
||||
if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
|
||||
t.Fatal("want error when the link code cannot be redeemed")
|
||||
}
|
||||
if len(f.tokens) != 0 {
|
||||
t.Errorf("want no token minted on a redeem failure, got %d", len(f.tokens))
|
||||
}
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
|
||||
t.Error("local auth was enabled without an owner — the gate must not open on a failed redeem")
|
||||
t.Run("audit failure still returns the login link", func(t *testing.T) {
|
||||
out, err := performSetupOwner(ctx, &fakeOwnerStore{auditErr: errors.New("audit down")}, "https://op.console.example.com", "root")
|
||||
if err != nil || out.auditErr == nil || out.setupTokenURL == "" {
|
||||
t.Fatalf("out = %+v err = %v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a local-auth failure fails the bind rather than minting an unredeemable URL", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", setErr: errors.New("db down")}
|
||||
if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
|
||||
t.Fatal("want error when local auth cannot be enabled")
|
||||
}
|
||||
if len(f.redeems) != 0 || len(f.tokens) != 0 {
|
||||
t.Errorf("atomic setup was partially recorded: redeems=%d tokens=%d", len(f.redeems), len(f.tokens))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a token-store failure rolls the bind back", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")}
|
||||
if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
|
||||
t.Fatal("want error when the setup token cannot be stored")
|
||||
}
|
||||
if len(f.redeems) != 0 {
|
||||
t.Errorf("link code was consumed despite token failure, got %d redeems", len(f.redeems))
|
||||
}
|
||||
if len(f.tokens) != 0 {
|
||||
t.Errorf("want no recorded token when the store fails, got %d", len(f.tokens))
|
||||
}
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
|
||||
t.Error("local auth stayed enabled despite transaction rollback")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an audit failure does not cost the operator their install", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", auditErr: errors.New("audit sink down")}
|
||||
out, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("an audit failure must not fail the bind: %v", err)
|
||||
}
|
||||
if out.auditErr == nil {
|
||||
t.Error("the audit failure was swallowed instead of surfaced on the outcome")
|
||||
}
|
||||
if out.setupTokenURL == "" {
|
||||
t.Error("no setup URL minted despite a recoverable audit failure")
|
||||
}
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
|
||||
t.Error("local auth was not enabled despite a recoverable audit failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults to the op.console host when adminHostname is empty", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
|
||||
out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root")
|
||||
if err != nil {
|
||||
t.Fatalf("performSetupMCBind: %v", err)
|
||||
}
|
||||
// The Owner is staff, so onboarding lands on the operator console, not the
|
||||
// player panel — the empty-host fallback must reflect that.
|
||||
if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") {
|
||||
t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL)
|
||||
t.Run("missing HTTPS origin cannot create an account", func(t *testing.T) {
|
||||
store := &fakeOwnerStore{}
|
||||
if _, err := performSetupOwner(ctx, store, "", "root"); err == nil || len(store.tokens) != 0 {
|
||||
t.Fatal("invalid origin accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
+16
-57
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/platform"
|
||||
"felis.lolicon.best/internal/store"
|
||||
@@ -100,20 +99,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
defer setup.drv.Close()
|
||||
|
||||
// The wizard's first screen asks the operator to join the server and run /link:
|
||||
// the Owner IS the Minecraft account, so the login gate must be UP before we ask
|
||||
// for a link code. This used to run after the wizard, which is why setup asked
|
||||
// for a code from a server that had never been started. On a re-run the Owner
|
||||
// already exists, so provisioning stays best-effort and never blocks the
|
||||
// operator from reaching the status screen.
|
||||
if err := provisionSystemServers(ctx, setup.cfg, stdout, !setup.adminExists); err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
fmt.Fprintln(stderr, "The Owner is bound by joining the login gate in-game, so setup cannot continue without it.")
|
||||
return 1
|
||||
}
|
||||
|
||||
gameAddr := setupGameAddress(setup.cfg.Server.RootDomain, setup.cfg.Velocity.GamePort)
|
||||
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), gameAddr, setup.adminExists)
|
||||
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
return 1
|
||||
@@ -122,22 +108,25 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
if panelURL == "" {
|
||||
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
|
||||
}
|
||||
reportSetupResult(stdout, res, bootstrapped, setup.adminExists, panelURL, gameAddr)
|
||||
// Game services are provisioned independently. A failed login/lobby must not
|
||||
// stop the host administrator from opening the panel to diagnose it.
|
||||
if err := provisionSystemServers(ctx, setup.cfg, stdout); err != nil {
|
||||
fmt.Fprintf(stdout, "felis setup: Minecraft services need attention: %v\n", err)
|
||||
}
|
||||
|
||||
reportSetupResult(stdout, res, bootstrapped, setup.adminExists, panelURL)
|
||||
return 0
|
||||
}
|
||||
|
||||
// reportSetupResult prints what the console did, past the alt-screen teardown that
|
||||
// wipes it. A run that ends with no Owner bound, skipped or quit, ends on how to bind
|
||||
// one: nobody can sign in to the panel until then.
|
||||
func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adminExisted bool, panelURL, gameAddr string) {
|
||||
// reportSetupResult preserves the browser handoff after the TUI closes.
|
||||
func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adminExisted bool, panelURL string) {
|
||||
if !adminExisted && !res.provisioned {
|
||||
defer fmt.Fprintf(stdout, "\nNo Owner is bound yet, so nobody can sign in to the panel. To bind one, run\n"+
|
||||
" sudo felis setup\nand join %s in Minecraft when it asks.\n", ownerJoinTarget(gameAddr))
|
||||
defer fmt.Fprintln(stdout, "\nOwner login is unfinished. Run sudo felis setup again to get a panel setup link; Minecraft is not required.")
|
||||
}
|
||||
|
||||
if !res.provisioned && !res.connectConfigured {
|
||||
if bootstrapped {
|
||||
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection setup skipped.")
|
||||
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; panel/connection setup unfinished.")
|
||||
if panelURL != "" {
|
||||
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
||||
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
|
||||
@@ -150,8 +139,6 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
|
||||
switch {
|
||||
case res.alreadySetUp:
|
||||
msg = "felis setup: already set up — nothing to change."
|
||||
case res.ownerSkipped:
|
||||
msg = "felis setup: finished without an Owner."
|
||||
}
|
||||
fmt.Fprintln(stdout, msg)
|
||||
if panelURL != "" {
|
||||
@@ -164,7 +151,7 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
|
||||
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\n", res.username)
|
||||
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
|
||||
if res.setupTokenURL != "" {
|
||||
fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
|
||||
fmt.Fprintf(stdout, "Open this URL to record your email and create a passkey (Minecraft is optional):\n\n %s\n\n", res.setupTokenURL)
|
||||
}
|
||||
if res.auditWarning != "" {
|
||||
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
|
||||
@@ -198,20 +185,9 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
|
||||
// then prints the login-first Velocity wiring. deploy/bootstrap.sh writes this
|
||||
// configuration for its host proxy; operators only need to mirror it when they
|
||||
// deliberately run Velocity elsewhere.
|
||||
//
|
||||
// required is set on a first run, where the next screen asks the operator to join
|
||||
// the server and run /link. There a gate that never comes up is not a degraded
|
||||
// install, it is an impossible one — so every soft landing below becomes a hard
|
||||
// error and we block until the gate reports Ready. On a re-run the Owner already
|
||||
// exists and nothing downstream needs the gate, so unconfigured images or an
|
||||
// unreachable cluster degrade to printed guidance exactly as before.
|
||||
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer, required bool) error {
|
||||
// fail is the one place the two modes diverge: fatal on a first run, guidance
|
||||
// on a re-run.
|
||||
|
||||
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer) error {
|
||||
fail := func(format string, args ...any) error {
|
||||
if required {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
fmt.Fprintf(out, "\nfelis setup: "+format+"\n", args...)
|
||||
return nil
|
||||
}
|
||||
@@ -219,10 +195,6 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
|
||||
return fail("login/lobby system servers NOT provisioned — set [velocity] login_image " +
|
||||
"and lobby_image in felis.toml (build them from deploy/limbo and deploy/lobby), then re-run `sudo felis setup`")
|
||||
}
|
||||
if required && cfg.Velocity.LoginImage == "" {
|
||||
return errors.New("the Owner binds by joining the login gate, but [velocity] login_image is not set in felis.toml " +
|
||||
"(build it from deploy/limbo), then re-run `sudo felis setup`")
|
||||
}
|
||||
cl, err := buildSystemServerClient()
|
||||
if err != nil {
|
||||
return fail("could not reach the cluster to provision the login/lobby system servers: %v\n"+
|
||||
@@ -242,7 +214,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
|
||||
// mount them are created: the login gate's token (login authenticates to
|
||||
// felis-api with it), the Velocity forwarding secret (every backend verifies the proxy's
|
||||
// signed handshake with it — without it the login gate would derive an OFFLINE
|
||||
// UUID and the Owner would bind the wrong Minecraft identity), and felis-config
|
||||
// UUID and players would bind the wrong Minecraft identity), and felis-config
|
||||
// (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to
|
||||
// self-record its world_backups row; without the replica the Job's volume
|
||||
// mount fails and every backup request strands in the cluster).
|
||||
@@ -268,19 +240,6 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
|
||||
fmt.Fprintf(out, " - %s: skipped (%s)\n", o.name, o.skipped)
|
||||
}
|
||||
}
|
||||
if required {
|
||||
if err := requiredProvisioningError(outcomes); err != nil {
|
||||
return fmt.Errorf("required Minecraft provisioning failed: %w", err)
|
||||
}
|
||||
fmt.Fprintln(out, "\nfelis setup: waiting for the login gate to accept players…")
|
||||
err := awaitLoginGateReady(ctx, cl, cfg.K8s.Namespace, loginGateReadyTimeout, loginGatePollInterval, func(p v1alpha1.Phase) {
|
||||
fmt.Fprintf(out, " login: %s\n", phaseOrPending(p))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(out, " login: Ready")
|
||||
}
|
||||
printVelocityWiringGuidance(out, cfg.Server.RootDomain)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,9 +33,6 @@ func setupPanelNodePort() int {
|
||||
}
|
||||
|
||||
func localPanelURL(rootDomain, adminHostname string) string {
|
||||
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
|
||||
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
|
||||
}
|
||||
host := defaultAdminHostname(rootDomain, adminHostname)
|
||||
if host == "" {
|
||||
return ""
|
||||
@@ -57,7 +54,7 @@ func rootDomainEmbeddedIP(rootDomain string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// setupGameAddress is where the operator joins in Minecraft to bind the Owner: the
|
||||
// setupGameAddress is where players join Minecraft: the
|
||||
// IP a nip.io or sslip.io root domain spells out (nothing to resolve), otherwise the
|
||||
// root domain, with the port when it is not Minecraft's default. The proxy lands
|
||||
// every fresh connection on the login server whatever name it was dialled by.
|
||||
@@ -75,15 +72,6 @@ func setupGameAddress(rootDomain string, gamePort int) string {
|
||||
return net.JoinHostPort(host, strconv.Itoa(gamePort))
|
||||
}
|
||||
|
||||
// gameAddrIsIP reports whether addr, a host or host:port, names its host by IP.
|
||||
func gameAddrIsIP(addr string) bool {
|
||||
host := addr
|
||||
if h, _, err := net.SplitHostPort(addr); err == nil {
|
||||
host = h
|
||||
}
|
||||
return net.ParseIP(host) != nil
|
||||
}
|
||||
|
||||
func localPanelOrigin() string {
|
||||
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
@@ -560,75 +558,6 @@ func convergeDerivedEnv(existing, desired *v1alpha1.MinecraftServer) []string {
|
||||
return changes
|
||||
}
|
||||
|
||||
// The login gate is a hard prerequisite of the Owner bind, so setup waits for it
|
||||
// rather than racing it. The ceiling covers a cold image pull on a fresh node;
|
||||
// the poll is fast enough that a warm start feels immediate.
|
||||
const (
|
||||
loginGateReadyTimeout = 5 * time.Minute
|
||||
loginGatePollInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// awaitLoginGateReady blocks until the login system server reports status.ready.
|
||||
//
|
||||
// The Owner claims their seat by JOINING the game and running /link, so the gate
|
||||
// being up is not a nicety — it is the precondition for the very next thing setup
|
||||
// asks of the operator. progress is called on each phase change so the caller can
|
||||
// show movement during a cold image pull; it may be nil.
|
||||
func awaitLoginGateReady(ctx context.Context, cl client.Client, namespace string, timeout, poll time.Duration, progress func(v1alpha1.Phase)) error {
|
||||
key := client.ObjectKey{Namespace: namespace, Name: naming.SystemLoginServer}
|
||||
deadline := time.Now().Add(timeout)
|
||||
last := v1alpha1.Phase("")
|
||||
for {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
switch err := cl.Get(ctx, key, &ms); {
|
||||
case err == nil:
|
||||
if ms.Status.Ready {
|
||||
return nil
|
||||
}
|
||||
if ms.Status.Phase != last {
|
||||
last = ms.Status.Phase
|
||||
if progress != nil {
|
||||
progress(last)
|
||||
}
|
||||
}
|
||||
// The operator only marks Failed once its OWN startup deadline has already
|
||||
// elapsed, so Failed is a settled verdict rather than a transient — sitting
|
||||
// out the rest of our timeout on top of it would only hide the reason.
|
||||
if ms.Status.Phase == v1alpha1.PhaseFailed {
|
||||
return fmt.Errorf("the login gate failed to start: %s", readyConditionMessage(&ms))
|
||||
}
|
||||
case !apierrors.IsNotFound(err):
|
||||
return err
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return fmt.Errorf("timed out after %s waiting for the login gate to become ready (last phase: %s)", timeout, phaseOrPending(last))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyConditionMessage is the operator's own account of why the gate is not
|
||||
// ready — far more useful to an operator than "phase: Failed".
|
||||
func readyConditionMessage(ms *v1alpha1.MinecraftServer) string {
|
||||
if c := meta.FindStatusCondition(ms.Status.Conditions, v1alpha1.ConditionReady); c != nil && c.Message != "" {
|
||||
return c.Message
|
||||
}
|
||||
return "no Ready condition was reported"
|
||||
}
|
||||
|
||||
// phaseOrPending names the empty phase, which means the operator has not
|
||||
// reconciled the server yet (commonly: the operator itself is not running).
|
||||
func phaseOrPending(p v1alpha1.Phase) string {
|
||||
if p == "" {
|
||||
return "not yet reconciled — is the felis operator running?"
|
||||
}
|
||||
return string(p)
|
||||
}
|
||||
|
||||
// provisionSecretReplicas copies the Secrets workload pods mount from the control
|
||||
// namespace into the namespaces those pods run in. The proxy's felis-service-token
|
||||
// is not among them: it lives in the control namespace and on the host, and a copy
|
||||
@@ -794,24 +723,3 @@ func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace
|
||||
}
|
||||
return systemServerOutcome{name: name, created: true, available: true}
|
||||
}
|
||||
|
||||
func requiredProvisioningError(outcomes []systemServerOutcome) error {
|
||||
required := map[string]struct{}{
|
||||
"limbo-token (minecraft ns)": {},
|
||||
"forwarding-secret (minecraft ns)": {},
|
||||
naming.SystemLoginServer: {},
|
||||
}
|
||||
for _, o := range outcomes {
|
||||
if o.err != nil {
|
||||
return fmt.Errorf("%s: %w", o.name, o.err)
|
||||
}
|
||||
if _, ok := required[o.name]; ok && !o.available {
|
||||
reason := o.skipped
|
||||
if reason == "" {
|
||||
reason = "object was not created"
|
||||
}
|
||||
return fmt.Errorf("%s unavailable: %s", o.name, reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
@@ -332,98 +331,6 @@ func TestEnsureSecretReplicaRefresh(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequiredProvisioningError(t *testing.T) {
|
||||
ready := []systemServerOutcome{
|
||||
{name: "limbo-token (minecraft ns)", available: true},
|
||||
{name: "forwarding-secret (minecraft ns)", available: true},
|
||||
{name: naming.SystemLoginServer, available: true},
|
||||
{name: naming.SystemLobbyServer, skipped: "image not configured"},
|
||||
}
|
||||
if err := requiredProvisioningError(ready); err != nil {
|
||||
t.Fatalf("ready outcomes: %v", err)
|
||||
}
|
||||
|
||||
missing := append([]systemServerOutcome(nil), ready...)
|
||||
missing[1] = systemServerOutcome{name: "forwarding-secret (minecraft ns)", skipped: "source missing"}
|
||||
if err := requiredProvisioningError(missing); err == nil || !strings.Contains(err.Error(), "forwarding-secret") {
|
||||
t.Fatalf("missing forwarding secret = %v, want named error", err)
|
||||
}
|
||||
|
||||
// The login gate cannot reach felis-api without its token, so setup must not
|
||||
// report success while that replica is missing.
|
||||
noToken := append([]systemServerOutcome(nil), ready...)
|
||||
noToken[0] = systemServerOutcome{name: "limbo-token (minecraft ns)", skipped: "source missing"}
|
||||
if err := requiredProvisioningError(noToken); err == nil || !strings.Contains(err.Error(), "limbo-token") {
|
||||
t.Fatalf("missing limbo token = %v, want named error", err)
|
||||
}
|
||||
|
||||
failed := append([]systemServerOutcome(nil), ready...)
|
||||
failed[3] = systemServerOutcome{name: naming.SystemLobbyServer, err: context.DeadlineExceeded}
|
||||
if err := requiredProvisioningError(failed); err == nil || !strings.Contains(err.Error(), naming.SystemLobbyServer) {
|
||||
t.Fatalf("lobby create failure = %v, want immediate named error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The Owner binds by joining the game, so setup blocks on the login gate rather
|
||||
// than racing it. What matters is that each ending is distinguishable: Ready
|
||||
// proceeds, Failed reports the operator's own reason instead of waiting out the
|
||||
// clock, and a gate that never appears (no operator reconciling it) times out
|
||||
// saying so rather than dropping the operator on a bind screen that cannot work.
|
||||
func TestAwaitLoginGateReady(t *testing.T) {
|
||||
scheme := newSystemServerScheme(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gate := func(mut func(*v1alpha1.MinecraftServer)) *v1alpha1.MinecraftServer {
|
||||
ms := &v1alpha1.MinecraftServer{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.SystemLoginServer, Namespace: "minecraft"},
|
||||
}
|
||||
mut(ms)
|
||||
return ms
|
||||
}
|
||||
|
||||
t.Run("returns once the gate is ready", func(t *testing.T) {
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gate(func(ms *v1alpha1.MinecraftServer) {
|
||||
ms.Status.Phase = v1alpha1.PhaseRunning
|
||||
ms.Status.Ready = true
|
||||
})).Build()
|
||||
if err := awaitLoginGateReady(ctx, cl, "minecraft", time.Second, 10*time.Millisecond, nil); err != nil {
|
||||
t.Fatalf("await: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails fast on Failed, carrying the operator's reason", func(t *testing.T) {
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gate(func(ms *v1alpha1.MinecraftServer) {
|
||||
ms.Status.Phase = v1alpha1.PhaseFailed
|
||||
ms.Status.Conditions = []metav1.Condition{{
|
||||
Type: v1alpha1.ConditionReady,
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "StartupTimeout",
|
||||
Message: "pod never became ready: ImagePullBackOff",
|
||||
LastTransitionTime: metav1.Now(),
|
||||
}}
|
||||
})).Build()
|
||||
start := time.Now()
|
||||
err := awaitLoginGateReady(ctx, cl, "minecraft", time.Minute, 10*time.Millisecond, nil)
|
||||
if err == nil {
|
||||
t.Fatal("await: nil error, want failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ImagePullBackOff") {
|
||||
t.Errorf("error = %q, want the operator's Ready-condition message", err)
|
||||
}
|
||||
if time.Since(start) > 5*time.Second {
|
||||
t.Error("await sat out the full timeout on a settled Failed verdict")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("times out when nothing ever reconciles the gate", func(t *testing.T) {
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||
err := awaitLoginGateReady(ctx, cl, "minecraft", 30*time.Millisecond, 10*time.Millisecond, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
||||
t.Fatalf("await = %v, want a timeout", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newSystemServerScheme(t *testing.T) *runtime.Scheme {
|
||||
t.Helper()
|
||||
scheme := runtime.NewScheme()
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestRootSummaryReadsAlertRoute(t *testing.T) {
|
||||
return route
|
||||
}
|
||||
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
|
||||
sum, ok := m.screen.(*summaryModel)
|
||||
if !ok || sum.alerts == nil || sum.alerts.relay != "" {
|
||||
t.Fatalf("summary after storage: %T %+v", m.screen, sum)
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
)
|
||||
|
||||
// mcBindModel is the `felis setup` Owner-establishment screen: the operator
|
||||
// joins the server, reads the one-time code the login server shows, and types it
|
||||
// here. The bound Minecraft account is promoted to the passwordless Owner, and a
|
||||
// one-time setup URL is minted for the first web login. It replaces the old
|
||||
// ownerModel bootstrap form in setup mode — no username/email/password is typed
|
||||
// here, the MC identity is the root of trust.
|
||||
//
|
||||
// The screen says where to join and what the code looks like, because the
|
||||
// operator arrives here straight from the installer with nothing else to go on.
|
||||
// A refused code returns to the form with the reason: CompleteOwnerSetup rolls
|
||||
// back on every failure, so another try is always safe. An empty code offers to
|
||||
// skip, and setup goes on to the connection and storage steps without an Owner.
|
||||
type mcBindModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
adminHost string
|
||||
osUser string
|
||||
gameAddr string // where to join in Minecraft; "" names no address
|
||||
|
||||
step mcBindStep
|
||||
form *huh.Form
|
||||
sp spinner.Model
|
||||
working string
|
||||
|
||||
linkCode string
|
||||
skip bool // the skip confirmation's answer
|
||||
note string // why the last code was refused, shown above the rebuilt form
|
||||
ownerIdentity string
|
||||
setupTokenURL string
|
||||
auditWarning string
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
// ownerSkippedMsg leaves the Owner step without binding one.
|
||||
type ownerSkippedMsg struct{}
|
||||
|
||||
type mcBindStep int
|
||||
|
||||
const (
|
||||
mcBindForm mcBindStep = iota
|
||||
mcBindWorking
|
||||
mcBindDone
|
||||
)
|
||||
|
||||
type mcBindMsg struct {
|
||||
outcome breakGlassOutcome
|
||||
err error
|
||||
}
|
||||
|
||||
func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser, gameAddr string) *mcBindModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = tuiLabel
|
||||
m := &mcBindModel{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
adminHost: adminHost,
|
||||
osUser: osUser,
|
||||
gameAddr: gameAddr,
|
||||
sp: sp,
|
||||
step: mcBindForm,
|
||||
}
|
||||
m.form = m.buildForm()
|
||||
return m
|
||||
}
|
||||
|
||||
// buildForm starts the code entry afresh, with the last refusal (if any) on top.
|
||||
func (m *mcBindModel) buildForm() *huh.Form {
|
||||
m.linkCode, m.skip = "", false
|
||||
desc := m.instructions()
|
||||
if m.note != "" {
|
||||
desc = m.note + "\n\n" + desc
|
||||
}
|
||||
return m.sized(newFelisForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Bind the Owner's Minecraft account").
|
||||
Description(desc),
|
||||
huh.NewInput().
|
||||
Title("Link code").
|
||||
Description("Leave it empty and press enter to skip this step for now.").
|
||||
Placeholder("K7M2QX9P").
|
||||
Value(&m.linkCode).
|
||||
Validate(validLinkCode),
|
||||
),
|
||||
// Asked only for an empty code, so an enter pressed too early cannot skip.
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Skip the Owner for now?").
|
||||
Description("Setup goes on to the connection and storage steps. Nobody can sign in\n"+
|
||||
"to the panel until an Owner is bound: run sudo felis setup again to bind one.").
|
||||
Affirmative("Skip for now").
|
||||
Negative("Enter a code").
|
||||
Value(&m.skip),
|
||||
).WithHideFunc(func() bool { return normalizeLinkCode(m.linkCode) != "" }),
|
||||
))
|
||||
}
|
||||
|
||||
// instructions is the way to a code, for an operator who has only this screen.
|
||||
func (m *mcBindModel) instructions() string {
|
||||
join := ownerJoinTarget(m.gameAddr)
|
||||
if m.gameAddr != "" && !gameAddrIsIP(m.gameAddr) {
|
||||
join += "\n (or this host's IP address while that name does not point here yet)"
|
||||
}
|
||||
return fmt.Sprintf("The Minecraft account you bind becomes the Owner and signs in to the\n"+
|
||||
"panel without a password.\n\n"+
|
||||
"1. In Minecraft (Java Edition), join %s\n"+
|
||||
"2. The login server opens a book with your link code; chat shows it too.\n"+
|
||||
" It is %d characters and works for %d minutes. /link prints a new one.\n"+
|
||||
"3. Type the code below. The web link in the book is for players: the\n"+
|
||||
" Owner's code goes here.",
|
||||
join, api.LinkCodeLen, int(api.LinkCodeTTL/time.Minute))
|
||||
}
|
||||
|
||||
// ownerJoinTarget names where to join in Minecraft to bind the Owner.
|
||||
func ownerJoinTarget(gameAddr string) string {
|
||||
if gameAddr == "" {
|
||||
return "this server"
|
||||
}
|
||||
return gameAddr
|
||||
}
|
||||
|
||||
// normalizeLinkCode is a code as the store keeps it: upper case, without the
|
||||
// spaces or dashes an operator may type to group it.
|
||||
func normalizeLinkCode(s string) string {
|
||||
return strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(s)))
|
||||
}
|
||||
|
||||
// validLinkCode catches a mistyped code before it costs a round trip. Empty is
|
||||
// valid: it asks to skip.
|
||||
func validLinkCode(s string) error {
|
||||
code := normalizeLinkCode(s)
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
if n := utf8.RuneCountInString(code); n != api.LinkCodeLen {
|
||||
return fmt.Errorf("a link code is %d characters; this is %d", api.LinkCodeLen, n)
|
||||
}
|
||||
for _, c := range code {
|
||||
if !strings.ContainsRune(api.LinkCodeAlphabet, c) {
|
||||
return fmt.Errorf("a link code never contains %q (codes leave out I, O, 0 and 1)", c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindFailureNote says why a code was refused and what to do next.
|
||||
func bindFailureNote(err error) string {
|
||||
if errors.Is(err, api.ErrLinkCodeInvalid) {
|
||||
return fmt.Sprintf("✗ That code was not accepted: it is mistyped, older than %d minutes, or\n"+
|
||||
" already used. Type /link in Minecraft for a new one.", int(api.LinkCodeTTL/time.Minute))
|
||||
}
|
||||
return "✗ Binding failed: " + err.Error() + "\n Nothing was changed; try again."
|
||||
}
|
||||
|
||||
func (m *mcBindModel) sized(f *huh.Form) *huh.Form {
|
||||
if m.width > 0 {
|
||||
return f.WithWidth(m.width).WithHeight(m.height)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *mcBindModel) setSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
if m.form != nil {
|
||||
m.form = m.form.WithWidth(w).WithHeight(h)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mcBindModel) Init() tea.Cmd { return m.form.Init() }
|
||||
|
||||
func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case mcBindMsg:
|
||||
if msg.err != nil {
|
||||
m.step = mcBindForm
|
||||
m.note = bindFailureNote(msg.err)
|
||||
m.form = m.buildForm()
|
||||
return m, m.form.Init()
|
||||
}
|
||||
m.note = ""
|
||||
m.step = mcBindDone
|
||||
m.ownerIdentity = msg.outcome.ownerIdentity
|
||||
m.setupTokenURL = msg.outcome.setupTokenURL
|
||||
if msg.outcome.auditErr != nil {
|
||||
m.auditWarning = msg.outcome.auditErr.Error()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.step == mcBindWorking {
|
||||
var cmd tea.Cmd
|
||||
m.sp, cmd = m.sp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.step {
|
||||
case mcBindDone:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "enter":
|
||||
return m, m.resultCmd()
|
||||
}
|
||||
return m, nil
|
||||
case mcBindWorking:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.step == mcBindForm && m.form != nil {
|
||||
form, cmd := m.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
m.form = f
|
||||
}
|
||||
switch m.form.State {
|
||||
case huh.StateCompleted:
|
||||
return m.onFormComplete()
|
||||
case huh.StateAborted:
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) {
|
||||
code := normalizeLinkCode(m.linkCode)
|
||||
if code == "" {
|
||||
if m.skip {
|
||||
return m, func() tea.Msg { return ownerSkippedMsg{} }
|
||||
}
|
||||
// "Enter a code": back to the entry, keeping any refusal on screen.
|
||||
m.form = m.buildForm()
|
||||
return m, m.form.Init()
|
||||
}
|
||||
m.step = mcBindWorking
|
||||
m.working = "Binding Minecraft account…"
|
||||
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
||||
out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser)
|
||||
return mcBindMsg{outcome: out, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mcBindModel) resultCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return ownerResultMsg{
|
||||
username: m.ownerIdentity,
|
||||
setupTokenURL: m.setupTokenURL,
|
||||
mode: "setup",
|
||||
accountable: m.osUser,
|
||||
auditWarning: m.auditWarning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mcBindModel) View() string {
|
||||
switch m.step {
|
||||
case mcBindWorking:
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Working…"
|
||||
}
|
||||
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n"
|
||||
case mcBindDone:
|
||||
return m.doneView()
|
||||
default:
|
||||
if m.form == nil {
|
||||
return ""
|
||||
}
|
||||
return m.form.View()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mcBindModel) doneView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
|
||||
|
||||
var box strings.Builder
|
||||
if m.ownerIdentity != "" {
|
||||
box.WriteString(tuiLabel.Render("minecraft ") + m.ownerIdentity + "\n")
|
||||
}
|
||||
if m.setupTokenURL != "" {
|
||||
if box.Len() > 0 {
|
||||
box.WriteString("\n")
|
||||
}
|
||||
box.WriteString(tuiLabel.Render("setup URL ") + "\n")
|
||||
for _, line := range wrapDisplayURL(m.setupTokenURL, 70) {
|
||||
box.WriteString(tuiPassword.Render(line) + "\n")
|
||||
}
|
||||
box.WriteString("\n")
|
||||
box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup.\nIt is shown only once."))
|
||||
}
|
||||
if m.auditWarning != "" {
|
||||
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
|
||||
b.WriteString(tuiAction("enter", "continue"))
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
)
|
||||
|
||||
// cmdMsgs runs cmd and the commands of any batch it returns, and collects the
|
||||
// messages.
|
||||
func cmdMsgs(cmd tea.Cmd) []tea.Msg {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
msg := cmd()
|
||||
if batch, ok := msg.(tea.BatchMsg); ok {
|
||||
var out []tea.Msg
|
||||
for _, c := range batch {
|
||||
out = append(out, cmdMsgs(c)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []tea.Msg{msg}
|
||||
}
|
||||
|
||||
// settle hands m the messages cmd produces, as the program would, and returns
|
||||
// them. A huh form draws its fields only once it has handled a message.
|
||||
func settle(m *mcBindModel, cmd tea.Cmd) []tea.Msg {
|
||||
msgs := cmdMsgs(cmd)
|
||||
for _, msg := range msgs {
|
||||
m.Update(msg)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// openBind is the bind screen as the wizard first shows it.
|
||||
func openBind(store ownerStore, gameAddr string) *mcBindModel {
|
||||
m := newMCBindModel(context.Background(), store, "console.example.com", "root", gameAddr)
|
||||
m.setSize(100, 60)
|
||||
settle(m, m.Init())
|
||||
return m
|
||||
}
|
||||
|
||||
// leavesBindScreen reports whether any of msgs ends the Owner step.
|
||||
func leavesBindScreen(msgs []tea.Msg) bool {
|
||||
for _, msg := range msgs {
|
||||
switch msg.(type) {
|
||||
case ownerResultMsg, ownerSkippedMsg, tea.QuitMsg:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestMCBindSaysWhereToJoinAndWhatTheCodeIs(t *testing.T) {
|
||||
view := openBind(&fakeOwnerStore{}, "10.0.0.5").View()
|
||||
for _, want := range []string{"join 10.0.0.5", "8 characters", "10 minutes", "/link", "Leave it empty"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("bind screen does not say %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if strings.Contains(view, "IP address while") {
|
||||
t.Errorf("an IP address needs no IP fallback:\n%s", view)
|
||||
}
|
||||
|
||||
named := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "play.example.net:25570")
|
||||
if got := named.instructions(); !strings.Contains(got, "join play.example.net:25570\n (or this host's IP address") {
|
||||
t.Errorf("a hostname should come with the IP fallback:\n%s", got)
|
||||
}
|
||||
unnamed := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "")
|
||||
if got := unnamed.instructions(); !strings.Contains(got, "join this server\n") {
|
||||
t.Errorf("no address should still say where to join:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidLinkCode(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, wantErr string
|
||||
}{
|
||||
{"", ""}, // skip
|
||||
{" ", ""},
|
||||
{"K7M2QX9P", ""},
|
||||
{"k7m2qx9p", ""},
|
||||
{" K7M2-QX9P ", ""},
|
||||
{"K7M2 QX9P", ""},
|
||||
{"ABCD12", "a link code is 8 characters; this is 6"},
|
||||
{"K7M2QX9PZ", "a link code is 8 characters; this is 9"},
|
||||
{"K7M2QX9O", `never contains 'O'`},
|
||||
{"K7M2QX90", `never contains '0'`},
|
||||
{"K7M2QX9É", `never contains 'É'`},
|
||||
} {
|
||||
err := validLinkCode(tc.in)
|
||||
switch {
|
||||
case tc.wantErr == "" && err != nil:
|
||||
t.Errorf("validLinkCode(%q) = %v, want nil", tc.in, err)
|
||||
case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)):
|
||||
t.Errorf("validLinkCode(%q) = %v, want an error with %q", tc.in, err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A refused code is the operator's most likely mistake; it must leave them on the
|
||||
// form with the reason, never end setup.
|
||||
func TestMCBindRefusedCodeStaysOnTheForm(t *testing.T) {
|
||||
store := &fakeOwnerStore{redeemErr: api.ErrLinkCodeInvalid}
|
||||
m := openBind(store, "10.0.0.5")
|
||||
m.linkCode = "k7m2-qx9p"
|
||||
_, cmd := m.onFormComplete()
|
||||
if m.step != mcBindWorking {
|
||||
t.Fatalf("step = %v after submit, want mcBindWorking", m.step)
|
||||
}
|
||||
var result tea.Msg
|
||||
for _, msg := range cmdMsgs(cmd) {
|
||||
if r, ok := msg.(mcBindMsg); ok {
|
||||
result = r
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("submitting a code did not try to bind it")
|
||||
}
|
||||
next, cmd := m.Update(result)
|
||||
if next != m || m.step != mcBindForm {
|
||||
t.Fatalf("after a refused code: model %T step %v, want the bind form", next, m.step)
|
||||
}
|
||||
if leavesBindScreen(settle(m, cmd)) {
|
||||
t.Fatal("a refused code ended the Owner step")
|
||||
}
|
||||
view := m.View()
|
||||
for _, want := range []string{"That code was not accepted", "older than 10 minutes", "Type /link", "join 10.0.0.5"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("refused-code form does not say %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if m.linkCode != "" {
|
||||
t.Errorf("the refused code %q is still in the field", m.linkCode)
|
||||
}
|
||||
|
||||
// The next code goes through, and the refusal leaves with it.
|
||||
store.redeemErr = nil
|
||||
m.linkCode = "K7M2QX9P"
|
||||
_, cmd = m.onFormComplete()
|
||||
for _, msg := range cmdMsgs(cmd) {
|
||||
if r, ok := msg.(mcBindMsg); ok {
|
||||
m.Update(r)
|
||||
}
|
||||
}
|
||||
if m.step != mcBindDone {
|
||||
t.Fatalf("step = %v after a good code, want mcBindDone", m.step)
|
||||
}
|
||||
if got := store.redeems[len(store.redeems)-1].code; got != "K7M2QX9P" {
|
||||
t.Errorf("bound code %q, want K7M2QX9P", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCBindOtherFailureStaysOnTheForm(t *testing.T) {
|
||||
m := openBind(&fakeOwnerStore{}, "10.0.0.5")
|
||||
_, cmd := m.Update(mcBindMsg{err: fmt.Errorf("complete owner setup: %w", errors.New("connection refused"))})
|
||||
if m.step != mcBindForm || leavesBindScreen(settle(m, cmd)) {
|
||||
t.Fatalf("a failed bind left the form: step %v", m.step)
|
||||
}
|
||||
view := m.View()
|
||||
for _, want := range []string{"Binding failed: complete owner setup: connection refused", "Nothing was changed"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("failed-bind form does not say %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty code skips only once the operator confirms; "Enter a code" goes back.
|
||||
func TestMCBindEmptyCodeSkipsOnlyWhenConfirmed(t *testing.T) {
|
||||
m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "10.0.0.5")
|
||||
m.linkCode, m.skip = " ", false
|
||||
_, cmd := m.onFormComplete()
|
||||
if m.step != mcBindForm || leavesBindScreen(cmdMsgs(cmd)) {
|
||||
t.Fatalf("declining the skip left the form: step %v", m.step)
|
||||
}
|
||||
|
||||
m.linkCode, m.skip = "", true
|
||||
_, cmd = m.onFormComplete()
|
||||
skipped := false
|
||||
for _, msg := range cmdMsgs(cmd) {
|
||||
if _, ok := msg.(ownerSkippedMsg); ok {
|
||||
skipped = true
|
||||
}
|
||||
}
|
||||
if !skipped {
|
||||
t.Fatal("a confirmed skip did not leave the Owner step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGoesOnWhenTheOwnerIsSkipped(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m.gameAddr = "10.0.0.5"
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
bind, ok := m.screen.(*mcBindModel)
|
||||
if !ok || bind.gameAddr != "10.0.0.5" {
|
||||
t.Fatalf("bind screen = %T without the game address", m.screen)
|
||||
}
|
||||
|
||||
m = drive(t, m, ownerSkippedMsg{})
|
||||
if m.stage != stageConnect {
|
||||
t.Fatalf("after skipping, stage = %v, want stageConnect", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*connectChooserModel); !ok {
|
||||
t.Fatalf("after skipping, screen = %T, want *connectChooserModel", m.screen)
|
||||
}
|
||||
if !m.result.ownerSkipped || m.result.provisioned {
|
||||
t.Fatalf("skip not recorded: %+v", m.result)
|
||||
}
|
||||
if got := m.reviewBody(int(stageOwner)); !strings.Contains(got, "Owner account skipped") {
|
||||
t.Errorf("review of the Owner step = %q", got)
|
||||
}
|
||||
|
||||
m = drive(t, m, connectResultMsg{method: connectLocal})
|
||||
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"})
|
||||
sum, ok := m.screen.(*summaryModel)
|
||||
if !ok {
|
||||
t.Fatalf("screen = %T, want *summaryModel", m.screen)
|
||||
}
|
||||
view := sum.View()
|
||||
for _, want := range []string{"Setup finished without an Owner", "not bound", "run sudo felis setup again and join 10.0.0.5"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("summary does not say %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"Setup complete", "You won't need this console again"} {
|
||||
if strings.Contains(view, unwanted) {
|
||||
t.Errorf("summary without an Owner says %q:\n%s", unwanted, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportSetupResultWithoutAnOwner(t *testing.T) {
|
||||
const hint = "No Owner is bound yet, so nobody can sign in to the panel."
|
||||
const bindLast = hint + " To bind one, run\n sudo felis setup\nand join 10.0.0.5 in Minecraft when it asks.\n"
|
||||
report := func(res breakGlassResult, adminExisted bool) string {
|
||||
var b bytes.Buffer
|
||||
reportSetupResult(&b, res, false, adminExisted, "https://10.0.0.5:30443", "10.0.0.5")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
out := report(breakGlassResult{ownerSkipped: true, connectMethod: connectLocal}, false)
|
||||
if !strings.Contains(out, "finished without an Owner") || strings.Contains(out, "cancelled") {
|
||||
t.Errorf("a skipped Owner reads as:\n%s", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, bindLast) {
|
||||
t.Errorf("a skipped Owner does not end on how to bind one:\n%s", out)
|
||||
}
|
||||
|
||||
out = report(breakGlassResult{}, false)
|
||||
if !strings.Contains(out, "cancelled — no changes made.") || !strings.HasSuffix(out, bindLast) {
|
||||
t.Errorf("quitting before an Owner is bound reads as:\n%s", out)
|
||||
}
|
||||
|
||||
out = report(breakGlassResult{ownerSkipped: true, connectMethod: connectReverseProxy, connectConfigured: true, reverseProxyGuide: "proxy guide"}, false)
|
||||
if !strings.Contains(out, "proxy guide") || !strings.HasSuffix(out, bindLast) {
|
||||
t.Errorf("a skipped Owner with a configured front reads as:\n%s", out)
|
||||
}
|
||||
|
||||
for name, res := range map[string]breakGlassResult{
|
||||
"re-run": {alreadySetUp: true},
|
||||
"provisioned": {provisioned: true, username: "mc-uuid-1"},
|
||||
} {
|
||||
adminExisted := name == "re-run"
|
||||
if out := report(res, adminExisted); strings.Contains(out, hint) {
|
||||
t.Errorf("%s: says no Owner is bound:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupGameAddress(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
root string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"10.211.55.6.nip.io", 0, "10.211.55.6"},
|
||||
{"10.211.55.6.nip.io", 25565, "10.211.55.6"},
|
||||
{"10.211.55.6.sslip.io.", 25570, "10.211.55.6:25570"},
|
||||
{"play.example.net", 0, "play.example.net"},
|
||||
{"play.example.net", 25570, "play.example.net:25570"},
|
||||
{"", 25570, ""},
|
||||
} {
|
||||
if got := setupGameAddress(tc.root, tc.port); got != tc.want {
|
||||
t.Errorf("setupGameAddress(%q, %d) = %q, want %q", tc.root, tc.port, got, tc.want)
|
||||
}
|
||||
}
|
||||
for addr, want := range map[string]bool{
|
||||
"10.0.0.5": true, "10.0.0.5:25570": true, "play.example.net": false, "play.example.net:25570": false,
|
||||
} {
|
||||
if got := gameAddrIsIP(addr); got != want {
|
||||
t.Errorf("gameAddrIsIP(%q) = %v, want %v", addr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// press sends key to m and runs a few rounds of the commands that follow, as the
|
||||
// program would, so huh can move between fields and groups.
|
||||
func press(m *mcBindModel, key tea.KeyMsg) {
|
||||
_, cmd := m.Update(key)
|
||||
for round := 0; round < 4 && cmd != nil; round++ {
|
||||
var next []tea.Cmd
|
||||
for _, msg := range cmdMsgs(cmd) {
|
||||
if _, c := m.Update(msg); c != nil {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
cmd = tea.Batch(next...)
|
||||
}
|
||||
}
|
||||
|
||||
// The skip question comes only for an empty code: a typed code goes straight to
|
||||
// the bind.
|
||||
func TestMCBindFormAsksBeforeSkipping(t *testing.T) {
|
||||
m := openBind(&fakeOwnerStore{}, "10.0.0.5")
|
||||
press(m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if view := m.View(); m.step != mcBindForm || !strings.Contains(view, "Skip the Owner for now?") {
|
||||
t.Fatalf("enter on an empty code should ask before skipping: step %v\n%s", m.step, view)
|
||||
}
|
||||
|
||||
m = openBind(&fakeOwnerStore{}, "10.0.0.5")
|
||||
press(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("K7M2QX9P")})
|
||||
press(m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step == mcBindForm {
|
||||
t.Fatalf("a typed code did not go to the bind:\n%s", m.View())
|
||||
}
|
||||
}
|
||||
@@ -211,23 +211,14 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCBindCarriesAuditWarning(t *testing.T) {
|
||||
m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "10.0.0.5")
|
||||
next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{
|
||||
ownerIdentity: "mc-uuid-1",
|
||||
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
|
||||
auditErr: errors.New("audit insert failed"),
|
||||
func TestSetupOwnerCarriesAuditWarning(t *testing.T) {
|
||||
m := newSetupOwnerModel(context.Background(), &fakeOwnerStore{}, "https://op.console.example.com", "root")
|
||||
_, cmd := m.Update(setupOwnerMsg{outcome: breakGlassOutcome{
|
||||
ownerUsername: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", auditErr: errors.New("audit insert failed"),
|
||||
}})
|
||||
bound := next.(*mcBindModel)
|
||||
if !strings.Contains(bound.doneView(), "audit insert failed") {
|
||||
t.Fatalf("done view did not surface audit warning:\n%s", bound.doneView())
|
||||
}
|
||||
res := bound.resultCmd()().(ownerResultMsg)
|
||||
if res.username != "mc-uuid-1" {
|
||||
t.Fatalf("result username = %q, want verified Minecraft UUID", res.username)
|
||||
}
|
||||
if res.auditWarning != "audit insert failed" {
|
||||
t.Fatalf("result audit warning = %q", res.auditWarning)
|
||||
res := cmd().(ownerResultMsg)
|
||||
if res.username != "owner" || res.auditWarning != "audit insert failed" {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+61
-46
@@ -94,9 +94,9 @@ type wizardStage int
|
||||
|
||||
const (
|
||||
stagePreflight wizardStage = iota
|
||||
stageOwner
|
||||
stageConnect
|
||||
stageStorage
|
||||
stageOwner
|
||||
stageSummary
|
||||
// stageMenu is the break-glass operation menu. It is appended last so the
|
||||
// setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed
|
||||
@@ -109,7 +109,7 @@ const (
|
||||
// and the post-install wizard owns cells 1–4. Defining it once keeps the two
|
||||
// programs' breadcrumbs identical so the rail reads as a single continuous bar
|
||||
// rather than restarting when the wizard takes over.
|
||||
var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Storage", "Done"}
|
||||
var setupRailSteps = []string{"Bootstrap", "Preflight", "Connection", "Storage", "Panel login", "Done"}
|
||||
|
||||
type rootModel struct {
|
||||
ctx context.Context
|
||||
@@ -142,7 +142,6 @@ type rootModel struct {
|
||||
panelHost string
|
||||
accessAud string
|
||||
namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op
|
||||
gameAddr string // where to join in Minecraft to bind the Owner (setupGameAddress)
|
||||
adminExists bool
|
||||
recovery recoveryConfig // how the account operations mail a recovery code
|
||||
// alertRoute reads where the watchdog's alerts go for the summary; nil
|
||||
@@ -212,14 +211,14 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case preflightDoneMsg:
|
||||
if m.adminExists {
|
||||
// Re-run: setup already happened. Land on the status screen.
|
||||
return m.showStatus()
|
||||
return m.startOwnerSetup()
|
||||
}
|
||||
m.stage = stageOwner
|
||||
if m.mode == consoleModeSetup {
|
||||
return m.adopt(newMCBindModel(m.ctx, m.store, defaultAdminHostname(m.rootDomain, m.adminHost), m.osUser, m.gameAddr))
|
||||
}
|
||||
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
|
||||
m.stage = stageConnect
|
||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
||||
|
||||
case setupReadyMsg:
|
||||
m.result.username = msg.username
|
||||
return m.showStatus()
|
||||
|
||||
case menuChoiceMsg:
|
||||
// The break-glass menu picked an account operation; build its screen. Both reuse
|
||||
@@ -290,21 +289,19 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.adminExists = true
|
||||
m.stage = stageConnect
|
||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
||||
|
||||
case ownerSkippedMsg:
|
||||
// The rest of the wizard needs no Owner; the summary and the exit message say
|
||||
// how to come back and bind one.
|
||||
m.result.ownerSkipped = true
|
||||
m.stage = stageConnect
|
||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
||||
if m.result.alreadySetUp {
|
||||
return m.showStatus()
|
||||
}
|
||||
return m.showSummary()
|
||||
|
||||
case connectResultMsg:
|
||||
m.applyConnectResult(msg)
|
||||
if m.reconfiguringConnect {
|
||||
// Changing only the connection — storage is already set, so skip it.
|
||||
m.reconfiguringConnect = false
|
||||
if m.result.setupTokenURL != "" {
|
||||
return m.startOwnerSetup()
|
||||
}
|
||||
return m.showSummary()
|
||||
}
|
||||
m.stage = stageStorage
|
||||
@@ -313,6 +310,9 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case storageResultMsg:
|
||||
m.result.storageMethod = msg.method
|
||||
m.result.storageDetail = msg.detail
|
||||
if !m.adminExists {
|
||||
return m.startOwnerSetup()
|
||||
}
|
||||
return m.showSummary()
|
||||
|
||||
case storageBackMsg:
|
||||
@@ -423,7 +423,7 @@ func (m *rootModel) displayStage() int {
|
||||
|
||||
// reviewBody renders a read-only recap of an already-completed step. Steps in
|
||||
// this wizard commit as you finish them (the Owner account and its one-time
|
||||
// password are created on submit), so review is deliberately look-only — there
|
||||
// login link are created on submit), so review is deliberately look-only — there
|
||||
// is no re-editing a step you've passed.
|
||||
func (m *rootModel) reviewBody(stage int) string {
|
||||
var b strings.Builder
|
||||
@@ -432,16 +432,8 @@ func (m *rootModel) reviewBody(stage int) string {
|
||||
b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
|
||||
b.WriteString(tuiHint.Render("Control plane verified before configuration."))
|
||||
case stageOwner:
|
||||
if m.result.ownerSkipped {
|
||||
b.WriteString(tuiWarn.Render("– Owner account skipped") + "\n")
|
||||
b.WriteString(tuiHint.Render("Run sudo felis setup again to bind it."))
|
||||
break
|
||||
}
|
||||
b.WriteString(tuiOK.Render("✓ Owner account") + "\n")
|
||||
if m.result.username != "" {
|
||||
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
|
||||
}
|
||||
b.WriteString(tuiHint.Render("Created and recorded. The one-time setup URL was shown on the Owner step."))
|
||||
b.WriteString(tuiOK.Render("✓ Panel login") + "\n")
|
||||
b.WriteString(tuiHint.Render("Open the one-time setup link in the summary to create your passkey."))
|
||||
case stageConnect:
|
||||
b.WriteString(tuiOK.Render("✓ Connection") + "\n")
|
||||
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n")
|
||||
@@ -504,10 +496,9 @@ func (m *rootModel) View() string {
|
||||
// adminExistsAtStart reports whether this run began with an Owner already
|
||||
// present (a re-run). The rail only makes sense for the first-run linear wizard.
|
||||
func (m *rootModel) adminExistsAtStart() bool {
|
||||
// adminExists flips true once we provision the Owner mid-run; the rail should
|
||||
// keep showing through the connect/summary stages of that same first run. So
|
||||
// only suppress the rail when the Owner pre-existed AND we never provisioned.
|
||||
return m.adminExists && !m.result.provisioned
|
||||
// First-run creation flips adminExists, but must keep its rail. Resuming an
|
||||
// unfinished browser login is still a re-run even though it renews a token.
|
||||
return m.result.alreadySetUp || (m.adminExists && !m.result.provisioned)
|
||||
}
|
||||
|
||||
func (m *rootModel) rail() string {
|
||||
@@ -554,7 +545,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) {
|
||||
m.result.connectConfigured = true
|
||||
m.result.reverseProxyGuide = msg.guide
|
||||
}
|
||||
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain, m.adminHost)
|
||||
m.result.panelURL = panelURLFor(msg.method, m.rootDomain, m.result.adminHostname)
|
||||
}
|
||||
|
||||
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
|
||||
@@ -566,9 +557,8 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
|
||||
return m.adopt(&summaryModel{
|
||||
panelURL: m.result.panelURL,
|
||||
ownerUsername: m.result.username,
|
||||
ownerSkipped: m.result.ownerSkipped,
|
||||
gameAddr: m.gameAddr,
|
||||
setupTokenURL: m.result.setupTokenURL,
|
||||
auditWarning: m.result.auditWarning,
|
||||
accessLabel: connectMethodLabel(m.result.connectMethod),
|
||||
storageLabel: m.result.storageDetail,
|
||||
routedHosts: routed,
|
||||
@@ -578,6 +568,28 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *rootModel) startOwnerSetup() (tea.Model, tea.Cmd) {
|
||||
m.stage = stageOwner
|
||||
method := m.result.connectMethod
|
||||
adminHost := defaultAdminHostname(m.rootDomain, m.adminHost)
|
||||
if m.result.adminHostname != "" {
|
||||
adminHost = m.result.adminHostname
|
||||
}
|
||||
if m.adminExists && !m.result.provisioned {
|
||||
m.result.alreadySetUp = true
|
||||
if m.accessAud != "" {
|
||||
method = connectCloudflare
|
||||
}
|
||||
}
|
||||
base := "https://" + adminHost
|
||||
if method == connectLocal {
|
||||
base = localPanelURL(m.rootDomain, adminHost)
|
||||
}
|
||||
model := newSetupOwnerModel(m.ctx, m.store, base, m.osUser)
|
||||
model.probe = func() error { return checkPanelAccess(m.rootDomain, adminHost).err }
|
||||
return m.adopt(model)
|
||||
}
|
||||
|
||||
// showStatus is the re-run landing: prove the backend is up, then point the
|
||||
// operator at the panel without forcing any reconfiguration.
|
||||
func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
|
||||
@@ -589,13 +601,16 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
|
||||
method = connectCloudflare
|
||||
accessLabel = connectMethodLabel(connectCloudflare)
|
||||
}
|
||||
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain, m.adminHost)
|
||||
m.result.panelURL = panelURLFor(method, m.rootDomain, m.result.adminHostname)
|
||||
return m.adopt(&summaryModel{
|
||||
panelURL: m.result.panelURL,
|
||||
accessLabel: accessLabel,
|
||||
alreadySetUp: true,
|
||||
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
|
||||
alerts: m.readAlertRoute(),
|
||||
panelURL: m.result.panelURL,
|
||||
ownerUsername: m.result.username,
|
||||
setupTokenURL: m.result.setupTokenURL,
|
||||
auditWarning: m.result.auditWarning,
|
||||
accessLabel: accessLabel,
|
||||
alreadySetUp: true,
|
||||
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
|
||||
alerts: m.readAlertRoute(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -609,9 +624,9 @@ func (m *rootModel) readAlertRoute() *alertRoute {
|
||||
return &r
|
||||
}
|
||||
|
||||
func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string {
|
||||
if method != connectLocal && panelHostname != "" {
|
||||
return "https://" + panelHostname
|
||||
func panelURLFor(method connectMethod, rootDomain, adminHostname string) string {
|
||||
if method != connectLocal {
|
||||
return "https://" + defaultAdminHostname(rootDomain, adminHostname)
|
||||
}
|
||||
return localPanelURL(rootDomain, adminHostname)
|
||||
}
|
||||
+46
-132
@@ -46,94 +46,38 @@ func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootMode
|
||||
|
||||
func TestRootSetupHappyPath(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
|
||||
// First-run setup begins at preflight.
|
||||
if m.stage != stagePreflight {
|
||||
t.Fatalf("initial stage = %v, want stagePreflight", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*preflightModel); !ok {
|
||||
t.Fatalf("initial screen = %T, want *preflightModel", m.screen)
|
||||
}
|
||||
|
||||
// Preflight done → MC-bind (setup mode establishes the Owner by binding a
|
||||
// Minecraft account, not by typing a username/password). The stage label is
|
||||
// still stageOwner; only the screen differs by mode.
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
if m.stage != stageOwner {
|
||||
t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*mcBindModel); !ok {
|
||||
t.Fatalf("after preflight, screen = %T, want *mcBindModel", m.screen)
|
||||
}
|
||||
|
||||
// Owner provisioned → Connection chooser.
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
|
||||
if m.stage != stageConnect {
|
||||
t.Fatalf("after owner, stage = %v, want stageConnect", m.stage)
|
||||
t.Fatalf("stage = %v, want Connection", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*connectChooserModel); !ok {
|
||||
t.Fatalf("after owner, screen = %T, want *connectChooserModel", m.screen)
|
||||
}
|
||||
if !m.result.provisioned || m.result.username != "owner" || m.result.setupTokenURL != "https://op.console.example.com/setup?token=t0ken" {
|
||||
t.Fatalf("owner result not recorded: %+v", m.result)
|
||||
}
|
||||
|
||||
// Reverse-proxy chosen → Storage chooser, with the connection recorded.
|
||||
guide := "caddy config…"
|
||||
m = drive(t, m, connectResultMsg{
|
||||
method: connectReverseProxy,
|
||||
panelHostname: "panel.felis.example.com",
|
||||
adminHostname: "admin.felis.example.com",
|
||||
guide: guide,
|
||||
})
|
||||
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", adminHostname: "new-admin.felis.example.com", guide: "caddy…"})
|
||||
if m.stage != stageStorage {
|
||||
t.Fatalf("after connect, stage = %v, want stageStorage", m.stage)
|
||||
t.Fatalf("stage = %v, want Storage", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*storageChooserModel); !ok {
|
||||
t.Fatalf("after connect, screen = %T, want *storageChooserModel", m.screen)
|
||||
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
|
||||
owner, ok := m.screen.(*setupOwnerModel)
|
||||
if !ok || owner.panelURL != "https://new-admin.felis.example.com" {
|
||||
t.Fatalf("owner setup = %#v", m.screen)
|
||||
}
|
||||
if !m.result.connectConfigured {
|
||||
t.Fatalf("connectConfigured not set")
|
||||
}
|
||||
if m.result.connectMethod != connectReverseProxy {
|
||||
t.Fatalf("connectMethod = %v, want connectReverseProxy", m.result.connectMethod)
|
||||
}
|
||||
if m.result.reverseProxyGuide != guide {
|
||||
t.Fatalf("reverseProxyGuide = %q, want %q", m.result.reverseProxyGuide, guide)
|
||||
}
|
||||
|
||||
// Storage chosen → Summary, with both the connection and storage recorded.
|
||||
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket · minio:9000"})
|
||||
if m.stage != stageSummary {
|
||||
t.Fatalf("after storage, stage = %v, want stageSummary", m.stage)
|
||||
if m.result.provisioned {
|
||||
t.Fatal("Owner must not be minted before configuration")
|
||||
}
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://new-admin.felis.example.com/setup?token=t0ken"})
|
||||
sum, ok := m.screen.(*summaryModel)
|
||||
if !ok {
|
||||
t.Fatalf("after storage, screen = %T, want *summaryModel", m.screen)
|
||||
if !ok || sum.setupTokenURL != m.result.setupTokenURL || sum.panelURL != "https://new-admin.felis.example.com" || sum.storageLabel != "s3://bucket" {
|
||||
t.Fatalf("summary = %#v", m.screen)
|
||||
}
|
||||
if m.result.storageMethod != storageS3 || m.result.storageDetail == "" {
|
||||
t.Fatalf("storage result not recorded: %+v", m.result)
|
||||
}
|
||||
if sum.storageLabel != m.result.storageDetail {
|
||||
t.Fatalf("summary storageLabel = %q, want %q", sum.storageLabel, m.result.storageDetail)
|
||||
}
|
||||
if want := "https://panel.felis.example.com"; sum.panelURL != want {
|
||||
t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want)
|
||||
}
|
||||
if want := "https://op.console.example.com/setup?token=t0ken"; sum.setupTokenURL != want {
|
||||
t.Fatalf("summary setupTokenURL = %q, want %q", sum.setupTokenURL, want)
|
||||
}
|
||||
if sum.alreadySetUp {
|
||||
t.Fatalf("first-run summary should not be marked alreadySetUp")
|
||||
if !strings.Contains(sum.View(), "Finish Owner login") || !strings.Contains(sum.View(), "Minecraft can be linked later") {
|
||||
t.Fatal(sum.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootSetupLocalSummary(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner"})
|
||||
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
|
||||
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner"})
|
||||
|
||||
sum, ok := m.screen.(*summaryModel)
|
||||
if !ok {
|
||||
@@ -155,9 +99,9 @@ func TestRootSetupLocalSummary(t *testing.T) {
|
||||
func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
|
||||
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
|
||||
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
|
||||
if _, ok := m.screen.(*summaryModel); !ok {
|
||||
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
|
||||
}
|
||||
@@ -174,6 +118,10 @@ func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
|
||||
// Completing it returns straight to the summary — NOT the storage chooser —
|
||||
// with the original storage recap intact.
|
||||
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", guide: "caddy…"})
|
||||
if _, ok := m.screen.(*setupOwnerModel); !ok {
|
||||
t.Fatalf("pending link should refresh, screen = %T", m.screen)
|
||||
}
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com/setup?token=fresh"})
|
||||
if m.stage != stageSummary {
|
||||
t.Fatalf("after reconfigure connect, stage = %v, want stageSummary", m.stage)
|
||||
}
|
||||
@@ -195,9 +143,9 @@ func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
|
||||
func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner"})
|
||||
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
|
||||
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner"})
|
||||
if _, ok := m.screen.(*summaryModel); !ok {
|
||||
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
|
||||
}
|
||||
@@ -232,6 +180,7 @@ func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
|
||||
func TestRootReconfigureSMTP(t *testing.T) {
|
||||
m := newTestRoot(true, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
m = drive(t, m, setupReadyMsg{username: "owner"})
|
||||
if _, ok := m.screen.(*summaryModel); !ok {
|
||||
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
|
||||
}
|
||||
@@ -257,6 +206,7 @@ func TestRootReconfigureSMTP(t *testing.T) {
|
||||
func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
|
||||
m := newTestRoot(true, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
m = drive(t, m, setupReadyMsg{username: "owner"})
|
||||
if _, ok := m.screen.(*summaryModel); !ok {
|
||||
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
|
||||
}
|
||||
@@ -280,23 +230,27 @@ func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootRerunLandsOnStatus(t *testing.T) {
|
||||
// adminExists at start of a setup run = re-run: preflight should skip straight
|
||||
// to the "manage in panel" status screen, never touching owner/connect.
|
||||
m := newTestRoot(true, consoleModeSetup, "")
|
||||
if _, ok := m.screen.(*preflightModel); !ok {
|
||||
t.Fatalf("re-run initial screen = %T, want *preflightModel", m.screen)
|
||||
}
|
||||
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
sum, ok := m.screen.(*summaryModel)
|
||||
if !ok {
|
||||
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
|
||||
if _, ok := m.screen.(*setupOwnerModel); !ok {
|
||||
t.Fatalf("screen = %T", m.screen)
|
||||
}
|
||||
if !sum.alreadySetUp {
|
||||
t.Fatalf("re-run summary should be marked alreadySetUp")
|
||||
m = drive(t, m, setupReadyMsg{username: "owner"})
|
||||
if m.stage != stageSummary || !m.result.alreadySetUp || m.result.provisioned {
|
||||
t.Fatalf("result = %+v", m.result)
|
||||
}
|
||||
if m.result.provisioned {
|
||||
t.Fatalf("re-run must not provision an owner")
|
||||
}
|
||||
|
||||
func TestRootRerunResumesUnfinishedLogin(t *testing.T) {
|
||||
m := newTestRoot(true, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com:30443/setup?token=new"})
|
||||
sum := m.screen.(*summaryModel)
|
||||
if sum.setupTokenURL == "" || !sum.alreadySetUp {
|
||||
t.Fatalf("summary = %+v", sum)
|
||||
}
|
||||
if strings.Contains(m.View(), "Bootstrap") {
|
||||
t.Fatal("renewing a login link restarted the deployment rail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,58 +296,18 @@ func key(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
|
||||
func TestRootRailReviewNavigation(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
|
||||
// On the Owner screen (text inputs) ← must NOT hijack the arrow: it stays
|
||||
// with the field, so we remain on the live screen.
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("← on the owner (text-input) screen entered review (%d); arrows belong to the field", m.reviewing)
|
||||
}
|
||||
|
||||
// Advance to the Connection chooser (a select — it yields ←/→).
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing)
|
||||
}
|
||||
|
||||
// ← walks back to Owner (read-only recap), then Preflight, then clamps.
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != int(stageOwner) {
|
||||
t.Fatalf("first ← = stage %d, want stageOwner %d", m.reviewing, stageOwner)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "Owner account") || !strings.Contains(v, "username") {
|
||||
t.Fatalf("owner review body missing recap, got:\n%s", v)
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != int(stagePreflight) {
|
||||
t.Fatalf("second ← = stage %d, want stagePreflight %d", m.reviewing, stagePreflight)
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != int(stagePreflight) {
|
||||
t.Fatalf("← past the first step should clamp, got %d", m.reviewing)
|
||||
}
|
||||
|
||||
// → walks forward; stepping past the last completed step returns to live.
|
||||
m = drive(t, m, key(tea.KeyRight))
|
||||
if m.reviewing != int(stageOwner) {
|
||||
t.Fatalf("→ = stage %d, want stageOwner %d", m.reviewing, stageOwner)
|
||||
if m.reviewing != int(stagePreflight) || !strings.Contains(m.View(), "Control plane verified") {
|
||||
t.Fatal("connection review should return to preflight")
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyRight))
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("→ past the last completed step should return live, reviewing = %d", m.reviewing)
|
||||
t.Fatal("right should return to live connection chooser")
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "reach the panel") {
|
||||
t.Fatalf("returning live should show the chooser, got:\n%s", v)
|
||||
}
|
||||
|
||||
// esc is an immediate escape hatch back to the live screen.
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing < 0 {
|
||||
t.Fatalf("← should re-enter review")
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyEsc))
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("esc should return to the live screen, reviewing = %d", m.reviewing)
|
||||
t.Fatal("escape should return to live screen")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,8 +325,8 @@ func TestSetupRailSpansBootstrap(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30})
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
if _, ok := m.screen.(*mcBindModel); !ok {
|
||||
t.Fatalf("expected MC-bind screen after preflight, got %T", m.screen)
|
||||
if _, ok := m.screen.(*connectChooserModel); !ok {
|
||||
t.Fatalf("expected connection screen after preflight, got %T", m.screen)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "✓ Bootstrap") {
|
||||
t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type setupOwnerMsg struct {
|
||||
outcome breakGlassOutcome
|
||||
err error
|
||||
}
|
||||
|
||||
type setupReadyMsg struct{ username string }
|
||||
|
||||
// setupOwnerModel waits for the panel, then issues a host-authorized login link.
|
||||
// It never needs a running Minecraft client or login server.
|
||||
type setupOwnerModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
panelURL, osUser string
|
||||
probe func() error
|
||||
sp spinner.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func newSetupOwnerModel(ctx context.Context, store ownerStore, panelURL, osUser string) *setupOwnerModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner, sp.Style = spinner.Dot, tuiLabel
|
||||
return &setupOwnerModel{ctx: ctx, store: store, panelURL: panelURL, osUser: osUser, sp: sp}
|
||||
}
|
||||
|
||||
func (m *setupOwnerModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.sp.Tick, func() tea.Msg {
|
||||
if m.probe != nil {
|
||||
if err := m.probe(); err != nil {
|
||||
return setupOwnerMsg{err: err}
|
||||
}
|
||||
}
|
||||
out, err := performSetupOwner(m.ctx, m.store, m.panelURL, m.osUser)
|
||||
return setupOwnerMsg{outcome: out, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *setupOwnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case setupOwnerMsg:
|
||||
if errors.Is(msg.err, api.ErrConflict) {
|
||||
return m, func() tea.Msg { return setupReadyMsg{username: msg.outcome.ownerUsername} }
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
res := ownerResultMsg{username: msg.outcome.ownerUsername,
|
||||
setupTokenURL: msg.outcome.setupTokenURL, mode: "setup", accountable: m.osUser}
|
||||
if msg.outcome.auditErr != nil {
|
||||
res.auditWarning = msg.outcome.auditErr.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
case spinner.TickMsg:
|
||||
if m.err == nil {
|
||||
var cmd tea.Cmd
|
||||
m.sp, cmd = m.sp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "r", "R", "enter":
|
||||
if m.err != nil {
|
||||
m.err = nil
|
||||
return m, m.Init()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *setupOwnerModel) View() string {
|
||||
if m.err != nil {
|
||||
return tuiWarn.Render("Panel login setup could not finish: "+m.err.Error()) + "\n\n" +
|
||||
tuiHint.Render("Minecraft is not required. Fix the reported service, then retry.") + "\n\n" +
|
||||
tuiAction("r/enter", "retry", "esc", "exit")
|
||||
}
|
||||
return " " + m.sp.View() + " " + tuiHint.Render("Preparing your first panel login…") + "\n"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestSetupOwnerWaitsForPanelBeforeMinting(t *testing.T) {
|
||||
store := &fakeOwnerStore{}
|
||||
m := newSetupOwnerModel(context.Background(), store, "https://op.console.example.com:30443", "root")
|
||||
m.probe = func() error { return errors.New("panel not ready") }
|
||||
batch := m.Init()().(tea.BatchMsg)
|
||||
_, cmd := m.Update(batch[1]())
|
||||
if cmd != nil || len(store.tokens) != 0 || !strings.Contains(m.View(), "panel not ready") {
|
||||
t.Fatal("unready panel issued login")
|
||||
}
|
||||
m.probe = func() error { return nil }
|
||||
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
batch = cmd().(tea.BatchMsg)
|
||||
_, cmd = m.Update(batch[1]())
|
||||
res := cmd().(ownerResultMsg)
|
||||
if res.setupTokenURL == "" || res.username != "owner" || len(store.tokens) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportSetupWithoutOwnerPointsAtBrowserSetup(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
reportSetupResult(&b, breakGlassResult{}, false, false, "https://op.console.example.com")
|
||||
if !strings.Contains(b.String(), "sudo felis setup") || strings.Contains(b.String(), "join ") {
|
||||
t.Fatalf("output = %s", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPanelSetupUsesPasskeyHostname(t *testing.T) {
|
||||
t.Setenv("FELIS_PANEL_NODEPORT", "30445")
|
||||
if got := localPanelURL("10.211.55.6.nip.io", ""); got != "https://op.console.10.211.55.6.nip.io:30445" {
|
||||
t.Fatalf("local setup URL = %q; a bare IP cannot enroll the panel passkey", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupGameAddress(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
root string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"10.211.55.6.nip.io", 0, "10.211.55.6"},
|
||||
{"10.211.55.6.nip.io", 25565, "10.211.55.6"},
|
||||
{"10.211.55.6.sslip.io.", 25570, "10.211.55.6:25570"},
|
||||
{"play.example.net", 0, "play.example.net"},
|
||||
{"play.example.net", 25570, "play.example.net:25570"},
|
||||
{"", 25570, ""},
|
||||
} {
|
||||
if got := setupGameAddress(tc.root, tc.port); got != tc.want {
|
||||
t.Errorf("setupGameAddress(%q, %d) = %q, want %q", tc.root, tc.port, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,8 @@ import (
|
||||
type summaryModel struct {
|
||||
panelURL string
|
||||
ownerUsername string
|
||||
ownerSkipped bool // the Owner step was skipped: say how to bind one
|
||||
gameAddr string // where to join in Minecraft to bind the Owner
|
||||
setupTokenURL string // one-time first-login URL; shown once
|
||||
auditWarning string
|
||||
accessLabel string
|
||||
storageLabel string // build-context storage backend recap; empty to omit
|
||||
routedHosts []string
|
||||
@@ -54,10 +53,10 @@ func (m *summaryModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
switch {
|
||||
case m.setupTokenURL != "":
|
||||
b.WriteString(tuiOK.Render("✓ Deployment ready. Finish Owner login in your browser.") + "\n\n")
|
||||
case m.alreadySetUp:
|
||||
b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n")
|
||||
case m.ownerSkipped:
|
||||
b.WriteString(tuiWarn.Render("⚠ Setup finished without an Owner.") + "\n\n")
|
||||
default:
|
||||
b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n")
|
||||
}
|
||||
@@ -66,9 +65,6 @@ func (m *summaryModel) View() string {
|
||||
if m.ownerUsername != "" {
|
||||
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
|
||||
}
|
||||
if m.ownerSkipped {
|
||||
card.WriteString(routeRow("owner ", "not bound: nobody can sign in to the panel yet", false))
|
||||
}
|
||||
if m.setupTokenURL != "" {
|
||||
card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n")
|
||||
card.WriteString(" " + tuiWarn.Render("one-time link — open it to finish login setup") + "\n")
|
||||
@@ -93,10 +89,13 @@ func (m *summaryModel) View() string {
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
|
||||
|
||||
if m.ownerSkipped {
|
||||
b.WriteString(tuiWarn.Render("To bind the Owner, run sudo felis setup again and join "+ownerJoinTarget(m.gameAddr)+" in Minecraft.") + "\n")
|
||||
if m.setupTokenURL != "" {
|
||||
b.WriteString(tuiHint.Render("Open the setup link, record your email, and create a passkey.\nMinecraft can be linked later from Account; it is not required for panel access.") + "\n")
|
||||
} else {
|
||||
b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n")
|
||||
b.WriteString(tuiHint.Render("Manage servers, users, and Minecraft identities in the panel.") + "\n")
|
||||
}
|
||||
if m.auditWarning != "" {
|
||||
b.WriteString(tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n")
|
||||
}
|
||||
if m.localHint {
|
||||
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
|
||||
|
||||
@@ -4765,6 +4765,7 @@ login_image = "${FELIS_LIMBO_IMAGE}"
|
||||
lobby_image = "${FELIS_LOBBY_IMAGE}"
|
||||
# The public port players connect on; the panel shows it in server addresses.
|
||||
game_port = ${FELIS_GAME_PORT}
|
||||
game_version = "${MC_VERSION:-}"
|
||||
|
||||
[registry]
|
||||
url = "${REGISTRY_URL}"
|
||||
|
||||
@@ -2034,7 +2034,7 @@ run_write() { # out-file [state-dir] [database-deployment]; under the installer'
|
||||
persisted_auth_source_blocks() { :; }
|
||||
. "$FNFILE"
|
||||
FELIS_ROOT_DOMAIN=r.example.com DB_USER=u DB_PASSWORD=p DB_NAME=d MINECRAFT_NS=minecraft \
|
||||
FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo FELIS_GAME_PORT=25570 \
|
||||
FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo FELIS_GAME_PORT=25570 MC_VERSION=26.3 \
|
||||
REGISTRY_URL=registry.felis.svc:5000 BUILD_NS=felis-build FELIS_ARCHIVE_LOCAL_PATH=/a \
|
||||
FELIS_OFFSITE_BUCKET= write_felis_toml "$OUT_TOML" 127.0.0.1:15432 "$DEPLOY"'
|
||||
}
|
||||
@@ -2075,6 +2075,7 @@ expect "a re-run carries the scheduled backup count" 'scheduled_keep = 14' "$out
|
||||
expect "a re-run carries the scheduled backup retention" 'scheduled_retention = "45d"' "$out"
|
||||
expect "the archive mount stays installer-owned" 'local_path = "/a"' "$out"
|
||||
expect "the panel learns the public game port" 'game_port = 25570' "$out"
|
||||
expect "the panel learns the built login protocol" 'game_version = "26.3"' "$out"
|
||||
expect "a re-run keeps the off-site bucket, set apart from the next section" '[offsite]
|
||||
endpoint = "https://objects.example"
|
||||
bucket = "felis-offsite"
|
||||
|
||||
+101
-3
@@ -3945,11 +3945,11 @@ paths:
|
||||
get:
|
||||
tags: [auth]
|
||||
operationId: ownerStatus
|
||||
summary: Report whether an Owner has been bound on this install.
|
||||
summary: Report whether an Owner has been created on this install.
|
||||
description: >-
|
||||
Public, pre-session probe the sign-in page reads on load. Until `felis setup`
|
||||
binds an Owner, local sign-in is off and every login door answers 403
|
||||
local_auth_disabled; the page then explains that no Owner exists and how to bind
|
||||
creates an Owner, local sign-in is off and every login door answers 403
|
||||
local_auth_disabled; the page then explains that no Owner exists and how to create
|
||||
one instead of offering the doors. It discloses only whether the install is
|
||||
still unclaimed, and claiming it needs root on the host. It is not gated on
|
||||
local_auth_enabled and does not draw on the login doors' per-address rate limit.
|
||||
@@ -6458,6 +6458,104 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/account/link/sources:
|
||||
get:
|
||||
tags: [account]
|
||||
operationId: linkSources
|
||||
summary: List configured sources for staff game-role designation.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ sessionCookie: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: Sources in game-authentication priority order; no upstream URLs are exposed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sources]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [tag, lookup_available]
|
||||
properties:
|
||||
tag: { type: string }
|
||||
lookup_available: { type: boolean }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/api/v1/account/link/profile:
|
||||
get:
|
||||
tags: [account]
|
||||
operationId: lookupProfile
|
||||
summary: Look up a role by name or native UUID in a selected authentication source.
|
||||
description: Staff-only preview; role lookup does not prove account ownership and creates no binding.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ sessionCookie: [] }]
|
||||
parameters:
|
||||
- { name: source, in: query, required: true, schema: { type: string } }
|
||||
- { name: profile, in: query, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: Role found. mc_uuid uses the exact same per-source mapping as game authentication.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source, name, profile_uuid, mc_uuid, auth_source]
|
||||
properties:
|
||||
source: { type: string }
|
||||
name: { type: string }
|
||||
profile_uuid: { type: string }
|
||||
mc_uuid: { type: string, format: uuid }
|
||||
auth_source: { type: string, enum: [mojang, thirdparty] }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { description: No matching role in the selected source. }
|
||||
'502': { description: Source returned an invalid or mismatched profile. }
|
||||
'503': { description: Source unavailable. }
|
||||
post:
|
||||
tags: [account]
|
||||
operationId: linkProfile
|
||||
summary: Designate a role as the authenticated staff account's game identity.
|
||||
description: Requires a fresh login factor. Re-queries the native UUID, maps it on the server, and binds only to the caller. Other users' bindings cannot be overwritten. Panel initialization does not require this operation.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ sessionCookie: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source, profile_uuid]
|
||||
properties:
|
||||
source: { type: string }
|
||||
profile_uuid: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: Role linked, idempotently for the same user and UUID.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [linked, mc_uuid, auth_source]
|
||||
properties:
|
||||
linked: { type: boolean }
|
||||
mc_uuid: { type: string, format: uuid }
|
||||
auth_source: { type: string, enum: [mojang, thirdparty] }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { description: Role no longer exists. }
|
||||
'409': { description: Role already linked to another user, or reauthentication required. }
|
||||
'502': { description: Source returned an invalid or mismatched profile. }
|
||||
'503': { description: Source unavailable. }
|
||||
|
||||
/api/v1/account/link/start:
|
||||
post:
|
||||
tags: [account]
|
||||
|
||||
@@ -643,6 +643,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// authenticated operation.
|
||||
{Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
|
||||
{Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, h: a.handleLinkVerify},
|
||||
// Staff can designate their own game identity after panel setup. Players
|
||||
// retain the in-game proof flow above.
|
||||
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
||||
{Method: "GET", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLookupProfile},
|
||||
{Method: "POST", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLinkProfile},
|
||||
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
|
||||
// one-time code for the caller's chosen address, /verify redeems it and flips
|
||||
// email_verified. App-tier like the link routes — proving control of your own
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type linkedProfile struct {
|
||||
Source string `json:"source"`
|
||||
Name string `json:"name"`
|
||||
ProfileUUID string `json:"profile_uuid"`
|
||||
MCUUID string `json:"mc_uuid"`
|
||||
AuthSource string `json:"auth_source"`
|
||||
}
|
||||
|
||||
func profileAPIBase(src AuthSource) string {
|
||||
if src.APIURL != "" {
|
||||
return strings.TrimRight(src.APIURL, "/")
|
||||
}
|
||||
const suffix = "/sessionserver/session/minecraft/hasJoined"
|
||||
if strings.HasSuffix(src.URL, suffix) {
|
||||
return strings.TrimSuffix(src.URL, suffix)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
|
||||
type sourceView struct {
|
||||
Tag string `json:"tag"`
|
||||
LookupAvailable bool `json:"lookup_available"`
|
||||
}
|
||||
sources := make([]sourceView, 0, len(a.AuthSources))
|
||||
for _, src := range a.AuthSources {
|
||||
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
|
||||
}
|
||||
|
||||
func (a *API) handleLookupProfile(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
profile, err := a.lookupProfile(r.Context(), q.Get("source"), q.Get("profile"))
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// handleLinkProfile is a staff designation, not proof of game-account ownership.
|
||||
// The user must already have panel authority and a fresh login factor. A client
|
||||
// supplies only the selected source and native role UUID; mapping and target user
|
||||
// are determined on the server.
|
||||
func (a *API) handleLinkProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p := principalFromContext(r.Context())
|
||||
if !a.requireReauth(w, r, p) {
|
||||
return
|
||||
}
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
ProfileUUID string `json:"profile_uuid"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if _, err := uuid.Parse(req.ProfileUUID); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "profile_uuid must be a role UUID"))
|
||||
return
|
||||
}
|
||||
profile, err := a.lookupProfile(r.Context(), req.Source, req.ProfileUUID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
err = a.Repo.LinkAccount(r.Context(), p.UserID, profile.MCUUID, profile.AuthSource)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
writeError(w, r, newError(http.StatusConflict, "already_linked", "that Minecraft role is linked to another user"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "account.link_profile", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": profile.MCUUID, "auth_source": profile.AuthSource})
|
||||
}
|
||||
|
||||
func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedProfile, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
id, idErr := uuid.Parse(input)
|
||||
if idErr != nil && !mcUsernameRe.MatchString(input) {
|
||||
return nil, newError(http.StatusBadRequest, "bad_request", "provide a Minecraft role name or UUID")
|
||||
}
|
||||
var src AuthSource
|
||||
found := false
|
||||
for _, candidate := range a.AuthSources {
|
||||
if candidate.Tag == source {
|
||||
src, found = candidate, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, newError(http.StatusBadRequest, "auth_source_unknown", "select a configured authentication source")
|
||||
}
|
||||
var target, method string
|
||||
var body io.Reader
|
||||
if src.Identity {
|
||||
method = http.MethodGet
|
||||
if idErr == nil {
|
||||
target = strings.TrimSuffix(src.URL, "/hasJoined") + "/profile/" + strings.ReplaceAll(id.String(), "-", "")
|
||||
} else {
|
||||
target = mojangProfileAPI + url.PathEscape(input)
|
||||
}
|
||||
} else {
|
||||
base := profileAPIBase(src)
|
||||
if base == "" {
|
||||
return nil, newError(http.StatusBadRequest, "auth_source_lookup_unsupported", "this source needs api_url for role lookup; game-code linking is still available")
|
||||
}
|
||||
if idErr == nil {
|
||||
method, target = http.MethodGet, base+"/sessionserver/session/minecraft/profile/"+strings.ReplaceAll(id.String(), "-", "")
|
||||
} else {
|
||||
method, target = http.MethodPost, base+"/api/profiles/minecraft"
|
||||
encoded, _ := json.Marshal([]string{input})
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Use the same bounded, redirect-free client as game authentication.
|
||||
resp, err := authHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source is unavailable")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
|
||||
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<16))
|
||||
var profile sessionProfile
|
||||
if method == http.MethodPost {
|
||||
var profiles []sessionProfile
|
||||
if err := decoder.Decode(&profiles); err != nil {
|
||||
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
|
||||
}
|
||||
for _, candidate := range profiles {
|
||||
if strings.EqualFold(candidate.Name, input) {
|
||||
profile = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if profile.ID == "" {
|
||||
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
|
||||
}
|
||||
} else if err := decoder.Decode(&profile); err != nil {
|
||||
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
|
||||
}
|
||||
profileID, err := uuid.Parse(profile.ID)
|
||||
if err != nil || !mcUsernameRe.MatchString(profile.Name) ||
|
||||
(idErr == nil && profileID != id) || (idErr != nil && !strings.EqualFold(profile.Name, input)) {
|
||||
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "the source returned a mismatched or invalid role")
|
||||
}
|
||||
canonical, err := canonicalProfileUUID(src, profile.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authSource := authSourceThirdParty
|
||||
if src.Identity {
|
||||
authSource = authSourceMojang
|
||||
}
|
||||
return &linkedProfile{Source: src.Tag, Name: profile.Name, ProfileUUID: profile.ID, MCUUID: canonical.String(), AuthSource: authSource}, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestStaffProfileDesignation(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
stubMojangNames(t)
|
||||
upstreamCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/profiles/minecraft":
|
||||
var names []string
|
||||
if err := json.NewDecoder(r.Body).Decode(&names); err != nil || len(names) != 1 || names[0] != "LemonMiaow" {
|
||||
t.Errorf("lookup names = %v err = %v", names, err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":"` + native + `","name":"LemonMiaow"}]`))
|
||||
case strings.HasPrefix(r.URL.Path, "/sessionserver/session/minecraft/profile/"):
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/hasJoined"):
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "owner", Username: "owner", Role: "owner"})
|
||||
a := newTestAPI(repo, newFakeCluster())
|
||||
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}
|
||||
a.External = staticExternal{p: p}
|
||||
src := AuthSource{Tag: "littleskin", Prefix: "LS", URL: server.URL + "/sessionserver/session/minecraft/hasJoined"}
|
||||
a.AuthSources = []AuthSource{src, {Tag: "custom", URL: server.URL + "/custom-check"}}
|
||||
h := a.ExternalHandler()
|
||||
lookup := func(source, profile string) *httptest.ResponseRecorder {
|
||||
return do(h, "GET", "/api/v1/account/link/profile?"+url.Values{"source": {source}, "profile": {profile}}.Encode(), "", nil)
|
||||
}
|
||||
w := do(h, "GET", "/api/v1/account/link/sources", "", nil)
|
||||
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), server.URL) {
|
||||
t.Fatalf("sources = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = lookup("littleskin", "LemonMiaow")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("lookup = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var profile linkedProfile
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, _ := canonicalProfileUUID(src, native)
|
||||
if profile.MCUUID != canonical.String() || profile.AuthSource != "thirdparty" || len(repo.links) != 0 {
|
||||
t.Fatalf("preview = %+v links = %v", profile, repo.links)
|
||||
}
|
||||
game := httptest.NewRecorder()
|
||||
a.handleHasJoined(game, httptest.NewRequest("GET", "/session/minecraft/hasJoined?username=LemonMiaow&serverId=abc123", nil))
|
||||
var authenticated sessionProfile
|
||||
if err := json.Unmarshal(game.Body.Bytes(), &authenticated); err != nil || game.Code != http.StatusOK || authenticated.ID != strings.ReplaceAll(profile.MCUUID, "-", "") {
|
||||
t.Fatalf("preview differs from game identity: %d %s, err = %v", game.Code, game.Body.String(), err)
|
||||
}
|
||||
body := `{"source":"littleskin","profile_uuid":"` + native + `"}`
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusOK || repo.links[canonical.String()] != "owner" {
|
||||
t.Fatalf("bind = %d %s links = %v", w.Code, w.Body.String(), repo.links)
|
||||
}
|
||||
// Server-side designation is idempotent and never consumes game link codes.
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusOK || len(repo.links) != 1 {
|
||||
t.Fatalf("repeat = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
repo.links[canonical.String()] = "another-user"
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusConflict || repo.links[canonical.String()] != "another-user" {
|
||||
t.Fatal("designation overwrote another user")
|
||||
}
|
||||
w = lookup("custom", "LemonMiaow")
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "auth_source_lookup_unsupported" {
|
||||
t.Fatalf("custom = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
before := upstreamCalls
|
||||
w = lookup("unknown", "LemonMiaow")
|
||||
if w.Code != http.StatusBadRequest || upstreamCalls != before {
|
||||
t.Fatal("unknown source queried an upstream")
|
||||
}
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", `{"source":"littleskin","profile_uuid":"`+native+`","user_id":"victim"}`, jsonHeader)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatal("caller can select another account")
|
||||
}
|
||||
// A local staff session must prove its factor before designating a role.
|
||||
p.ViaSession = true
|
||||
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
|
||||
before = upstreamCalls
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" || upstreamCalls != before {
|
||||
t.Fatalf("stale staff session = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
p.ReauthAt = a.now()
|
||||
repo.links[canonical.String()] = "owner"
|
||||
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("proven staff session = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// A player's session never reaches either lookup or designation.
|
||||
p.Role = "user"
|
||||
before = upstreamCalls
|
||||
for _, w := range []*httptest.ResponseRecorder{lookup("littleskin", "LemonMiaow"), do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)} {
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("player route = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if upstreamCalls != before {
|
||||
t.Fatal("player reached role lookup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLookupRejectsInvalidResponses(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
status, want int
|
||||
}{
|
||||
{"missing", "", 204, 404},
|
||||
{"unavailable", "", 503, 503},
|
||||
{"invalid JSON", "broken", 200, 502},
|
||||
{"different UUID", `{"id":"223456781234423482341234567890ab","name":"LemonMiaow"}`, 200, 502},
|
||||
{"invalid name", `{"id":"` + native + `","name":"invalid name"}`, 200, 502},
|
||||
{"redirect", "", 302, 503},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
a.AuthSources = []AuthSource{{Tag: "test", APIURL: server.URL}}
|
||||
_, err := a.lookupProfile(t.Context(), "test", native)
|
||||
var apiErr *apiError
|
||||
if !errors.As(err, &apiErr) || apiErr.status != tc.want {
|
||||
t.Fatalf("lookup err = %v, want %d", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialProfileUUIDIsPreserved(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
a.AuthSources = []AuthSource{{Tag: "mojang", Identity: true, URL: server.URL + "/session/minecraft/hasJoined"}}
|
||||
profile, err := a.lookupProfile(t.Context(), "mojang", native)
|
||||
if err != nil || profile.MCUUID != uuid.MustParse(native).String() || profile.AuthSource != "mojang" {
|
||||
t.Fatalf("profile = %+v err = %v", profile, err)
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Pre-session install-state probe. Until `felis setup` binds an Owner, local sign-in is
|
||||
// Pre-session install-state probe. Until `felis setup` creates an Owner, local sign-in is
|
||||
// off and every login door answers 403 local_auth_disabled, so the sign-in page would
|
||||
// offer four doors that all fail. This Public route lets the page say instead that no
|
||||
// Owner exists yet and how to bind one.
|
||||
//
|
||||
// It discloses one bit: whether the install is still unclaimed. Claiming it needs root
|
||||
// on the host (`felis setup` or the break-glass console) plus a Minecraft join whose
|
||||
// link code is typed into that terminal; no web door works before then, so knowing the
|
||||
// on the host (`felis setup` or the break-glass console); no web door works before then, so knowing the
|
||||
// bit gives a remote caller nothing to act on. It must answer while local auth is off,
|
||||
// so unlike its sibling doors it is not gated on local_auth_enabled.
|
||||
//
|
||||
|
||||
@@ -78,6 +78,7 @@ type AuthSource struct {
|
||||
Tag string
|
||||
Prefix string
|
||||
URL string
|
||||
APIURL string // optional Yggdrasil API root for role lookup
|
||||
Identity bool
|
||||
}
|
||||
|
||||
@@ -145,15 +146,12 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
|
||||
// nor onto another source's. resolveHasJoined has already screened both shapes and
|
||||
// skipped unusable ones as failed; the two guards below are the last line before
|
||||
// anything leaves, kept even though nothing reaches them.
|
||||
var canonical uuid.UUID
|
||||
if src.Identity {
|
||||
id, err := uuid.Parse(prof.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
canonical = id
|
||||
} else {
|
||||
canonical, err := canonicalProfileUUID(src, prof.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if !src.Identity {
|
||||
// A third-party source is untrusted input, its name included: nothing stops a
|
||||
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
|
||||
// characters, all of which must not be relayed straight into the proxy's player
|
||||
@@ -162,7 +160,6 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
|
||||
|
||||
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
|
||||
// already keeps the two apart as identities, but the proxy's player registry is
|
||||
@@ -224,6 +221,15 @@ func prefixedName(prefix, name string) string {
|
||||
return p + name
|
||||
}
|
||||
|
||||
// canonicalProfileUUID is shared by game login and staff-initiated role binding.
|
||||
// Keep the source's native ID byte-for-byte: existing third-party identities use it.
|
||||
func canonicalProfileUUID(src AuthSource, nativeID string) (uuid.UUID, error) {
|
||||
if src.Identity {
|
||||
return uuid.Parse(nativeID)
|
||||
}
|
||||
return uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+nativeID)), nil
|
||||
}
|
||||
|
||||
// mojangProfileAPI answers the one question that decides a rename: is this username
|
||||
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
|
||||
// instead of the real Mojang.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
|
||||
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` host bootstrap
|
||||
// flow mints a one-time token and prints a URL like:
|
||||
//
|
||||
// https://op.console.<root>/setup?token=<raw>
|
||||
@@ -58,7 +58,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Hash the raw token — only the hash is stored (mirroring session cookies and
|
||||
// setup token creation in performSetupMCBind).
|
||||
// setup token creation in performSetupOwner).
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
tokenHash := hex.EncodeToString(sum[:])
|
||||
|
||||
|
||||
+34
-59
@@ -264,86 +264,61 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
|
||||
return userID, mcUUID, authSource, nil
|
||||
}
|
||||
|
||||
// CompleteOwnerSetup consumes an in-game link code, creates-or-promotes the bound
|
||||
// account to the passwordless Owner (role='owner'), enables local auth, and stores
|
||||
// the one-time first-login token in one transaction. It is the `felis setup`
|
||||
// MC-bind path: the operator enters limbo, runs /link, and types the code here.
|
||||
// Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game
|
||||
// login can never self-elevate — this DELIBERATELY elevates: an unlinked UUID is
|
||||
// born directly as staff, and an already-linked account (player OR staff) is
|
||||
// promoted in place, preserving its id so any live sessions and its username
|
||||
// survive. The elevation is gated by the caller's local-root break-glass
|
||||
// authority, not by anything in-band. Returns the Owner's (userID, mcUUID,
|
||||
// authSource); an absent or expired code is ErrLinkCodeInvalid and consumes
|
||||
// nothing. Any failure in the auth-toggle or token writes rolls the elevation and
|
||||
// code consumption back, leaving the operator able to retry setup.
|
||||
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
|
||||
tokenHash string, tokenExpiresAt time.Time) (string, string, string, error) {
|
||||
// CompleteOwnerSetup creates the first Owner and a one-time panel login under
|
||||
// host-root authority. An unfinished setup renews the link without resetting the
|
||||
// account; an Owner with a login factor is left untouched (ErrConflict).
|
||||
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time,
|
||||
tokenHash string, tokenExpiresAt time.Time) (string, string, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||
|
||||
var mcUUID, authSource string
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
|
||||
code, now).Scan(&mcUUID, &authSource); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", "", "", ErrLinkCodeInvalid
|
||||
case err != nil:
|
||||
return "", "", "", err
|
||||
// Serialize first-run creation as well as link renewal, including the case
|
||||
// where there is no user row to lock yet.
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('felis.owner_setup'))`); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh
|
||||
// staff row (role='owner') with a uuid-derived username; an already-linked
|
||||
// account is promoted to role='owner' in place (idempotent when it already is),
|
||||
// keeping its id and username. Setup elevates on purpose, so there is no staff
|
||||
// refusal here — that guard belongs to the player path only.
|
||||
userID := newUserID
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
|
||||
userID, username := newUserID, "owner"
|
||||
var onboarded bool
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT u.id, u.username, u.email_verified OR EXISTS (
|
||||
SELECT 1 FROM webauthn_credentials c WHERE c.user_id = u.id)
|
||||
FROM users u WHERE u.role IN ('owner', 'admin') AND u.deleted_at IS NULL
|
||||
ORDER BY (u.role = 'owner') DESC, u.created_at, u.id LIMIT 1 FOR UPDATE`,
|
||||
).Scan(&userID, &username, &onboarded)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, role, created_at) VALUES ($1, $2, 'owner', $3)`,
|
||||
newUserID, mcUUID, now); err != nil {
|
||||
return "", "", "", fmt.Errorf("create owner: %w", err)
|
||||
newUserID, username, now); err != nil {
|
||||
return "", "", fmt.Errorf("create owner: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO account_links (user_id, mc_uuid, auth_source, verified_at) VALUES ($1, $2, $3, $4)`,
|
||||
newUserID, mcUUID, authSource, now); err != nil {
|
||||
return "", "", "", fmt.Errorf("write account link: %w", err)
|
||||
}
|
||||
userID = newUserID
|
||||
case err != nil:
|
||||
return "", "", "", err
|
||||
default:
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET role = 'owner' WHERE id = $1`, userID); err != nil {
|
||||
return "", "", "", fmt.Errorf("promote owner: %w", err)
|
||||
}
|
||||
return "", "", err
|
||||
case onboarded:
|
||||
return userID, username, ErrConflict
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO platform_settings (key, value, updated_at) VALUES ($1, $2::jsonb, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
|
||||
LocalAuthEnabledKey, "true", now); err != nil {
|
||||
return "", "", "", fmt.Errorf("enable local auth: %w", err)
|
||||
return "", "", fmt.Errorf("enable local auth: %w", err)
|
||||
}
|
||||
// A renewed link replaces any unused links for this account.
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM setup_tokens WHERE user_id = $1`, userID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO setup_tokens (token_hash, user_id, expires_at, created_at) VALUES ($1, $2, $3, $4)`,
|
||||
tokenHash, userID, tokenExpiresAt, now); err != nil {
|
||||
return "", "", "", fmt.Errorf("mint setup token: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
|
||||
return "", "", "", fmt.Errorf("consume link code: %w", err)
|
||||
return "", "", fmt.Errorf("mint setup token: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", "", "", err
|
||||
return "", "", err
|
||||
}
|
||||
return userID, mcUUID, authSource, nil
|
||||
return userID, username, nil
|
||||
}
|
||||
|
||||
// QuotaCheck reports whether accepting a server with resource spec `incoming`
|
||||
@@ -3008,8 +2983,8 @@ func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now tim
|
||||
}
|
||||
|
||||
// CreateSetupToken persists a one-time first-web-login token, storing only its
|
||||
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind
|
||||
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
|
||||
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-creation
|
||||
// path uses CompleteOwnerSetup so Owner creation, local auth, and this token
|
||||
// commit atomically; this lower-level helper remains for callers that already
|
||||
// established the user. The token is redeemed exactly once by RedeemSetupToken.
|
||||
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
||||
|
||||
@@ -62,6 +62,8 @@ type AuthSourceConfig struct {
|
||||
Tag string `toml:"tag"`
|
||||
Prefix string `toml:"prefix"`
|
||||
URL string `toml:"url"`
|
||||
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
|
||||
APIURL string `toml:"api_url"`
|
||||
}
|
||||
|
||||
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
||||
@@ -149,6 +151,9 @@ type VelocityConfig struct {
|
||||
// FELIS_GAME_PORT). The panel adds it to the server addresses players copy
|
||||
// when it is not Minecraft's default; 0 means that default, 25565.
|
||||
GamePort int `toml:"game_port"`
|
||||
// GameVersion is the login/lobby protocol built by bootstrap. Empty means
|
||||
// unknown for custom images; the panel must not guess a client version.
|
||||
GameVersion string `toml:"game_version"`
|
||||
}
|
||||
|
||||
// AuthConfig is the [auth] table: the two privileged faces and the Cloudflare
|
||||
@@ -721,6 +726,11 @@ func (c *Config) validateAuthSources() error {
|
||||
if problem := hasJoinedURLProblem(s.URL); problem != "" {
|
||||
return fmt.Errorf("config: [[auth_source]] %q url %q %s", s.Tag, s.URL, problem)
|
||||
}
|
||||
if s.APIURL != "" {
|
||||
if problem := hasJoinedURLProblem(s.APIURL); problem != "" {
|
||||
return fmt.Errorf("config: [[auth_source]] %q api_url %q %s", s.Tag, s.APIURL, problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -501,6 +501,28 @@ func TestLoadRejectsUnqueryableAuthSourceURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourceProfileAPIURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
url string
|
||||
valid bool
|
||||
}{
|
||||
{"https://ygg.example.net/api/yggdrasil", true},
|
||||
{"http://127.0.0.1:8080/ygg", true},
|
||||
{"ygg.example.net/api", false},
|
||||
{"http://ygg.example.net/api", false},
|
||||
{"https://ygg.example.net/api?token=x", false},
|
||||
} {
|
||||
cfg, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \"https://ygg.example.net/custom-check\"\napi_url = \""+tc.url+"\"\n"))
|
||||
if tc.valid {
|
||||
if err != nil || cfg.AuthSources[0].APIURL != tc.url {
|
||||
t.Fatalf("api_url %q = %v, %v", tc.url, cfg, err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), "api_url") {
|
||||
t.Fatalf("invalid api_url %q: %v", tc.url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A source reached over plaintext can be answered by anyone on the path, who can then log in
|
||||
// as any player of that source. Only a same-host or private-network root may skip TLS, and
|
||||
// that is decided on the literal host, since nothing is resolved at load time.
|
||||
|
||||
@@ -26,8 +26,9 @@ type runtimeConfig struct {
|
||||
PanelHostname string `json:"panelHostname,omitempty"`
|
||||
AdminHostname string `json:"adminHostname,omitempty"`
|
||||
// GamePort is the public Minecraft port, absent when it is the default 25565.
|
||||
GamePort int `json:"gamePort,omitempty"`
|
||||
Build buildInfo `json:"build"`
|
||||
GamePort int `json:"gamePort,omitempty"`
|
||||
GameVersion string `json:"gameVersion,omitempty"`
|
||||
Build buildInfo `json:"build"`
|
||||
}
|
||||
|
||||
// buildInfo is the resolved build stamp the panel renders in its version badge.
|
||||
@@ -113,7 +114,7 @@ func parseBuildVersion(raw string) buildInfo {
|
||||
// right surface (player console vs SysAdmin console) without a rebuild. gamePort
|
||||
// is the public Minecraft port ([velocity] game_port), which the SPA appends to
|
||||
// the server addresses players copy; 0 or 25565 leaves them bare.
|
||||
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string, distributed ...bool) http.Handler {
|
||||
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, gameVersion, version string, distributed ...bool) http.Handler {
|
||||
files, err := fs.Sub(static, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -125,6 +126,7 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort
|
||||
panelHostname: panelHost,
|
||||
adminHostname: adminHost,
|
||||
gamePort: publicGamePort(gamePort),
|
||||
gameVersion: gameVersion,
|
||||
build: parseBuildVersion(version),
|
||||
files: files,
|
||||
fileServer: http.FileServer(http.FS(files)),
|
||||
@@ -151,6 +153,7 @@ type handler struct {
|
||||
panelHostname string
|
||||
adminHostname string
|
||||
gamePort int
|
||||
gameVersion string
|
||||
build buildInfo
|
||||
files fs.FS
|
||||
fileServer http.Handler
|
||||
@@ -231,6 +234,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
PanelHostname: h.panelHostname,
|
||||
AdminHostname: h.adminHostname,
|
||||
GamePort: h.gamePort,
|
||||
GameVersion: h.gameVersion,
|
||||
Build: h.build,
|
||||
})
|
||||
case h.hasStaticFile(r.URL.Path):
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
|
||||
}
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
})
|
||||
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "v1.2.3")
|
||||
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "26.3", "v1.2.3")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
@@ -41,7 +41,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" {
|
||||
if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" || cfg.GameVersion != "26.3" {
|
||||
t.Fatalf("config = %+v", cfg)
|
||||
}
|
||||
// The tiering plumbing surfaces the two console hostnames so one bundle can
|
||||
@@ -69,7 +69,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
|
||||
// /config.json carries it, except when a bare hostname already reaches the proxy.
|
||||
func TestHandlerPublishesNonDefaultGamePort(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want int }{{0, 0}, {25565, 0}, {25570, 25570}} {
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "v1.2.3")
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "", "v1.2.3")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config.json", nil))
|
||||
var cfg map[string]any
|
||||
@@ -115,7 +115,7 @@ func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
|
||||
// its hash (and nothing else inline), cannot be framed, and cache by name:
|
||||
// hashed assets forever, the page itself never without revalidation.
|
||||
func TestHandlerSetsPageSecurityAndCacheHeaders(t *testing.T) {
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "v1.2.3")
|
||||
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "26.3", "v1.2.3")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
// Through the tunnel: TLS to the origin as well, the edge's scheme in XFP.
|
||||
|
||||
@@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler {
|
||||
api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
})
|
||||
return Handler(api, "example.test", "", "", 0, "")
|
||||
return Handler(api, "example.test", "", "", 0, "", "")
|
||||
}
|
||||
|
||||
func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) {
|
||||
|
||||
@@ -126,18 +126,23 @@ func TestLinkWritesStampTheAPIClock(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("owner setup", func(t *testing.T) {
|
||||
mc := testUUID(t)
|
||||
r, setupDB := setupRepository(t)
|
||||
id := "usr-clk-owner-" + suffix(t)
|
||||
tok := "tok-clk-" + suffix(t)
|
||||
if got, _, _, err := repo.CompleteOwnerSetup(ctx, id, code(mc), now, tok, now.Add(time.Hour)); err != nil || got != id {
|
||||
t.Fatalf("CompleteOwnerSetup = %q, %v; want %s", got, err, id)
|
||||
if got, _, err := r.CompleteOwnerSetup(ctx, id, now, tok, now.Add(time.Hour)); err != nil || got != id {
|
||||
t.Fatalf("setup = %q, %v", got, err)
|
||||
}
|
||||
for _, query := range []string{
|
||||
`SELECT created_at FROM users WHERE id = '` + id + `'`,
|
||||
`SELECT created_at FROM setup_tokens WHERE token_hash = '` + tok + `'`,
|
||||
`SELECT updated_at FROM platform_settings WHERE key = '` + api.LocalAuthEnabledKey + `'`,
|
||||
} {
|
||||
var stamp time.Time
|
||||
if err := setupDB.QueryRow(query).Scan(&stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStamp(t, "owner setup clock", stamp, now)
|
||||
}
|
||||
wantStamp(t, "user created_at", createdAt(id), now)
|
||||
wantStamp(t, "verified_at", linkedAt(mc), now)
|
||||
wantStamp(t, "setup token created_at",
|
||||
stampAt(t, `SELECT created_at FROM setup_tokens WHERE token_hash = $1`, tok), now)
|
||||
wantStamp(t, "local auth updated_at",
|
||||
stampAt(t, `SELECT updated_at FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey), now)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+152
-15
@@ -1138,25 +1138,162 @@ func TestOwnerProvisioningWritesOwnerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The setup wizard's MC-bind path establishes THE Owner, so it writes the same
|
||||
// role as break-glass rather than a plain admin.
|
||||
func TestCompleteOwnerSetupWritesOwnerRole(t *testing.T) {
|
||||
// First-owner initialization needs an empty installation, rather than the
|
||||
// accounts left by other contract tests. Migrate an isolated schema using the
|
||||
// same database and real migration files.
|
||||
func setupRepository(t *testing.T) (*api.PGRepo, *sql.DB) {
|
||||
t.Helper()
|
||||
schema := "setup_" + suffix(t)
|
||||
mustExec(t, `CREATE SCHEMA `+schema)
|
||||
u, err := url.Parse(os.Getenv("FELIS_TEST_PG_URL"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
drv, err := store.Open(context.Background(), u.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { drv.Close(); mustExec(t, `DROP SCHEMA `+schema+` CASCADE`) })
|
||||
ms, err := store.LoadMigrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Up(context.Background(), drv, ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return api.NewPGRepo(drv.DB()), drv.DB()
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupContract(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow().Truncate(time.Second)
|
||||
id, username, err := r.CompleteOwnerSetup(ctx, "first-owner", now, "first-link", now.Add(time.Hour))
|
||||
if err != nil || id != "first-owner" || username != "owner" {
|
||||
t.Fatalf("setup = %q, %q, %v", id, username, err)
|
||||
}
|
||||
var role string
|
||||
if err := setupDB.QueryRow(`SELECT role FROM users WHERE id = $1`, id).Scan(&role); err != nil || role != "owner" {
|
||||
t.Fatalf("role = %q, %v", role, err)
|
||||
}
|
||||
linked, err := r.IsLinked(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if linked {
|
||||
t.Fatal("first panel login must not create a game binding")
|
||||
}
|
||||
// Deliberately fail token insertion. Auth/account state and the old link survive.
|
||||
if err := r.CreateSetupToken(ctx, "collision", id, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Reusing an existing token hash for a different user forces the insert to fail.
|
||||
_, err = setupDB.Exec(`INSERT INTO users (id, username, role) VALUES ('other-user', 'other', 'user')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = setupDB.Exec(`UPDATE setup_tokens SET user_id = 'other-user' WHERE token_hash = 'collision'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "collision", now.Add(time.Hour)); err == nil {
|
||||
t.Fatal("token collision accepted")
|
||||
}
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'first-link'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("old link lost after rollback: %d %v", count, err)
|
||||
}
|
||||
resumed, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "renewed-link", now.Add(time.Hour))
|
||||
if err != nil || resumed != id {
|
||||
t.Fatalf("resume = %q %v", resumed, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM users WHERE role = 'owner'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("duplicate owners: %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE user_id = $1`, id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("unused links not replaced: %d %v", count, err)
|
||||
}
|
||||
if _, err := setupDB.Exec(`UPDATE users SET email = '[email protected]', email_verified = true WHERE id = $1`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "must-not-exist", now.Add(time.Hour)); !errors.Is(err, api.ErrConflict) {
|
||||
t.Fatalf("settled account = %v", err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'renewed-link'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatal("settled account was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupSerializesFirstOwner(t *testing.T) {
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow()
|
||||
mc := testUUID(t)
|
||||
code := "osc-" + suffix(t)
|
||||
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(10*time.Minute)); err != nil {
|
||||
t.Fatalf("CreateLinkCode: %v", err)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, _, err := r.CompleteOwnerSetup(context.Background(), fmt.Sprintf("racer-%d", i), now, fmt.Sprintf("token-%d", i), now.Add(time.Hour))
|
||||
errs <- err
|
||||
}(i)
|
||||
}
|
||||
newID := "usr-setup-" + suffix(t)
|
||||
userID, gotUUID, src, err := repo.CompleteOwnerSetup(ctx, newID, code, now,
|
||||
"tok-"+suffix(t), now.Add(time.Hour))
|
||||
if err != nil || userID != newID || gotUUID != mc || src != "mojang" {
|
||||
t.Fatalf("CompleteOwnerSetup = (%s, %s, %s, %v), want (%s, %s, mojang, nil)",
|
||||
userID, gotUUID, src, err, newID, mc)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if role := userRole(t, newID); role != "owner" {
|
||||
t.Fatalf("CompleteOwnerSetup role = %q, want owner", role)
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM users WHERE role = 'owner'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("owners = %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("links = %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupCreationRollsBack(t *testing.T) {
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow()
|
||||
// Failing the final insert must undo the new Owner and
|
||||
// local-auth setting as well, not strand an account without a usable link.
|
||||
if _, err := setupDB.Exec(`ALTER TABLE setup_tokens ADD CONSTRAINT reject_setup_token CHECK (token_hash <> 'rejected')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := r.CompleteOwnerSetup(context.Background(), "rolled-back", now, "rejected", now.Add(time.Hour)); err == nil {
|
||||
t.Fatal("failed token insert accepted")
|
||||
}
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM users`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial Owner remained: %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial local-auth setting remained: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOwnerSetupPreservesPasskey(t *testing.T) {
|
||||
r, setupDB := setupRepository(t)
|
||||
now := mustNow()
|
||||
id, _, err := r.CompleteOwnerSetup(context.Background(), "owner-key", now, "first-link", now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setupDB.Exec(`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, name) VALUES ('key', $1, 'credential', 'public-key', 'Laptop')`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, name, err := r.CompleteOwnerSetup(context.Background(), "unused", now, "new-link", now.Add(time.Hour)); !errors.Is(err, api.ErrConflict) || got != id || name != "owner" {
|
||||
t.Fatalf("established passkey = %q %q %v", got, name, err)
|
||||
}
|
||||
var count int
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("existing credential changed: %d %v", count, err)
|
||||
}
|
||||
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'new-link'`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("setup created a reset link: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -371,6 +371,8 @@ func APIDeployment(p Params) *appsv1.Deployment {
|
||||
}
|
||||
env = append(env,
|
||||
corev1.EnvVar{Name: "FELIS_IMAGE", Value: p.FelisImage},
|
||||
// Passkey origins must include the same public port the Service exposes.
|
||||
corev1.EnvVar{Name: "FELIS_PANEL_NODEPORT", Value: fmt.Sprint(p.PanelNodePort)},
|
||||
// The api's own internal-face base URL, so it derives the submission
|
||||
// context URLs that build Pods fetch through it. Same value the login gate
|
||||
// is handed; one address for one face.
|
||||
|
||||
@@ -331,6 +331,15 @@ func TestAPIService_NodePort(t *testing.T) {
|
||||
p.PanelNodePort = 30445
|
||||
svc := apiService(p)
|
||||
dep := APIDeployment(p)
|
||||
foundPort := false
|
||||
for _, env := range dep.Spec.Template.Spec.Containers[0].Env {
|
||||
if env.Name == "FELIS_PANEL_NODEPORT" {
|
||||
foundPort = env.Value == "30445"
|
||||
}
|
||||
}
|
||||
if !foundPort {
|
||||
t.Fatal("API passkey origins do not know the Service's public NodePort")
|
||||
}
|
||||
|
||||
if svc.Name != SAAPI || svc.Namespace != p.ControlNamespace {
|
||||
t.Errorf("api Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, SAAPI)
|
||||
|
||||
+42
-1
@@ -1,4 +1,4 @@
|
||||
import { test, expect, t } from "./fixtures";
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
|
||||
test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
|
||||
await page.goto("/servers");
|
||||
@@ -54,6 +54,47 @@ test("an admin reaches the user list", async ({ page, signIn }) => {
|
||||
await expect(page.getByText("[email protected]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unlinked Owner can manage the panel, then preview and confirm a game role", async ({ page, signIn }) => {
|
||||
const profile = { source: "littleskin", name: "LemonMiaow", profile_uuid: "123456781234423482341234567890ab", mc_uuid: "canonical-role", auth_source: "thirdparty" };
|
||||
let linked = false;
|
||||
let designations = 0;
|
||||
await page.route("**/api/v1/account/link/start", (route) => route.fulfill({ json: { linked } }));
|
||||
await page.route("**/api/v1/account/link/sources", (route) => route.fulfill({ json: { sources: [{ tag: "littleskin", lookup_available: true }] } }));
|
||||
await page.route("**/api/v1/account/link/profile**", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST") {
|
||||
expect(request.postDataJSON()).toEqual({ source: profile.source, profile_uuid: profile.profile_uuid });
|
||||
linked = true;
|
||||
designations++;
|
||||
await route.fulfill({ json: { linked: true, mc_uuid: profile.mc_uuid, auth_source: profile.auth_source } });
|
||||
} else {
|
||||
expect(new URL(request.url()).searchParams.get("profile")).toBe("LemonMiaow");
|
||||
await route.fulfill({ json: profile });
|
||||
}
|
||||
});
|
||||
await page.route("**/config.json", (route) => route.fulfill({ json: { apiBase: "/api/v1", rootDomain: "mc.example", gameVersion: "26.3", gamePort: 25570 } }));
|
||||
await signIn("owner");
|
||||
await page.goto("/admin/users");
|
||||
await expect(page.getByRole("heading", { name: t("admin:users_title") })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/account");
|
||||
await page.getByLabel(t("account:staff_profile")).fill("LemonMiaow");
|
||||
await page.getByRole("button", { name: t("account:staff_lookup") }).click();
|
||||
await expect(page.getByText(profile.profile_uuid)).toBeVisible();
|
||||
expect(designations).toBe(0);
|
||||
await expectFitsScreen(page);
|
||||
await page.getByRole("button", { name: t("account:staff_confirm") }).click();
|
||||
await expect(page.getByText(t("account:staff_linked_desc"))).toBeVisible();
|
||||
expect(designations).toBe(1);
|
||||
|
||||
await page.getByText(t("account:game_guide"), { exact: true }).click();
|
||||
await expect(page.getByText(t("account:game_version", { version: "26.3" }))).toBeVisible();
|
||||
await expect(page.getByText("mc.example:25570", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t("account:game_lobby"))).toBeVisible();
|
||||
await expectFitsScreen(page);
|
||||
});
|
||||
|
||||
// Admin pages are chunks of their own, so a player never downloads them: every
|
||||
// page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the
|
||||
// dev server, /assets/UsersPage-<hash>.js in a build).
|
||||
|
||||
@@ -105,5 +105,23 @@
|
||||
"reauth_sign_in_desc": "Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes.",
|
||||
"reauth_sign_in_btn": "Sign out and sign in again",
|
||||
"reauth_done_continue": "Confirmed. Press Continue to add the passkey.",
|
||||
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out."
|
||||
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out.",
|
||||
"staff_linked_desc": "This role is linked to your administrator account and identifies you in-game.",
|
||||
"staff_link_desc": "Link a game role to identify yourself in-game. Panel management is already available; you can do this later.",
|
||||
"staff_sources_loading": "Loading authentication sources…",
|
||||
"staff_source_mojang": "Minecraft official",
|
||||
"staff_source": "Authentication source",
|
||||
"staff_profile": "Minecraft role name or UUID",
|
||||
"staff_lookup": "Look up role",
|
||||
"staff_working": "Working…",
|
||||
"staff_lookup_unsupported": "This source needs api_url configured before roles can be looked up. You can continue using the panel.",
|
||||
"staff_confirm_desc": "Confirm this role as your game identity. It will carry your administrator authority in-game.",
|
||||
"staff_confirm": "Confirm role link",
|
||||
"staff_source_help": "Yggdrasil is Minecraft’s account authentication protocol. Official and third-party providers use it to supply role profiles. Select the source used by your launcher and enter a role name or UUID, not an email.",
|
||||
"game_guide": "Prepare to enter Minecraft",
|
||||
"game_version": "Use Minecraft Java Edition {{version}} for the login server and lobby. Target servers may require another version or mods; check their instructions.",
|
||||
"game_version_unknown": "The login server’s client version is not configured. Ask the operator to confirm it and set game_version; the panel build version does not determine it.",
|
||||
"game_lobby": "The login server verifies your identity before sending you to the lobby. The lobby is a hub for choosing a target server: use /menu and wait for it to start. Check the server list if login or lobby is not ready.",
|
||||
"game_thirdparty": "For a third-party source, configure the same provider in your launcher. Profile lookup reads an identity; joining still requires authentication with that provider.",
|
||||
"staff_code_alternative": "Provider does not support lookup? Use an in-game link code"
|
||||
}
|
||||
@@ -24,14 +24,10 @@
|
||||
"bind_hint_no_address": "In Minecraft (Java Edition), join this server. The login server gives a one-time bind code on your first join; after that, type /link in-game for a new one.",
|
||||
"no_owner_title": "No Owner yet, so nobody can sign in",
|
||||
"no_owner_subtitle": "The Owner is the account that owns this server",
|
||||
"no_owner_intro": "This server has no Owner bound yet. Every sign-in method stays off until one is. Bind one as follows:",
|
||||
"no_owner_step_setup": "On the server, run this command in a terminal. It opens straight onto the Owner binding:",
|
||||
"no_owner_step_join": "In Minecraft (Java Edition), join this address. The login server opens a book with your bind code, and chat shows it too:",
|
||||
"no_owner_step_join_no_address": "In Minecraft (Java Edition), join this server at the address the terminal shows. The login server opens a book with your bind code, and chat shows it too.",
|
||||
"no_owner_ip_fallback": "While the domain does not point at this server yet, join by the server's IP address instead.",
|
||||
"no_owner_step_code": "Type the code into the terminal. The web link in the book is for players; the Owner's code goes into the terminal.",
|
||||
"no_owner_step_link": "Open the setup link the terminal then shows, and set an email and a passkey. After that, you can sign in here.",
|
||||
"no_owner_recheck": "Done binding? Check again",
|
||||
"no_owner_intro": "The host administrator needs to initialize the first Owner login. Minecraft is not required.",
|
||||
"no_owner_step_setup": "Run this command on the host to finish deployment and configure panel access:",
|
||||
"no_owner_step_link": "Open the one-time setup link in your browser, record an email, and create a passkey. Link a Minecraft role later from Account.",
|
||||
"no_owner_recheck": "Finished setup? Check again",
|
||||
"no_owner_rechecking": "Checking…",
|
||||
"no_owner_still_unbound": "There is still no Owner. Check that the terminal has shown the setup link.",
|
||||
"bind_btn": "Verify & Sign In",
|
||||
@@ -67,5 +63,6 @@
|
||||
"setup_create_passkey": "Create passkey",
|
||||
"setup_registering": "Registering…",
|
||||
"setup_default_passkey_name": "Default passkey",
|
||||
"session_ended_notice": "Your session expired or was revoked. Sign in again to go back to the page you were on."
|
||||
"session_ended_notice": "Your session expired or was revoked. Sign in again to go back to the page you were on.",
|
||||
"setup_game_optional": "Finish login setup to open the panel. A Minecraft role can be linked later from Account."
|
||||
}
|
||||
@@ -49,5 +49,6 @@
|
||||
"go_to_servers_btn": "Manage My Servers",
|
||||
"fleet_flat_webgl": "WebGL is unavailable in this browser, so the fleet is shown flat.",
|
||||
"fleet_flat_error": "The 3D view failed to load, so the fleet is shown flat.",
|
||||
"fleet_flat_empty": "No servers yet"
|
||||
"fleet_flat_empty": "No servers yet",
|
||||
"staff_unlinked_desc": "No Minecraft role is linked yet. Panel management is available; choose an authentication source and link a role from Account when you are ready to play."
|
||||
}
|
||||
@@ -135,5 +135,9 @@
|
||||
"schedule_limit": "This server already has as many scheduled tasks as it can hold. Delete one first.",
|
||||
"schedule_running": "This task is running right now. Try again once the run finishes.",
|
||||
"schedule_stale": "The server has a new owner since this task was saved. Save the task again before running it.",
|
||||
"bad_schedule": "The task was not saved: {{detail}}"
|
||||
"bad_schedule": "The task was not saved: {{detail}}",
|
||||
"auth_source_unknown": "Select a configured authentication source.",
|
||||
"auth_source_unavailable": "The authentication source is unavailable or returned an invalid role. Try again later.",
|
||||
"auth_source_lookup_unsupported": "Role lookup is not configured for this authentication source.",
|
||||
"minecraft_profile_not_found": "No role matched in this source. Check the role name or UUID."
|
||||
}
|
||||
@@ -104,5 +104,23 @@
|
||||
"reauth_sign_in_desc": "管理员账户也可以退出后重新登录,重新登录后 5 分钟内视为已验证。",
|
||||
"reauth_sign_in_btn": "退出并重新登录",
|
||||
"reauth_done_continue": "已确认。点“继续”添加 Passkey。",
|
||||
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。"
|
||||
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。",
|
||||
"staff_linked_desc": "此角色已关联到你的管理员账户,进服时将识别你的管理身份。",
|
||||
"staff_link_desc": "关联游戏角色后,进服时可识别你的管理身份。你现在已能使用全部面板管理功能,可以稍后再关联。",
|
||||
"staff_sources_loading": "正在读取认证源…",
|
||||
"staff_source_mojang": "Minecraft 正版",
|
||||
"staff_source": "认证源",
|
||||
"staff_profile": "Minecraft 角色名或 UUID",
|
||||
"staff_lookup": "查询角色",
|
||||
"staff_working": "处理中…",
|
||||
"staff_lookup_unsupported": "此认证源需要配置 api_url 才能查询角色。你仍可继续管理面板。",
|
||||
"staff_confirm_desc": "确认将这个角色关联到你的管理员账户,进服时赋予对应的管理员身份。",
|
||||
"staff_confirm": "确认关联此角色",
|
||||
"staff_source_help": "“世界树”(Yggdrasil)是 Minecraft 的账号认证协议。正版与第三方认证站都通过它提供角色信息;请选择实际登录游戏所用的认证源,输入的是角色名或 UUID,不是邮箱。",
|
||||
"game_guide": "准备进入 Minecraft",
|
||||
"game_version": "使用 Minecraft Java 版 {{version}} 连接登录服和大厅。目标服务器可能需要不同版本或模组,请查看对应服务器说明。",
|
||||
"game_version_unknown": "登录服的客户端版本尚未配置,请由运维确认后填写 game_version;不要按面板版本猜测。",
|
||||
"game_lobby": "连接后先经过登录服验证身份,随后进入大厅。大厅是选择目标服务器的中转站,可用 /menu 选择服务器并等待它启动;登录服或大厅未就绪时,可在服务列表查看状态。",
|
||||
"game_thirdparty": "使用第三方认证源时,启动器也须配置同一个认证站。角色查询只能读取身份,进服时仍需通过该站的登录验证。",
|
||||
"staff_code_alternative": "认证站不支持查询?也可以使用游戏内链接码"
|
||||
}
|
||||
@@ -24,14 +24,10 @@
|
||||
"bind_hint_no_address": "用 Minecraft Java 版加入本服务器。第一次进入时登录服会给出一次性绑定码,之后在游戏内输入 /link 获取新的。",
|
||||
"no_owner_title": "还没有 Owner,暂时无法登录",
|
||||
"no_owner_subtitle": "Owner 是这台服务器的所有者账号",
|
||||
"no_owner_intro": "这台服务器还没有绑定 Owner。绑定完成前,所有登录方式都处于关闭状态。按以下步骤完成绑定:",
|
||||
"no_owner_step_setup": "在服务器终端运行下面的命令,它会直接进入 Owner 绑定:",
|
||||
"no_owner_step_join": "用 Minecraft Java 版加入下面的地址。登录服会打开一本书,并在聊天栏显示绑定码:",
|
||||
"no_owner_step_join_no_address": "用 Minecraft Java 版加入这台服务器,地址显示在终端里。登录服会打开一本书,并在聊天栏显示绑定码。",
|
||||
"no_owner_ip_fallback": "域名还没有解析到这台服务器时,改用服务器的 IP 地址加入。",
|
||||
"no_owner_step_code": "把绑定码输入终端。书里的网页链接供玩家使用,Owner 的绑定码要输入终端。",
|
||||
"no_owner_step_link": "打开终端随后显示的设置链接,设置邮箱和通行密钥。完成后就能在这里登录。",
|
||||
"no_owner_recheck": "已完成绑定,重新检查",
|
||||
"no_owner_intro": "这台服务器尚未创建 Owner 登录。主机管理员完成以下步骤后即可使用面板,无需启动 Minecraft。",
|
||||
"no_owner_step_setup": "在服务器终端运行下面的命令,完成部署与面板访问设置:",
|
||||
"no_owner_step_link": "用浏览器打开终端给出的一次性设置链接,登记邮箱并创建通行密钥。之后可在账户页关联 Minecraft 角色。",
|
||||
"no_owner_recheck": "已完成设置,重新检查",
|
||||
"no_owner_rechecking": "检查中…",
|
||||
"no_owner_still_unbound": "还没有检测到 Owner。请确认终端已经显示设置链接。",
|
||||
"bind_btn": "验证并登录",
|
||||
@@ -67,5 +63,6 @@
|
||||
"setup_create_passkey": "创建通行密钥",
|
||||
"setup_registering": "注册中…",
|
||||
"setup_default_passkey_name": "默认通行密钥",
|
||||
"session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。"
|
||||
"session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。",
|
||||
"setup_game_optional": "完成登录设置即可进入管理面板。Minecraft 角色可以稍后在账户页关联。"
|
||||
}
|
||||
@@ -49,5 +49,6 @@
|
||||
"go_to_servers_btn": "操作我的服务器",
|
||||
"fleet_flat_webgl": "浏览器未启用 WebGL,已切换为平面视图。",
|
||||
"fleet_flat_error": "3D 视图加载失败,已切换为平面视图。",
|
||||
"fleet_flat_empty": "还没有服务器"
|
||||
"fleet_flat_empty": "还没有服务器",
|
||||
"staff_unlinked_desc": "Minecraft 角色尚未关联。面板管理功能已可使用;准备进入游戏时,可在账户页选择认证源并关联角色。"
|
||||
}
|
||||
@@ -135,5 +135,9 @@
|
||||
"schedule_limit": "这台服务器的计划任务数量已达上限,请先删除一个。",
|
||||
"schedule_running": "这个任务正在执行,请等本次执行结束后再试。",
|
||||
"schedule_stale": "保存这个任务后服务器换了所有者,请先重新保存任务再执行。",
|
||||
"bad_schedule": "计划任务未保存:{{detail}}"
|
||||
"bad_schedule": "计划任务未保存:{{detail}}",
|
||||
"auth_source_unknown": "请选择已配置的认证源。",
|
||||
"auth_source_unavailable": "认证源暂时不可用或返回了无效角色,请稍后重试。",
|
||||
"auth_source_lookup_unsupported": "此认证源尚未配置角色查询地址。",
|
||||
"minecraft_profile_not_found": "在所选认证源中没有找到这个角色,请检查角色名或 UUID。"
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
Identity,
|
||||
KickResult,
|
||||
LinkResult,
|
||||
MinecraftAuthSource,
|
||||
MinecraftProfile,
|
||||
LinkStatus,
|
||||
BindResult,
|
||||
PasskeyCredential,
|
||||
@@ -943,6 +945,14 @@ export const api = rejectingSync({
|
||||
linkVerify: (code: string) =>
|
||||
request<LinkResult>("POST", "/account/link/verify", { code }),
|
||||
|
||||
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
||||
|
||||
lookupProfile: (source: string, profile: string) =>
|
||||
request<MinecraftProfile>("GET", `/account/link/profile?${new URLSearchParams({ source, profile })}`),
|
||||
|
||||
linkProfile: (source: string, profile_uuid: string) =>
|
||||
request<LinkResult>("POST", "/account/link/profile", { source, profile_uuid }),
|
||||
|
||||
emailStart: (email: string) =>
|
||||
request<{ sent: boolean; expires_at: string }>("POST", "/account/email/start", { email }),
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ afterEach(() => {
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("reads the server's config and build stamp", async () => {
|
||||
serve({ apiBase: "/api/v1", rootDomain: "mc.example", adminHostname: "op.console.mc.example", build });
|
||||
serve({ apiBase: "/api/v1", rootDomain: "mc.example", adminHostname: "op.console.mc.example", gameVersion: "26.3", build });
|
||||
const { loadConfig } = await freshConfig();
|
||||
|
||||
const cfg = await loadConfig();
|
||||
@@ -37,6 +37,7 @@ describe("loadConfig", () => {
|
||||
rootDomain: "mc.example",
|
||||
panelHostname: undefined,
|
||||
adminHostname: "op.console.mc.example",
|
||||
gameVersion: "26.3",
|
||||
build,
|
||||
});
|
||||
expect(cfg.fallback).toBeUndefined();
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface RuntimeConfig {
|
||||
adminHostname?: string;
|
||||
/** The public Minecraft port, absent when it is the default 25565. */
|
||||
gamePort?: number;
|
||||
/** Login/lobby Minecraft protocol, unknown for unconfigured custom images. */
|
||||
gameVersion?: string;
|
||||
/** What the server runs, for the version badge. */
|
||||
build?: BuildInfo;
|
||||
/** /config.json could not be read, so these are build-time defaults and
|
||||
@@ -85,6 +87,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
|
||||
panelHostname: raw.panelHostname,
|
||||
adminHostname: raw.adminHostname,
|
||||
gamePort: parsePort(raw.gamePort),
|
||||
gameVersion: typeof raw.gameVersion === "string" && raw.gameVersion ? raw.gameVersion : undefined,
|
||||
build: parseBuild(raw.build),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1114,8 +1114,8 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Report whether an Owner has been bound on this install.
|
||||
* @description Public, pre-session probe the sign-in page reads on load. Until `felis setup` binds an Owner, local sign-in is off and every login door answers 403 local_auth_disabled; the page then explains that no Owner exists and how to bind one instead of offering the doors. It discloses only whether the install is still unclaimed, and claiming it needs root on the host. It is not gated on local_auth_enabled and does not draw on the login doors' per-address rate limit.
|
||||
* Report whether an Owner has been created on this install.
|
||||
* @description Public, pre-session probe the sign-in page reads on load. Until `felis setup` creates an Owner, local sign-in is off and every login door answers 403 local_auth_disabled; the page then explains that no Owner exists and how to create one instead of offering the doors. It discloses only whether the install is still unclaimed, and claiming it needs root on the host. It is not gated on local_auth_enabled and does not draw on the login doors' per-address rate limit.
|
||||
*/
|
||||
get: operations["ownerStatus"];
|
||||
put?: never;
|
||||
@@ -1938,6 +1938,47 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/link/sources": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List configured sources for staff game-role designation. */
|
||||
get: operations["linkSources"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/link/profile": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Look up a role by name or native UUID in a selected authentication source.
|
||||
* @description Staff-only preview; role lookup does not prove account ownership and creates no binding.
|
||||
*/
|
||||
get: operations["lookupProfile"];
|
||||
put?: never;
|
||||
/**
|
||||
* Designate a role as the authenticated staff account's game identity.
|
||||
* @description Requires a fresh login factor. Re-queries the native UUID, maps it on the server, and binds only to the caller. Other users' bindings cannot be overwritten. Panel initialization does not require this operation.
|
||||
*/
|
||||
post: operations["linkProfile"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/link/start": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8949,6 +8990,153 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
linkSources: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Sources in game-authentication priority order; no upstream URLs are exposed. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
sources: {
|
||||
tag: string;
|
||||
lookup_available: boolean;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
};
|
||||
};
|
||||
lookupProfile: {
|
||||
parameters: {
|
||||
query: {
|
||||
source: string;
|
||||
profile: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role found. mc_uuid uses the exact same per-source mapping as game authentication. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
source: string;
|
||||
name: string;
|
||||
profile_uuid: string;
|
||||
/** Format: uuid */
|
||||
mc_uuid: string;
|
||||
/** @enum {string} */
|
||||
auth_source: "mojang" | "thirdparty";
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description No matching role in the selected source. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source returned an invalid or mismatched profile. */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source unavailable. */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
linkProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
source: string;
|
||||
/** Format: uuid */
|
||||
profile_uuid: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Role linked, idempotently for the same user and UUID. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
linked: boolean;
|
||||
/** Format: uuid */
|
||||
mc_uuid: string;
|
||||
/** @enum {string} */
|
||||
auth_source: "mojang" | "thirdparty";
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Role no longer exists. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Role already linked to another user */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source returned an invalid or mismatched profile. */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source unavailable. */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
linkStart: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -357,6 +357,19 @@ export interface CreateServerRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export interface MinecraftAuthSource {
|
||||
tag: string;
|
||||
lookup_available: boolean;
|
||||
}
|
||||
|
||||
export interface MinecraftProfile {
|
||||
source: string;
|
||||
name: string;
|
||||
profile_uuid: string;
|
||||
mc_uuid: string;
|
||||
auth_source: string;
|
||||
}
|
||||
|
||||
/** LinkStatus projects POST /account/link/start (spec §10): whether the caller's
|
||||
* session is already bound to a Minecraft identity. The endpoint also returns an
|
||||
* API-consumer `instructions` string; the panel renders its own player-facing copy
|
||||
|
||||
@@ -9,6 +9,11 @@ import { Account } from "./Account";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
passkeyList: vi.fn(),
|
||||
linkStatus: vi.fn(),
|
||||
linkSources: vi.fn(),
|
||||
lookupProfile: vi.fn(),
|
||||
linkProfile: vi.fn(),
|
||||
linkVerify: vi.fn(),
|
||||
passkeyDelete: vi.fn(),
|
||||
listMySessions: vi.fn(),
|
||||
migrateStatus: vi.fn(),
|
||||
@@ -22,7 +27,11 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
linkStatus: () => Promise.resolve({ linked: true }),
|
||||
linkStatus: mocks.linkStatus,
|
||||
linkSources: mocks.linkSources,
|
||||
lookupProfile: mocks.lookupProfile,
|
||||
linkProfile: mocks.linkProfile,
|
||||
linkVerify: mocks.linkVerify,
|
||||
migrateStatus: mocks.migrateStatus,
|
||||
migrateIssueCode: mocks.migrateIssueCode,
|
||||
passkeyList: mocks.passkeyList,
|
||||
@@ -68,6 +77,11 @@ function renderAccount() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.linkStatus.mockReset().mockResolvedValue({ linked: true });
|
||||
mocks.linkSources.mockReset().mockResolvedValue({ sources: [{ tag: "littleskin", lookup_available: true }] });
|
||||
mocks.lookupProfile.mockReset();
|
||||
mocks.linkProfile.mockReset();
|
||||
mocks.linkVerify.mockReset();
|
||||
mocks.passkeyList.mockReset();
|
||||
mocks.passkeyDelete.mockReset();
|
||||
mocks.listMySessions.mockReset();
|
||||
@@ -206,3 +220,61 @@ describe("Account migration", () => {
|
||||
expect(screen.queryByPlaceholderText(t("account:migration_target_placeholder"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("staff Minecraft role designation", () => {
|
||||
const profile = { source: "littleskin", name: "LemonMiaow", profile_uuid: "123456781234423482341234567890ab", mc_uuid: "canonical-role", auth_source: "thirdparty" };
|
||||
beforeEach(() => {
|
||||
mocks.identity = { ...identity(true), role: "owner", is_admin: true, is_owner: true };
|
||||
mocks.linkStatus.mockResolvedValue({ linked: false });
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
|
||||
mocks.lookupProfile.mockResolvedValue(profile);
|
||||
mocks.linkProfile.mockResolvedValue({ linked: true, mc_uuid: profile.mc_uuid });
|
||||
});
|
||||
|
||||
async function previewRole() {
|
||||
await userEvent.type(await screen.findByLabelText(t("account:staff_profile")), "LemonMiaow");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:staff_lookup") }));
|
||||
await screen.findByRole("button", { name: t("account:staff_confirm") });
|
||||
}
|
||||
|
||||
it("previews a role in the chosen source before confirmation writes a binding", async () => {
|
||||
renderAccount();
|
||||
await previewRole();
|
||||
expect(mocks.lookupProfile).toHaveBeenCalledWith("littleskin", "LemonMiaow");
|
||||
expect(screen.getByText(profile.profile_uuid)).toBeTruthy();
|
||||
expect(mocks.linkProfile).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:staff_confirm") }));
|
||||
expect(mocks.linkProfile).toHaveBeenCalledWith("littleskin", profile.profile_uuid);
|
||||
expect(await screen.findByText(t("account:staff_linked_desc"))).toBeTruthy();
|
||||
expect(mocks.linkVerify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a preview when the input changes", async () => {
|
||||
renderAccount();
|
||||
await previewRole();
|
||||
await userEvent.type(screen.getByLabelText(t("account:staff_profile")), "2");
|
||||
expect(screen.queryByRole("button", { name: t("account:staff_confirm") })).toBeNull();
|
||||
expect(mocks.linkProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the role preview and explains a refused binding", async () => {
|
||||
mocks.linkProfile.mockRejectedValue({ status: 409, code: "already_linked", message: "" });
|
||||
renderAccount();
|
||||
await previewRole();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:staff_confirm") }));
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:already_linked"));
|
||||
expect(screen.getByText(profile.profile_uuid)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("retains game-code proof for players", async () => {
|
||||
mocks.identity = identity(true);
|
||||
mocks.linkVerify.mockResolvedValue({ linked: true, mc_uuid: profile.mc_uuid });
|
||||
renderAccount();
|
||||
await userEvent.type(await screen.findByLabelText(t("account:link_code")), "abcd1234");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:verify_btn") }));
|
||||
expect(mocks.linkVerify).toHaveBeenCalledWith("ABCD1234");
|
||||
expect(mocks.linkSources).not.toHaveBeenCalled();
|
||||
expect(mocks.linkProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+118
-17
@@ -11,13 +11,16 @@ import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, clientError, humanizeError } from "@/lib/api";
|
||||
import { formatAbsolute } from "@/lib/format";
|
||||
import type { PasskeyCredential } from "@/lib/types";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import type { MinecraftProfile, PasskeyCredential } from "@/lib/types";
|
||||
import { useAsync, useConfig } from "@/lib/hooks";
|
||||
import { AccountSessionsCard } from "@/pages/AccountSessions";
|
||||
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import { entryAddress } from "@/lib/config";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,11 +31,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// The Account page is the web half of the §10 link flow. A code is born in-game
|
||||
// (online-mode auth proves the UUID) and consumed here (the session proves the
|
||||
// user) — so this page only ever reports status and redeems a code; it can never
|
||||
// originate a binding. The Minecraft-link card is a small state machine: checking
|
||||
// → linked, or → the two-step "get a code in-game, enter it here" form.
|
||||
// Players redeem a code proven in-game. Staff can designate a role from a
|
||||
// configured authentication source after their panel login is established.
|
||||
|
||||
export function Account() {
|
||||
const status = useAsync(() => api.linkStatus(), []);
|
||||
@@ -293,6 +293,7 @@ export function Account() {
|
||||
}
|
||||
}
|
||||
|
||||
const codeForm = <LinkForm code={code} setCode={setCode} submitting={submitting} error={error} onSubmit={submit} />;
|
||||
return (
|
||||
<>
|
||||
<PageHeader icon={UserRound} title={t("title")} subtitle={t("subtitle")} />
|
||||
@@ -305,20 +306,23 @@ export function Account() {
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{linked ? (
|
||||
<LinkedState uuid={verifiedUUID} />
|
||||
<LinkedState uuid={verifiedUUID} staff={identity?.is_admin === true} />
|
||||
) : status.loading && !status.data ? (
|
||||
<Loading label={t("checking_link")} />
|
||||
) : status.error ? (
|
||||
<ErrorState error={status.error} onRetry={status.reload} />
|
||||
) : identity?.is_admin ? (
|
||||
<StaffLinkForm onLinked={(uuid) => { setVerifiedUUID(uuid); void refresh(); }} />
|
||||
) : (
|
||||
<LinkForm
|
||||
code={code}
|
||||
setCode={setCode}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
codeForm
|
||||
)}
|
||||
{identity?.is_admin && !linked && (
|
||||
<details className="mt-4 space-y-3">
|
||||
<summary className="cursor-pointer text-muted-foreground">{t("staff_code_alternative")}</summary>
|
||||
{codeForm}
|
||||
</details>
|
||||
)}
|
||||
<MinecraftGuide />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -575,7 +579,7 @@ export function Account() {
|
||||
/** LinkedState confirms the binding. The UUID is shown only when this session
|
||||
* just verified it (start does not return it), so a pre-existing link renders
|
||||
* the confirmation without a UUID rather than inventing one. */
|
||||
function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
function LinkedState({ uuid, staff }: { uuid: string | null; staff: boolean }) {
|
||||
const { t } = useTranslation("account");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -583,7 +587,7 @@ function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
{t("linked_title")}
|
||||
</div>
|
||||
<p className="text-muted-foreground">{t("linked_desc")}</p>
|
||||
<p className="text-muted-foreground">{t(staff ? "staff_linked_desc" : "linked_desc")}</p>
|
||||
{uuid && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs uppercase tracking-wide">{t("uuid_label")}</span>
|
||||
@@ -596,6 +600,103 @@ function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StaffLinkForm({ onLinked }: { onLinked: (uuid: string) => void }) {
|
||||
const { t } = useTranslation("account");
|
||||
const sources = useAsync(() => api.linkSources(), []);
|
||||
const reauth = useReauth();
|
||||
const [selected, setSelected] = useState("");
|
||||
const [input, setInput] = useState("");
|
||||
const [preview, setPreview] = useState<MinecraftProfile | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const source = selected || sources.data?.sources[0]?.tag || "";
|
||||
const available = sources.data?.sources.find((item) => item.tag === source)?.lookup_available;
|
||||
|
||||
async function lookup(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy || !input.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await api.lookupProfile(source, input.trim()));
|
||||
} catch (err) {
|
||||
setError(humanizeError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (busy || !preview) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await reauth.guard(() => api.linkProfile(preview.source, preview.profile_uuid));
|
||||
onLinked(result.mc_uuid);
|
||||
} catch (err) {
|
||||
if (!isReauthCancelled(err)) setError(humanizeError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.loading && !sources.data) return <Loading label={t("staff_sources_loading")} />;
|
||||
if (sources.error) return <ErrorState error={sources.error} onRetry={sources.reload} />;
|
||||
|
||||
const sourceLabel = (tag: string) => tag === "mojang" ? t("staff_source_mojang") : tag;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">{t("staff_link_desc")}</p>
|
||||
<p className="text-muted-foreground">{t("staff_source_help")}</p>
|
||||
<form onSubmit={lookup} className="space-y-3 max-w-lg">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-source">{t("staff_source")}</Label>
|
||||
<Select value={source} disabled={busy} onValueChange={(value) => { setSelected(value); setPreview(null); setError(null); }}>
|
||||
<SelectTrigger id="role-source"><SelectValue placeholder={t("staff_source")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.data?.sources.map((item) => <SelectItem key={item.tag} value={item.tag}>{sourceLabel(item.tag)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-profile">{t("staff_profile")}</Label>
|
||||
<Input id="role-profile" value={input} disabled={busy} maxLength={64} autoComplete="off" spellCheck={false}
|
||||
onChange={(e) => { setInput(e.target.value); setPreview(null); setError(null); }} />
|
||||
</div>
|
||||
{available === false && <p className="text-muted-foreground">{t("staff_lookup_unsupported")}</p>}
|
||||
<Button type="submit" disabled={busy || !available || !input.trim()}>{t(busy ? "staff_working" : "staff_lookup")}</Button>
|
||||
</form>
|
||||
{preview && (
|
||||
<div className="space-y-3 rounded-md border p-4 max-w-lg">
|
||||
<p className="font-medium">{preview.name}</p>
|
||||
<p className="text-muted-foreground">{sourceLabel(preview.source)}</p>
|
||||
<code className="block break-all font-mono text-xs">{preview.profile_uuid}</code>
|
||||
<p className="text-muted-foreground">{t("staff_confirm_desc")}</p>
|
||||
<Button disabled={busy} onClick={() => void confirm()}>{t(busy ? "staff_working" : "staff_confirm")}</Button>
|
||||
</div>
|
||||
)}
|
||||
<InlineError message={error} />
|
||||
{reauth.dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinecraftGuide() {
|
||||
const { t } = useTranslation("account");
|
||||
const cfg = useConfig();
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<details className="mt-4 border-t pt-4 space-y-3">
|
||||
<summary className="cursor-pointer font-medium">{t("game_guide")}</summary>
|
||||
<p className="text-muted-foreground">{cfg.gameVersion ? t("game_version", { version: cfg.gameVersion }) : t("game_version_unknown")}</p>
|
||||
{!cfg.fallback && <CopyAddress address={entryAddress(cfg)} />}
|
||||
<p className="text-muted-foreground">{t("game_lobby")}</p>
|
||||
<p className="text-muted-foreground">{t("game_thirdparty")}</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** LinkForm is the two-step redemption UX: get a code in-game, then enter it. The
|
||||
* input auto-uppercases for instant feedback; the server also trims + uppercases,
|
||||
* so this is cosmetic, not the source of truth. */
|
||||
|
||||
@@ -103,7 +103,7 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) {
|
||||
function LinkCard({ link, staff }: { link: AsyncState<{ linked: boolean }>; staff: boolean }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (link.error) {
|
||||
@@ -164,11 +164,11 @@ function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) {
|
||||
<>
|
||||
<div className="space-y-1 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" />
|
||||
<AlertTriangle className="h-5 w-5 shrink-0" />
|
||||
<span>{t("account_unlinked_title")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("account_unlinked_desc")}
|
||||
{t(staff ? "staff_unlinked_desc" : "account_unlinked_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/account" className="shrink-0 w-full sm:w-auto">
|
||||
@@ -290,7 +290,7 @@ function FleetView({
|
||||
{/* 1. 游戏角色绑定 Banner */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<LinkCard link={link} />
|
||||
<LinkCard link={link} staff={isAdmin} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -215,8 +215,7 @@ describe("operator sign-in", () => {
|
||||
});
|
||||
|
||||
|
||||
// Until `felis setup` binds an Owner every door answers "disabled", so the page says
|
||||
// why and how to bind one, naming the address to join in Minecraft.
|
||||
// Host setup creates the Owner before any game identity is linked.
|
||||
describe("an install with no Owner", () => {
|
||||
const NO_OWNER = () => t("auth:no_owner_title");
|
||||
|
||||
@@ -224,35 +223,19 @@ describe("an install with no Owner", () => {
|
||||
calls.authOwnerStatus.mockResolvedValue({ owner_bound: false });
|
||||
});
|
||||
|
||||
it("explains why nobody can sign in, with the command and the address to join", async () => {
|
||||
it("guides the administrator from host setup to the browser without entering Minecraft", async () => {
|
||||
config.value = { apiBase: "/api/v1", rootDomain: "203.0.113.7.nip.io", gamePort: 25570 };
|
||||
await renderLogin(NO_OWNER());
|
||||
|
||||
expect(screen.getByText("sudo felis setup")).toBeTruthy();
|
||||
expect(await screen.findByText("203.0.113.7:25570")).toBeTruthy();
|
||||
expect(screen.queryByText(t("auth:no_owner_ip_fallback"))).toBeNull();
|
||||
// None of the doors that cannot work is offered.
|
||||
expect(screen.getByText(t("auth:no_owner_step_link"))).toBeTruthy();
|
||||
expect(screen.queryByText("203.0.113.7:25570")).toBeNull();
|
||||
expect(screen.queryByText("/link")).toBeNull();
|
||||
expect(screen.queryByLabelText(t("auth:email_address"))).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("auth:passkey_btn") })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("auth:tab_bind_btn") })).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the IP when the address is a domain name", async () => {
|
||||
config.value = { apiBase: "/api/v1", rootDomain: "mc.example" };
|
||||
await renderLogin(NO_OWNER());
|
||||
|
||||
expect(await screen.findByText("mc.example")).toBeTruthy();
|
||||
expect(screen.getByText(t("auth:no_owner_ip_fallback"))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("points at the terminal for the address when config.json could not be read", async () => {
|
||||
config.value = { apiBase: "/api/v1", rootDomain: "localhost", fallback: true };
|
||||
await renderLogin(NO_OWNER());
|
||||
|
||||
expect(screen.getByText(t("auth:no_owner_step_join_no_address"))).toBeTruthy();
|
||||
expect(screen.queryByText("localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("checks again on request and shows the doors once an Owner is bound", async () => {
|
||||
calls.authOwnerStatus
|
||||
.mockResolvedValueOnce({ owner_bound: false })
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { loginReturnPath } from "@/lib/auth";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { entryAddress, isIPAddress, loadConfig } from "@/lib/config";
|
||||
import { entryAddress, loadConfig } from "@/lib/config";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
@@ -111,7 +111,7 @@ export function Login() {
|
||||
}
|
||||
if (identity) return <Navigate to={next} replace />;
|
||||
if (ownerBound === false) {
|
||||
return <NoOwnerNotice joinAddr={joinAddr} onBound={() => setOwnerBound(true)} />;
|
||||
return <NoOwnerNotice onBound={() => setOwnerBound(true)} />;
|
||||
}
|
||||
|
||||
async function handleBindSubmit(e: FormEvent) {
|
||||
@@ -595,11 +595,11 @@ export function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// NoOwnerNotice replaces the doors on an install `felis setup` has not bound an Owner
|
||||
// NoOwnerNotice replaces the doors on an install `felis setup` has not created an Owner
|
||||
// on. Local sign-in is off until it does, so every door would answer "disabled"; this
|
||||
// says why and walks through the binding, which happens in the server's terminal plus
|
||||
// one Minecraft join. The steps match the terminal's own bind screen (tui_mc_bind.go).
|
||||
function NoOwnerNotice({ joinAddr, onBound }: { joinAddr: string; onBound: () => void }) {
|
||||
// says why and walks through provisioning in the server's terminal plus
|
||||
// the browser handoff produced by the host setup console.
|
||||
function NoOwnerNotice({ onBound }: { onBound: () => void }) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
@@ -628,18 +628,6 @@ function NoOwnerNotice({ joinAddr, onBound }: { joinAddr: string; onBound: () =>
|
||||
sudo felis setup
|
||||
</code>
|
||||
</>,
|
||||
joinAddr ? (
|
||||
<>
|
||||
<p>{t("no_owner_step_join")}</p>
|
||||
<CopyAddress address={joinAddr} className="mt-1" />
|
||||
{!isIPAddress(joinAddr) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">{t("no_owner_ip_fallback")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("no_owner_step_join_no_address")}</p>
|
||||
),
|
||||
<p>{t("no_owner_step_code")}</p>,
|
||||
<p>{t("no_owner_step_link")}</p>,
|
||||
];
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("Setup", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: t("auth:setup_retry") }));
|
||||
|
||||
expect(await screen.findByText(t("auth:setup_welcome", { name: "owner" }))).toBeTruthy();
|
||||
expect(screen.getByText(t("auth:setup_game_optional"))).toBeTruthy();
|
||||
expect(calls.setupRedeem.mock.calls).toEqual([["raw-token"], ["raw-token"]]);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useTier } from "@/lib/tier";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
|
||||
// Setup is the Owner's first-run onboarding wizard (spec §B setup bootstrap). The
|
||||
// `felis setup` MC-bind flow prints https://op.console.<root>/setup?token=<raw> —
|
||||
// `felis setup` host-authorized flow prints https://op.console.<root>/setup?token=<raw> —
|
||||
// the Owner is staff, so onboarding lands on the operator console, not the player
|
||||
// panel; this page redeems that one-time token (minting a lockdown session), then drives the
|
||||
// two remaining steps — record an email, enroll a passkey — before handing off to the
|
||||
@@ -167,7 +167,8 @@ export function Setup() {
|
||||
return (
|
||||
<AuthLayout title={t("setup_title")} subtitle={t("setup_welcome", { name: state.username })}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<p className="text-sm text-muted-foreground">{t("setup_game_optional")}</p>
|
||||
{!state.email ? (
|
||||
<EmailStep initialEmail={state.email} onRecorded={reload} />
|
||||
) : !state.has_passkey ? (
|
||||
|
||||
Reference in new issue
Block a user