Unverified Commit 5dc8eb92 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(operator): secure system server workloads

parent 688c86da
Loading
Loading
Loading
Loading
+7 −7
Changes for cmd/felis/manifests.go: 7 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -30,9 +30,9 @@ func (m *multiFlag) Set(v string) error {
// multi-document YAML stream on stdout, ready for `kubectl apply -f -`.
//
// It is a pure renderer: it never contacts a cluster and holds no credentials.
// --velocity-cidr is REQUIRED because the game NetworkPolicy fails closed without
// it; emitting a bundle whose 25565 ingress admitted no one would silently break
// the server, so the generator refuses rather than guess.
// --velocity-cidr records the proxy host addresses allowed by the game NetworkPolicy.
// Kubernetes permits resident-node traffic regardless, but remote proxy deployments
// need an explicit CIDR, so the renderer refuses to guess.
func cmdManifests(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("manifests", flag.ContinueOnError)
	fs.SetOutput(stderr)
@@ -48,18 +48,18 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
	worldsHostPath := fs.String("worlds-host-path", "", "node directory under which each world PVC is visible as <path>/<pvc>; enables the reaper CronJob (requires --backup-pvc and --archive-local-path)")
	archiveLocalPath := fs.String("archive-local-path", "", "path the backup PVC is mounted at in the reaper CronJob; MUST equal felis.toml [archive] local_path")
	var velocityCIDRs multiFlag
	fs.Var(&velocityCIDRs, "velocity-cidr", "CIDR of an off-cluster Velocity proxy host allowed to reach game port 25565 (repeatable, REQUIRED)")
	fs.Var(&velocityCIDRs, "velocity-cidr", "CIDR of a Velocity proxy host allowed to reach game port 25565 (repeatable, REQUIRED)")
	var packageCIDRs multiFlag
	fs.Var(&packageCIDRs, "package-cidr", "CIDR of a package mirror build Pods may reach (repeatable; default none = no internet egress)")
	if err := fs.Parse(args); err != nil {
		return 2
	}

	// --velocity-cidr is mandatory: the game policy is fail-closed, so omitting it
	// would render a server nobody can reach. Fail loudly at generation time.
	// Keep proxy placement explicit. This matters for remote proxies and documents
	// the expected source even when Velocity runs on the resident node.
	if len(velocityCIDRs) == 0 {
		fmt.Fprintln(stderr, "felis manifests: at least one --velocity-cidr is required "+
			"(the game NetworkPolicy fails closed without it; pass the Velocity proxy host CIDR, e.g. --velocity-cidr 10.0.0.5/32)")
			"(pass the Velocity proxy host CIDR, e.g. --velocity-cidr 10.0.0.5/32)")
		return 2
	}

+4 −1
Changes for internal/apis/felis/v1alpha1/minecraftserver_types.go: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -16,6 +16,9 @@ const (
	LabelManagedBy = GroupName + "/managed-by"
	// LabelComponent distinguishes the workload role (server, rcon, ...).
	LabelComponent = GroupName + "/component"
	// LabelSystemRole identifies setup-owned system servers. Its value is the
	// reserved role name (for example, "login" or "lobby").
	LabelSystemRole = GroupName + "/system-role"
)

// DesiredState is the operator-facing intent toggle (spec §4 spec.desiredState).
@@ -121,7 +124,7 @@ type MinecraftServerSpec struct {
	// Jar is the server jar path/name inside the image, if the entrypoint
	// needs it explicitly.
	Jar string `json:"jar,omitempty"`
	// JavaMemory is the heap sizing passed as -Xmx/-Xms (e.g. "4G").
	// JavaMemory is the maximum heap sizing passed as -Xmx (e.g. "4G").
	JavaMemory string `json:"javaMemory,omitempty"`
	// JavaFlags are additional JVM flags (e.g. Aikar's flags).
	JavaFlags []string `json:"javaFlags,omitempty"`
+26 −0
Changes for internal/naming/naming.go: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ var (
// 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":    {},
@@ -56,6 +57,31 @@ const (
	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) {
+9 −6
Changes for internal/naming/naming_test.go: 9 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ func TestValidateServerName(t *testing.T) {
		{"has space", false},                         // illegal char
		{"-leading", false},                          // leading hyphen
		{"trailing-", false},                         // trailing hyphen
		{"login", false},                             // reserved system server
		{"lobby", false},                             // reserved
		{"admin", false},                             // reserved
		{"api", false},                               // reserved
@@ -64,13 +65,15 @@ func TestValidateSystemServerName(t *testing.T) {
		}
	}

	// The two paths must genuinely differ on reserved names: user path refuses
	// "lobby", system path accepts it.
	if naming.ValidateServerName("lobby") == nil {
		t.Error("ValidateServerName(lobby) accepted; reserved name must be refused for users")
	// The two paths must genuinely differ on system names: the user path
	// refuses them while the system path accepts them.
	for _, name := range []string{"login", "lobby"} {
		if naming.ValidateServerName(name) == nil {
			t.Errorf("ValidateServerName(%s) accepted; reserved name must be refused for users", name)
		}
		if naming.ValidateSystemServerName(name) != nil {
			t.Errorf("ValidateSystemServerName(%s) refused; system path must accept it", name)
		}
	if naming.ValidateSystemServerName("lobby") != nil {
		t.Error("ValidateSystemServerName(lobby) refused; system path must accept it")
	}
}

+34 −9
Changes for internal/operator/builders.go: 34 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,11 @@ import (
// system server (see buildEnv), sourced from a Secret, never a literal.
const envServiceToken = "FELIS_SERVICE_TOKEN"

// envForwardingSecret is the environment variable a backend reads the Velocity
// modern-forwarding secret from. Unlike the service token it goes to EVERY backend
// (see buildEnv), because Velocity's forwarding mode is proxy-wide.
const envForwardingSecret = "FELIS_FORWARDING_SECRET"

// Workload constants shared by the builders.
const (
	// GamePort is the Minecraft TCP port the proxy and readiness probe target.
@@ -86,11 +91,6 @@ func rconAddress(server *v1alpha1.MinecraftServer) string {
	return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, rconPort(server))
}

// gameAddress is the in-cluster game endpoint advertised when Running.
func gameAddress(server *v1alpha1.MinecraftServer) string {
	return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, GamePort)
}

// preStopScript is the operator-injected graceful-shutdown sequence (spec §7):
// flush the world, then stop the server, both over RCON. It relies on rcon-cli
// being present in the Felis base image and reading the RCON_* env injected
@@ -309,12 +309,13 @@ func buildEnv(server *v1alpha1.MinecraftServer) []corev1.EnvVar {
	// link status), so it — and only it — receives the service token. Injected
	// from a Secret in this namespace, never inlined into the CRD (the same
	// discipline as RCON_PASSWORD above; the CRD's EnvVar type has no valueFrom
	// precisely so a user server cannot mount an arbitrary secret). Keyed off the
	// reserved "login" name, which naming.ValidateServerName forbids any user
	// server from claiming — so this can never leak the token into a user's pod.
	// precisely so a user server cannot mount an arbitrary secret). Require both
	// the reserved name and the setup-owned system-role label: the label prevents
	// a legacy user server named "login" from receiving the token after upgrade.
	// The Secret must exist in this (minecraft) namespace; `felis setup` replicates
	// it there from the control namespace before creating this server.
	if server.Name == naming.SystemLoginServer {
	if server.Name == naming.SystemLoginServer &&
		server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLoginServer {
		env = append(env, corev1.EnvVar{
			Name: envServiceToken,
			ValueFrom: &corev1.EnvVarSource{
@@ -325,6 +326,30 @@ func buildEnv(server *v1alpha1.MinecraftServer) []corev1.EnvVar {
			},
		})
	}
	// The Velocity modern-forwarding secret goes to EVERY backend, system and user
	// alike — not because user servers are trusted, but because Velocity's forwarding
	// mode is one proxy-wide setting: with it on, a backend that cannot verify the
	// signed handshake rejects every login the proxy sends it. Withholding the secret
	// from user servers would not harden them, it would simply make them unjoinable.
	// It is the backend's proof that a login really came from the proxy (and so that
	// the player's UUID is Mojang-verified, not offline-derived) — the pod-level fence
	// against bypassing the proxy is the NetworkPolicy, not this value's secrecy.
	//
	// A user server is built from an operator-typed Dockerfile, so Felis cannot make it
	// consume this; the two images Felis does build (deploy/limbo, deploy/lobby) read it
	// in their entrypoints and refuse to start without it. Optional so a cluster whose
	// proxy is not in modern mode — no Secret provisioned — still schedules its pods
	// instead of wedging them all in CreateContainerConfigError.
	env = append(env, corev1.EnvVar{
		Name: envForwardingSecret,
		ValueFrom: &corev1.EnvVarSource{
			SecretKeyRef: &corev1.SecretKeySelector{
				LocalObjectReference: corev1.LocalObjectReference{Name: naming.ForwardingSecretName},
				Key:                  naming.ForwardingSecretKey,
				Optional:             boolPtr(true),
			},
		},
	})
	return env
}

Loading