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

feat: configure authenticated player entry routes

parent 07973a4b
Loading
Loading
Loading
Loading
+71 −0
Changes for docs/openapi.yaml: 71 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -341,6 +341,18 @@ components:
        startedAt: { type: string, format: date-time }
        logsAvailable: { type: boolean }

    EntryPolicySettings:
      type: object
      required: [mode, defaultServer, requireAccountLink, offlineAction, waitingSpace, fallbackServer, revision]
      properties:
        mode: { type: string, enum: [lobby, direct, domain] }
        defaultServer: { type: string, description: Required for direct mode; optional fallback destination for unmatched hostnames. }
        requireAccountLink: { type: boolean, description: Require Limbo web sign-in and panel association in addition to proxy game identity authentication. }
        offlineAction: { type: string, enum: [wake, fallback, disconnect] }
        waitingSpace: { type: string, enum: [login, lobby], description: Web sign-in currently requires lobby waiting. }
        fallbackServer: { type: string, description: Required for fallback action and distinct from the default server. }
        revision: { type: string, description: Opaque revision; stale or concurrent writes return 409. }

    WakePolicySettings:
      type: object
      required: [maxRunningServers, wakeCooldownSeconds, revision, managed]
@@ -4454,6 +4466,65 @@ paths:
        '409': { description: Another node task is running or this task cannot be retried. }
        '503': { description: Host node execution service is unavailable. }

  /api/v1/internal/settings/entry-policy:
    get:
      tags: [internal]
      operationId: internalEntryPolicy
      summary: Read player entry policy for the authenticated proxy and login gate.
      x-felis-face: [internal]
      x-felis-tier: service
      x-felis-callers: [velocity, limbo]
      security: [{ serviceToken: [] }]
      responses:
        '200':
          description: Current policy; proxy snapshots it for each new connection.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntryPolicySettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/v1/settings/entry-policy:
    get:
      tags: [account]
      operationId: getEntryPolicy
      summary: Read player entry policy (Owner).
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ sessionCookie: [] }]
      responses:
        '200':
          description: Current policy and revision; an unsaved deployment preserves its legacy routing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntryPolicySettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    put:
      tags: [account]
      operationId: setEntryPolicy
      summary: Save player entry policy (Owner, fresh reauthentication).
      description: Applies to new connections within the proxy's 15-second refresh interval. Does not change online-mode, autostart authorization or existing players, and does not stop system spaces automatically.
      x-felis-face: [external]
      x-felis-tier: owner
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EntryPolicySettings' }
      responses:
        '200':
          description: Saved policy and revision.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntryPolicySettings' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { description: Selected server does not exist. }
        '409': { description: Policy changed; reload before saving. }

  /api/v1/settings/wake-policy:
    get:
      tags: [account]
+46 −0
Changes for docs/operations.md: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -903,3 +903,49 @@ configuration, or test the profile-query API.
Saving requires Owner access on the operator host and recent reauthentication for
a local session. A revision conflict preserves the draft; discard it and reload
before editing the newer configuration.

## 8. Player entry policy

Owner → Platform settings → Player entry policy configures the proxy's destination
independently from game identity authentication and panel account association.

| Mode | Destination after authentication |
| --- | --- |
| Lobby | Formal lobby, where the player selects a server |
| Direct to main server | Selected main server, regardless of connection hostname |
| Route by hostname | Server matched by hostname; optional default for unmatched hostnames |

For a single-server deployment, select **Direct to main server**, choose the main
server, and disable **Require panel account linking**. A running main server then
accepts authenticated players without entering the login space or lobby. Game
identity authentication and the global blacklist remain enforced; this setting does
not enable offline-mode or change authentication sources. Players can associate a
panel account separately through `/link`.

Choose how to handle an offline destination:

- **Start automatically and wait** uses the existing wake permission, maintenance,
  admission limit and cooldown checks. Select a running Limbo login space or formal
  lobby for the wait. An unlinked player requires an autostart policy that permits
  their verified identity; selecting a main server does not grant startup rights.
- **Enter a fallback server** requires a separate running server. The proxy does not
  start the fallback implicitly; it rejects the connection if both destinations
  are unavailable.
- **Reject with an explanation** requires no waiting space.

When panel account linking is required, Limbo retains its web sign-in, blacklist
check, timeout and release controls. The existing web sign-in flow requires the
login space and formal lobby, and uses the lobby for startup waiting. Automatic
Limbo waiting does not issue a link code or start a web sign-in timer; the proxy's
queue controls startup progress and disconnects Limbo waits after failure or timeout.
A failed transfer includes the backend refusal when available.

Saving requires fresh Owner reauthentication and the current revision. The proxy
reads changes within 15 seconds and snapshots the policy for each new connection;
current players keep their connection policy. Existing deployments retain hostname
routing, required web sign-in and lobby waiting until a policy is saved. Update the
API and game plugins together before using this setting.

