feat(panel): player management

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:50 +08:00
1 parent 8f41a003b0
commit 15c58d982d
19 files changed
+2282 -7

No files matched your search

+3
View File
@@ -317,7 +317,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
// strict-charset-validated structured fields (handlers_access.go).
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelist},
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelistList},
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/players", h: a.handleAccessPlayers},
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/kick", h: a.handleAccessKick},
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBan},
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList},
{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}/status", h: a.handleStatus},
+130
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"felis.lolicon.best/internal/naming"
@@ -179,6 +180,43 @@ func (a *API) handleAccessWhitelistList(w http.ResponseWriter, r *http.Request)
})
}
// handleAccessPlayers is the read projector for the online roster: it runs the
// vanilla "list" command and returns the online/max tally, a best-effort parse of
// the online player names, and the raw reply. Like the whitelist read, the parse
// is vanilla-specific (the names arrive after the count line's colon) and the raw
// output is always returned so a differing format never loses information. This is
// the only place the panel can learn WHO is online — Status.Players carries the
// count alone (§141), so this reuses the same RCON reply the prober already sees.
func (a *API) handleAccessPlayers(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
out, ok := a.issueAccessCommand(w, r, name, "list")
if !ok {
return
}
online, max, players := parseListOutput(out)
writeJSON(w, http.StatusOK, map[string]any{
"name": name, "online": online, "max": max, "players": players, "output": out,
})
}
// handleAccessBanList is the read projector for the ban list: it runs "banlist"
// and returns a best-effort parse of the banned names PLUS the raw reply, like the
// whitelist / players reads. Unlike them the parse cannot key on a colon tail — a
// ban entry reads "<name> was banned by <source>: <reason>" and the reason carries
// its own colon — so parseBanlistOutput anchors on the ban marker + name charset
// instead. The raw reply is always returned so a plugin or localised format never
// loses information. No audit (a read).
func (a *API) handleAccessBanList(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
out, ok := a.issueAccessCommand(w, r, name, "banlist")
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"name": name, "players": parseBanlistOutput(out), "output": out,
})
}
// banRequest is the body of POST .../access/ban (deny / restore a player's
// ability to join, spec §7). It carries NO reason field on purpose: a free-text
// reason would be the one place a structured request could splice a second RCON
@@ -217,6 +255,39 @@ func (a *API) handleAccessBan(w http.ResponseWriter, r *http.Request) {
})
}
// kickRequest is the body of POST .../access/kick (remove a player from the
// server right now, spec §7). Like ban it carries NO reason field — a free-text
// reason is the one place a structured request could splice a second RCON command,
// and it buys nothing the audit log does not already record.
type kickRequest struct {
Player string `json:"player"`
}
// handleAccessKick kicks a player off the running server via "kick <player>".
// Unlike ban it does not block rejoining; it is the immediate "get out now" that
// pairs with the online roster. Single-action, so the body carries only a player.
func (a *API) handleAccessKick(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var body kickRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if !mcNameRe.MatchString(body.Player) {
writeError(w, r, errInvalidPlayer)
return
}
out, ok := a.issueAccessCommand(w, r, name, "kick "+body.Player)
if !ok {
return
}
a.audit(r, principalFromContext(r.Context()).Email, "access.kick", name)
writeJSON(w, http.StatusOK, map[string]any{
"name": name, "player": body.Player, "output": out,
})
}
// permissionRequest is the body of POST .../access/permission: a fine-grained
// LuckPerms permission grant/deny on a single node, optionally scoped to a world
// (spec §7 细致的权限调整 + world 范围).
@@ -351,3 +422,62 @@ func parseWhitelistOutput(out string) []string {
}
return players
}
// listCountRe matches the count line of vanilla's "list" reply, e.g.
// "There are 3 of a max of 20 players online: alice, bob, carol". It mirrors the
// operator prober's listReplyPattern; kept local so the api package does not
// depend on operator internals for a read it already has the reply for.
var listCountRe = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online`)
// parseListOutput extracts (online, max, names) from vanilla's "list" reply. The
// count comes from the "N of a max of M" line; the names come from the tail after
// the colon, comma-separated (each name is [A-Za-z0-9_], so it never contains a
// colon or comma of its own). Best-effort and vanilla-specific — the raw reply is
// always returned alongside — so a plugin or localised format loses nothing. names
// is non-nil so the JSON renders [] not null; online/max are 0 when the count line
// does not match (e.g. an empty or unrecognised reply).
func parseListOutput(out string) (online, max int, players []string) {
players = []string{}
if i := strings.Index(out, ":"); i >= 0 {
for _, part := range strings.Split(out[i+1:], ",") {
if p := strings.TrimSpace(part); p != "" {
players = append(players, p)
}
}
}
if m := listCountRe.FindStringSubmatch(out); m != nil {
online, _ = strconv.Atoi(m[1])
max, _ = strconv.Atoi(m[2])
}
return online, max, players
}
// banEntryRe matches one player-ban entry in vanilla's "banlist" reply, anchored on
// the "<name> was banned by" marker with the name pinned to mcNameRe's charset. The
// anchoring is deliberate and NOT interchangeable with the whitelist/list tail
// parse: "banlist" emits one command-feedback message PER ban, and RCON concatenates
// them with a separator that is server/version-dependent (newline, space, or none),
// so a line- or colon-split parser could run the "There are N ban(s):" header into
// the first entry and emit a non-name. Keying only on the marker + name charset
// yields the SAME names under every separator and structurally cannot return a
// non-name (group 1 IS the charset), so a corrupt entry can never reach the one-tap
// pardon button. It also sidesteps the reason's own colon, which the tail parse can't.
//
// We issue plain "banlist", which in vanilla lists PLAYER bans only (IP bans are the
// separate "banlist ips", which nothing here ever issues), so a "1.2.3.4 was banned
// by ..." line — whose trailing octet the charset would otherwise capture as a bogus
// short name — never reaches this parser.
var banEntryRe = regexp.MustCompile(`([A-Za-z0-9_]{1,16}) was banned by`)
// parseBanlistOutput extracts banned player names from vanilla's "banlist" reply,
// whose entries read "<name> was banned by <source>: <reason>". Best-effort and
// vanilla-specific — the raw reply is always returned alongside — so a plugin or
// localised format loses nothing; the "There are no ban(s)." / header lines carry no
// marker and are skipped. Returns a non-nil empty slice so the JSON renders [] not null.
func parseBanlistOutput(out string) []string {
players := []string{}
for _, m := range banEntryRe.FindAllStringSubmatch(out, -1) {
players = append(players, m[1])
}
return players
}
+214
View File
@@ -44,6 +44,9 @@ func TestAccessTranslation(t *testing.T) {
`{"action":"ban","player":"Griefer_99"}`, "ban Griefer_99", "access.ban.ban"},
{"pardon", "/api/v1/servers/survival/access/ban",
`{"action":"pardon","player":"Griefer_99"}`, "pardon Griefer_99", "access.ban.pardon"},
// kick has no action field — a single verb — so its label is "access.kick".
{"kick", "/api/v1/servers/survival/access/kick",
`{"player":"Griefer_99"}`, "kick Griefer_99", "access.kick"},
// The *bool trap: omitted value defaults to true (grant), NOT false (deny).
{"permission set default grant", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"Steve","node":"essentials.fly"}`,
@@ -100,6 +103,8 @@ func TestAccessInjectionRejected(t *testing.T) {
{"whitelist player newline", "/api/v1/servers/survival/access/whitelist", `{"action":"add","player":"ev\nop x"}`},
{"ban player semicolon", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev;il"}`},
{"ban player space", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev il"}`},
{"kick player space", "/api/v1/servers/survival/access/kick", `{"player":"ev il"}`},
{"kick player newline", "/api/v1/servers/survival/access/kick", `{"player":"ev\nop x"}`},
{"permission player space", "/api/v1/servers/survival/access/permission",
`{"action":"set","player":"ev il","node":"essentials.fly"}`},
{"group player newline", "/api/v1/servers/survival/access/group",
@@ -383,3 +388,212 @@ func TestParseWhitelistOutput(t *testing.T) {
}
}
}
// TestAccessPlayers exercises the online-roster read projector: GET runs "list"
// and returns the online/max tally, the parsed names, and the raw reply, owner-
// gated like the writes and never auditing.
func TestAccessPlayers(t *testing.T) {
t.Run("parses tally, names and raw output", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.reply = "There are 3 of a max of 20 players online: alice, bob, carol"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var resp struct {
Name string `json:"name"`
Online int `json:"online"`
Max int `json:"max"`
Players []string `json:"players"`
Output string `json:"output"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if resp.Name != "survival" || resp.Online != 3 || resp.Max != 20 || resp.Output != console.reply {
t.Fatalf("unexpected response %+v", resp)
}
if len(resp.Players) != 3 || resp.Players[0] != "alice" || resp.Players[2] != "carol" {
t.Fatalf("players = %#v, want [alice bob carol]", resp.Players)
}
if console.gotCommand != "list" {
t.Fatalf("console got %q, want %q", console.gotCommand, "list")
}
if len(repo.audits) != 0 {
t.Fatalf("GET players must not audit: %+v", repo.audits)
}
})
t.Run("empty server -> [] not null", func(t *testing.T) {
api, _, _, console := mkAccess(t)
console.reply = "There are 0 of a max of 20 players online:"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if string(raw["players"]) != "[]" {
t.Fatalf("players = %s, want []", raw["players"])
}
})
t.Run("non-owner -> 403, no RCON call", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if console.calls != 0 {
t.Fatal("a forbidden caller must not reach RCON")
}
})
}
// TestParseListOutput unit-tests the "list" parser directly, including formats
// issueAccessCommand never produces but a real server might. Names parse from the
// colon tail independently of the count line, so both are pinned separately.
func TestParseListOutput(t *testing.T) {
cases := []struct {
in string
online, max int
want []string
}{
{"There are 3 of a max of 20 players online: alice, bob, carol", 3, 20, []string{"alice", "bob", "carol"}},
{"There are 1 of a max of 20 players online: Steve", 1, 20, []string{"Steve"}},
{"There are 0 of a max of 20 players online:", 0, 20, []string{}},
{"There are 0 of a max of 20 players online", 0, 20, []string{}},
{"", 0, 0, []string{}},
// A colon tail with no recognised count line still yields names, tally 0.
{"Online: a, b ,c", 0, 0, []string{"a", "b", "c"}},
}
for _, tc := range cases {
online, max, got := parseListOutput(tc.in)
if got == nil {
t.Fatalf("parseListOutput(%q) names = nil, want non-nil slice", tc.in)
}
if online != tc.online || max != tc.max {
t.Fatalf("parseListOutput(%q) = (%d,%d), want (%d,%d)", tc.in, online, max, tc.online, tc.max)
}
if len(got) != len(tc.want) {
t.Fatalf("parseListOutput(%q) names = %#v, want %#v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("parseListOutput(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
}
}
}
}
// TestAccessBanList exercises the ban-list read projector: GET runs "banlist",
// returns the parsed names plus the raw reply, is owner-gated, and never audits.
func TestAccessBanList(t *testing.T) {
t.Run("parses players and returns raw output", func(t *testing.T) {
api, repo, _, console := mkAccess(t)
console.reply = "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam"
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var resp struct {
Name string `json:"name"`
Players []string `json:"players"`
Output string `json:"output"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if resp.Name != "survival" || resp.Output != console.reply {
t.Fatalf("unexpected response %+v", resp)
}
if len(resp.Players) != 2 || resp.Players[0] != "Steve" || resp.Players[1] != "Alex" {
t.Fatalf("players = %#v, want [Steve Alex]", resp.Players)
}
if console.gotCommand != "banlist" {
t.Fatalf("console got %q, want %q", console.gotCommand, "banlist")
}
if len(repo.audits) != 0 {
t.Fatalf("GET ban must not audit: %+v", repo.audits)
}
})
t.Run("empty ban list -> [] not null", func(t *testing.T) {
api, _, _, console := mkAccess(t)
console.reply = "There are no bans."
api.External = staticExternal{p: accessOwner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if string(raw["players"]) != "[]" {
t.Fatalf("players = %s, want []", raw["players"])
}
})
t.Run("non-owner -> 403, no RCON call", func(t *testing.T) {
api, _, _, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if console.calls != 0 {
t.Fatal("a forbidden caller must not reach RCON")
}
})
}
// TestParseBanlistOutput unit-tests the ban parser directly. The critical cases are
// the SEPARATOR variants: "banlist" emits one feedback message per ban and RCON's
// concatenation separator is version-dependent, so the parser must return the same
// names whether entries are newline-, space-, or non-separated — and must never let
// the header or a reason's own colon/spaces leak into a name (a corrupt name would
// arm an off-charset pardon).
func TestParseBanlistOutput(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{
"newline-separated",
"There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam",
[]string{"Steve", "Alex"},
},
{
// The separator the wire format might actually use: header + entries
// concatenated with spaces, reasons carrying spaces of their own.
"space-concatenated with spaced reasons",
"There are 2 ban(s): Steve was banned by Server: griefing spawn Alex was banned by Server: spam",
[]string{"Steve", "Alex"},
},
{"single ban", "There are 1 ban(s):\nNotch was banned by Console: rude", []string{"Notch"}},
{"no bans", "There are no bans.", []string{}},
{"empty", "", []string{}},
}
for _, tc := range cases {
got := parseBanlistOutput(tc.in)
if got == nil {
t.Fatalf("%s: parseBanlistOutput(%q) = nil, want non-nil slice", tc.name, tc.in)
}
if len(got) != len(tc.want) {
t.Fatalf("%s: parseBanlistOutput(%q) = %#v, want %#v", tc.name, tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("%s: parseBanlistOutput(%q)[%d] = %q, want %q", tc.name, tc.in, i, got[i], tc.want[i])
}
}
}
}