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

+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;