Unverified Commit dab8fc21 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(setup): bind owner through login gate

parent 5dc8eb92
Loading
Loading
Loading
Loading
+57 −21
Changes for cmd/felis/breakglass.go: 57 added lines, 21 removed lines.
Original line number Diff line number Diff line
@@ -83,14 +83,11 @@ type ownerStore interface {
	// Operator. The row is role=admin, identical in shape to the Owner — Felis has no
	// separate operator DB role (migration 0003: staff = role=admin).
	InsertOperator(ctx context.Context, id, username, email string) error
	// RedeemLinkCodeForOwner consumes an in-game link code and creates-or-promotes
	// the bound user to role='admin' (Owner). It is the `felis setup` MC-bind path:
	// the operator enters limbo, runs /link, types the code here, and the bound
	// account becomes the passwordless Owner. Unlike RedeemPlayerBindCode it does NOT
	// refuse staff — setup deliberately elevates the bound account.
	RedeemLinkCodeForOwner(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error)
	// CreateSetupToken mints a one-time setup token for first-web-login bootstrap.
	CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) 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)
	SetSetting(ctx context.Context, key string, value []byte) error
	// Audit records the break-glass accountability row.
	Audit(ctx context.Context, e api.AuditEntry) error
@@ -347,6 +344,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
	auditErr      error  // non-nil if the accountability row could not be written
}

@@ -387,11 +385,17 @@ func newSetupToken() (raw, hash string, err error) {
}

// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
// binds their Minecraft account via an in-game /link code, the bound user is
// promoted to role='admin' (passwordless Owner), and a one-time setup URL is
// minted for the first web login where the Owner verifies email / enrolls a
// passkey. adminHostname is the op.console host the URL points at.
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname string) (breakGlassOutcome, error) {
// binds their Minecraft account via a one-time link code the login gate handed
// them in-game, the bound user is promoted to role='admin' (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
// op.console host the URL points at; 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")
@@ -400,23 +404,51 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname s
	if newID == "" {
		return breakGlassOutcome{}, errors.New("generate owner id: entropy source failed")
	}
	userID, _, _, err := s.RedeemLinkCodeForOwner(ctx, newID, code, time.Now())
	if err != nil {
		return breakGlassOutcome{}, fmt.Errorf("bind minecraft account: %w", err)
	}
	raw, hash, err := newSetupToken()
	if err != nil {
		return breakGlassOutcome{}, err
	}
	if err := s.CreateSetupToken(ctx, hash, userID, time.Now().Add(setupTokenTTL)); err != nil {
		return breakGlassOutcome{}, fmt.Errorf("mint setup token: %w", err)
	now := time.Now()
	_, mcUUID, authSource, err := s.CompleteOwnerSetup(
		ctx, newID, code, now, hash, now.Add(setupTokenTTL))
	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"
	}
	url := "https://" + host + "/setup?token=" + raw
	return breakGlassOutcome{setupTokenURL: url}, nil
	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,
	})
}

