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

This commit is contained in:
Lemon-miaow committed 2026-09-27 02:55:30 +08:00
1 parent d807fd5887
commit 4a26bf4ca0
23 files changed
+1190 -32

No files matched your search

+95
View File
@@ -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
View File
@@ -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 —
+24 -4
View File
@@ -33,7 +33,11 @@ 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
mine map[string][]MyServerView
// 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
// a test can prove the fleet read degrades rather than 500ing.
@@ -285,9 +289,10 @@ 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{},
mine: map[string][]MyServerView{},
owners: map[string]ServerOwnership{},
claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
allowEntries: map[string][]AllowlistEntry{},
mine: map[string][]MyServerView{},
owners: map[string]ServerOwnership{},
claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{},
seeded: map[string]bool{}, aliases: map[string]string{},
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
@@ -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
View File
@@ -0,0 +1,96 @@
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
View File
@@ -0,0 +1,136 @@
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())
}
})
}
+1
View File
@@ -38,6 +38,7 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
"ServerInfo": ServerInfo{},
"FleetServer": fleetServerView{},
"MyServerView": MyServerView{},
"AllowlistEntry": AllowlistEntry{},
"BackupView": BackupView{},
"Build": build.Build{},
"Image": build.Image{},
+69 -6
View File
@@ -423,6 +423,10 @@ func (p *PGRepo) ServerResources(ctx context.Context, name string) (ResourceSpec
// as last_active_at, so without the reset a new owner who configures it from
// the panel before anyone joins would lose it on the next reaper run, with no
// warning and no archive of their own.
//
// It also empties the wake allowlist. Every entry is a player who joined while
// someone else held the server (or nobody did), so it vouches for nothing on the
// new owner's; a friend of the new owner is added again by their next join.
func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
@@ -489,6 +493,9 @@ func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, er
if n != 1 {
return false, nil
}
if _, err := tx.ExecContext(ctx, `DELETE FROM server_allowlist WHERE server_name = $1`, name); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
@@ -517,10 +524,12 @@ func quotaAllows(maxServers, maxCPU, maxMem, maxStor sql.NullInt64,
return true
}
// UserInAllowlist reports whether any Minecraft UUID linked to the user is on the
// wake allowlist with its wake right intact (revoked_at IS NULL).
func (p *PGRepo) UserInAllowlist(ctx context.Context, name, userID string) (bool, error) {
const q = `SELECT EXISTS(
SELECT 1 FROM server_allowlist sa JOIN account_links al ON al.mc_uuid = sa.mc_uuid
WHERE sa.server_name = $1 AND al.user_id = $2)`
WHERE sa.server_name = $1 AND al.user_id = $2 AND sa.revoked_at IS NULL)`
var ok bool
err := p.db.QueryRowContext(ctx, q, name, userID).Scan(&ok)
return ok, err
@@ -529,15 +538,65 @@ func (p *PGRepo) UserInAllowlist(ctx context.Context, name, userID string) (bool
// UUIDInAllowlist is the internal-face allowlist check keyed by the in-game UUID
// directly (spec §9.4). The server_allowlist table is UUID-keyed, so the
// velocity-driven wake — which knows the joining player only by their online-mode
// UUID — needs no account_links bridge (contrast UserInAllowlist).
// UUID — needs no account_links bridge (contrast UserInAllowlist). A revoked
// entry does not count.
func (p *PGRepo) UUIDInAllowlist(ctx context.Context, name, mcUUID string) (bool, error) {
const q = `SELECT EXISTS(
SELECT 1 FROM server_allowlist WHERE server_name = $1 AND mc_uuid = $2)`
SELECT 1 FROM server_allowlist WHERE server_name = $1 AND mc_uuid = $2 AND revoked_at IS NULL)`
var ok bool
err := p.db.QueryRowContext(ctx, q, name, mcUUID).Scan(&ok)
return ok, err
}
// ServerAllowlist lists a server's wake allowlist, newest first, revoked entries
// included so the owner can give the right back. Username is the live account the
// UUID is linked to, empty when there is none: only linked players get past the
// login gate, so an unnamed entry belongs to a closed or unlinked account.
func (p *PGRepo) ServerAllowlist(ctx context.Context, name string) ([]AllowlistEntry, error) {
rows, err := p.db.QueryContext(ctx,
`SELECT sa.mc_uuid::text, COALESCE(u.username, ''), sa.added_at, sa.revoked_at IS NULL
FROM server_allowlist sa
LEFT JOIN account_links al ON al.mc_uuid = sa.mc_uuid
LEFT JOIN users u ON u.id = al.user_id AND u.deleted_at IS NULL
WHERE sa.server_name = $1
ORDER BY sa.added_at DESC, sa.mc_uuid`, name)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AllowlistEntry{}
for rows.Next() {
var e AllowlistEntry
if err := rows.Scan(&e.MCUUID, &e.Username, &e.AddedAt, &e.CanWake); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// SetAllowlistWake gives an allowlisted UUID its wake right back or takes it away.
// ErrNotFound when the UUID is not on the server's list. Taking it away keeps the
// row (revoked_at) so the player's next join cannot restore it; repeating either
// call changes nothing, and a repeated revoke keeps the first revoked_at.
func (p *PGRepo) SetAllowlistWake(ctx context.Context, name, mcUUID string, canWake bool) error {
res, err := p.db.ExecContext(ctx,
`UPDATE server_allowlist
SET revoked_at = CASE WHEN $3 THEN NULL ELSE COALESCE(revoked_at, now()) END
WHERE server_name = $1 AND mc_uuid = $2`, name, mcUUID, canWake)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// UserByMCUUID resolves a verified in-game UUID to its linked user_id (spec §10
// account_links), or ErrNotFound when the UUID is not linked to any account. A
// link whose account is dead reads the same as no link at all (audit #33), so the
@@ -558,7 +617,8 @@ func (p *PGRepo) UserByMCUUID(ctx context.Context, mcUUID string) (string, error
}
// RecordJoin renews activity and auto-appends the UUID to the allowlist in one
// transaction (spec §7, §9.4). A missing server is ErrNotFound.
// transaction (spec §7, §9.4). A missing server is ErrNotFound. An entry the
// owner revoked stays revoked: the append leaves an existing row alone.
func (p *PGRepo) RecordJoin(ctx context.Context, name, mcUUID string) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
@@ -1938,9 +1998,12 @@ func (p *PGRepo) DeleteUser(ctx context.Context, userID, _ string) error {
return ErrNotFound
}
// Release all owned servers.
// Release all owned servers, emptying their wake allowlists: the players on
// them were the departing owner's to vouch for (ClaimServer does the same).
if _, err := tx.ExecContext(ctx,
`UPDATE servers SET owner_id = NULL WHERE owner_id = $1 AND deleted_at IS NULL`,
`WITH released AS (
UPDATE servers SET owner_id = NULL WHERE owner_id = $1 AND deleted_at IS NULL RETURNING name)
DELETE FROM server_allowlist WHERE server_name IN (SELECT name FROM released)`,
userID); err != nil {
return err
}
+20
View File
@@ -336,6 +336,15 @@ type Repo interface {
// must be keyed by UUID directly (the allowlist table is UUID-keyed; the
// account_links join in UserInAllowlist only exists to bridge the web side).
UUIDInAllowlist(ctx context.Context, name, mcUUID string) (bool, error)
// ServerAllowlist lists the server's wake allowlist, newest first, revoked
// entries included, each with the live account its UUID is linked to.
ServerAllowlist(ctx context.Context, name string) ([]AllowlistEntry, error)
// SetAllowlistWake gives an allowlisted UUID its wake right back (true) or
// takes it away (false); ErrNotFound when the UUID is not on the list. A
// revoked entry stays on the list so the player's next join cannot restore it.
// Both allowlist checks above skip revoked entries, and a change of owner
// (claim, reaper release, account deletion) empties the list.
SetAllowlistWake(ctx context.Context, name, mcUUID string, canWake bool) error
// UserByMCUUID resolves a verified in-game UUID to the user_id it is linked to
// (spec §10 account_links), or ErrNotFound when the UUID is not linked. The
// internal-face wake uses it to apply the owner bypass for a player known only
@@ -826,6 +835,17 @@ type UserDetail struct {
LinkedAccounts []LinkedAccount `json:"linked_accounts,omitempty"`
}
// AllowlistEntry is one player on a server's wake allowlist (server_allowlist):
// someone who joined it, and so may wake it under autostartPolicy=allowlist
// unless the owner took that away (CanWake false). Username is the live account
// the UUID is linked to, empty when there is none.
type AllowlistEntry struct {
MCUUID string `json:"mc_uuid"`
Username string `json:"username,omitempty"`
AddedAt time.Time `json:"added_at"`
CanWake bool `json:"can_wake"`
}
// LinkedAccount is one verified MC-UUID binding (account_links, spec §10).
type LinkedAccount struct {
MCUUID string `json:"mc_uuid"`
+176
View File
@@ -0,0 +1,176 @@
//go:build pgint
package pgint
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/reaper"
)
// The wake allowlist: the owner reads it with each player's live account name, a
// revoked entry stops both wake checks and stays revoked through the player's next
// join, and every change of owner (claim, reaper release, account deletion) empties
// the list of that server alone. The list used to only grow: nothing could read or
// revoke it, and a reclaimed server handed the old owner's players to the new one.
func TestWakeAllowlistLifecycle(t *testing.T) {
ctx := context.Background()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
seed := func(prefix string) string {
t.Helper()
name := prefix + "-" + suffix(t)
if err := repo.SeedServer(ctx, name, name+"-s", 100, 128, 1024); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
return name
}
join := func(name, id string) {
t.Helper()
if err := repo.RecordJoin(ctx, name, id); err != nil {
t.Fatalf("RecordJoin(%s): %v", name, err)
}
}
friend := newUser(t, "user", "alfriend")
linked, stray := testUUID(t), testUUID(t)
exec(`INSERT INTO account_links (user_id, mc_uuid) VALUES ($1, $2)`, friend.ID, linked)
names := strings.NewReplacer(linked, "linked", stray, "stray", friend.Username, "friend")
list := func(name string) string {
t.Helper()
es, err := repo.ServerAllowlist(ctx, name)
if err != nil {
t.Fatalf("ServerAllowlist(%s): %v", name, err)
}
parts := []string{}
for _, e := range es {
if time.Since(e.AddedAt) > 2*time.Hour {
t.Fatalf("added_at %v is not the join time", e.AddedAt)
}
parts = append(parts, fmt.Sprintf("%s:%s:%v", e.MCUUID, e.Username, e.CanWake))
}
return names.Replace(strings.Join(parts, ","))
}
canWake := func(name string) string {
t.Helper()
byUUID, err := repo.UUIDInAllowlist(ctx, name, linked)
if err != nil {
t.Fatalf("UUIDInAllowlist: %v", err)
}
byUser, err := repo.UserInAllowlist(ctx, name, friend.ID)
if err != nil {
t.Fatalf("UserInAllowlist: %v", err)
}
return fmt.Sprintf("uuid=%v user=%v", byUUID, byUser)
}
name, bystander := seed("al"), seed("alb")
join(name, stray)
exec(`UPDATE server_allowlist SET added_at = now() - interval '1 hour' WHERE server_name = $1 AND mc_uuid = $2`, name, stray)
join(name, linked)
if got, want := list(name), "linked:friend:true,stray::true"; got != want {
t.Fatalf("list = %s, want %s", got, want)
}
if got := canWake(name); got != "uuid=true user=true" {
t.Fatalf("before revoking: %s", got)
}
// Revoking stops both checks, a repeat keeps the first revoked_at, and the
// player's next join leaves the entry revoked.
if err := repo.SetAllowlistWake(ctx, name, linked, false); err != nil {
t.Fatalf("revoke: %v", err)
}
revokedAt := func() time.Time {
t.Helper()
var at time.Time
if err := db.QueryRowContext(ctx, `SELECT revoked_at FROM server_allowlist WHERE server_name = $1 AND mc_uuid = $2`,
name, linked).Scan(&at); err != nil {
t.Fatalf("read revoked_at: %v", err)
}
return at
}
first := revokedAt()
time.Sleep(10 * time.Millisecond)
if err := repo.SetAllowlistWake(ctx, name, linked, false); err != nil {
t.Fatalf("revoke again: %v", err)
}
if again := revokedAt(); !again.Equal(first) {
t.Fatalf("a repeated revoke moved revoked_at from %v to %v", first, again)
}
join(name, linked)
if got := canWake(name); got != "uuid=false user=false" {
t.Fatalf("after revoking and rejoining: %s", got)
}
if got, want := list(name), "linked:friend:false,stray::true"; got != want {
t.Fatalf("list after revoking = %s, want %s", got, want)
}
if err := repo.SetAllowlistWake(ctx, name, linked, true); err != nil {
t.Fatalf("restore: %v", err)
}
if got := canWake(name); got != "uuid=true user=true" {
t.Fatalf("after restoring: %s", got)
}
for _, c := range []struct{ server, id string }{{name, testUUID(t)}, {"alnone-" + suffix(t), linked}, {bystander, linked}} {
if err := repo.SetAllowlistWake(ctx, c.server, c.id, false); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("SetAllowlistWake(%s) off the list = %v, want ErrNotFound", c.server, err)
}
}
// An account that is gone no longer names its entry.
exec(`UPDATE users SET deleted_at = now() WHERE id = $1`, friend.ID)
if got, want := list(name), "linked::true,stray::true"; got != want {
t.Fatalf("list after the account closed = %s, want %s", got, want)
}
// Each change of owner empties the list of the server that changed hands and
// no other: the bystander, owned by someone else, keeps its entry throughout.
owner, keeper := newUser(t, "user", "alowner"), newUser(t, "user", "alkeeper")
if ok, err := repo.ClaimServer(ctx, bystander, keeper.ID); err != nil || !ok {
t.Fatalf("claim the bystander = %v, %v", ok, err)
}
join(bystander, linked)
claim := func() {
t.Helper()
if ok, err := repo.ClaimServer(ctx, name, owner.ID); err != nil || !ok {
t.Fatalf("claim = %v, %v", ok, err)
}
}
for _, step := range []struct {
label string
before func()
run func()
}{
{"claim", func() {}, claim},
{"reaper release", func() { join(name, linked) }, func() {
if err := reaper.NewPGStore(db).ReleaseWorld(ctx, name, time.Now()); err != nil {
t.Fatalf("ReleaseWorld: %v", err)
}
}},
{"account deletion", func() { claim(); join(name, linked) }, func() {
if err := repo.DeleteUser(ctx, owner.ID, "pgint"); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
}},
} {
step.before()
if list(name) == "" {
t.Fatalf("%s: the list is empty before the step", step.label)
}
step.run()
if got := list(name); got != "" {
t.Fatalf("after %s: list = %s, want empty", step.label, got)
}
if got, want := list(bystander), "linked::true"; got != want {
t.Fatalf("after %s: bystander list = %s, want %s", step.label, got, want)
}
}
}
+8 -3
View File
@@ -82,11 +82,16 @@ func (s *PGStore) InsertBackup(ctx context.Context, rec BackupRecord) error {
// ReleaseWorld releases ownership and resets the activity clock and warnings —
// without deleting the row (red line ②). The resource cache stays: the server
// keeps its spec, an ownerless row is in nobody's quota sum, and the next claim is
// gated on that size and counts it.
// gated on that size and counts it. The wake allowlist is emptied in the same
// statement: its players were vouched for by the owner being released, and an
// ownerless server set to autostartPolicy=allowlist would otherwise stay
// wakeable by them.
func (s *PGStore) ReleaseWorld(ctx context.Context, name string, at time.Time) error {
const q = `UPDATE servers
const q = `WITH released AS (
UPDATE servers
SET owner_id = NULL, last_active_at = $2, warned_3d_at = NULL, warned_1d_at = NULL
WHERE name = $1 AND deleted_at IS NULL`
WHERE name = $1 AND deleted_at IS NULL RETURNING name)
DELETE FROM server_allowlist WHERE server_name IN (SELECT name FROM released)`
_, err := s.db.ExecContext(ctx, q, name, at)
return err
}
@@ -0,0 +1,6 @@
-- An owner can take a player's right to wake the server away (PUT
-- /servers/{name}/allowlist/{uuid}). The row stays with revoked_at set instead of
-- being deleted, because a deleted row comes straight back on the player's next
-- join (RecordJoin appends ON CONFLICT DO NOTHING) and the owner's choice would
-- last only until then. Both wake gates read revoked_at IS NULL.
ALTER TABLE server_allowlist ADD COLUMN revoked_at timestamptz;
@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WakeListSection } from "./WakeListSection";
import type { AllowlistEntry } from "@/lib/types";
const calls = vi.hoisted(() => ({ serverAllowlist: vi.fn(), setAllowlistWake: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const STEVE = "0f8fad5b-d9cb-469f-a165-70867728950e";
const GHOST = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
const ALEX = "16fd2706-8baf-433b-82eb-8c7fada847da";
const joined = new Date(Date.now() - 3 * 86400_000).toISOString();
const entry = (mc_uuid: string, can_wake: boolean, username?: string): AllowlistEntry => ({
mc_uuid,
username,
added_at: joined,
can_wake,
});
beforeEach(() => {
calls.serverAllowlist.mockReset();
calls.setAllowlistWake.mockReset();
});
function rowOf(label: string) {
return screen.getByText(label).closest("li") as HTMLElement;
}
describe("WakeListSection", () => {
it("lists who may wake the server, marks revoked players and names unlinked ones", async () => {
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
const steve = await screen.findByText("Steve");
expect(calls.serverAllowlist).toHaveBeenCalledWith("lobby");
const steveRow = steve.closest("li") as HTMLElement;
expect(within(steveRow).queryByText("Revoked")).toBeNull();
expect(within(steveRow).getByText("0f8fad5b").getAttribute("title")).toBe(STEVE);
expect(within(steveRow).getByText("first joined 3 days ago")).toBeTruthy();
expect(within(steveRow).getByRole("button", { name: "Take away Steve's right to wake the server" }).textContent).toBe(
"Revoke",
);
const ghostRow = rowOf("Player without a linked account");
expect(within(ghostRow).getByText("Revoked")).toBeTruthy();
expect(
within(ghostRow).getByRole("button", { name: "Allow Player without a linked account to wake the server again" })
.textContent,
).toBe("Allow again");
// The list is in effect: no note about the policy.
expect(screen.queryByText(/this list has no effect/)).toBeNull();
});
it("says the list has no effect under the other two policies", async () => {
calls.serverAllowlist.mockResolvedValue([]);
const owner = render(<WakeListSection name="lobby" policy="ownerOnly" defaultOpen />);
expect(await screen.findByText(/Owner only, so only you and admins/)).toBeTruthy();
expect(screen.getByText("No player has joined this server yet.")).toBeTruthy();
owner.unmount();
render(<WakeListSection name="lobby" policy="public" defaultOpen />);
expect(await screen.findByText(/Public, so any player's join starts it/)).toBeTruthy();
});
it("revokes and restores in place, reading the list again after each change", async () => {
const user = userEvent.setup();
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
calls.setAllowlistWake.mockResolvedValue(undefined);
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await screen.findByText("Steve");
calls.serverAllowlist.mockResolvedValue([entry(STEVE, false, "Steve"), entry(GHOST, false)]);
await user.click(screen.getByRole("button", { name: "Take away Steve's right to wake the server" }));
expect(calls.setAllowlistWake).toHaveBeenCalledWith("lobby", STEVE, false);
expect((await screen.findByRole("status")).textContent).toBe("Steve can no longer wake the server.");
expect(await within(rowOf("Steve")).findByText("Revoked")).toBeTruthy();
expect(calls.serverAllowlist).toHaveBeenCalledTimes(2);
calls.serverAllowlist.mockResolvedValue([entry(STEVE, false, "Steve"), entry(GHOST, true)]);
await user.click(
screen.getByRole("button", { name: "Allow Player without a linked account to wake the server again" }),
);
expect(calls.setAllowlistWake).toHaveBeenLastCalledWith("lobby", GHOST, true);
expect((await screen.findByRole("status")).textContent).toBe(
"Player without a linked account can wake the server again.",
);
});
it("reports a refused change and a failed read", async () => {
const user = userEvent.setup();
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve")]);
calls.setAllowlistWake.mockRejectedValue({ status: 403, code: "forbidden", message: "forbidden" });
const first = render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await user.click(await screen.findByRole("button", { name: "Take away Steve's right to wake the server" }));
expect((await screen.findByRole("alert")).textContent).toBe("You are not allowed to do that.");
expect(within(rowOf("Steve")).queryByText("Revoked")).toBeNull();
first.unmount();
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
expect((await screen.findByRole("alert")).textContent).toBe("Couldn't load the wake list.");
});
it("finds a player by account name or by UUID once the list is long", async () => {
const user = userEvent.setup();
const many = Array.from({ length: 9 }, (_, i) =>
entry(`00000000-0000-0000-0000-00000000000${i}`, true, `Player${i}`),
);
calls.serverAllowlist.mockResolvedValue([...many, entry(ALEX, true, "Alex")]);
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await screen.findByText("Player0");
// Ten a page: Alex, tenth, is on the first page until a search narrows it.
const search = screen.getByPlaceholderText("Search players…");
await user.type(search, "alex");
expect(screen.getByText("Alex")).toBeTruthy();
expect(screen.queryByText("Player0")).toBeNull();
await user.clear(search);
await user.type(search, "8baf-433b");
expect(screen.getByText("Alex")).toBeTruthy();
expect(screen.queryByText("Player1")).toBeNull();
});
});
describe("WakeListSection while a change is in flight", () => {
it("holds every row's button until the change lands, so two cannot cross", async () => {
const user = userEvent.setup();
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
let land: () => void = () => {};
calls.setAllowlistWake.mockReturnValue(new Promise<void>((resolve) => (land = resolve)));
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
await user.click(await screen.findByRole("button", { name: "Take away Steve's right to wake the server" }));
const other = within(rowOf("Player without a linked account")).getByRole("button");
expect((other as HTMLButtonElement).disabled).toBe(true);
land();
await screen.findByRole("status");
expect((other as HTMLButtonElement).disabled).toBe(false);
});
});
@@ -0,0 +1,192 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlarmClock, Loader2, RotateCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { api, humanizeError } from "@/lib/api";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { useAsync } from "@/lib/hooks";
import type { AllowlistEntry, AutostartPolicy } from "@/lib/types";
import { cn } from "@/lib/utils";
import { CollapsibleSection, FeedbackLine, PagerFooter, SearchBox, usePagedNames, type Feedback } from "./shared";
/** WakeListSection shows who may wake the server while it sleeps under the "wake
* list" autostart policy, and lets the owner take that right away or give it back.
* Felis keeps this list itself, so unlike the other player blocks it works whether
* the server is running or not — which is when it matters most. A player lands on
* it by joining once; a revoked row stays (marked) so a rejoin cannot undo the
* owner's choice, which is also why the action is a reversible toggle and needs
* no confirm step. */
export function WakeListSection({
name,
policy,
defaultOpen = false,
}: {
name: string;
policy?: AutostartPolicy;
defaultOpen?: boolean;
}) {
const { t, i18n } = useTranslation("servers");
const { data, error, loading, reload } = useAsync(() => api.serverAllowlist(name), [name]);
const [busy, setBusy] = useState<string | null>(null);
const [fb, setFb] = useState<Feedback>(null);
const entries = useMemo(() => data ?? [], [data]);
const label = useCallback((e: AllowlistEntry) => e.username || t("wake_list_unlinked"), [t]);
// The shared list engine filters strings; each entry is searchable by its account
// name and its UUID, and the key maps a page back to its rows.
const byKey = useMemo(
() => new Map(entries.map((e) => [`${e.username ?? ""}\u0000${e.mc_uuid}`, e])),
[entries],
);
const keys = useMemo(() => [...byKey.keys()], [byKey]);
const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } =
usePagedNames(keys);
const toggle = useCallback(
async (e: AllowlistEntry) => {
const player = label(e);
setFb(null);
setBusy(e.mc_uuid);
try {
await api.setAllowlistWake(name, e.mc_uuid, !e.can_wake);
setFb({
kind: "ok",
msg: t(e.can_wake ? "wake_list_revoked" : "wake_list_restored", { player }),
});
reload();
} catch (err) {
setFb({ kind: "err", msg: humanizeError(err) });
} finally {
setBusy(null);
}
},
[name, label, reload, t],
);
const now = Date.now();
const inactive = policy === "ownerOnly" || policy === "public";
return (
<CollapsibleSection
icon={<AlarmClock className="h-4 w-4" />}
title={t("wake_list_title")}
count={!loading && !error ? entries.length : undefined}
defaultOpen={defaultOpen}
actions={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
onClick={reload}
disabled={loading}
title={t("access_refresh")}
aria-label={t("access_refresh")}
>
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("wake_list_desc")}</p>
{inactive && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{t(policy === "public" ? "wake_list_inactive_public" : "wake_list_inactive_owner")}
</p>
)}
{loading && !data ? (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
</div>
) : error ? (
<p role="alert" className="text-xs text-destructive">
{t("wake_list_load_error")}
</p>
) : entries.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">{t("wake_list_empty")}</p>
<p className="mt-1 text-xs text-muted-foreground/80">{t("wake_list_empty_hint")}</p>
</div>
) : (
<div className={cn("space-y-2 transition-opacity", loading && "pointer-events-none opacity-60")}>
{showSearch && <SearchBox value={query} onChange={onQuery} />}
<ul className="grid grid-cols-1 gap-2.5 md:grid-cols-2">
{shown.length === 0 ? (
<li className="col-span-full py-6 text-center text-xs text-muted-foreground">
{t("access_search_no_match", { query: query.trim() })}
</li>
) : (
pageItems.map((key) => {
const e = byKey.get(key)!;
const player = label(e);
return (
<li
key={e.mc_uuid}
className={cn(
"flex items-center justify-between gap-3 rounded-lg border border-border p-2.5 transition-colors",
e.can_wake ? "bg-card/25 hover:bg-accent/40" : "bg-muted/30",
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"truncate text-sm font-medium",
(!e.username || !e.can_wake) && "text-muted-foreground",
)}
>
{player}
</span>
{!e.can_wake && (
<Badge variant="muted" className="shrink-0 font-normal">
{t("wake_list_revoked_badge")}
</Badge>
)}
</div>
<p className="truncate text-[11px] text-muted-foreground">
<span className="font-mono" title={e.mc_uuid}>
{e.mc_uuid.slice(0, 8)}
</span>
{" · "}
<span title={formatAbsolute(e.added_at, i18n.language)}>
{t("wake_list_joined", { when: formatRelative(e.added_at, now, i18n.language) })}
</span>
</p>
</div>
<Button
variant={e.can_wake ? "ghost" : "outline"}
size="sm"
className={cn("h-8 shrink-0", e.can_wake && "text-muted-foreground hover:text-destructive")}
onClick={() => toggle(e)}
disabled={busy !== null}
aria-label={t(e.can_wake ? "wake_list_revoke_aria" : "wake_list_restore_aria", { player })}
>
{busy === e.mc_uuid && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t(e.can_wake ? "wake_list_revoke" : "wake_list_restore")}
</Button>
</li>
);
})
)}
</ul>
{needFooter && (
<PagerFooter
q={q}
shownCount={shown.length}
total={entries.length}
pageCount={pageCount}
clampedPage={clampedPage}
onPage={setPage}
/>
)}
</div>
)}
<FeedbackLine fb={fb} />
</div>
</CollapsibleSection>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@
"fleet_endpoint_idle": "Not running",
"policy_owneronly": "Owner only",
"policy_public": "Public",
"policy_allowlist": "Allowlist",
"policy_allowlist": "Wake list",
"fleet_empty_title": "No servers",
"fleet_empty_hint": "No MinecraftServer exists in the cluster yet.",
"fleet_no_match_title": "No matches",
+18 -2
View File
@@ -53,7 +53,7 @@
"log_jump_latest": "Jump to latest",
"players_title": "Player management",
"players_link_title": "Player management",
"players_link_desc": "Manage the whitelist, online players, and bans.",
"players_link_desc": "Manage the whitelist, online players, bans, and the wake list.",
"luckperms_link_title": "LuckPerms Permissions",
"luckperms_link_desc": "Manage player groups and fine-grained permission nodes.",
"backups_link_title": "Backups & restore",
@@ -111,6 +111,22 @@
"access_cancel": "Cancel",
"access_banned": "Banned {{player}}.",
"access_pardoned": "Pardoned {{player}}.",
"wake_list_title": "Wake list",
"wake_list_desc": "With the autostart policy set to Wake list, a sleeping server can be started only by you, admins, and the players here who may wake it. A player is added on their first join. Once you take someone's wake right away, rejoining does not give it back; you can allow them again here. The list is emptied when the server changes owner.",
"wake_list_inactive_owner": "The autostart policy is Owner only, so only you and admins can start it while it sleeps and this list has no effect. Switch it to Wake list under Edit server configuration on the console to use it.",
"wake_list_inactive_public": "The autostart policy is Public, so any player's join starts it and this list has no effect. Switch it to Wake list under Edit server configuration on the console so only the players here can start it.",
"wake_list_load_error": "Couldn't load the wake list.",
"wake_list_empty": "No player has joined this server yet.",
"wake_list_empty_hint": "Players show up here after their first join.",
"wake_list_unlinked": "Player without a linked account",
"wake_list_joined": "first joined {{when}}",
"wake_list_revoked_badge": "Revoked",
"wake_list_revoke": "Revoke",
"wake_list_restore": "Allow again",
"wake_list_revoke_aria": "Take away {{player}}'s right to wake the server",
"wake_list_restore_aria": "Allow {{player}} to wake the server again",
"wake_list_revoked": "{{player}} can no longer wake the server.",
"wake_list_restored": "{{player}} can wake the server again.",
"create_server_btn": "New server",
"create_server_title": "Create a server",
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
@@ -131,7 +147,7 @@
"create_server_policy": "Autostart policy",
"create_server_policy_owner": "Owner only — wake from the panel",
"create_server_policy_public": "Public — any player join wakes it",
"create_server_policy_allowlist": "Allowlist — listed players wake it",
"create_server_policy_allowlist": "Wake list — players who have joined wake it",
"create_server_cancel": "Cancel",
"create_server_submit": "Create",
"edit_server_title": "Edit Server Config",
+1 -1
View File
@@ -29,7 +29,7 @@
"fleet_endpoint_idle": "未运行",
"policy_owneronly": "仅所有者",
"policy_public": "公开",
"policy_allowlist": "白名单",
"policy_allowlist": "唤醒名单",
"fleet_empty_title": "暂无服务器",
"fleet_empty_hint": "集群中尚未创建任何 MinecraftServer。",
"fleet_no_match_title": "无匹配结果",
+18 -2
View File
@@ -53,7 +53,7 @@
"log_jump_latest": "滚动到最新",
"players_title": "玩家管理",
"players_link_title": "玩家管理",
"players_link_desc": "管理白名单、在线玩家与封禁。",
"players_link_desc": "管理白名单、在线玩家、封禁与唤醒名单。",
"luckperms_link_title": "LuckPerms 权限",
"luckperms_link_desc": "管理玩家用户组与细粒度权限节点。",
"backups_link_title": "备份与恢复",
@@ -111,6 +111,22 @@
"access_cancel": "取消",
"access_banned": "已封禁 {{player}}。",
"access_pardoned": "已解封 {{player}}。",
"wake_list_title": "唤醒名单",
"wake_list_desc": "自动启动策略设为「唤醒名单」时,服务器休眠后只有你、管理员和这里能唤醒的玩家可以把它启动。玩家第一次进服会自动加入;取消某人的唤醒权限后,他再进服也不会恢复,需要时在这里重新允许。服务器换主人时名单清空。",
"wake_list_inactive_owner": "当前自动启动策略是「仅所有者」,休眠时只有你和管理员能启动,这份名单暂不生效。在控制台的「编辑服务器配置」里改成「唤醒名单」后生效。",
"wake_list_inactive_public": "当前自动启动策略是「公开」,任何玩家进服都能启动,这份名单暂不生效。在控制台的「编辑服务器配置」里改成「唤醒名单」后,只有名单里能唤醒的玩家可以启动。",
"wake_list_load_error": "无法加载唤醒名单。",
"wake_list_empty": "还没有玩家进过这台服务器。",
"wake_list_empty_hint": "玩家第一次进服后会出现在这里。",
"wake_list_unlinked": "未绑定账号的玩家",
"wake_list_joined": "{{when}}首次进服",
"wake_list_revoked_badge": "已取消",
"wake_list_revoke": "取消唤醒",
"wake_list_restore": "重新允许",
"wake_list_revoke_aria": "取消 {{player}} 的唤醒权限",
"wake_list_restore_aria": "重新允许 {{player}} 唤醒服务器",
"wake_list_revoked": "已取消 {{player}} 的唤醒权限。",
"wake_list_restored": "已重新允许 {{player}} 唤醒服务器。",
"create_server_btn": "新建服务器",
"create_server_title": "创建服务器",
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
@@ -131,7 +147,7 @@
"create_server_policy": "自动启动策略",
"create_server_policy_owner": "仅所有者——通过面板启动",
"create_server_policy_public": "公开——任意玩家加入即启动",
"create_server_policy_allowlist": "白名单——仅名单内玩家可启动",
"create_server_policy_allowlist": "唤醒名单——进过服、未被取消唤醒的玩家可启动",
"create_server_cancel": "取消",
"create_server_submit": "创建",
"edit_server_title": "编辑服务器配置",
+13
View File
@@ -1,5 +1,6 @@
import type {
AccessResult,
AllowlistEntry,
ApiError,
AutostartPolicy,
BackupView,
@@ -461,6 +462,18 @@ export const api = rejectingSync({
accessKick: (name: string, player: string) =>
request<KickResult>("POST", urlPath`/servers/${name}/access/kick`, { player }),
// Wake allowlist (GET/PUT /servers/{name}/allowlist): Felis's own record of who
// may wake the server under autostartPolicy "allowlist". Owner-or-admin gated,
// and unlike the access routes above it needs no running server.
serverAllowlist: (name: string) =>
request<{ server: string; entries: AllowlistEntry[] }>(
"GET",
urlPath`/servers/${name}/allowlist`,
).then((r) => r.entries ?? []),
setAllowlistWake: (name: string, mcUUID: string, canWake: boolean) =>
request<void>("PUT", urlPath`/servers/${name}/allowlist/${mcUUID}`, { can_wake: canWake }),
accessLuckPermsInfo: (name: string, player: string) =>
request<{
player: string;
+122
View File
@@ -649,6 +649,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/allowlist": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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.
*/
get: operations["listAllowlist"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/allowlist/{uuid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* 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.
*/
put: operations["setAllowlistWake"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/status": {
parameters: {
query?: never;
@@ -2378,6 +2418,20 @@ export interface components {
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
system?: boolean;
};
/** @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. */
AllowlistEntry: {
/** Format: uuid */
mc_uuid: string;
/** @description The live Felis account the UUID is linked to; omitted when there is none (the account was closed or the link removed). */
username?: string;
/**
* Format: date-time
* @description The player's first join.
*/
added_at: string;
/** @description False once the owner or an admin took the wake right away. */
can_wake: boolean;
};
/** @description One row of the caller's server list (internal/api/repo.go MyServerView). */
MyServerView: {
name: string;
@@ -4311,6 +4365,74 @@ export interface operations {
503: components["responses"]["ServiceUnavailable"];
};
};
listAllowlist: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The allowlist. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
server: string;
entries: components["schemas"]["AllowlistEntry"][];
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
};
};
setAllowlistWake: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
uuid: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
can_wake: boolean;
};
};
};
responses: {
/** @description Changed. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description No such server, or the UUID is not on its allowlist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
status: {
parameters: {
query?: never;
+1
View File
@@ -34,6 +34,7 @@ type Holds<X extends true> = X;
export type WireParity = [
Holds<Parity<T.ServerStatus, S["ServerInfo"]>>,
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
Holds<Parity<T.AllowlistEntry, S["AllowlistEntry"]>>,
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
Holds<Parity<T.BackupView, S["BackupView"]>>,
Holds<Parity<T.Build, S["Build"]>>,
+11
View File
@@ -104,6 +104,17 @@ export interface BanlistResult {
output: string;
}
/** AllowlistEntry is one row of GET /servers/{name}/allowlist: a player who joined
* the server, and so may wake it under autostartPolicy "allowlist" unless the owner
* took that away (can_wake false; the row stays so a rejoin cannot undo it).
* username is the live Felis account the UUID is linked to, absent when none. */
export interface AllowlistEntry {
mc_uuid: string;
username?: string;
added_at: string;
can_wake: boolean;
}
/** AccessResult is the common echo of a successful access mutation (whitelist add/
* remove, ban/pardon): the server replays the structured action it ran plus the
* raw RCON `output`, which the panel surfaces verbatim as confirmation. */
+10 -1
View File
@@ -22,6 +22,12 @@ vi.mock("@/lib/api", async (importOriginal) => {
vi.mock("@/components/players/OnlineSection", () => ({ OnlineSection: () => <div data-testid="online" /> }));
vi.mock("@/components/players/WhitelistSection", () => ({ WhitelistSection: () => <div /> }));
vi.mock("@/components/players/BansSection", () => ({ BansSection: () => <div /> }));
// The wake list reads Postgres, so the page shows it whether the server runs or not.
vi.mock("@/components/players/WakeListSection", () => ({
WakeListSection: (p: { policy?: string; defaultOpen?: boolean }) => (
<div data-testid="wake-list" data-policy={p.policy} data-open={String(!!p.defaultOpen)} />
),
}));
const status = (over: Record<string, unknown>) => ({ name: "lobby", subdomain: "lobby", ready: false, ...over });
@@ -47,14 +53,17 @@ function renderPage() {
describe("ServerPlayers following the server", () => {
it("switches over once the server is up, and back when it stops", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped", autostartPolicy: "allowlist" }));
renderPage();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
// Asleep, the wake list is the one block left, so it opens.
expect(screen.getByTestId("wake-list").dataset).toMatchObject({ policy: "allowlist", open: "true" });
// Waiting on the server: the fast pace.
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
expect(await screen.findByTestId("online")).toBeTruthy();
expect(screen.getByTestId("wake-list").dataset.open).toBe("false");
// Running: the slow pace, which still sees an idle stop.
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
+21 -12
View File
@@ -8,13 +8,15 @@ import { PageHeader } from "@/components/PageHeader";
import { OnlineSection } from "@/components/players/OnlineSection";
import { WhitelistSection } from "@/components/players/WhitelistSection";
import { BansSection } from "@/components/players/BansSection";
import { WakeListSection } from "@/components/players/WakeListSection";
import { api } from "@/lib/api";
import { STATUS_POLL_SLOW_MS, STATUS_POLL_FAST_MS, useAsync, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership";
/** ServerPlayers is the per-server player-management subpage (/servers/:name/players):
* whitelist today, online roster and bans as they land. It owns its own gating —
* the online roster, whitelist and bans over RCON while the server runs, and the wake
* list, which Felis keeps itself, at any time. It owns its own gating —
* ownership (from /me/servers, since GET status never carries `owned`) and server
* readiness — because as a route it can be reached directly, not just from a link. */
export function ServerPlayers() {
@@ -88,23 +90,30 @@ export function ServerPlayers() {
) : !owned ? (
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? (
<NotRunning
title={t("players_not_running_title")}
body={t("players_not_running_body")}
serverName={name}
phase={data.phase}
desiredState={data.desiredState}
failure={failure}
autoRestarts={data.autoRestarts}
onWoken={reload}
/>
<div className="space-y-4">
<NotRunning
title={t("players_not_running_title")}
body={t("players_not_running_body")}
serverName={name}
phase={data.phase}
desiredState={data.desiredState}
failure={failure}
autoRestarts={data.autoRestarts}
onWoken={reload}
/>
{/* The wake list is Felis's own record, so it stays manageable while the
server sleeps, which is when it decides who may start it. */}
<WakeListSection name={name} policy={data.autostartPolicy} defaultOpen />
</div>
) : (
<div className="space-y-4">
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
join → who may NOT. Each collapses to an index row (shared.tsx). */}
join → who may NOT, then who may wake it while it sleeps. Each
collapses to an index row (shared.tsx). */}
<OnlineSection name={name} />
<WhitelistSection name={name} />
<BansSection name={name} />
<WakeListSection name={name} policy={data.autostartPolicy} />
</div>
)}
</>