feat(panel): player management
This commit is contained in:
19 files changed
+2282
-7
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user