diff --git a/internal/operator/builders.go b/internal/operator/builders.go index 8340c87..aecacc0 100644 --- a/internal/operator/builders.go +++ b/internal/operator/builders.go @@ -5,6 +5,7 @@ import ( "strconv" "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/naming" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -12,6 +13,11 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" ) +// envServiceToken is the environment variable the felis-limbo login plugin reads +// its internal-API bearer credential from. It is injected ONLY into the login +// system server (see buildEnv), sourced from a Secret, never a literal. +const envServiceToken = "FELIS_SERVICE_TOKEN" + // Workload constants shared by the builders. const ( // GamePort is the Minecraft TCP port the proxy and readiness probe target. @@ -148,6 +154,33 @@ func servicePorts(server *v1alpha1.MinecraftServer) []corev1.ServicePort { return ports } +// readinessProbe selects the pod readiness probe. By default it is a plain TCP +// check on the game port; when the server declares an HTTP health port +// (StartupSpec.HealthHTTPPort > 0) it becomes an HTTP GET on that port, so an +// RCON-less loader's own "started" signal — not the mere fact that the game +// socket is bound — gates readiness. Timings are identical across both modes. +func readinessProbe(server *v1alpha1.MinecraftServer) *corev1.Probe { + probe := &corev1.Probe{ + InitialDelaySeconds: 20, + PeriodSeconds: 10, + FailureThreshold: 6, + } + if hp := server.Spec.Startup.HealthHTTPPort; hp > 0 { + path := server.Spec.Startup.HealthHTTPPath + if path == "" { + path = "/healthz" + } + probe.ProbeHandler = corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Path: path, Port: intstr.FromInt32(hp)}, + } + return probe + } + probe.ProbeHandler = corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(GamePort)}, + } + return probe +} + // buildStatefulSet renders the workload for replicas in {0,1}. It is where // graceful shutdown is injected: the pod gets terminationGracePeriodSeconds and // (when enabled) a preStop RCON save+stop hook. @@ -172,16 +205,17 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32) (*appsv1 VolumeMounts: []corev1.VolumeMount{ {Name: dataVolumeName, MountPath: dataMountPath}, }, - // Readiness here is a plain TCP check (spec §5: readinessProbe is only + // Readiness defaults to a plain TCP check (spec §5: readinessProbe is only // tcpSocket; the RCON gate is enforced by the operator, not the kubelet). - ReadinessProbe: &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(GamePort)}, - }, - InitialDelaySeconds: 20, - PeriodSeconds: 10, - FailureThreshold: 6, - }, + // An RCON-less loader may instead publish an HTTP health endpoint (see + // StartupSpec.HealthHTTPPort) that reports true readiness — used below when + // set. + ReadinessProbe: readinessProbe(server), + } + if hp := server.Spec.Startup.HealthHTTPPort; hp > 0 { + container.Ports = append(container.Ports, corev1.ContainerPort{ + Name: "health", ContainerPort: hp, Protocol: corev1.ProtocolTCP, + }) } if len(server.Spec.Args) > 0 { container.Args = append([]string(nil), server.Spec.Args...) @@ -270,6 +304,27 @@ func buildEnv(server *v1alpha1.MinecraftServer) []corev1.EnvVar { }}, ) } + // The login system server is the ONE workload that authenticates to the + // felis-api internal face (its felis-limbo plugin mints bind codes and polls + // 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. + // 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 { + env = append(env, corev1.EnvVar{ + Name: envServiceToken, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName}, + Key: naming.ServiceTokenSecretKey, + }, + }, + }) + } return env } diff --git a/internal/operator/builders_internal_test.go b/internal/operator/builders_internal_test.go new file mode 100644 index 0000000..13699f2 --- /dev/null +++ b/internal/operator/builders_internal_test.go @@ -0,0 +1,113 @@ +package operator + +import ( + "testing" + + "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/naming" + corev1 "k8s.io/api/core/v1" +) + +// findEnv returns the env var with the given name, or nil. +func findEnv(env []corev1.EnvVar, name string) *corev1.EnvVar { + for i := range env { + if env[i].Name == name { + return &env[i] + } + } + return nil +} + +// By default readiness is a plain TCP check on the game port (spec §5). +func TestReadinessProbeDefaultsToTCP(t *testing.T) { + p := readinessProbe(&v1alpha1.MinecraftServer{}) + if p.TCPSocket == nil || p.HTTPGet != nil { + t.Fatalf("default probe should be TCPSocket, got %+v", p.ProbeHandler) + } + if p.TCPSocket.Port.IntVal != GamePort { + t.Errorf("TCP probe port = %d, want %d", p.TCPSocket.Port.IntVal, GamePort) + } +} + +// When a server declares an HTTP health port, readiness gates on an HTTP GET so +// an RCON-less loader's own "started" signal (felis-limbo) marks it Ready. +func TestReadinessProbeHTTPWhenHealthPortSet(t *testing.T) { + s := &v1alpha1.MinecraftServer{} + s.Spec.Startup.HealthHTTPPort = 8080 + p := readinessProbe(s) + if p.HTTPGet == nil || p.TCPSocket != nil { + t.Fatalf("expected HTTPGet probe, got %+v", p.ProbeHandler) + } + if p.HTTPGet.Port.IntVal != 8080 { + t.Errorf("HTTP probe port = %d, want 8080", p.HTTPGet.Port.IntVal) + } + if p.HTTPGet.Path != "/healthz" { + t.Errorf("HTTP probe path = %q, want default /healthz", p.HTTPGet.Path) + } +} + +func TestReadinessProbeHTTPCustomPath(t *testing.T) { + s := &v1alpha1.MinecraftServer{} + s.Spec.Startup.HealthHTTPPort = 9000 + s.Spec.Startup.HealthHTTPPath = "/ready" + p := readinessProbe(s) + if p.HTTPGet == nil || p.HTTPGet.Path != "/ready" || p.HTTPGet.Port.IntVal != 9000 { + t.Fatalf("custom HTTP probe wrong: %+v", p.HTTPGet) + } +} + +// A server with a health port also exposes it as a named container port so the +// kubelet can reach it. +func TestBuildStatefulSetAddsHealthPort(t *testing.T) { + s := &v1alpha1.MinecraftServer{} + s.Spec.Storage.Size = "1Gi" + s.Spec.Startup.HealthHTTPPort = 8080 + sts, err := buildStatefulSet(s, 1) + if err != nil { + t.Fatalf("buildStatefulSet: %v", err) + } + found := false + for _, port := range sts.Spec.Template.Spec.Containers[0].Ports { + if port.Name == "health" && port.ContainerPort == 8080 { + found = true + } + } + if !found { + t.Error("health container port 8080 not exposed") + } +} + +// The login system server (and ONLY it) receives the service token, sourced from a +// Secret via secretKeyRef — never a literal — so its felis-limbo plugin can +// authenticate to the felis-api internal face. +func TestBuildEnvInjectsServiceTokenForLogin(t *testing.T) { + s := &v1alpha1.MinecraftServer{} + s.Name = naming.SystemLoginServer + tok := findEnv(buildEnv(s), envServiceToken) + if tok == nil { + t.Fatalf("%s not injected for the login server", envServiceToken) + } + if tok.Value != "" { + t.Errorf("%s carries a literal value %q — it must be a secretKeyRef", envServiceToken, tok.Value) + } + if tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil { + t.Fatalf("%s must be sourced from a secretKeyRef", envServiceToken) + } + ref := tok.ValueFrom.SecretKeyRef + if ref.Name != naming.ServiceTokenSecretName || ref.Key != naming.ServiceTokenSecretKey { + t.Errorf("secretKeyRef = %s/%s, want %s/%s", ref.Name, ref.Key, naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey) + } +} + +// A user server (any non-login name) must NOT receive the service token — the +// reserved-name gate is what stops the internal credential leaking into a player's +// pod. naming.ValidateServerName forbids users from ever claiming "login". +func TestBuildEnvWithholdsServiceTokenFromUserServers(t *testing.T) { + for _, name := range []string{"survival", "creative", naming.SystemLobbyServer} { + s := &v1alpha1.MinecraftServer{} + s.Name = name + if tok := findEnv(buildEnv(s), envServiceToken); tok != nil { + t.Errorf("%s: service token leaked into a non-login server", name) + } + } +}