Unused login/lobby spaces can be stopped from **Login & lobby**. Saving a policy
does not stop them automatically or disconnect existing players. Re-running setup
preserves the desired state of existing system servers.
+3 −0
Changes for internal/api/api.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -437,6 +437,7 @@ func (a *API) internalAPIRoutes() []apiRoute {
		// and keyed by the verified UUID (not the scanned code), so it consumes nothing
		// and is safe to poll repeatedly.
		{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", Callers: gate, h: a.handleLinkStatus},
		{Method: "GET", Pattern: "/api/v1/internal/settings/entry-policy", Callers: gate, h: a.handleGetEntryPolicy},
		// Account migration (spec §B3 inherit), in-game side: /felis migrate puts the
		// account linked to the running player's verified UUID into migrate mode. Internal
		// only — the initiator is proven by online-mode auth, and the sensitive proof
@@ -655,6 +656,8 @@ func (a *API) externalAPIRoutes() []apiRoute {
		{Method: "POST", Pattern: "/api/v1/settings/node-control/tasks", Owner: true, Admin: true, h: a.handleStartNodeTask},
		{Method: "GET", Pattern: "/api/v1/settings/node-control/tasks/{id}", Owner: true, Admin: true, h: a.handleNodeTask},
		{Method: "POST", Pattern: "/api/v1/settings/node-control/tasks/{id}/retry", Owner: true, Admin: true, h: a.handleRetryNodeTask},
		{Method: "GET", Pattern: "/api/v1/settings/entry-policy", Owner: true, Admin: true, h: a.handleGetEntryPolicy},
		{Method: "PUT", Pattern: "/api/v1/settings/entry-policy", Owner: true, Admin: true, h: a.handleSetEntryPolicy},
		{Method: "GET", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleGetWakePolicy},
		{Method: "PUT", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleSetWakePolicy},
		{Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
+137 −0
Changes for internal/api/handlers_entry_policy.go: 137 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"net/http"

	"felis.lolicon.best/internal/naming"
)

const entryPolicyKey = "player_entry_policy"

type entryPolicy struct {
	Mode               string `json:"mode"`
	DefaultServer      string `json:"defaultServer"`
	RequireAccountLink bool   `json:"requireAccountLink"`
	OfflineAction      string `json:"offlineAction"`
	WaitingSpace       string `json:"waitingSpace"`
	FallbackServer     string `json:"fallbackServer"`
}

type entryPolicyView struct {
	entryPolicy
	Revision string `json:"revision"`
}

func defaultEntryPolicy() entryPolicy {
	// Preserve existing host routing and web association until the Owner saves a policy.
	return entryPolicy{Mode: "domain", RequireAccountLink: true, OfflineAction: "wake", WaitingSpace: "lobby"}
}

func (p entryPolicy) valid() bool {
	if p.Mode != "lobby" && p.Mode != "direct" && p.Mode != "domain" {
		return false
	}
	if p.OfflineAction != "wake" && p.OfflineAction != "fallback" && p.OfflineAction != "disconnect" {
		return false
	}
	if p.WaitingSpace != "login" && p.WaitingSpace != "lobby" {
		return false
	}
	if p.RequireAccountLink && p.WaitingSpace != "lobby" {
		return false
	}
	if p.Mode == "direct" && p.DefaultServer == "" {
		return false
	}
	if p.OfflineAction == "fallback" && (p.FallbackServer == "" || p.FallbackServer == p.DefaultServer) {
		return false
	}
	for _, name := range []string{p.DefaultServer, p.FallbackServer} {
		if name != "" && (naming.ValidateServerName(name) != nil || naming.IsSystemServer(name)) {
			return false
		}
	}
	return true
}

func (a *API) readEntryPolicy(ctx context.Context) (entryPolicyView, []byte, error) {
	view := entryPolicyView{entryPolicy: defaultEntryPolicy()}
	raw, err := a.Repo.GetSetting(ctx, entryPolicyKey)
	if errors.Is(err, ErrNotFound) {
		raw = nil
	} else if err != nil {
		return view, nil, err
	} else if err = json.Unmarshal(raw, &view.entryPolicy); err != nil {
		return view, nil, err
	}
	if !view.entryPolicy.valid() {
		return view, nil, errors.New("invalid player entry policy")
	}
	canonical, _ := json.Marshal(view.entryPolicy)
	sum := sha256.Sum256(canonical)
	view.Revision = hex.EncodeToString(sum[:])
	return view, raw, nil
}

func (a *API) handleGetEntryPolicy(w http.ResponseWriter, r *http.Request) {
	view, _, err := a.readEntryPolicy(r.Context())
	if err != nil {
		writeError(w, r, err)
		return
	}
	writeJSON(w, 200, view)
}

func (a *API) handleSetEntryPolicy(w http.ResponseWriter, r *http.Request) {
	if !a.requireReauth(w, r, principalFromContext(r.Context())) {
		return
	}
	if err := requireJSONContentType(r); err != nil {
		writeError(w, r, err)
		return
	}
	var body entryPolicyView
	if err := decodeJSON(w, r, &body); err != nil {
		writeError(w, r, err)
		return
	}
	if !body.entryPolicy.valid() {
		writeError(w, r, newError(400, "bad_request", "invalid entry mode, target or offline policy"))
		return
	}
	for _, name := range []string{body.DefaultServer, body.FallbackServer} {
		if name == "" {
			continue
		}
		if _, err := a.Cluster.GetServer(r.Context(), name); err != nil {
			a.writeLookupError(w, r, err)
			return
		}
	}
	current, expected, err := a.readEntryPolicy(r.Context())
	if err != nil {
		writeError(w, r, err)
		return
	}
	if body.Revision != current.Revision {
		writeError(w, r, newError(409, "conflict", "player entry policy changed; reload before saving"))
		return
	}
	raw, _ := json.Marshal(body.entryPolicy)
	if err = a.Repo.CompareAndSetSetting(r.Context(), entryPolicyKey, expected, raw); err != nil {
		writeError(w, r, err)
		return
	}
	a.audit(r, "platform.entry_policy", "platform")
	view, _, err := a.readEntryPolicy(r.Context())
	if err != nil {
		writeError(w, r, err)
		return
	}
	writeJSON(w, http.StatusOK, view)
}
+88 −0
Changes for internal/api/handlers_entry_policy_test.go: 88 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"context"
	"encoding/json"
	"testing"
)

func TestEntryPolicyPersistenceAndAuthorization(t *testing.T) {
	repo, cluster := newFakeRepo(), newFakeCluster()
	cluster.byName["main"] = &ServerInfo{Name: "main"}
	a := newTestAPI(repo, cluster)
	path := "/api/v1/settings/entry-policy"
	for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
		a.External = staticExternal{p: p}
		for _, method := range []string{"GET", "PUT"} {
			if w := do(a.ExternalHandler(), method, path, `{}`, jsonHeader); w.Code != 401 && w.Code != 403 {
				t.Fatalf("unauthorized: %d", w.Code)
			}
		}
	}
	p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}
	a.External = staticExternal{p: p}
	view, _, err := a.readEntryPolicy(context.Background())
	if err != nil || !view.RequireAccountLink || view.Mode != "domain" {
		t.Fatal(view, err)
	}
	save := func(v entryPolicyView) int {
		body, _ := json.Marshal(v)
		return do(a.ExternalHandler(), "PUT", path, string(body), jsonHeader).Code
	}
	view.Mode = "direct"
	view.DefaultServer = "main"
	view.RequireAccountLink = false
	view.WaitingSpace = "login"
	if code := save(view); code != 200 {
		t.Fatal("save", code)
	}
	if code := save(view); code != 409 {
		t.Fatal("stale save", code)
	}
	replica := newTestAPI(repo, cluster)
	persisted, _, err := replica.readEntryPolicy(context.Background())
	if err != nil || persisted.Mode != "direct" || persisted.DefaultServer != "main" || persisted.RequireAccountLink {
		t.Fatal(persisted, err)
	}
	persisted.DefaultServer = "missing"
	if code := save(persisted); code != 404 {
		t.Fatal("missing target", code)
	}
	p.ViaSession = true
	p.EmailVerified = true
	repo.passkeyCreds["key"] = PasskeyCredential{ID: "key", UserID: "owner", UserVerified: true}
	persisted.DefaultServer = "main"
	if code := save(persisted); code != 403 {
		t.Fatal("reauth", code)
	}
	repo.settings[entryPolicyKey] = []byte(`{"mode":"invalid"}`)
	if _, _, err := a.readEntryPolicy(context.Background()); err == nil {
		t.Fatal("invalid persisted policy accepted")
	}
}

