fix(panel): 服务器地址改为一键复制并带上非默认游戏端口,玩家列表去掉无意义的连接地址列,管理员列改称内部地址

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:51:16 +08:00
1 parent bb9c2168df
commit f156e385f0
20 files changed
+379 -59

No files matched your search

+7
View File
@@ -108,6 +108,10 @@ type VelocityConfig struct {
// LobbyImage is the container image for the always-on "lobby" hub (Paper plus
// the felis-paper /menu plugin). Same skip-when-empty contract as LoginImage.
LobbyImage string `toml:"lobby_image"`
// GamePort is the public TCP port the proxy accepts players on (bootstrap's
// FELIS_GAME_PORT). The panel adds it to the server addresses players copy
// when it is not Minecraft's default; 0 means that default, 25565.
GamePort int `toml:"game_port"`
}
// AuthConfig is the [auth] table: the two privileged faces and the access-JWT
@@ -459,6 +463,9 @@ func (c *Config) Validate() error {
default:
return fmt.Errorf("config: [registry] build_user_namespaces %q must be auto, on or off", c.Registry.BuildUserNamespaces)
}
if p := c.Velocity.GamePort; p < 0 || p > 65535 {
return fmt.Errorf("config: [velocity] game_port %d must be 1-65535 (0 keeps 25565)", p)
}
if n := c.Registry.MaxConcurrentBuilds; n < 0 || n > 6 {
return fmt.Errorf("config: [registry] max_concurrent_builds %d must be 1-6 (0 keeps 2)", n)
}
+19
View File
@@ -117,6 +117,25 @@ build_user_namespaces = "yes"
}
}
func TestLoadGamePort(t *testing.T) {
base := `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[velocity]
`
cfg, err := config.Load(writeTOML(t, base+"game_port = 25570\n"))
if err != nil || cfg.Velocity.GamePort != 25570 {
t.Fatalf("game_port 25570: cfg=%+v err=%v", cfg, err)
}
for _, bad := range []string{"-1", "65536"} {
if _, err := config.Load(writeTOML(t, base+"game_port = "+bad+"\n")); err == nil || !strings.Contains(err.Error(), "game_port") {
t.Errorf("game_port %s: err = %v, want it rejected", bad, err)
}
}
}
func TestLoadAppliesDefaults(t *testing.T) {
cfg, err := config.Load(writeTOML(t, `
[server]
+26 -7
View File
@@ -20,11 +20,13 @@ import (
var static embed.FS
type runtimeConfig struct {
APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"`
PanelHostname string `json:"panelHostname,omitempty"`
AdminHostname string `json:"adminHostname,omitempty"`
Build buildInfo `json:"build"`
APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"`
PanelHostname string `json:"panelHostname,omitempty"`
AdminHostname string `json:"adminHostname,omitempty"`
// GamePort is the public Minecraft port, absent when it is the default 25565.
GamePort int `json:"gamePort,omitempty"`
Build buildInfo `json:"build"`
}
// buildInfo is the resolved build stamp the panel renders in its version badge.
@@ -107,8 +109,10 @@ func parseBuildVersion(raw string) buildInfo {
// configured console.<root_domain> and op.console.<root_domain> hostnames (either
// may be empty when that face is not deployed); they let the SPA detect which home
// it is being served from by comparing location.host, so one bundle can render the
// right surface (player console vs SysAdmin console) without a rebuild.
func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) http.Handler {
// right surface (player console vs SysAdmin console) without a rebuild. gamePort
// is the public Minecraft port ([velocity] game_port), which the SPA appends to
// the server addresses players copy; 0 or 25565 leaves them bare.
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string) http.Handler {
files, err := fs.Sub(static, "static")
if err != nil {
panic(err)
@@ -118,6 +122,7 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string)
rootDomain: rootDomain,
panelHostname: panelHost,
adminHostname: adminHost,
gamePort: publicGamePort(gamePort),
build: parseBuildVersion(version),
files: files,
fileServer: http.FileServer(http.FS(files)),
@@ -125,11 +130,24 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string)
}
}
// defaultGamePort is the port a Minecraft client dials when the address has none.
const defaultGamePort = 25565
// publicGamePort is the port the SPA should print, or 0 when a bare hostname
// already reaches the proxy.
func publicGamePort(p int) int {
if p == defaultGamePort {
return 0
}
return p
}
type handler struct {
api http.Handler
rootDomain string
panelHostname string
adminHostname string
gamePort int
build buildInfo
files fs.FS
fileServer http.Handler
@@ -208,6 +226,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
RootDomain: h.rootDomain,
PanelHostname: h.panelHostname,
AdminHostname: h.adminHostname,
GamePort: h.gamePort,
Build: h.build,
})
case h.hasStaticFile(r.URL.Path):
+23 -2
View File
@@ -18,7 +18,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
}
w.WriteHeader(http.StatusTeapot)
})
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", "v1.2.3")
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "v1.2.3")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
@@ -65,6 +65,27 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
// carry. The "+g<sha>" form is what deploy/bootstrap.sh's dev channel links in, and
// it is the one that regressed: before it was parsed, a dev build fell through to the
// default case and the badge rendered the whole stamp as the release with no commit.
// The SPA appends the public game port to the addresses players copy, so
// /config.json carries it, except when a bare hostname already reaches the proxy.
func TestHandlerPublishesNonDefaultGamePort(t *testing.T) {
for _, tc := range []struct{ in, want int }{{0, 0}, {25565, 0}, {25570, 25570}} {
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "v1.2.3")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config.json", nil))
var cfg map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
got, present := cfg["gamePort"]
switch {
case tc.want == 0 && present:
t.Errorf("game port %d: gamePort = %v, want absent", tc.in, got)
case tc.want != 0 && got != float64(tc.want):
t.Errorf("game port %d: gamePort = %v, want %d", tc.in, got, tc.want)
}
}
}
func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
for _, tc := range []struct {
raw string
@@ -94,7 +115,7 @@ func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
// its hash (and nothing else inline), cannot be framed, and cache by name:
// hashed assets forever, the page itself never without revalidation.
func TestHandlerSetsPageSecurityAndCacheHeaders(t *testing.T) {
h := Handler(http.NotFoundHandler(), "example.test", "", "", "v1.2.3")
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "v1.2.3")
w := httptest.NewRecorder()
// Through the tunnel: TLS to the origin as well, the edge's scheme in XFP.
+1 -1
View File
@@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler {
api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
return Handler(api, "example.test", "", "", "")
return Handler(api, "example.test", "", "", 0, "")
}
func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) {