Unverified Commit 4425060d authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(lobby): 大厅变成安全的中转大厅并提示 /menu,大厅上限 200、登录门不限人数

parent fda14e9e
Loading
Loading
Loading
Loading
+4 −0
Changes for deploy/limbo/README.md: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -116,6 +116,10 @@ LOOHP/Limbo would otherwise default to `30000`, unreachable through the Velocity
idempotent, so a persisted world volume keeps all its other `server.properties`
settings. Do **not** override `FELIS_GAME_PORT` except in lockstep with the operator.

It also pins `max-players=-1` (no cap, Limbo's own default): unbound players wait at
the gate for up to ten minutes and a stopped server's players all fall back here at
once, so a cap left on the volume would turn players away at the door.

## Configure (deployer's responsibility)

One setting this image does **not** guess (it keeps the release's own default):
+10 −1
Changes for deploy/limbo/entrypoint.sh: 10 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -61,7 +61,11 @@ set_prop() {
    # The secret is base64/hex-ish, but a '/' or '&' would still break a bare sed s///.
    # '|' as the delimiter plus escaping it is enough for every value we write.
    esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g')
    sed -i "s|^$1=.*|$1=${esc}|" "$PROPS"
    # Through a temp file rather than sed -i, which BSD sed reads differently, so the
    # same function runs under the entrypoint tests on any machine.
    sed "s|^$1=.*|$1=${esc}|" "$PROPS" > "$PROPS.tmp"
    cat "$PROPS.tmp" > "$PROPS"
    rm -f "$PROPS.tmp"
  else
    printf '%s=%s\n' "$1" "$2" >> "$PROPS"
  fi
@@ -75,6 +79,11 @@ set_prop bungeecord false
set_prop bungee-guard false
set_prop velocity-modern true
set_prop forwarding-secrets "$SECRET"
# Unbound players wait here for up to ten minutes, and a stopped server's players all
# fall back here at once, so the gate must never be full. -1 (no cap) is Limbo's own
# default; it is pinned so a hand-edited properties file on the volume cannot bring
# a cap back.
set_prop max-players -1

echo "felis-limbo: server-port=${PORT}, velocity-modern=true (forwarding secret loaded, UUIDs are Mojang-verified)"
JAVA_MEMORY_ARG=""
+22 −0
Changes for deploy/lobby/README.md: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -46,3 +46,25 @@ sudo felis setup
- Align `online-mode` / player forwarding with the off-cluster Velocity proxy.
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
  felis-api token by design (spec §12).

## What the lobby allows

felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in,
or leave a mark on:

- every world is peaceful, with natural spawning, PvP, mob griefing and TNT off,
  time frozen at noon, clear weather and inventories kept;
- players take no damage and never go hungry; a fall into the void lands at spawn;
- a player without `felis.lobby.build` joins at spawn in adventure mode and cannot
  break or place blocks, use buckets, trample farmland, light fires, or harm mobs,
  item frames, paintings, armor stands or vehicles. Buttons, doors, pressure plates
  and containers keep working;
- every join gets a chat line with a click that runs `/menu`.

`felis.lobby.build` defaults to ops. To let an admin build the lobby, grant it with
LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console)
or op them.

The entrypoint pins `max-players=200` on every boot, over Paper's default of 20: every
authenticated player passes through here, and a stopped server's players arrive all at
once.
+13 −1
Changes for deploy/lobby/entrypoint.sh: 13 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -57,7 +57,11 @@ set_prop() {
    # otherwise corrupt this bare sed s||| and silently break the key. Same escaping as
    # deploy/limbo — without it an unlucky password kills the console/permission channel.
    esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g')
    sed -i "s|^$1=.*|$1=${esc}|" "$PROPS"
    # Through a temp file rather than sed -i, which BSD sed reads differently, so the
    # same function runs under the entrypoint tests on any machine.
    sed "s|^$1=.*|$1=${esc}|" "$PROPS" > "$PROPS.tmp"
    cat "$PROPS.tmp" > "$PROPS"
    rm -f "$PROPS.tmp"
  else
    printf '%s=%s\n' "$1" "$2" >> "$PROPS"
  fi
@@ -66,6 +70,14 @@ set_prop() {
set_prop server-port "$PORT"
set_prop online-mode false

# Every authenticated player passes through the lobby, and a stopped server's players
# arrive together (they fall back to the login gate, which sends them straight on).
# Paper's default cap of 20 would turn the 21st away at the door. 200 is far above
# what one node serves at once, and a flood beyond it is refused at the door instead
# of running the 1Gi lobby out of memory. What the world itself allows (no damage, no
# building, the /menu hint) is felis-paper's LobbyGuard.
set_prop max-players 200

# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
# for readiness and the player tally, and felis-api runs console/permission commands over
# it. Paper only reads these three keys from server.properties, so the operator's injected
+133 −0
Changes for game_entrypoint_test.go: 133 added lines, 0 removed lines.
Original line number Diff line number Diff line
package felis

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
)

// The lobby and login gate take their player cap from server.properties, which the
// entrypoint rewrites on every boot over whatever the volume already holds. These run
// the shipped entrypoints the way a pod does (image and volume paths pointed into temp
// dirs, java replaced by a stub that exits) and read the file the server would start on.

// runEntrypoint runs the embedded entrypoint with its runtime dir holding the given
// files and server.properties seeded with props ("" for a first boot), and returns
// server.properties afterwards.
func runEntrypoint(t *testing.T, name, runtimeVar string, files []string, props string, env ...string) string {
	t.Helper()
	root := t.TempDir()
	runtime, data, bin := filepath.Join(root, "image"), filepath.Join(root, "data"), filepath.Join(root, "bin")
	for _, f := range files {
		p := filepath.Join(runtime, f)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte("jar"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	for _, d := range []string{data, bin} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	if props != "" {
		if err := os.WriteFile(filepath.Join(data, "server.properties"), []byte(props), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.WriteFile(filepath.Join(bin, "java"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatal(err)
	}

	script := readGameStackFile(t, name)
	for old, repl := range map[string]string{
		runtimeVar:         `RUNTIME_DIR="` + runtime + `"`,
		`DATA_DIR="/data"`: `DATA_DIR="` + data + `"`,
	} {
		if !strings.Contains(script, old) {
			t.Fatalf("%s no longer sets %s", name, old)
		}
		script = strings.Replace(script, old, repl, 1)
	}
	path := filepath.Join(root, "entrypoint.sh")
	if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
		t.Fatal(err)
	}

	cmd := exec.Command("sh", path)
	cmd.Env = append([]string{"PATH=" + bin + ":" + os.Getenv("PATH"), "FELIS_FORWARDING_SECRET=fwd-test"}, env...)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("%s failed: %v\n%s", name, err, out)
	}
	got, err := os.ReadFile(filepath.Join(data, "server.properties"))
	if err != nil {
		t.Fatal(err)
	}
	return string(got)
}

// propLines lists the key's lines, so a key written twice shows up as two.
func propLines(props, key string) []string {
	var out []string
	for _, line := range strings.Split(props, "\n") {
		if strings.HasPrefix(line, key+"=") {
			out = append(out, line)
		}
	}
	return out
}

func assertProp(t *testing.T, props, key, want string) {
	t.Helper()
	got := propLines(props, key)
	if len(got) != 1 || got[0] != key+"="+want {
		t.Errorf("%s: got %q, want exactly [%s=%s]\nserver.properties:\n%s", key, got, key, want, props)
	}
}

var lobbyImage = []string{"paper.jar", "plugins/felis-paper.jar", "plugins/LuckPerms.jar"}

func TestLobbyEntrypointLiftsThePlayerCap(t *testing.T) {
	t.Run("over the cap Paper wrote on an earlier boot", func(t *testing.T) {
		props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
			"#Minecraft server properties\nmax-players=20\nmotd=Kept as it was\n")
		assertProp(t, props, "max-players", "200")
		assertProp(t, props, "motd", "Kept as it was")
	})
	t.Run("on a first boot", func(t *testing.T) {
		props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage, "")
		assertProp(t, props, "max-players", "200")
		assertProp(t, props, "online-mode", "false")
	})
}

// The RCON password is arbitrary bytes from a Secret; each of sed's special characters
// has to land in the file as itself when an earlier boot's line is replaced.
func TestLobbyEntrypointWritesTheRconPasswordVerbatim(t *testing.T) {
	const password = `a|b\c&d/e`
	props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
		"enable-rcon=false\nrcon.password=stale\n", "RCON_PASSWORD="+password)
	assertProp(t, props, "enable-rcon", "true")
	assertProp(t, props, "rcon.password", password)
}

var limboImage = []string{"Limbo.jar", "plugins/felis-limbo.jar"}

func TestLimboEntrypointNeverCapsTheGate(t *testing.T) {
	t.Run("over a cap left on the volume", func(t *testing.T) {
		props := runEntrypoint(t, "deploy/limbo/entrypoint.sh", `RUNTIME_DIR="/limbo"`, limboImage,
			"max-players=10\nlevel-name=world;spawn.schem\n")
		assertProp(t, props, "max-players", "-1")
		assertProp(t, props, "level-name", "world;spawn.schem")
		assertProp(t, props, "velocity-modern", "true")
	})
	t.Run("on a first boot", func(t *testing.T) {
		props := runEntrypoint(t, "deploy/limbo/entrypoint.sh", `RUNTIME_DIR="/limbo"`, limboImage, "")
		assertProp(t, props, "max-players", "-1")
		assertProp(t, props, "forwarding-secrets", "fwd-test")
	})
}
Loading