A server created through the panel never had RCON. CreateServer built a
MinecraftServerSpec without a Rcon block at all, so the field took its zero value
and every downstream consumer read Enabled=false. Nothing failed loudly: the
operator skips the probe when RCON is off and marks the server Ready on pod
readiness alone, so the panel showed "运行中" for a server the control plane could
not talk to. Everything that rides the write channel (spec §8 写=RCON) was dead —
the online-player list returned nothing because Status.Players is only ever
sampled by the probe, and console writes answered 503 ErrConsoleUnavailable
because internal/api/console.go refuses when Enabled is false.
The whole RCON machinery already existed — builders gate the service port,
container port, preStop save-and-stop hook and the RCON_* env on Spec.Rcon,
the reconciler probes and reports, console.go dials, the NetworkPolicy opens
25575 to {api, operator}. The only thing missing was that nobody ever turned it
on or created a password. This wires the three layers that were absent.
Provisioning lives in the operator, not in felis-api. felis-api holds secrets:get
and not create, and giving it create solely to mint a password it immediately
stops caring about (console.go re-reads the Secret at command time) would widen
the API's powers for nothing. The operator already reads every Secret in the
namespace, so adding create there grants no read it did not have. It also makes
provisioning declarative: a Secret deleted by hand comes back on the next pass, a
controller reference garbage-collects it with the server so no delete path has to
remember it, and a server that predates RCON only needs spec.rcon filled in for
the password to appear. The name comes from naming.RconSecretName so felis-api,
`felis setup` and the operator cannot drift apart on it.
RCON is enabled per system service rather than by default, because enabling it on
a backend that serves no RCON listener is destructive rather than merely useless:
the operator gates readiness on the probe, so such a server never leaves Starting
and is eventually marked Failed. The login limbo is exactly that backend
(LOOHP/Limbo has no RCON) and it is the front door, so it stays off; the lobby
runs Paper and is administered through the panel like any other server, so it is
on.
Paper only reads RCON settings from server.properties, so the operator's injected
RCON_PASSWORD did nothing on its own — felis-lobby's entrypoint now writes the
three keys on every boot. Rewriting them each time makes the copy in the world
volume derived state rather than the source of truth, so an owner who edits them
through the panel's file editor cannot lock the control plane out of their own
server. Without a password it sets enable-rcon=false and warns rather than
refusing to start: unlike the forwarding secret, a missing RCON password degrades
the server rather than making it unsafe.
That password landing in server.properties is a §286 exposure (RCON 密码绝不下发
前端), since server.properties is readable through the file editor. It is redacted
on read rather than the file being denied outright the way config/paper-global.yml
is: the forwarding secret is cluster-wide material that merely happens to sit in
the volume, whereas server.properties is the single most-edited config an owner
has, and hiding one line should not cost them MOTD, difficulty and view-distance.
The write path is deliberately left alone — the boot-time rewrite restores the
real value, which is what makes redacting rather than denying safe here.
Also guards idle auto-stop on Rcon.Enabled. Status.Players is only meaningful
when the probe ran; with RCON off it keeps its zero value, which that branch would
have read as "empty" and used to stop a server full of people. AutoStopEnabled is
not currently settable through any path, so this is a latent footgun rather than a
live bug, but it is one line and the alternative is discovering it in production.
Checks: the operator provisions a missing Secret with a 32-hex-char password and a
controller reference, and does not rotate an existing one; idle auto-stop stays
inert without RCON; the editor redacts rcon.password from the world root's
server.properties while leaving the rest of the file (and a plugin's own nested
copy) intact; login has RCON off and lobby has it on with the shared secret name;
CreateServer sets the block. That last one departs from K8sCluster being
integration-tested against a live cluster: this defect was a struct literal
missing a field, it shipped, and a fake client is enough to pin a struct literal.
Existing servers are NOT migrated by this change — CreateServer only covers new
ones and ensureSystemServers is create-if-absent, so a `felis setup` re-run will
not touch an existing lobby. A deployed install additionally needs the
felis-lobby image rebuilt and re-imported for the entrypoint change, and its pods
recreated, before the RCON keys reach server.properties.
175 lines
7.6 KiB
Go
175 lines
7.6 KiB
Go
// Package naming enforces the portability and admission rules (spec §2, §22):
|
|
// a server name matches ^[a-z0-9-]{3,32}$ and is non-reserved, and every
|
|
// hostname must be a single label under the configured root_domain. The root
|
|
// domain is never hardcoded — it is always supplied by config — so this package
|
|
// stays free of any deployment-specific domain.
|
|
package naming
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
serverNameRE = regexp.MustCompile(`^[a-z0-9-]{3,32}$`)
|
|
dnsLabelRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
)
|
|
|
|
// reserved subdomains/server names that users may not claim: proxy/lobby and
|
|
// the platform's own faces.
|
|
var reserved = map[string]struct{}{
|
|
"login": {},
|
|
"lobby": {},
|
|
"admin": {},
|
|
"panel": {},
|
|
"console": {}, // the player web console (console.<root>); op.console carries a dot and can never collide
|
|
"api": {},
|
|
"felis": {},
|
|
"velocity": {},
|
|
"registry": {},
|
|
"internal": {},
|
|
"www": {},
|
|
}
|
|
|
|
// System server names Felis provisions itself. They deliberately live on the
|
|
// reserved list so no user can claim them, yet the platform must be able to
|
|
// create them — see ValidateSystemServerName.
|
|
const (
|
|
// SystemLoginServer is the always-on limbo auth gate (LOOHP/Limbo). It is the
|
|
// front door every fresh connection lands on and the ONLY safe fallback: a
|
|
// stopped/starting backend routes here, never onward past authentication.
|
|
SystemLoginServer = "login"
|
|
// SystemLobbyServer is the post-auth /menu hub (Paper + felis-paper). It is
|
|
// reachable only after the login gate passes a player through, so it must
|
|
// never be used as a fallback target (that would bypass the gate).
|
|
SystemLobbyServer = "lobby"
|
|
)
|
|
|
|
// ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
|
|
// credential Secret (spec §7). They are one source of truth shared across
|
|
// subsystems: the platform renderer wires this Secret into the felis-api
|
|
// Deployment, and the operator injects it into the login system server's pod as
|
|
// FELIS_SERVICE_TOKEN via a secretKeyRef (never a literal). The Secret itself is
|
|
// provisioned out-of-band (deploy/bootstrap.sh) and, for the login gate, replicated
|
|
// into the minecraft namespace by `felis setup`; these constants only name it.
|
|
const (
|
|
ServiceTokenSecretName = "felis-service-token"
|
|
ServiceTokenSecretKey = "token"
|
|
)
|
|
|
|
// ForwardingSecretName / ForwardingSecretKey name the Velocity modern player-info
|
|
// forwarding secret — the shared HMAC key the proxy signs each login handshake with
|
|
// and every backend verifies. It is what makes a backend's idea of "who is this
|
|
// player" trustworthy: with modern forwarding on, the UUID arrives inside the signed
|
|
// forwarding payload rather than being derived offline from the username, which is
|
|
// the whole basis of the Owner bind (the Owner IS a Minecraft account, claimed by
|
|
// joining the login gate). Legacy/BungeeCord forwarding carries no secret at all and
|
|
// fails OPEN — anyone who can reach a backend directly can assert any UUID — so Felis
|
|
// mandates modern (spec §20).
|
|
//
|
|
// Unlike the service token this is NOT login-only: Velocity's forwarding mode is a
|
|
// single proxy-wide setting, so once it is "modern" EVERY backend must speak it or it
|
|
// rejects the proxy's logins outright. The secret authenticates the PROXY to the
|
|
// backend; every backend verifies it before accepting the forwarded identity. The
|
|
// NetworkPolicy narrows game-port reachability to declared Velocity CIDRs for non-node
|
|
// traffic, but Kubernetes always permits traffic from a pod's resident node, so the
|
|
// policy is defense in depth and never replaces HMAC verification.
|
|
//
|
|
// Provisioned out-of-band (deploy/bootstrap.sh, the same run that writes Velocity's
|
|
// forwarding.secret) and replicated into the minecraft namespace by `felis setup`.
|
|
const (
|
|
ForwardingSecretName = "felis-forwarding-secret"
|
|
ForwardingSecretKey = "secret"
|
|
)
|
|
|
|
// ValidateServerName checks the §22 name rule and reservation list.
|
|
func ValidateServerName(name string) error {
|
|
if !serverNameRE.MatchString(name) {
|
|
return fmt.Errorf("naming: invalid server name %q: must match ^[a-z0-9-]{3,32}$", name)
|
|
}
|
|
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
|
|
return fmt.Errorf("naming: server name %q must not start or end with '-'", name)
|
|
}
|
|
if _, ok := reserved[name]; ok {
|
|
return fmt.Errorf("naming: server name %q is reserved", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateSystemServerName checks the §22 format rule (^[a-z0-9-]{3,32}$, no
|
|
// leading/trailing dash) but DELIBERATELY skips the reservation check. It is the
|
|
// admission path for platform-provisioned system services (login, lobby), which
|
|
// carry reserved names on purpose: users can never claim them via
|
|
// ValidateServerName, yet setup must still be able to create them. It is not a
|
|
// public claim path — only the setup/system-service provisioner calls it.
|
|
func ValidateSystemServerName(name string) error {
|
|
if !serverNameRE.MatchString(name) {
|
|
return fmt.Errorf("naming: invalid system server name %q: must match ^[a-z0-9-]{3,32}$", name)
|
|
}
|
|
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
|
|
return fmt.Errorf("naming: system server name %q must not start or end with '-'", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// worldVolumeName mirrors operator.dataVolumeName: the per-server StatefulSet's
|
|
// volumeClaimTemplate is named "world", so a single-replica server's world PVC
|
|
// is "world-<name>-0". This is the one naming convention shared by the operator
|
|
// (which creates the PVC), the reaper (which deletes it), and restore (which
|
|
// mounts it), so it lives here rather than being duplicated per subsystem.
|
|
const worldVolumeName = "world"
|
|
|
|
// WorldPVCName returns the world PersistentVolumeClaim name for a server,
|
|
// matching the operator's StatefulSet volumeClaimTemplate naming
|
|
// ("world-<name>-0" for the sole replica).
|
|
func WorldPVCName(server string) string {
|
|
return worldVolumeName + "-" + server + "-0"
|
|
}
|
|
|
|
// RconSecretKey is the key holding the password inside a server's RCON Secret.
|
|
const RconSecretKey = "password"
|
|
|
|
// RconSecretName returns the per-server RCON password Secret name. Like
|
|
// WorldPVCName this convention is shared rather than duplicated: felis-api writes
|
|
// it into spec.rcon.secretRef when creating a server, `felis setup` writes the
|
|
// same for the system lobby, and the operator both provisions the Secret under
|
|
// that name and reads it back for the readiness probe. One password per server,
|
|
// never a shared one — the console grants whoever holds it full command authority
|
|
// over that server, so a single cluster-wide value would make every owner an
|
|
// operator of everyone else's world.
|
|
func RconSecretName(server string) string {
|
|
return server + "-rcon"
|
|
}
|
|
|
|
// Hostname composes subdomain.rootDomain after validating the subdomain.
|
|
func Hostname(subdomain, rootDomain string) (string, error) {
|
|
if err := ValidateServerName(subdomain); err != nil {
|
|
return "", err
|
|
}
|
|
if rootDomain == "" {
|
|
return "", fmt.Errorf("naming: root domain is empty")
|
|
}
|
|
return subdomain + "." + rootDomain, nil
|
|
}
|
|
|
|
// ValidateHostname enforces the §2 invariant that host is a single label
|
|
// directly under rootDomain.
|
|
func ValidateHostname(host, rootDomain string) error {
|
|
if rootDomain == "" {
|
|
return fmt.Errorf("naming: root domain is empty")
|
|
}
|
|
suffix := "." + rootDomain
|
|
if !strings.HasSuffix(host, suffix) {
|
|
return fmt.Errorf("naming: hostname %q must be under %q", host, rootDomain)
|
|
}
|
|
label := strings.TrimSuffix(host, suffix)
|
|
if label == "" || strings.Contains(label, ".") {
|
|
return fmt.Errorf("naming: hostname %q must be a single label under %q", host, rootDomain)
|
|
}
|
|
if !dnsLabelRE.MatchString(label) {
|
|
return fmt.Errorf("naming: invalid hostname label %q", label)
|
|
}
|
|
return nil
|
|
}
|