// auditBreakGlass writes the break-glass accountability row. The actor is the
@@ -495,6 +527,10 @@ func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error
// post-exit summary. provisioned is false on cancel.
type breakGlassResult struct {
	provisioned bool
	// alreadySetUp marks the re-run landing (the status screen): setup ran, found an
	// Owner, and deliberately changed nothing. Without it a re-run is indistinguishable
	// from a cancel and reports itself as one.
	alreadySetUp  bool
	isOperator    bool // an Operator was added rather than the Owner provisioned
	mode          string
	accountable   string
+80 −25
Changes for cmd/felis/breakglass_test.go: 80 added lines, 25 removed lines.
Original line number Diff line number Diff line
@@ -28,7 +28,7 @@ type fakeOwnerStore struct {
	users    map[string]*api.StaffUser // keyed by username
	admins   bool                      // AdminExists answer

	// RedeemLinkCodeForOwner's success result. redeemUserID defaults to the fresh id
	// CompleteOwnerSetup's success result. redeemUserID defaults to the fresh id
	// the caller passes (the unlinked-UUID case) when left empty.
	redeemUserID     string
	redeemMCUUID     string
@@ -50,14 +50,14 @@ type upsertCall struct {
	id, username, email string
}

// setupTokenCall is a recorded CreateSetupToken write. Only the hash is persisted.
// setupTokenCall is a recorded setup-token write. Only the hash is persisted.
type setupTokenCall struct {
	tokenHash string
	userID    string
	expiresAt time.Time
}

// redeemCall records the inputs RedeemLinkCodeForOwner was called with.
// redeemCall records the inputs CompleteOwnerSetup was called with.
type redeemCall struct {
	newUserID string
	code      string
@@ -107,29 +107,30 @@ func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email s
	return nil
}

// RedeemLinkCodeForOwner records the call and returns the configured Owner identity
// (or the injected error). The real method consumes a link code and promotes the
// bound account; the fake models only its inputs and outputs.
func (f *fakeOwnerStore) RedeemLinkCodeForOwner(_ context.Context, newUserID, code string, _ time.Time) (string, string, string, error) {
// 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
	}
	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
	if userID == "" {
		userID = newUserID // unlinked UUID → the fresh id becomes the Owner
	}
	return userID, f.redeemMCUUID, f.redeemAuthSource, nil
}

// CreateSetupToken records a minted setup token (hash only), or fails with the
// injected error without recording it.
func (f *fakeOwnerStore) CreateSetupToken(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
	if f.createTokenErr != nil {
		return f.createTokenErr
	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 nil
	return userID, f.redeemMCUUID, f.redeemAuthSource, nil
}

func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) error {
@@ -648,11 +649,29 @@ func TestPerformSetupMCBind(t *testing.T) {
	ctx := context.Background()

	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"}
		out, err := performSetupMCBind(ctx, f, "  abc-123  ", "op.console.example.com")
		f := &fakeOwnerStore{redeemUserID: "usr-owner-1", redeemMCUUID: "mc-uuid-1", redeemAuthSource: "mojang"}
		out, err := performSetupMCBind(ctx, f, "  abc-123  ", "op.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://op.console.example.com/setup?token="
		if !strings.HasPrefix(out.setupTokenURL, prefix) {
			t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix)
@@ -692,40 +711,76 @@ func TestPerformSetupMCBind(t *testing.T) {

	t.Run("an empty link code mints nothing", func(t *testing.T) {
		f := &fakeOwnerStore{}
		if _, err := performSetupMCBind(ctx, f, "   ", "op.console.example.com"); err == nil {
		if _, err := performSetupMCBind(ctx, f, "   ", "op.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("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", "op.console.example.com"); err == nil {
		if _, err := performSetupMCBind(ctx, f, "abc-123", "op.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("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", "op.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 surfaces after the bind", func(t *testing.T) {
	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", "op.console.example.com"); err == nil {
		if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil {
			t.Fatal("want error when the setup token cannot be stored")
		}
		if len(f.redeems) != 1 {
			t.Errorf("want the redeem to have happened before the token write, got %d", len(f.redeems))
		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", "op.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 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", "  ")
		out, err := performSetupMCBind(ctx, f, "abc-123", "  ", "root")
		if err != nil {
			t.Fatalf("performSetupMCBind: %v", err)
		}
+91 −36
Changes for cmd/felis/setup.go: 91 added lines, 36 removed lines.
Original line number Diff line number Diff line
@@ -11,7 +11,9 @@ import (
	"time"

	"felis.lolicon.best/internal/api"
	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/naming"
	"felis.lolicon.best/internal/platform"
	"felis.lolicon.best/internal/store"
)
@@ -102,6 +104,18 @@ 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
	}

	res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, 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)
@@ -121,7 +135,13 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
			}
			return 0
		}
		fmt.Fprintln(stdout, "felis setup: cancelled — no changes made.")
		// A re-run lands on the status screen, which changes nothing by design —
		// reporting that as "cancelled" reads as a failure the operator did not cause.
		msg := "felis setup: cancelled — no changes made."
		if res.alreadySetUp {
			msg = "felis setup: already set up — nothing to change."
		}
		fmt.Fprintln(stdout, msg)
		if panelURL != "" {
			fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
		}
@@ -161,31 +181,42 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
		}
	}

	// After a real setup pass (Owner provisioned and/or edge configured), make
	// sure the always-on login/lobby system services exist. This is idempotent
	// and best-effort — it never fails the setup that got this far.
	if res.provisioned || res.connectConfigured {
		provisionSystemServers(ctx, setup.cfg, stdout)
	}
	return 0
}

// provisionSystemServers ensures the login limbo and lobby system services exist
// after setup, then prints the off-cluster Velocity wiring the operator must
// apply by hand (Felis never writes the off-cluster proxy config). It is
// best-effort: unconfigured images or an unreachable cluster degrade to guidance
// rather than failing setup.
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer) {
// provisionSystemServers ensures the login limbo and lobby system services exist,
// 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.
	fail := func(format string, args ...any) error {
		if required {
			return fmt.Errorf(format, args...)
		}
		fmt.Fprintf(out, "\nfelis setup: "+format+"\n", args...)
		return nil
	}
	if cfg.Velocity.LoginImage == "" && cfg.Velocity.LobbyImage == "" {
		fmt.Fprintln(out, "\nfelis setup: 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`.")
		return
		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 {
		fmt.Fprintf(out, "\nfelis setup: could not reach the cluster to provision the login/lobby system servers: %v\n"+
			"Re-run `sudo felis setup` on the control-plane host once the cluster is reachable.\n", err)
		return
		return fail("could not reach the cluster to provision the login/lobby system servers: %v\n"+
			"Re-run `sudo felis setup` on the control-plane host once the cluster is reachable", err)
	}
	// The login limbo authenticates to the felis-api INTERNAL face, so it needs the
	// internal base URL, the root domain (to link players at the console), and the
@@ -197,9 +228,19 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
	// renamed it must replicate the Secret by hand.
	controlNS := platform.DefaultControlNamespace
	apiBaseURL := platform.InternalAPIBaseURL(controlNS)
	tokenOutcome := ensureServiceTokenReplica(ctx, cl, controlNS, cfg.K8s.Namespace)
	// Both Secrets must land in the minecraft namespace before the pods that mount
	// them are created: the service token (login authenticates to felis-api with it)
	// and 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).
	secretOutcomes := []systemServerOutcome{
		ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
			naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token"),
		ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
			naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret"),
	}
	outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain)
	outcomes = append([]systemServerOutcome{tokenOutcome}, outcomes...)
	outcomes = append(secretOutcomes, outcomes...)
	fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):")
	for _, o := range outcomes {
		switch {
@@ -211,30 +252,44 @@ 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
}

// printVelocityWiringGuidance emits the manual off-cluster Velocity config that
// enforces the login-first topology. Felis auto-registers login/lobby as dynamic
// backends via /api/v1/servers, but the proxy's DEFAULT landing and waiting-park
// target live in velocity.toml on the off-cluster Java host, which Felis never
// writes. The one invariant: the default landing and the initial wait-park are
// BOTH the login gate — never the lobby — so no connection reaches the lobby (or
// any backend) without passing authentication first. The Paper lobby is reached
// only when the login gate transfers an authenticated player onward.
// printVelocityWiringGuidance records the login-first topology bootstrap applies to
// its host proxy and an external proxy must mirror. Felis auto-registers login/lobby
// as dynamic backends via /api/v1/servers, while velocity.toml owns the static
// login-only fallback. The invariant is stateful: every fresh connection lands on
// login; only login may release a linked player to the lobby; and the proxy may then
// redirect that release to the originally requested backend or park it in the lobby
// while the backend wakes.
func printVelocityWiringGuidance(out io.Writer, rootDomain string) {
	fmt.Fprintln(out, "\nfelis setup: finish the login topology on the off-cluster Velocity host (velocity.toml):")
	fmt.Fprintln(out, "\nfelis setup: Velocity login topology (bootstrap configured the host proxy automatically):")
	fmt.Fprintln(out, "  If Velocity runs on another host, mirror these settings there:")
	fmt.Fprintln(out, "  1. Set the DEFAULT landing server to \"login\" so every fresh connection hits the")
	fmt.Fprintln(out, "     auth gate first (try = [\"login\"] under [servers], and the default forced-host).")
	fmt.Fprintln(out, "  2. Point the waiting-park target at the gate, NOT the lobby:")
	fmt.Fprintln(out, "     set FELIS_LOBBY_SERVER=login (or lobby-server=login). The limbo holds waiters")
	fmt.Fprintln(out, "     while their backend wakes, and a player is never parked past authentication.")
	fmt.Fprintln(out, "  3. Leave the Paper \"lobby\" OUT of the default/fallback paths — it is reached only")
	fmt.Fprintln(out, "     when the login gate transfers an authenticated player onward.")
	fmt.Fprintln(out, "  2. Keep the gate and post-auth lobby distinct:")
	fmt.Fprintln(out, "     set FELIS_LOGIN_SERVER=login and FELIS_LOBBY_SERVER=lobby")
	fmt.Fprintln(out, "     (or login-server=login / lobby-server=lobby).")
	fmt.Fprintln(out, "  3. Leave the Paper \"lobby\" OUT of every default/fallback path. The proxy accepts")
	fmt.Fprintln(out, "     it only as login's authenticated release target, then restores the requested route.")
	fmt.Fprintln(out, "  Rationale: rather refuse a connection when login is down than route a player past")
	fmt.Fprintln(out, "  the gate. Felis already refuses to give any server a fallback of \"lobby\".")
	if rootDomain != "" {
		fmt.Fprintf(out, "  (login is the front door for %s; per-server subdomains fall back to login while waking.)\n", rootDomain)
		fmt.Fprintf(out, "  (login is the front door for %s; linked players wait in lobby while a target wakes.)\n", rootDomain)
	}
}

+7 −0

File changed.

Preview size limit exceeded, changes collapsed.

+177 −25

File changed.

Preview size limit exceeded, changes collapsed.

Loading