Unverified Commit efbbe276 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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.
parent 75845d8f
Loading
Loading
Loading
Loading
+1 −1
Changes for README.md: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for README_EN.md: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/api.go: 4 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/api_test.go: 19 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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)
				}
			}
+25 −71
Changes for cmd/felis/breakglass.go: 25 added lines, 71 removed lines.
Original line number Diff line number Diff line
@@ -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,46 +423,19 @@ 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)
	}
	// 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
	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,
		return out, err
	}
	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
}

// auditBreakGlass writes the break-glass accountability row. The actor is the
@@ -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
Loading