Unverified Commit 15c58d98 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(panel): player management

parent 8f41a003
Loading
Loading
Loading
Loading
+133 −0
Changes for docs/openapi.yaml: 133 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1069,7 +1069,88 @@ paths:
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/servers/{name}/access/players:
    get:
      tags: [access]
      operationId: accessPlayers
      summary: List online players via RCON (spec §7). Owner/admin only.
      description: >-
        Runs "list" against the live server and returns the online/max tally, a
        best-effort parse of the online player names, and the raw reply. This is
        the only source of WHO is online — Status.Players carries the count alone.
        The RCON password is never accepted or returned (spec §286).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Online players.
          content:
            application/json:
              schema:
                type: object
                required: [name, online, max, players, output]
                properties:
                  name: { type: string }
                  online: { type: integer }
                  max: { type: integer }
                  players: { type: array, items: { type: string } }
                  output: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Server not running.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/servers/{name}/access/ban:
    get:
      tags: [access]
      operationId: accessBanList
      summary: List banned players via RCON (spec §7). Owner/admin only.
      description: >-
        Runs "banlist" against the live server and returns a best-effort parse
        plus the raw reply. The RCON password is never accepted or returned
        (spec §286).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Banned players.
          content:
            application/json:
              schema:
                type: object
                required: [name, players, output]
                properties:
                  name: { type: string }
                  players: { type: array, items: { type: string } }
                  output: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Server not running.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    post:
      tags: [access]
      operationId: accessBan
@@ -1112,6 +1193,58 @@ paths:
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/servers/{name}/access/kick:
    post:
      tags: [access]
      operationId: accessKick
      summary: Kick a player off the running server (spec §7). Owner/admin only.
      description: >-
        Translates to the RCON "kick <player>" command. Unlike ban it does not
        block rejoining. Carries no reason field (a free-text reason would be an
        injection vector; the audit log records intent). The RCON password is
        never accepted or returned (spec §286).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [player]
              properties:
                player: { type: string }
      responses:
        '200':
          description: The player was kicked; the raw RCON reply is in output.
          content:
            application/json:
              schema:
                type: object
                required: [name, player, output]
                properties:
                  name: { type: string }
                  player: { type: string }
                  output: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Server not running.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/servers/{name}/access/permission:
    post:
      tags: [access]
+3 −0
Changes for internal/api/api.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/handlers_access.go: 130 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/handlers_access_test.go: 214 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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])
			}
		}
	}
}
+210 −1

File changed.

Preview size limit exceeded, changes collapsed.

Loading