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

+1 -1
View File
@@ -392,7 +392,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname), defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname), defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
resolvedVersion()) cfg.Velocity.GamePort, resolvedVersion())
internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
+2
View File
@@ -3091,6 +3091,8 @@ egress_mode = "${FELIS_EGRESS_MODE}"
# into k3s by build_game_stack below, so setup never has to be told "build these first". # into k3s by build_game_stack below, so setup never has to be told "build these first".
login_image = "${FELIS_LIMBO_IMAGE}" login_image = "${FELIS_LIMBO_IMAGE}"
lobby_image = "${FELIS_LOBBY_IMAGE}" lobby_image = "${FELIS_LOBBY_IMAGE}"
# The public port players connect on; the panel shows it in server addresses.
game_port = ${FELIS_GAME_PORT}
[registry] [registry]
url = "${REGISTRY_URL}" url = "${REGISTRY_URL}"
+2 -1
View File
@@ -1412,7 +1412,7 @@ run_write() { # out-file
persisted_auth_source_blocks() { :; } persisted_auth_source_blocks() { :; }
. "$FNFILE" . "$FNFILE"
FELIS_ROOT_DOMAIN=r.example.com DB_USER=u DB_PASSWORD=p DB_NAME=d MINECRAFT_NS=minecraft \ FELIS_ROOT_DOMAIN=r.example.com DB_USER=u DB_PASSWORD=p DB_NAME=d MINECRAFT_NS=minecraft \
FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo \ FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo FELIS_GAME_PORT=25570 \
REGISTRY_URL=registry.felis.svc:5000 BUILD_NS=felis-build FELIS_ARCHIVE_LOCAL_PATH=/a \ REGISTRY_URL=registry.felis.svc:5000 BUILD_NS=felis-build FELIS_ARCHIVE_LOCAL_PATH=/a \
FELIS_OFFSITE_BUCKET= write_felis_toml "$OUT_TOML" 127.0.0.1' FELIS_OFFSITE_BUCKET= write_felis_toml "$OUT_TOML" 127.0.0.1'
} }
@@ -1435,6 +1435,7 @@ expect "a re-run carries the archive retention window" 'retention = "30d"' "$out
expect "a re-run carries the on-demand backup count" 'manual_keep = 3' "$out" expect "a re-run carries the on-demand backup count" 'manual_keep = 3' "$out"
expect "a re-run carries the on-demand backup cooldown" 'manual_cooldown = "1h"' "$out" expect "a re-run carries the on-demand backup cooldown" 'manual_cooldown = "1h"' "$out"
expect "the archive mount stays installer-owned" 'local_path = "/a"' "$out" expect "the archive mount stays installer-owned" 'local_path = "/a"' "$out"
expect "the panel learns the public game port" 'game_port = 25570' "$out"
expect "a re-run keeps the off-site bucket, set apart from the next section" '[offsite] expect "a re-run keeps the off-site bucket, set apart from the next section" '[offsite]
endpoint = "https://objects.example" endpoint = "https://objects.example"
bucket = "felis-offsite" bucket = "felis-offsite"
+7
View File
@@ -108,6 +108,10 @@ type VelocityConfig struct {
// LobbyImage is the container image for the always-on "lobby" hub (Paper plus // 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. // the felis-paper /menu plugin). Same skip-when-empty contract as LoginImage.
LobbyImage string `toml:"lobby_image"` 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 // AuthConfig is the [auth] table: the two privileged faces and the access-JWT
@@ -459,6 +463,9 @@ func (c *Config) Validate() error {
default: default:
return fmt.Errorf("config: [registry] build_user_namespaces %q must be auto, on or off", c.Registry.BuildUserNamespaces) 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 { 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) 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) { func TestLoadAppliesDefaults(t *testing.T) {
cfg, err := config.Load(writeTOML(t, ` cfg, err := config.Load(writeTOML(t, `
[server] [server]
+26 -7
View File
@@ -20,11 +20,13 @@ import (
var static embed.FS var static embed.FS
type runtimeConfig struct { type runtimeConfig struct {
APIBase string `json:"apiBase"` APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"` RootDomain string `json:"rootDomain"`
PanelHostname string `json:"panelHostname,omitempty"` PanelHostname string `json:"panelHostname,omitempty"`
AdminHostname string `json:"adminHostname,omitempty"` AdminHostname string `json:"adminHostname,omitempty"`
Build buildInfo `json:"build"` // 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. // 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 // 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 // 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 // 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. // right surface (player console vs SysAdmin console) without a rebuild. gamePort
func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) http.Handler { // 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") files, err := fs.Sub(static, "static")
if err != nil { if err != nil {
panic(err) panic(err)
@@ -118,6 +122,7 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string)
rootDomain: rootDomain, rootDomain: rootDomain,
panelHostname: panelHost, panelHostname: panelHost,
adminHostname: adminHost, adminHostname: adminHost,
gamePort: publicGamePort(gamePort),
build: parseBuildVersion(version), build: parseBuildVersion(version),
files: files, files: files,
fileServer: http.FileServer(http.FS(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 { type handler struct {
api http.Handler api http.Handler
rootDomain string rootDomain string
panelHostname string panelHostname string
adminHostname string adminHostname string
gamePort int
build buildInfo build buildInfo
files fs.FS files fs.FS
fileServer http.Handler fileServer http.Handler
@@ -208,6 +226,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
RootDomain: h.rootDomain, RootDomain: h.rootDomain,
PanelHostname: h.panelHostname, PanelHostname: h.panelHostname,
AdminHostname: h.adminHostname, AdminHostname: h.adminHostname,
GamePort: h.gamePort,
Build: h.build, Build: h.build,
}) })
case h.hasStaticFile(r.URL.Path): case h.hasStaticFile(r.URL.Path):
+23 -2
View File
@@ -18,7 +18,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
} }
w.WriteHeader(http.StatusTeapot) 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() w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) 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 // 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 // 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. // 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) { func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
raw string raw string
@@ -94,7 +115,7 @@ func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
// its hash (and nothing else inline), cannot be framed, and cache by name: // its hash (and nothing else inline), cannot be framed, and cache by name:
// hashed assets forever, the page itself never without revalidation. // hashed assets forever, the page itself never without revalidation.
func TestHandlerSetsPageSecurityAndCacheHeaders(t *testing.T) { 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() w := httptest.NewRecorder()
// Through the tunnel: TLS to the origin as well, the edge's scheme in XFP. // 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) { api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) w.WriteHeader(http.StatusTeapot)
}) })
return Handler(api, "example.test", "", "", "") return Handler(api, "example.test", "", "", 0, "")
} }
func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) { func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) {
+1 -1
View File
@@ -24,7 +24,7 @@ test("a player sees their own server, can claim an unowned one, and has no admin
await expect(own.getByRole("link", { name: t("servers:console") })).toBeVisible(); await expect(own.getByRole("link", { name: t("servers:console") })).toBeVisible();
await expect(own.getByRole("button", { name: t("servers:stop"), exact: true })).toBeVisible(); await expect(own.getByRole("button", { name: t("servers:stop"), exact: true })).toBeVisible();
const unowned = page.getByRole("row").filter({ hasText: "Claimable Node" }); const unowned = page.getByRole("row").filter({ hasText: "Claimable Node" });
await expect(unowned.getByRole("button", { name: t("servers:claim") })).toBeVisible(); await expect(unowned.getByRole("button", { name: t("servers:claim"), exact: true })).toBeVisible();
await expect(unowned.getByRole("button", { name: t("servers:stop"), exact: true })).toHaveCount(0); await expect(unowned.getByRole("button", { name: t("servers:stop"), exact: true })).toHaveCount(0);
await expect(page.getByRole("link", { name: t("navigation:admin_users") })).toHaveCount(0); await expect(page.getByRole("link", { name: t("navigation:admin_users") })).toHaveCount(0);
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { CopyAddress } from "./CopyAddress";
function stubClipboard(writeText: (text: string) => Promise<void>) {
const fn = vi.fn(writeText);
Object.defineProperty(navigator, "clipboard", { value: { writeText: fn }, configurable: true });
return fn;
}
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
window.getSelection()?.removeAllRanges();
});
describe("CopyAddress", () => {
it("shows the address as text, never as a web link", () => {
render(<CopyAddress address="survival.mc.example:25570" />);
expect(screen.getByText("survival.mc.example:25570")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();
});
it("copies the address and says so, then settles back", async () => {
vi.useFakeTimers();
const writeText = stubClipboard(async () => {});
render(<CopyAddress address="survival.mc.example:25570" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example:25570" }));
});
expect(writeText).toHaveBeenCalledWith("survival.mc.example:25570");
expect(screen.getByRole("status").textContent).toBe("Copied");
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByRole("status").textContent).toBe("");
});
it("selects the address for the system menu when the clipboard is refused", async () => {
stubClipboard(async () => {
throw new DOMException("denied", "NotAllowedError");
});
render(<CopyAddress address="survival.mc.example" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" }));
});
expect(window.getSelection()?.toString()).toBe("survival.mc.example");
expect(screen.getByRole("status").textContent).toBe("Selected — copy it from the menu");
});
it("selects the address where there is no clipboard at all", async () => {
render(<CopyAddress address="survival.mc.example" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" }));
});
expect(window.getSelection()?.toString()).toBe("survival.mc.example");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const FEEDBACK_MS = 2000;
// CopyAddress shows the address a Minecraft client joins and copies it with one
// tap. Minecraft is not a web page, so the address is never a link. When the
// clipboard is refused (an insecure origin, an embedded browser) the text is
// selected instead, ready for the system copy menu.
export function CopyAddress({ address, className }: { address: string; className?: string }) {
const { t } = useTranslation("common");
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
const textRef = useRef<HTMLElement>(null);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
function selectText() {
const node = textRef.current;
const selection = window.getSelection();
if (!node || !selection) return;
const range = document.createRange();
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
}
async function copy() {
window.clearTimeout(timer.current);
try {
// Without a clipboard (an insecure origin) this throws, and lands below too.
await navigator.clipboard.writeText(address);
setState("copied");
} catch {
selectText();
setState("failed");
}
timer.current = window.setTimeout(() => setState("idle"), FEEDBACK_MS);
}
const feedback =
state === "copied" ? t("address_copied") : state === "failed" ? t("address_copy_failed") : "";
// After a refused copy the whole address shows, selected, with the hint on its
// own line; truncated, the selection would hide what the menu is about to copy.
const failed = state === "failed";
return (
<span
className={cn(
"inline-flex min-w-0 max-w-full flex-wrap items-center gap-x-1 text-xs",
className,
)}
>
<code
ref={textRef}
className={cn(
"min-w-0 select-all font-mono text-muted-foreground",
failed ? "break-all" : "truncate",
)}
title={address}
>
{address}
</code>
<button
type="button"
onClick={() => void copy()}
aria-label={t("copy_address", { address })}
title={t("copy_address", { address })}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{state === "copied" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
<span
role="status"
className={cn(
"shrink-0",
failed ? "basis-full" : "whitespace-nowrap",
state === "copied" ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{feedback}
</span>
</span>
);
}
+4 -1
View File
@@ -34,5 +34,8 @@
"reload_page": "Reload page", "reload_page": "Reload page",
"config_unavailable": "The panel configuration (/config.json) did not load, so server addresses and links shown here may be wrong. Reload to try again.", "config_unavailable": "The panel configuration (/config.json) did not load, so server addresses and links shown here may be wrong. Reload to try again.",
"build_dev": "dev", "build_dev": "dev",
"open_menu": "Open menu" "open_menu": "Open menu",
"copy_address": "Copy address {{address}}",
"address_copied": "Copied",
"address_copy_failed": "Selected — copy it from the menu"
} }
+1 -1
View File
@@ -20,7 +20,7 @@
"fleet_col_owner": "Owner", "fleet_col_owner": "Owner",
"fleet_col_players": "Players", "fleet_col_players": "Players",
"fleet_col_policy": "Autostart", "fleet_col_policy": "Autostart",
"fleet_col_endpoint": "Endpoint", "fleet_col_endpoint": "Internal address",
"fleet_col_actions": "Actions", "fleet_col_actions": "Actions",
"fleet_unclaimed": "Unclaimed", "fleet_unclaimed": "Unclaimed",
"fleet_owner_you": "You", "fleet_owner_you": "You",
+4 -1
View File
@@ -34,5 +34,8 @@
"reload_page": "刷新页面", "reload_page": "刷新页面",
"config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。", "config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。",
"build_dev": "开发版", "build_dev": "开发版",
"open_menu": "打开菜单" "open_menu": "打开菜单",
"copy_address": "复制地址 {{address}}",
"address_copied": "已复制",
"address_copy_failed": "已选中,请用系统菜单复制"
} }
+1 -1
View File
@@ -20,7 +20,7 @@
"fleet_col_owner": "所有者", "fleet_col_owner": "所有者",
"fleet_col_players": "在线", "fleet_col_players": "在线",
"fleet_col_policy": "自启策略", "fleet_col_policy": "自启策略",
"fleet_col_endpoint": "连接地址", "fleet_col_endpoint": "内部地址",
"fleet_col_actions": "操作", "fleet_col_actions": "操作",
"fleet_unclaimed": "未认领", "fleet_unclaimed": "未认领",
"fleet_owner_you": "你", "fleet_owner_you": "你",
+27
View File
@@ -105,3 +105,30 @@ describe("versionLabel", () => {
expect(versionLabel({ version: "dev", release: "dev", dev: true })).toBe("dev"); expect(versionLabel({ version: "dev", release: "dev", dev: true })).toBe("dev");
}); });
}); });
describe("joinAddress", () => {
const base = { apiBase: "/api/v1", rootDomain: "mc.example" };
it("is the bare hostname on the default port", async () => {
const { joinAddress } = await freshConfig();
expect(joinAddress("survival", base)).toBe("survival.mc.example");
expect(joinAddress("survival", { ...base, gamePort: 25565 })).toBe("survival.mc.example");
});
it("adds a port the proxy moved off the default", async () => {
const { joinAddress } = await freshConfig();
expect(joinAddress("survival", { ...base, gamePort: 25570 })).toBe("survival.mc.example:25570");
});
it("reads the port from config.json and drops one no client could dial", async () => {
serve({ apiBase: "/api/v1", rootDomain: "mc.example", gamePort: 25570 });
let mod = await freshConfig();
expect((await mod.loadConfig()).gamePort).toBe(25570);
for (const bad of [0, 70000, "25570", 1.5]) {
serve({ apiBase: "/api/v1", rootDomain: "mc.example", gamePort: bad });
mod = await freshConfig();
expect((await mod.loadConfig()).gamePort).toBeUndefined();
}
});
});
+17
View File
@@ -22,6 +22,8 @@ export interface RuntimeConfig {
panelHostname?: string; panelHostname?: string;
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */ /** Operator-console hostname (op.console.<root>), absent when unconfigured. */
adminHostname?: string; adminHostname?: string;
/** The public Minecraft port, absent when it is the default 25565. */
gamePort?: number;
/** What the server runs, for the version badge. */ /** What the server runs, for the version badge. */
build?: BuildInfo; build?: BuildInfo;
/** /config.json could not be read, so these are build-time defaults and /** /config.json could not be read, so these are build-time defaults and
@@ -35,6 +37,13 @@ const FALLBACK: RuntimeConfig = {
fallback: true, fallback: true,
}; };
// A port that could not be dialed is dropped; the bare hostname is the safer guess.
function parsePort(raw: unknown): number | undefined {
return Number.isInteger(raw) && (raw as number) > 0 && (raw as number) <= 65535
? (raw as number)
: undefined;
}
function parseBuild(raw: unknown): BuildInfo | undefined { function parseBuild(raw: unknown): BuildInfo | undefined {
if (!raw || typeof raw !== "object") return undefined; if (!raw || typeof raw !== "object") return undefined;
const b = raw as Partial<BuildInfo>; const b = raw as Partial<BuildInfo>;
@@ -73,6 +82,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
rootDomain: raw.rootDomain, rootDomain: raw.rootDomain,
panelHostname: raw.panelHostname, panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname, adminHostname: raw.adminHostname,
gamePort: parsePort(raw.gamePort),
build: parseBuild(raw.build), build: parseBuild(raw.build),
}; };
} catch (err) { } catch (err) {
@@ -87,3 +97,10 @@ export async function loadConfig(): Promise<RuntimeConfig> {
export function hostFor(subdomain: string, cfg: RuntimeConfig): string { export function hostFor(subdomain: string, cfg: RuntimeConfig): string {
return `${subdomain}.${cfg.rootDomain}`; return `${subdomain}.${cfg.rootDomain}`;
} }
/** joinAddress is what a player types into Minecraft to reach a server: its
* hostname, with the port only when the proxy listens off the default 25565. */
export function joinAddress(subdomain: string, cfg: RuntimeConfig): string {
const host = hostFor(subdomain, cfg);
return cfg.gamePort && cfg.gamePort !== 25565 ? `${host}:${cfg.gamePort}` : host;
}
+3 -2
View File
@@ -12,7 +12,8 @@ import { api, consoleStreamURL, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { canManage } from "@/lib/ownership"; import { canManage } from "@/lib/ownership";
import { hostFor } from "@/lib/config"; import { joinAddress } from "@/lib/config";
import { CopyAddress } from "@/components/CopyAddress";
import type { Phase, AutostartPolicy } from "@/lib/types"; import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog"; import { EditServerDialog } from "@/components/EditServerDialog";
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
@@ -223,7 +224,7 @@ export function ServerConsole() {
<PageHeader <PageHeader
icon={Terminal} icon={Terminal}
title={data.displayName || data.name} title={data.displayName || data.name}
subtitle={cfg ? hostFor(data.subdomain, cfg) : undefined} subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} /> <PhaseBadge phase={data.phase} />
+49 -2
View File
@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import type { FleetServer } from "@/lib/types"; import type { FleetServer, MyServerView } from "@/lib/types";
import { ServersPage } from "./ServersPage"; import { ServersPage } from "./ServersPage";
const tier = vi.hoisted(() => ({ isAdmin: true, identity: { email: "[email protected]" } })); const tier = vi.hoisted(() => ({ isAdmin: true, identity: { email: "[email protected]" } }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/config", async (importActual) => {
const actual = await importActual<typeof import("@/lib/config")>(); const actual = await importActual<typeof import("@/lib/config")>();
return { return {
...actual, ...actual,
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test" }), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 }),
}; };
}); });
vi.mock("@/lib/api", async (importActual) => { vi.mock("@/lib/api", async (importActual) => {
@@ -45,6 +45,7 @@ async function tableRow(name: string) {
} }
beforeEach(() => { beforeEach(() => {
tier.isAdmin = true;
calls.fleet.mockReset(); calls.fleet.mockReset();
calls.myServers.mockReset(); calls.myServers.mockReset();
}); });
@@ -92,3 +93,49 @@ describe("ServersPage fleet ownership", () => {
expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull(); expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull();
}); });
}); });
describe("ServersPage addresses", () => {
it("offers a player the join address to copy and no internal address", async () => {
tier.isAdmin = false;
const mine: MyServerView = {
name: "survival",
subdomain: "survival",
owned: true,
claimable: false,
phase: "Running",
playersOnline: 3,
playersMax: 20,
};
calls.myServers.mockResolvedValue([mine]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const table = await screen.findByRole("table");
expect(within(table).queryByText("Internal address")).toBeNull();
// The player's view has no endpoint, so a column for it could only say "Not running".
expect(within(table).queryByText("Not running")).toBeNull();
const survival = await tableRow("survival");
expect(survival.getByRole("button", { name: "Copy address survival.example.test:25570" })).toBeTruthy();
expect(survival.queryByRole("link", { name: /survival\.example\.test/ })).toBeNull();
});
it("keeps the proxy's internal address for admins", async () => {
calls.fleet.mockResolvedValue([
row("survival", { phase: "Running", ready: true, endpointAddress: "10.43.0.10:25565" }),
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const table = await screen.findByRole("table");
expect(within(table).getByText("Internal address")).toBeTruthy();
const survival = await tableRow("survival");
expect(survival.getByText("10.43.0.10:25565")).toBeTruthy();
expect(survival.getByRole("button", { name: "Copy address survival.example.test:25570" })).toBeTruthy();
});
});
+38 -38
View File
@@ -4,7 +4,6 @@ import {
Network, Network,
Play, Play,
Terminal, Terminal,
ExternalLink,
RefreshCw, RefreshCw,
Server, Server,
Users, Users,
@@ -37,11 +36,12 @@ import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { CopyAddress } from "@/components/CopyAddress";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { hostFor, type RuntimeConfig } from "@/lib/config"; import { joinAddress, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy"; import { matchScore } from "@/lib/fuzzy";
import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types"; import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -355,10 +355,11 @@ export function ServersPage() {
/> />
) : ( ) : (
<> <>
{/* Cards below 2xl (two per row from md); the admin table needs {/* Cards until the table fits (two per row from md). The admin
about 1100px of content width for all its columns, which the table needs about 1100px of content width for all its columns,
page only has from 2xl beside the sidebar. */} which the page only has from 2xl beside the sidebar; the
<ul className="grid gap-3 md:grid-cols-2 2xl:hidden"> player's four columns fit from xl. */}
<ul className={cn("grid gap-3 md:grid-cols-2", isAdmin ? "2xl:hidden" : "xl:hidden")}>
{paged.map((s) => ( {paged.map((s) => (
<ServerMobileCard <ServerMobileCard
key={s.name} key={s.name}
@@ -369,7 +370,12 @@ export function ServersPage() {
/> />
))} ))}
</ul> </ul>
<Card className="hidden overflow-hidden border border-border/80 2xl:block"> <Card
className={cn(
"hidden overflow-hidden border border-border/80",
isAdmin ? "2xl:block" : "xl:block",
)}
>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
@@ -378,7 +384,7 @@ export function ServersPage() {
{isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th>} {isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th>}
<th className="px-4 py-2.5 font-medium">{t("fleet_col_players")}</th> <th className="px-4 py-2.5 font-medium">{t("fleet_col_players")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_policy")}</th> <th className="px-4 py-2.5 font-medium">{t("fleet_col_policy")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th> {isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>}
<th className="px-4 py-2.5 text-right font-medium"> <th className="px-4 py-2.5 text-right font-medium">
{t("fleet_col_actions")} {t("fleet_col_actions")}
</th> </th>
@@ -526,20 +532,6 @@ function ServerActions({
); );
} }
function HostLink({ host }: { host: string }) {
return (
<a
href={`https://${host}`}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 max-w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground md:max-w-[15rem]"
>
<span className="truncate">{host}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
);
}
function OwnerLabel({ server }: { server: UnifiedServer }) { function OwnerLabel({ server }: { server: UnifiedServer }) {
const { t } = useTranslation("ops"); const { t } = useTranslation("ops");
const { t: ts } = useTranslation("servers"); const { t: ts } = useTranslation("servers");
@@ -611,7 +603,7 @@ function ServerRow({
onChanged: () => void; onChanged: () => void;
}) { }) {
const { t } = useTranslation("ops"); const { t } = useTranslation("ops");
const host = server.subdomain ? hostFor(server.subdomain, cfg) : ""; const address = server.subdomain ? joinAddress(server.subdomain, cfg) : "";
return ( return (
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40"> <tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
@@ -620,7 +612,7 @@ function ServerRow({
<ServerName server={server} /> <ServerName server={server} />
<PhaseBadge phase={server.phase} /> <PhaseBadge phase={server.phase} />
</div> </div>
{host && <HostLink host={host} />} {address && <CopyAddress address={address} className="md:max-w-[16rem]" />}
</td> </td>
{isAdmin && ( {isAdmin && (
<td className="px-4 py-3 align-middle text-left"> <td className="px-4 py-3 align-middle text-left">
@@ -639,9 +631,11 @@ function ServerRow({
<span className="text-muted-foreground">—</span> <span className="text-muted-foreground">—</span>
)} )}
</td> </td>
<td className="px-4 py-3 align-middle text-left"> {isAdmin && (
<EndpointLabel server={server} /> <td className="px-4 py-3 align-middle text-left">
</td> <EndpointLabel server={server} />
</td>
)}
<td className="px-4 py-3 align-middle"> <td className="px-4 py-3 align-middle">
<ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} /> <ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} />
</td> </td>
@@ -664,20 +658,22 @@ function ServerMobileCard({
onChanged: () => void; onChanged: () => void;
}) { }) {
const { t } = useTranslation("ops"); const { t } = useTranslation("ops");
const host = server.subdomain ? hostFor(server.subdomain, cfg) : ""; const address = server.subdomain ? joinAddress(server.subdomain, cfg) : "";
return ( return (
<li className="flex"> <li className="flex min-w-0">
<Card className="flex flex-1 flex-col border border-border/80"> <Card className="flex min-w-0 flex-1 flex-col border border-border/80">
<CardContent className="flex flex-1 flex-col gap-3 p-4"> <CardContent className="flex flex-1 flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3"> <div className="space-y-1">
<div className="min-w-0"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<ServerName server={server} /> <ServerName server={server} />
</div> </div>
{host && <HostLink host={host} />} <PhaseBadge phase={server.phase} />
</div> </div>
<PhaseBadge phase={server.phase} /> {/* Its own line: the join address is what a player came for, so it
gets the card's full width rather than sharing it with the badge. */}
{address && <CopyAddress address={address} />}
</div> </div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm"> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{isAdmin && ( {isAdmin && (
@@ -698,10 +694,14 @@ function ServerMobileCard({
<dd className="text-xs">{t(POLICY_KEY[server.autostartPolicy])}</dd> <dd className="text-xs">{t(POLICY_KEY[server.autostartPolicy])}</dd>
</> </>
)} )}
<dt className="text-xs text-muted-foreground">{t("fleet_col_endpoint")}</dt> {isAdmin && (
<dd className="min-w-0"> <>
<EndpointLabel server={server} /> <dt className="text-xs text-muted-foreground">{t("fleet_col_endpoint")}</dt>
</dd> <dd className="min-w-0">
<EndpointLabel server={server} />
</dd>
</>
)}
</dl> </dl>
<div className="mt-auto border-t border-border/50 pt-3"> <div className="mt-auto border-t border-border/50 pt-3">
<ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} /> <ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} />