fix(allowlist): 唤醒名单可查看、可取消或恢复唤醒权限,换主人时清空
This commit is contained in:
23 files changed
+1190
-32
No files matched your search
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user