func TestEntryPolicyValidation(t *testing.T) {
	good := entryPolicy{Mode: "direct", DefaultServer: "main", OfflineAction: "wake", WaitingSpace: "login"}
	if !good.valid() {
		t.Fatal("valid policy rejected")
	}
	for _, mutate := range []func(*entryPolicy){
		func(p *entryPolicy) { p.Mode = "invalid" }, func(p *entryPolicy) { p.DefaultServer = "" }, func(p *entryPolicy) { p.DefaultServer = "login" }, func(p *entryPolicy) { p.OfflineAction = "fallback" }, func(p *entryPolicy) { p.OfflineAction = "fallback"; p.FallbackServer = "main" }, func(p *entryPolicy) { p.WaitingSpace = "invalid" }, func(p *entryPolicy) { p.RequireAccountLink = true },
	} {
		p := good
		mutate(&p)
		if p.valid() {
			t.Fatal("invalid policy accepted", p)
		}
	}
}

func TestEntryPolicyInternalCallerBoundary(t *testing.T) {
	a := newTestAPI(newFakeRepo(), newFakeCluster())
	a.Internal = CallerTokens{CallerVelocity: "proxy", CallerLimbo: "login", CallerBuild: "build"}
	for token, want := range map[string]int{"proxy": 200, "login": 200, "build": 403, "invalid": 401} {
		if w := do(a.InternalHandler(), "GET", "/api/v1/internal/settings/entry-policy", "", map[string]string{"Authorization": "Bearer " + token}); w.Code != want {
			t.Fatal(token, w.Code, w.Body.String())
		}
	}
}
Loading