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

fix(allowlist): 唤醒名单可查看、可取消或恢复唤醒权限,换主人时清空

parent d807fd58
Loading
Loading
Loading
Loading
+95 −0
Changes for docs/openapi.yaml: 95 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -540,6 +540,24 @@ components:
                so the cockpit renders them read-only instead of offering actions
                that would 400.

    AllowlistEntry:
      type: object
      description: >-
        One player on a server's wake allowlist (internal/api/repo.go
        AllowlistEntry): someone who joined the server, and so may wake it under
        autostartPolicy=allowlist unless the owner took that away.
      required: [mc_uuid, added_at, can_wake]
      properties:
        mc_uuid: { type: string, format: uuid }
        username:
          type: string
          description: >-
            The live Felis account the UUID is linked to; omitted when there is
            none (the account was closed or the link removed).
        added_at: { type: string, format: date-time, description: The player's first join. }
        can_wake:
          type: boolean
          description: False once the owner or an admin took the wake right away.
    MyServerView:
      type: object
      description: One row of the caller's server list (internal/api/repo.go MyServerView).
@@ -2403,6 +2421,83 @@ paths:
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/servers/{name}/allowlist:
    get:
      tags: [access]
      operationId: listAllowlist
      summary: The server's wake allowlist, newest first. Owner/admin only.
      description: >-
        Who may wake the server while it sleeps under autostartPolicy=allowlist. A
        player lands here by joining the server once; entries whose wake right was
        taken away stay listed with can_wake false, so it can be given back. Felis
        keeps this list itself, so it answers whether the server is running or not.
        A change of owner (claim, reaper release, account deletion) empties it.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The allowlist.
          content:
            application/json:
              schema:
                type: object
                required: [server, entries]
                properties:
                  server: { type: string }
                  entries:
                    type: array
                    items: { $ref: '#/components/schemas/AllowlistEntry' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/servers/{name}/allowlist/{uuid}:
    put:
      tags: [access]
      operationId: setAllowlistWake
      summary: Take a player's wake right away or give it back. Owner/admin only.
      description: >-
        can_wake false keeps the entry on the list with its wake right revoked, so
        the player's next join does not restore it; true gives it back. Repeating
        either is harmless. Audited as allowlist.revoke / allowlist.restore.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [can_wake]
              properties:
                can_wake: { type: boolean }
      responses:
        '204':
          description: Changed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No such server, or the UUID is not on its allowlist.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/servers/{name}/status:
    get:
      tags: [servers]
+5 −0
Changes for internal/api/api.go: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -511,6 +511,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
		{Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup},
		{Method: "GET", Pattern: "/api/v1/servers/{name}/access/luckperms/{player}", h: a.handleAccessLuckPermsInfo},
		// Wake allowlist (autostartPolicy=allowlist): Felis's own Postgres record of
		// who may wake the server, owner/admin-gated inside the handlers like the
		// access routes above (handlers_allowlist.go).
		{Method: "GET", Pattern: "/api/v1/servers/{name}/allowlist", h: a.handleAllowlistList},
		{Method: "PUT", Pattern: "/api/v1/servers/{name}/allowlist/{uuid}", h: a.handleAllowlistSetWake},
		{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
		// Identity self-read (spec §14 tiering): the panel reads this once at boot to
		// learn its own tier and decide which navigation surfaces to render. App-tier —
+20 −0
Changes for internal/api/api_test.go: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -33,6 +33,10 @@ type fakeRepo struct {
	// internal/velocity wake uses (name -> mc_uuid -> on list). allowlist above is
	// the account_links-bridged web view of the same data.
	allowUUID map[string]map[string]bool
	// allowEntries is the listing face of server_allowlist (name -> rows as
	// ServerAllowlist returns them); SetAllowlistWake flips CanWake in place.
	allowEntries map[string][]AllowlistEntry
	allowlistErr error // forces ServerAllowlist to fail
	mine         map[string][]MyServerView
	// owners mirrors the ServerOwners join (name -> claim state); a live unclaimed
	// server appears with an empty OwnerID. ownersErr forces the lookup to fail so
@@ -285,6 +289,7 @@ func newFakeRepo() *fakeRepo {
		bySub: map[string]*ServerRecord{}, byName: map[string]*ServerRecord{},
		linked: map[string]bool{}, quota: map[string]bool{},
		allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
		allowEntries: map[string][]AllowlistEntry{},
		mine:         map[string][]MyServerView{},
		owners:       map[string]ServerOwnership{},
		claimOK:      map[string]bool{}, claimQuotaRefuse: map[string]bool{},
@@ -804,6 +809,21 @@ func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error)
func (f *fakeRepo) UUIDInAllowlist(_ context.Context, n, uuid string) (bool, error) {
	return f.allowUUID[n][uuid], nil
}
func (f *fakeRepo) ServerAllowlist(_ context.Context, n string) ([]AllowlistEntry, error) {
	if f.allowlistErr != nil {
		return nil, f.allowlistErr
	}
	return append([]AllowlistEntry{}, f.allowEntries[n]...), nil
}
func (f *fakeRepo) SetAllowlistWake(_ context.Context, n, uuid string, canWake bool) error {
	for i := range f.allowEntries[n] {
		if f.allowEntries[n][i].MCUUID == uuid {
			f.allowEntries[n][i].CanWake = canWake
			return nil
		}
	}
	return ErrNotFound
}
func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error) {
	if u, ok := f.links[uuid]; ok && !f.seededDead(u) {
		return u, nil
+96 −0
Changes for internal/api/handlers_allowlist.go: 96 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"net/http"

	"github.com/google/uuid"

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

// The wake allowlist (server_allowlist) decides who may wake a sleeping server
// whose autostartPolicy is allowlist. A player lands on it by joining the server
// once (RecordJoin); these two routes let the owner, or an admin, see who is on it
// and take a player's wake right away or give it back. It is Felis's own record in
// Postgres, so it answers whether the server is running or not, unlike the
// Minecraft whitelist under /access, which is the game server's and needs RCON.

// allowlistWakeRequest is the PUT /servers/{name}/allowlist/{uuid} body.
type allowlistWakeRequest struct {
	CanWake *bool `json:"can_wake"`
}

// allowlistServer resolves {name} for the allowlist routes and applies their
// gate: 400 for a malformed name, 404 for a server that does not exist, 403 for a
// caller who neither owns it nor is an admin.
func (a *API) allowlistServer(w http.ResponseWriter, r *http.Request) (string, bool) {
	name := r.PathValue("name")
	if err := naming.ValidateServerName(name); err != nil {
		writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
		return "", false
	}
	rec, err := a.Repo.ServerByName(r.Context(), name)
	if err != nil {
		a.writeLookupError(w, r, err)
		return "", false
	}
	if !a.isOwnerOrAdmin(principalFromContext(r.Context()), rec) {
		writeError(w, r, errForbidden)
		return "", false
	}
	return name, true
}

// handleAllowlistList returns the server's wake allowlist, newest first,
// including the players whose wake right was taken away.
func (a *API) handleAllowlistList(w http.ResponseWriter, r *http.Request) {
	name, ok := a.allowlistServer(w, r)
	if !ok {
		return
	}
	entries, err := a.Repo.ServerAllowlist(r.Context(), name)
	if err != nil {
		writeError(w, r, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"server": name, "entries": entries})
}

// handleAllowlistSetWake takes a player's wake right away (can_wake false) or
// gives it back (true). The entry stays on the list either way, so a revoked
// player's next join does not quietly undo the owner's choice.
func (a *API) handleAllowlistSetWake(w http.ResponseWriter, r *http.Request) {
	name, ok := a.allowlistServer(w, r)
	if !ok {
		return
	}
	id, err := uuid.Parse(r.PathValue("uuid"))
	if err != nil {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "invalid Minecraft UUID"))
		return
	}
	var req allowlistWakeRequest
	if err := decodeJSON(w, r, &req); err != nil {
		writeError(w, r, err)
		return
	}
	if req.CanWake == nil {
		writeError(w, r, newError(http.StatusBadRequest, "bad_request", "can_wake is required"))
		return
	}
	if err := a.Repo.SetAllowlistWake(r.Context(), name, id.String(), *req.CanWake); err != nil {
		a.writeLookupError(w, r, err)
		return
	}
	p := principalFromContext(r.Context())
	e := AuditEntry{Actor: auditActor(p), Action: "allowlist.revoke", ServerName: name}
	if *req.CanWake {
		e.Action = "allowlist.restore"
	}
	if p != nil {
		e.ActorUserID = p.UserID
	}
	e.Payload = auditPayload(map[string]any{"mc_uuid": id.String()})
	a.auditEntry(r, e)
	w.WriteHeader(http.StatusNoContent)
}
+136 −0
Changes for internal/api/handlers_allowlist_test.go: 136 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"testing"
	"time"
)

// TestAllowlistRoutes covers GET /servers/{name}/allowlist and PUT
// /servers/{name}/allowlist/{uuid}: the owner and an admin read and change the
// list, anyone else is refused before the repo is touched, a UUID that is not on
// the list is 404, and each change is audited with the UUID it touched.
func TestAllowlistRoutes(t *testing.T) {
	const friend = "0f8fad5b-d9cb-469f-a165-70867728950e"
	const other = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
	owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
	admin := &Principal{UserID: "admin1", Role: "admin", ViaAdminAccess: true}
	stranger := &Principal{UserID: "other", Email: "[email protected]", Role: "user"}
	added := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)

	mk := func(p *Principal) (*API, *fakeRepo) {
		repo := newFakeRepo()
		repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
		repo.allowEntries["survival"] = []AllowlistEntry{
			{MCUUID: friend, Username: "Steve", AddedAt: added, CanWake: true},
			{MCUUID: other, AddedAt: added.Add(-time.Hour), CanWake: false},
		}
		a := newTestAPI(repo, newFakeCluster())
		a.External = staticExternal{p: p}
		return a, repo
	}
	list := func(t *testing.T, a *API) []AllowlistEntry {
		t.Helper()
		w := do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/allowlist", "", nil)
		if w.Code != http.StatusOK {
			t.Fatalf("list: code = %d (%s)", w.Code, w.Body.String())
		}
		var resp struct {
			Server  string           `json:"server"`
			Entries []AllowlistEntry `json:"entries"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
			t.Fatalf("list: bad JSON: %v", err)
		}
		if resp.Server != "survival" {
			t.Fatalf("list: server = %q", resp.Server)
		}
		return resp.Entries
	}

	for _, tc := range []struct {
		label string
		p     *Principal
	}{{"owner", owner}, {"admin", admin}} {
		t.Run(tc.label+" lists and revokes", func(t *testing.T) {
			a, repo := mk(tc.p)
			got := list(t, a)
			if len(got) != 2 || got[0].MCUUID != friend || got[0].Username != "Steve" || !got[0].CanWake ||
				got[1].MCUUID != other || got[1].CanWake || !got[0].AddedAt.Equal(added) {
				t.Fatalf("entries = %+v", got)
			}

			// An upper-case UUID names the same entry: the route canonicalizes it.
			w := do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/allowlist/"+strings.ToUpper(friend),
				`{"can_wake":false}`, jsonHeader)
			if w.Code != http.StatusNoContent {
				t.Fatalf("revoke: code = %d (%s)", w.Code, w.Body.String())
			}
			if list(t, a)[0].CanWake {
				t.Fatal("revoke left the wake right in place")
			}
			if len(repo.audits) != 1 || repo.audits[0].Action != "allowlist.revoke" ||
				repo.audits[0].ServerName != "survival" || repo.audits[0].ActorUserID != tc.p.UserID ||
				!strings.Contains(string(repo.audits[0].Payload), friend) {
				t.Fatalf("revoke audit = %+v", repo.audits)
			}

			w = do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/allowlist/"+other,
				`{"can_wake":true}`, jsonHeader)
			if w.Code != http.StatusNoContent {
				t.Fatalf("restore: code = %d (%s)", w.Code, w.Body.String())
			}
			if !list(t, a)[1].CanWake {
				t.Fatal("restore did not give the wake right back")
			}
			if len(repo.audits) != 2 || repo.audits[1].Action != "allowlist.restore" {
				t.Fatalf("restore audit = %+v", repo.audits)
			}
		})
	}

	t.Run("stranger is refused on both routes", func(t *testing.T) {
		a, repo := mk(stranger)
		if w := do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/allowlist", "", nil); w.Code != http.StatusForbidden {
			t.Fatalf("list: code = %d, want 403", w.Code)
		}
		w := do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/allowlist/"+friend, `{"can_wake":false}`, jsonHeader)
		if w.Code != http.StatusForbidden {
			t.Fatalf("revoke: code = %d, want 403", w.Code)
		}
		if !repo.allowEntries["survival"][0].CanWake || len(repo.audits) != 0 {
			t.Fatalf("a refused revoke changed something: %+v, audits %+v", repo.allowEntries["survival"], repo.audits)
		}
	})

	t.Run("unknown server, unknown entry and bad input", func(t *testing.T) {
		a, repo := mk(owner)
		for _, c := range []struct {
			method, path, body string
			want               int
		}{
			{"GET", "/api/v1/servers/nowhere/allowlist", "", http.StatusNotFound},
			{"PUT", "/api/v1/servers/nowhere/allowlist/" + friend, `{"can_wake":false}`, http.StatusNotFound},
			{"PUT", "/api/v1/servers/survival/allowlist/11111111-2222-3333-4444-555555555555", `{"can_wake":false}`, http.StatusNotFound},
			{"PUT", "/api/v1/servers/survival/allowlist/not-a-uuid", `{"can_wake":false}`, http.StatusBadRequest},
			{"PUT", "/api/v1/servers/survival/allowlist/" + friend, `{}`, http.StatusBadRequest},
			{"GET", "/api/v1/servers/Bad_Name/allowlist", "", http.StatusBadRequest},
		} {
			if w := do(a.ExternalHandler(), c.method, c.path, c.body, jsonHeader); w.Code != c.want {
				t.Errorf("%s %s %s: code = %d, want %d (%s)", c.method, c.path, c.body, w.Code, c.want, w.Body.String())
			}
		}
		if !repo.allowEntries["survival"][0].CanWake || len(repo.audits) != 0 {
			t.Fatalf("a failed call changed something: %+v, audits %+v", repo.allowEntries["survival"], repo.audits)
		}

		repo.allowlistErr = errors.New("db down")
		w := do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/allowlist", "", nil)
		if w.Code != http.StatusInternalServerError || strings.Contains(w.Body.String(), "db down") {
			t.Fatalf("a failed read: code = %d (%s), want an opaque 500", w.Code, w.Body.String())
		}
	})
}
Loading