feat(api): 内部面 token 按调用方拆分为 velocity/limbo/build/ops 并按路由限定调用方,审计来源区分调用方,安装器生成并下发各自 Secret,新增 felis rotate-token 轮换命令
This commit is contained in:
38 files changed
+1359
-209
No files matched your search
+21
-4
@@ -119,9 +119,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
|
||||
metrics.SetBuildInfo("api", resolvedVersion())
|
||||
|
||||
token := os.Getenv("FELIS_SERVICE_TOKEN")
|
||||
if token == "" {
|
||||
fmt.Fprintln(stderr, "felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers")
|
||||
internalAuth, err := internalCallerTokens(os.Getenv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis api: internal face tokens: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, ct := range naming.CallerTokens {
|
||||
if internalAuth[api.Caller(ct.Caller)] == "" {
|
||||
fmt.Fprintf(stderr, "felis api: warning: %s unset — the internal face turns the %s caller away\n", ct.APIEnv, ct.Caller)
|
||||
}
|
||||
}
|
||||
|
||||
// Email one-time codes go through the [smtp] relay when one is configured; the
|
||||
@@ -299,7 +305,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
// Build-log stream (spec §16) is scoped to the BUILD namespace — the same
|
||||
// value the Builder renders Jobs into — so it follows where build Pods run.
|
||||
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
|
||||
Internal: api.BearerTokenAuth{Token: token},
|
||||
Internal: internalAuth,
|
||||
Builder: builder,
|
||||
Images: imagePinner(cfg.Registry.URL),
|
||||
Restorer: restorer,
|
||||
@@ -800,3 +806,14 @@ func imagePinner(registry string) api.ImagePinner {
|
||||
}
|
||||
return imagepin.Resolver{Registry: registry}
|
||||
}
|
||||
|
||||
// internalCallerTokens reads each internal caller's token from the env var the
|
||||
// Deployment feeds it from (naming.CallerTokens). Two callers sharing a value
|
||||
// would make the caller ambiguous, so that refuses to start.
|
||||
func internalCallerTokens(getenv func(string) string) (api.CallerTokens, error) {
|
||||
tokens := map[api.Caller]string{}
|
||||
for _, ct := range naming.CallerTokens {
|
||||
tokens[api.Caller(ct.Caller)] = strings.TrimSpace(getenv(ct.APIEnv))
|
||||
}
|
||||
return api.NewCallerTokens(tokens)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
)
|
||||
|
||||
// felis-api maps each env var onto the caller the Deployment feeds it for, so the
|
||||
// build Job's token (FELIS_BUILD_TOKEN) authenticates as build and only as build.
|
||||
func TestInternalCallerTokensReadEachCallersEnv(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"FELIS_SERVICE_TOKEN": "v-tok",
|
||||
"FELIS_LIMBO_TOKEN": "l-tok",
|
||||
"FELIS_BUILD_TOKEN": " b-tok\n",
|
||||
"FELIS_OPS_TOKEN": "o-tok",
|
||||
}
|
||||
auth, err := internalCallerTokens(func(k string) string { return env[k] })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for tok, want := range map[string]api.Caller{"v-tok": api.CallerVelocity, "l-tok": api.CallerLimbo, "b-tok": api.CallerBuild, "o-tok": api.CallerOps} {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
if got, err := auth.Authenticate(r); err != nil || got != want {
|
||||
t.Errorf("%s: got (%q, %v), want %q", tok, got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An install where the build namespace still holds a copy of the proxy's token
|
||||
// would let that copy act as the proxy; the api refuses to start on it.
|
||||
env["FELIS_BUILD_TOKEN"] = "v-tok"
|
||||
if _, err := internalCallerTokens(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "same value") {
|
||||
t.Fatalf("shared token: err = %v, want a same-value refusal", err)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// backupnow is the break-glass "back up a world now" op (§B4 "Sync"). Unlike halt —
|
||||
// which writes the CRD directly — a backup needs felis-api's deployment coordinates
|
||||
// (FELIS_IMAGE / FELIS_BACKUP_PVC) to render the one-shot backup Job, so the console
|
||||
// cannot do it in-process. It POSTs the felis-api INTERNAL face (service-token auth)
|
||||
// cannot do it in-process. It POSTs the felis-api INTERNAL face (ops-token auth)
|
||||
// while the API is alive, and the API renders the Job and audits the action. This file
|
||||
// is the pure core (no bubbletea); tui_backupnow.go is the terminal glue.
|
||||
|
||||
@@ -33,7 +33,7 @@ type backupNowOutcome struct {
|
||||
|
||||
// resolveInternalAPI reads the two things the on-node console needs to reach the
|
||||
// felis-api internal face: the felis-api-internal Service ClusterIP (the host's
|
||||
// resolver is not CoreDNS, so the cluster-DNS name is useless here) and the service
|
||||
// resolver is not CoreDNS, so the cluster-DNS name is useless here) and the ops
|
||||
// token. Both live in the control namespace.
|
||||
func resolveInternalAPI(ctx context.Context, cl client.Client, controlNamespace string) (baseURL, token string, err error) {
|
||||
var svc corev1.Service
|
||||
@@ -45,13 +45,14 @@ func resolveInternalAPI(ctx context.Context, cl client.Client, controlNamespace
|
||||
return "", "", fmt.Errorf("%s Service has no ClusterIP yet", platform.APIInternalServiceName)
|
||||
}
|
||||
|
||||
// The console's own token, which the api serves on the backup route alone.
|
||||
var sec corev1.Secret
|
||||
if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNamespace, Name: naming.ServiceTokenSecretName}, &sec); err != nil {
|
||||
return "", "", fmt.Errorf("get %s Secret: %w", naming.ServiceTokenSecretName, err)
|
||||
if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNamespace, Name: naming.OpsTokenSecretName}, &sec); err != nil {
|
||||
return "", "", fmt.Errorf("get %s Secret (re-run the installer to create it): %w", naming.OpsTokenSecretName, err)
|
||||
}
|
||||
token = string(sec.Data[naming.ServiceTokenSecretKey])
|
||||
if token == "" {
|
||||
return "", "", fmt.Errorf("secret %s has no %s key", naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey)
|
||||
return "", "", fmt.Errorf("secret %s has no %s key", naming.OpsTokenSecretName, naming.ServiceTokenSecretKey)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%d", ip, platform.APIInternalPort), token, nil
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/naming"
|
||||
"felis.lolicon.best/internal/platform"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -28,9 +27,15 @@ func internalAPIObjs(clusterIP, token string) []client.Object {
|
||||
ObjectMeta: metav1.ObjectMeta{Name: platform.APIInternalServiceName, Namespace: bgControlNS},
|
||||
Spec: corev1.ServiceSpec{ClusterIP: clusterIP},
|
||||
},
|
||||
// The console presents the ops token; the proxy's felis-service-token sits
|
||||
// beside it and must not be the one picked up.
|
||||
&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: bgControlNS},
|
||||
Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte(token)},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "felis-ops-token", Namespace: bgControlNS},
|
||||
Data: map[string][]byte{"token": []byte(token)},
|
||||
},
|
||||
&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "felis-service-token", Namespace: bgControlNS},
|
||||
Data: map[string][]byte{"token": []byte("proxy-" + token)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
"felis.lolicon.best/internal/platform"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
// rotate-token replaces one internal caller's token (naming.CallerTokens): a new
|
||||
// value goes into the installer's record, the control-namespace Secret and the
|
||||
// replica the caller's pods mount, felis-api rolls so it accepts only the new
|
||||
// value, and then the caller restarts so it presents it. Between the api's
|
||||
// rollout and the caller's restart the caller is turned away with 401; for the
|
||||
// login gate and the proxy that is the few seconds of a pod or unit restart.
|
||||
|
||||
const (
|
||||
defaultSecretsEnvPath = "/etc/felis/secrets.env"
|
||||
defaultLinkPropsPath = "/opt/felis/velocity/plugins/felis-link/felis-link.properties"
|
||||
velocityUnit = "felis-velocity"
|
||||
)
|
||||
|
||||
// installerTokenKeys names each caller's token in the installer's secrets.env
|
||||
// (deploy/bootstrap.sh load_or_make_secrets). A re-run of the installer applies
|
||||
// these values to the Secrets, so a rotation that skipped the file would be
|
||||
// undone by the next upgrade.
|
||||
var installerTokenKeys = map[string]string{
|
||||
"velocity": "SERVICE_TOKEN",
|
||||
"limbo": "LIMBO_TOKEN",
|
||||
"build": "BUILD_TOKEN",
|
||||
"ops": "OPS_TOKEN",
|
||||
}
|
||||
|
||||
type tokenRotator struct {
|
||||
cl client.Client
|
||||
controlNS string
|
||||
minecraftNS string
|
||||
buildNS string
|
||||
// secretsEnv and linkProps are the installer's record and the proxy's
|
||||
// felis-link.properties; a missing file is reported and skipped.
|
||||
secretsEnv string
|
||||
linkProps string
|
||||
newToken func() (string, error)
|
||||
// rollAPI restarts felis-api and waits for the rollout.
|
||||
rollAPI func(ctx context.Context) error
|
||||
// restartUnit restarts a systemd unit on this host.
|
||||
restartUnit func(ctx context.Context, unit string) error
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func cmdRotateToken(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("rotate-token", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml")
|
||||
secretsEnv := fs.String("secrets-env", defaultSecretsEnvPath, "the installer's secrets file, updated so a re-run keeps the new value")
|
||||
linkProps := fs.String("link-properties", defaultLinkPropsPath, "the host proxy's felis-link.properties (velocity only)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(stderr, "Usage: felis rotate-token [flags] <%s>\n\n", strings.Join(callerNames(), "|"))
|
||||
fmt.Fprintln(stderr, "Replaces one internal caller's token: the Secrets, felis-api, then the caller itself.")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
if _, ok := callerToken(fs.Arg(0)); !ok {
|
||||
fmt.Fprintf(stderr, "felis rotate-token: unknown caller %q (one of %s)\n", fs.Arg(0), strings.Join(callerNames(), ", "))
|
||||
return 2
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(stderr, "felis rotate-token: refused — rotating writes the cluster Secrets and the installer's secrets file, so it must run as root (try: sudo felis rotate-token "+fs.Arg(0)+")")
|
||||
return 1
|
||||
}
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis rotate-token: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
cl, err := buildSystemServerClient()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis rotate-token: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
buildNS := cfg.Registry.BuildNamespace
|
||||
if buildNS == "" {
|
||||
buildNS = platform.DefaultBuildNamespace
|
||||
}
|
||||
r := tokenRotator{
|
||||
cl: cl,
|
||||
controlNS: platform.DefaultControlNamespace,
|
||||
minecraftNS: cfg.K8s.Namespace,
|
||||
buildNS: buildNS,
|
||||
secretsEnv: *secretsEnv,
|
||||
linkProps: *linkProps,
|
||||
newToken: randomToken,
|
||||
rollAPI: func(ctx context.Context) error {
|
||||
if err := kubectl(ctx, "-n", platform.DefaultControlNamespace, "rollout", "restart", "deployment/felis-api"); err != nil {
|
||||
return err
|
||||
}
|
||||
return kubectl(ctx, "-n", platform.DefaultControlNamespace, "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
||||
},
|
||||
restartUnit: func(ctx context.Context, unit string) error { return systemctl(ctx, "restart", unit) },
|
||||
out: stdout,
|
||||
}
|
||||
if err := r.rotate(context.Background(), fs.Arg(0)); err != nil {
|
||||
fmt.Fprintf(stderr, "felis rotate-token: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func callerNames() []string {
|
||||
names := make([]string, 0, len(naming.CallerTokens))
|
||||
for _, ct := range naming.CallerTokens {
|
||||
names = append(names, ct.Caller)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func callerToken(name string) (naming.CallerToken, bool) {
|
||||
for _, ct := range naming.CallerTokens {
|
||||
if ct.Caller == name {
|
||||
return ct, true
|
||||
}
|
||||
}
|
||||
return naming.CallerToken{}, false
|
||||
}
|
||||
|
||||
// randomToken is 32 random bytes in hex, the shape the installer generates.
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (r tokenRotator) rotate(ctx context.Context, caller string) error {
|
||||
ct, ok := callerToken(caller)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown caller %q", caller)
|
||||
}
|
||||
tok, err := r.newToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate a token: %w", err)
|
||||
}
|
||||
|
||||
// The installer's record first: from here on, whatever fails, a re-run of the
|
||||
// installer puts the new value everywhere.
|
||||
switch err := setKeyValueLine(r.secretsEnv, installerTokenKeys[ct.Caller], "=", tok); {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
fmt.Fprintf(r.out, " - %s: not found, skipped (this host was not installed by deploy/bootstrap.sh)\n", r.secretsEnv)
|
||||
case err != nil:
|
||||
return fmt.Errorf("record the new token in %s: %w", r.secretsEnv, err)
|
||||
default:
|
||||
fmt.Fprintf(r.out, " - %s: %s updated\n", r.secretsEnv, installerTokenKeys[ct.Caller])
|
||||
}
|
||||
|
||||
namespaces := []string{r.controlNS}
|
||||
replica := map[string]string{"minecraft": r.minecraftNS, "build": r.buildNS}[ct.Replica]
|
||||
if replica != "" && replica != r.controlNS {
|
||||
namespaces = append(namespaces, replica)
|
||||
}
|
||||
for _, ns := range namespaces {
|
||||
if err := writeTokenSecret(ctx, r.cl, ns, ct.Secret, tok); err != nil {
|
||||
return fmt.Errorf("write Secret %s/%s: %w", ns, ct.Secret, err)
|
||||
}
|
||||
fmt.Fprintf(r.out, " - Secret %s/%s: updated\n", ns, ct.Secret)
|
||||
}
|
||||
|
||||
hostProxy := false
|
||||
if ct.Caller == "velocity" {
|
||||
switch err := setKeyValueLine(r.linkProps, "service-token", "=", tok); {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
fmt.Fprintf(r.out, " - %s: not found; set service-token in your proxy's felis-link.properties to the value in Secret %s/%s and restart it\n",
|
||||
r.linkProps, r.controlNS, ct.Secret)
|
||||
case err != nil:
|
||||
return fmt.Errorf("write the proxy's token into %s: %w", r.linkProps, err)
|
||||
default:
|
||||
hostProxy = true
|
||||
fmt.Fprintf(r.out, " - %s: service-token updated\n", r.linkProps)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.rollAPI(ctx); err != nil {
|
||||
return fmt.Errorf("roll felis-api: %w", err)
|
||||
}
|
||||
fmt.Fprintln(r.out, " - felis-api: rolled out, accepting only the new token")
|
||||
|
||||
switch ct.Caller {
|
||||
case "velocity":
|
||||
if hostProxy {
|
||||
if err := r.restartUnit(ctx, velocityUnit); err != nil {
|
||||
return fmt.Errorf("restart %s: %w", velocityUnit, err)
|
||||
}
|
||||
fmt.Fprintf(r.out, " - %s: restarted (players on the proxy were disconnected and can rejoin)\n", velocityUnit)
|
||||
}
|
||||
case "limbo":
|
||||
if err := r.cl.DeleteAllOf(ctx, &corev1.Pod{}, client.InNamespace(r.minecraftNS),
|
||||
client.MatchingLabels{v1alpha1.LabelServer: naming.SystemLoginServer}); err != nil {
|
||||
return fmt.Errorf("restart the login gate: %w", err)
|
||||
}
|
||||
fmt.Fprintln(r.out, " - login gate: pod restarted to read the new token")
|
||||
case "build":
|
||||
fmt.Fprintln(r.out, " - builds: the next build Job reads the new token; one fetching its context right now fails and can be submitted again")
|
||||
case "ops":
|
||||
fmt.Fprintln(r.out, " - felis backup-now reads the new token on its next run")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTokenSecret sets the token in a Secret, creating it when absent.
|
||||
func writeTokenSecret(ctx context.Context, cl client.Client, namespace, name, token string) error {
|
||||
var sec corev1.Secret
|
||||
err := cl.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &sec)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return cl.Create(ctx, &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte(token)},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sec.Data == nil {
|
||||
sec.Data = map[string][]byte{}
|
||||
}
|
||||
sec.Data[naming.ServiceTokenSecretKey] = []byte(token)
|
||||
return cl.Update(ctx, &sec)
|
||||
}
|
||||
|
||||
// setKeyValueLine rewrites the `key<sep>value` line of a flat key/value file
|
||||
// (secrets.env, a .properties file), appending one when the key is absent. The
|
||||
// file is replaced atomically and keeps its mode and owner: felis-link.properties
|
||||
// is root:felis-velocity 0640, and the proxy must still be able to read it.
|
||||
func setKeyValueLine(path, key, sep, value string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
||||
found := false
|
||||
for i, ln := range lines {
|
||||
k, _, ok := strings.Cut(ln, sep)
|
||||
if ok && strings.TrimSpace(k) == key {
|
||||
lines[i] = key + sep + value
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
lines = append(lines, key+sep+value)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
if err := tmp.Chown(int(st.Uid), int(st.Gid)); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tmp.WriteString(strings.Join(lines, "\n") + "\n"); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
)
|
||||
|
||||
type rotationRig struct {
|
||||
r tokenRotator
|
||||
cl client.Client
|
||||
out *bytes.Buffer
|
||||
events []string
|
||||
dir string
|
||||
}
|
||||
|
||||
func tokenSecret(ns, name, val string) *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name},
|
||||
Data: map[string][]byte{"token": []byte(val)},
|
||||
}
|
||||
}
|
||||
|
||||
func serverPod(ns, name, server string) *corev1.Pod {
|
||||
return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name,
|
||||
Labels: map[string]string{"felis.lolicon.best/server": server}}}
|
||||
}
|
||||
|
||||
func newRotationRig(t *testing.T, objs ...client.Object) *rotationRig {
|
||||
t.Helper()
|
||||
rig := &rotationRig{out: &bytes.Buffer{}, dir: t.TempDir()}
|
||||
rig.cl = fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(objs...).Build()
|
||||
rig.r = tokenRotator{
|
||||
cl: rig.cl,
|
||||
controlNS: "felis",
|
||||
minecraftNS: "minecraft",
|
||||
buildNS: "felis-build",
|
||||
secretsEnv: filepath.Join(rig.dir, "secrets.env"),
|
||||
linkProps: filepath.Join(rig.dir, "felis-link.properties"),
|
||||
newToken: func() (string, error) { return "NEWTOKEN", nil },
|
||||
rollAPI: func(context.Context) error {
|
||||
rig.events = append(rig.events, "roll-api")
|
||||
return nil
|
||||
},
|
||||
restartUnit: func(_ context.Context, unit string) error {
|
||||
rig.events = append(rig.events, "restart "+unit)
|
||||
return nil
|
||||
},
|
||||
out: rig.out,
|
||||
}
|
||||
return rig
|
||||
}
|
||||
|
||||
func (rig *rotationRig) secret(t *testing.T, ns, name string) string {
|
||||
t.Helper()
|
||||
var s corev1.Secret
|
||||
if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: name}, &s); err != nil {
|
||||
return "<missing>"
|
||||
}
|
||||
return string(s.Data["token"])
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, body string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateLimboToken(t *testing.T) {
|
||||
rig := newRotationRig(t,
|
||||
tokenSecret("felis", "felis-limbo-token", "old"),
|
||||
tokenSecret("minecraft", "felis-limbo-token", "old"),
|
||||
tokenSecret("felis", "felis-service-token", "proxy"),
|
||||
serverPod("minecraft", "login-0", "login"),
|
||||
serverPod("minecraft", "survival-0", "survival"),
|
||||
)
|
||||
writeTestFile(t, rig.r.secretsEnv, "DB_PASSWORD=db\nSERVICE_TOKEN=proxy\nLIMBO_TOKEN=old\nOPS_TOKEN=ops\n", 0o600)
|
||||
|
||||
// felis-api must roll only after both copies hold the new value, and the login
|
||||
// pod must still be there then: restarting it earlier would have it present
|
||||
// the new token to an api that does not know it yet.
|
||||
rig.r.rollAPI = func(context.Context) error {
|
||||
rig.events = append(rig.events, "roll-api")
|
||||
if got := rig.secret(t, "felis", "felis-limbo-token"); got != "NEWTOKEN" {
|
||||
t.Errorf("api rolled while the control Secret held %q", got)
|
||||
}
|
||||
if got := rig.secret(t, "minecraft", "felis-limbo-token"); got != "NEWTOKEN" {
|
||||
t.Errorf("api rolled while the minecraft replica held %q", got)
|
||||
}
|
||||
var pod corev1.Pod
|
||||
if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: "login-0"}, &pod); err != nil {
|
||||
t.Errorf("the login pod was restarted before the api rolled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := rig.r.rotate(context.Background(), "limbo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(rig.r.secretsEnv)
|
||||
if string(raw) != "DB_PASSWORD=db\nSERVICE_TOKEN=proxy\nLIMBO_TOKEN=NEWTOKEN\nOPS_TOKEN=ops\n" {
|
||||
t.Errorf("secrets.env = %q", raw)
|
||||
}
|
||||
if info, _ := os.Stat(rig.r.secretsEnv); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("secrets.env mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
if got := rig.secret(t, "felis", "felis-service-token"); got != "proxy" {
|
||||
t.Errorf("the proxy's token changed to %q", got)
|
||||
}
|
||||
var pods corev1.PodList
|
||||
if err := rig.cl.List(context.Background(), &pods, client.InNamespace("minecraft")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pods.Items) != 1 || pods.Items[0].Name != "survival-0" {
|
||||
t.Errorf("pods left = %v, want only survival-0 (the login pod restarted, user servers untouched)", pods.Items)
|
||||
}
|
||||
if strings.Join(rig.events, ",") != "roll-api" {
|
||||
t.Errorf("events = %v, want only the api roll (no unit restart for limbo)", rig.events)
|
||||
}
|
||||
if strings.Contains(rig.out.String(), "NEWTOKEN") {
|
||||
t.Errorf("the new token was printed: %s", rig.out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateVelocityTokenOnTheHostProxy(t *testing.T) {
|
||||
rig := newRotationRig(t, tokenSecret("felis", "felis-service-token", "old"))
|
||||
writeTestFile(t, rig.r.secretsEnv, "SERVICE_TOKEN=old\n", 0o600)
|
||||
writeTestFile(t, rig.r.linkProps, "# Generated\napi-base-url=http://10.0.0.1:8081\nservice-token=old\nroot-domain=example.com\n", 0o640)
|
||||
|
||||
if err := rig.r.rotate(context.Background(), "velocity"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(rig.r.linkProps)
|
||||
if string(raw) != "# Generated\napi-base-url=http://10.0.0.1:8081\nservice-token=NEWTOKEN\nroot-domain=example.com\n" {
|
||||
t.Errorf("felis-link.properties = %q", raw)
|
||||
}
|
||||
if info, _ := os.Stat(rig.r.linkProps); info.Mode().Perm() != 0o640 {
|
||||
t.Errorf("properties mode = %v, want 0640 (the proxy's group must still read it)", info.Mode().Perm())
|
||||
}
|
||||
if got := rig.secret(t, "felis", "felis-service-token"); got != "NEWTOKEN" {
|
||||
t.Errorf("control Secret = %q, want NEWTOKEN", got)
|
||||
}
|
||||
// The proxy's token has no replica: it must not appear in a workload namespace.
|
||||
if got := rig.secret(t, "minecraft", "felis-service-token"); got != "<missing>" {
|
||||
t.Errorf("rotation copied the proxy token into minecraft (%q)", got)
|
||||
}
|
||||
if strings.Join(rig.events, ",") != "roll-api,restart felis-velocity" {
|
||||
t.Errorf("events = %v, want the api roll then the proxy restart", rig.events)
|
||||
}
|
||||
}
|
||||
|
||||
// An external proxy has no felis-link.properties here: the Secret still rotates,
|
||||
// nothing is restarted on this host, and the output says where the value is
|
||||
// without printing it.
|
||||
func TestRotateVelocityTokenForAnExternalProxy(t *testing.T) {
|
||||
rig := newRotationRig(t, tokenSecret("felis", "felis-service-token", "old"))
|
||||
if err := rig.r.rotate(context.Background(), "velocity"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rig.secret(t, "felis", "felis-service-token"); got != "NEWTOKEN" {
|
||||
t.Errorf("control Secret = %q, want NEWTOKEN", got)
|
||||
}
|
||||
if strings.Join(rig.events, ",") != "roll-api" {
|
||||
t.Errorf("events = %v, want no proxy restart", rig.events)
|
||||
}
|
||||
out := rig.out.String()
|
||||
if !strings.Contains(out, "felis/felis-service-token") || strings.Contains(out, "NEWTOKEN") {
|
||||
t.Errorf("output should point at the Secret without the value: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateBuildTokenReachesTheBuildNamespace(t *testing.T) {
|
||||
rig := newRotationRig(t, tokenSecret("felis", "felis-build-token", "old"))
|
||||
// An install from before per-caller tokens has no BUILD_TOKEN line yet.
|
||||
writeTestFile(t, rig.r.secretsEnv, "SERVICE_TOKEN=proxy", 0o600)
|
||||
if err := rig.r.rotate(context.Background(), "build"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rig.secret(t, "felis-build", "felis-build-token"); got != "NEWTOKEN" {
|
||||
t.Errorf("build replica = %q, want NEWTOKEN (created when absent)", got)
|
||||
}
|
||||
if got := rig.secret(t, "felis", "felis-build-token"); got != "NEWTOKEN" {
|
||||
t.Errorf("control Secret = %q, want NEWTOKEN", got)
|
||||
}
|
||||
raw, _ := os.ReadFile(rig.r.secretsEnv)
|
||||
if string(raw) != "SERVICE_TOKEN=proxy\nBUILD_TOKEN=NEWTOKEN\n" {
|
||||
t.Errorf("secrets.env = %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed api rollout stops the rotation before the caller restarts: the login
|
||||
// gate keeps running on the old value the old api pods still accept.
|
||||
func TestRotateStopsWhenTheAPIDoesNotRoll(t *testing.T) {
|
||||
rig := newRotationRig(t,
|
||||
tokenSecret("felis", "felis-limbo-token", "old"),
|
||||
serverPod("minecraft", "login-0", "login"),
|
||||
)
|
||||
rig.r.rollAPI = func(context.Context) error { return errors.New("rollout timed out") }
|
||||
err := rig.r.rotate(context.Background(), "limbo")
|
||||
if err == nil || !strings.Contains(err.Error(), "rollout timed out") {
|
||||
t.Fatalf("err = %v, want the rollout failure", err)
|
||||
}
|
||||
var pod corev1.Pod
|
||||
if err := rig.cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: "login-0"}, &pod); err != nil {
|
||||
t.Error("the login pod was restarted although the api never rolled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateRefusesAnUnknownCaller(t *testing.T) {
|
||||
rig := newRotationRig(t)
|
||||
writeTestFile(t, rig.r.secretsEnv, "SERVICE_TOKEN=proxy\n", 0o600)
|
||||
if err := rig.r.rotate(context.Background(), "admin"); err == nil {
|
||||
t.Fatal("rotated a token for an unknown caller")
|
||||
}
|
||||
if raw, _ := os.ReadFile(rig.r.secretsEnv); string(raw) != "SERVICE_TOKEN=proxy\n" {
|
||||
t.Errorf("secrets.env = %q, want it untouched", raw)
|
||||
}
|
||||
if len(rig.events) != 0 {
|
||||
t.Errorf("events = %v, want nothing touched", rig.events)
|
||||
}
|
||||
}
|
||||
|
||||
// The installer and rotate-token must agree on where each caller's token lives:
|
||||
// rotate-token writes installerTokenKeys into secrets.env, and the installer
|
||||
// applies those same keys to the Secrets on its next run. A key the installer
|
||||
// does not read would be silently reverted by the next upgrade.
|
||||
func TestInstallerProvisionsEveryCallerToken(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "deploy", "bootstrap.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(raw)
|
||||
for caller, key := range installerTokenKeys {
|
||||
ct, ok := callerToken(caller)
|
||||
if !ok {
|
||||
t.Fatalf("installerTokenKeys names unknown caller %q", caller)
|
||||
}
|
||||
if !strings.Contains(script, key+`="${`+key+`:-$(openssl rand -hex 32)}"`) {
|
||||
t.Errorf("bootstrap.sh does not generate %s", key)
|
||||
}
|
||||
if !strings.Contains(script, key+"=${"+key+"}\n") {
|
||||
t.Errorf("bootstrap.sh does not persist %s to secrets.env", key)
|
||||
}
|
||||
apply := regexp.MustCompile(`apply_literal_secret "\$CONTROL_NS" ` + regexp.QuoteMeta(ct.Secret) + ` token "\$` + key + `"`)
|
||||
if !apply.MatchString(script) {
|
||||
t.Errorf("bootstrap.sh does not apply %s from %s in the control namespace", ct.Secret, key)
|
||||
}
|
||||
}
|
||||
// The replicas the installer applies straight into the workload namespaces, so an
|
||||
// upgrade has them before the new operator and build Jobs reference them.
|
||||
for _, line := range []string{
|
||||
`apply_literal_secret "$MINECRAFT_NS" felis-limbo-token token "$LIMBO_TOKEN"`,
|
||||
`apply_literal_secret "$BUILD_NS" felis-build-token token "$BUILD_TOKEN"`,
|
||||
`kube -n "$MINECRAFT_NS" delete secret felis-service-token --ignore-not-found`,
|
||||
`kube -n "$BUILD_NS" delete secret felis-service-token --ignore-not-found`,
|
||||
} {
|
||||
if !strings.Contains(script, line) {
|
||||
t.Errorf("bootstrap.sh lacks %s", line)
|
||||
}
|
||||
}
|
||||
for _, ns := range []string{"MINECRAFT_NS", "BUILD_NS"} {
|
||||
if strings.Contains(script, `apply_literal_secret "$`+ns+`" felis-service-token`) {
|
||||
t.Errorf("bootstrap.sh still copies the proxy's token into %s", ns)
|
||||
}
|
||||
}
|
||||
if len(installerTokenKeys) != len(callerNames()) {
|
||||
t.Errorf("installerTokenKeys covers %d callers, naming.CallerTokens lists %d", len(installerTokenKeys), len(callerNames()))
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ Commands:
|
||||
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
|
||||
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
|
||||
converge Fill in fields a newer desired spec added to already-installed system servers
|
||||
rotate-token Replace one internal caller's token and restart what holds it (velocity|limbo|build|ops; requires root/sudo)
|
||||
watchdog Check the platform once and mail the owners what has gone wrong (run by felis-watchdog.timer)
|
||||
version Print the build stamp of this binary
|
||||
update Report which platform components have updates available
|
||||
@@ -67,6 +68,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
|
||||
"apply": cmdApply,
|
||||
"setup": cmdSetup,
|
||||
"converge": cmdConverge,
|
||||
"rotate-token": cmdRotateToken,
|
||||
"breakGlass": cmdBreakGlass,
|
||||
"bootstrap-assets": cmdBootstrapAssets,
|
||||
"init-forwarding": cmdInitForwarding,
|
||||
|
||||
+8
-33
@@ -13,7 +13,6 @@ import (
|
||||
"felis.lolicon.best/internal/api"
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
"felis.lolicon.best/internal/platform"
|
||||
"felis.lolicon.best/internal/store"
|
||||
)
|
||||
@@ -216,18 +215,18 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
|
||||
"Re-run `sudo felis setup` on the control-plane host once the cluster is reachable", err)
|
||||
}
|
||||
// The login limbo authenticates to the felis-api INTERNAL face, so it needs the
|
||||
// internal base URL, the root domain (to link players at the console), and the
|
||||
// service token. The first two are plain env baked into the pod here; the token
|
||||
// is a Secret the operator injects by reference — but a secretKeyRef is
|
||||
// namespace-local, so first replicate the token Secret from the control namespace
|
||||
// into the minecraft namespace where the login pod runs. The control namespace is
|
||||
// internal base URL, the root domain (to link players at the console), and its
|
||||
// own token (felis-limbo-token). The first two are plain env baked into the pod
|
||||
// here; the token is a Secret the operator injects by reference — but a
|
||||
// secretKeyRef is namespace-local, so first replicate the token Secret from the
|
||||
// control namespace into the minecraft namespace where the login pod runs. The control namespace is
|
||||
// the platform default (there is no felis.toml override for it); a deployment that
|
||||
// renamed it must replicate the Secret by hand.
|
||||
controlNS := platform.DefaultControlNamespace
|
||||
apiBaseURL := platform.InternalAPIBaseURL(controlNS)
|
||||
// These Secrets must land in the minecraft namespace before the pods that
|
||||
// mount them are created: the service token (login authenticates to felis-api
|
||||
// with it), the Velocity forwarding secret (every backend verifies the proxy's
|
||||
// mount them are created: the login gate's token (login authenticates to
|
||||
// felis-api with it), the Velocity forwarding secret (every backend verifies the proxy's
|
||||
// signed handshake with it — without it the login gate would derive an OFFLINE
|
||||
// UUID and the Owner would bind the wrong Minecraft identity), and felis-config
|
||||
// (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to
|
||||
@@ -239,31 +238,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
|
||||
if buildNS == "" {
|
||||
buildNS = platform.DefaultBuildNamespace
|
||||
}
|
||||
secretOutcomes := []systemServerOutcome{
|
||||
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
|
||||
naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token", "minecraft ns", false),
|
||||
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
|
||||
naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret", "minecraft ns", false),
|
||||
// refresh=true: felis-config is the rendered config, not a credential. The
|
||||
// backup/restore/fileedit Jobs and the reaper mount this copy, so a re-run
|
||||
// must update it when the control plane's render has moved on (a stale copy
|
||||
// e.g. keeps an old database URL after a credential rotation).
|
||||
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
|
||||
"felis-config", "felis.toml", "config", "minecraft ns", true),
|
||||
// The reaper's pre-reap warning emails authenticate with the same relay
|
||||
// password felis-api uses; the reaper pod runs in the minecraft namespace,
|
||||
// where a secretKeyRef resolves only against a local mirror. Skipped while
|
||||
// the relay is not configured yet — the "configure email" screen refreshes
|
||||
// both mirrors when it applies.
|
||||
ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace,
|
||||
"felis-smtp", "password", "smtp", "minecraft ns", false),
|
||||
// The build namespace needs the same token: the build Job's fetch
|
||||
// initContainer reads the submission context from the internal face. Best
|
||||
// effort — a deployment that only installs the control plane simply never
|
||||
// builds a user submission.
|
||||
ensureSecretReplica(ctx, cl, controlNS, buildNS,
|
||||
naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token", "felis-build ns", false),
|
||||
}
|
||||
secretOutcomes := provisionSecretReplicas(ctx, cl, controlNS, cfg.K8s.Namespace, buildNS)
|
||||
outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname))
|
||||
outcomes = append(secretOutcomes, outcomes...)
|
||||
fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):")
|
||||
|
||||
+48
-10
@@ -588,15 +588,51 @@ func phaseOrPending(p v1alpha1.Phase) string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
// provisionSecretReplicas copies the Secrets workload pods mount from the control
|
||||
// namespace into the namespaces those pods run in. The proxy's felis-service-token
|
||||
// is not among them: it lives in the control namespace and on the host, and a copy
|
||||
// anywhere else would open every game route to whoever reads that namespace.
|
||||
func provisionSecretReplicas(ctx context.Context, cl client.Client, controlNS, minecraftNS, buildNS string) []systemServerOutcome {
|
||||
return []systemServerOutcome{
|
||||
// refresh=true for both caller tokens: the control namespace holds the
|
||||
// current value and `felis rotate-token` replaces it there, so a replica
|
||||
// that differs is stale and the login gate would be turned away with it.
|
||||
ensureSecretReplica(ctx, cl, controlNS, minecraftNS,
|
||||
naming.LimboTokenSecretName, naming.ServiceTokenSecretKey, "limbo-token", "minecraft ns", true),
|
||||
ensureSecretReplica(ctx, cl, controlNS, minecraftNS,
|
||||
naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret", "minecraft ns", false),
|
||||
// refresh=true: felis-config is the rendered config, not a credential. The
|
||||
// backup/restore/fileedit Jobs and the reaper mount this copy, so a re-run
|
||||
// must update it when the control plane's render has moved on (a stale copy
|
||||
// e.g. keeps an old database URL after a credential rotation).
|
||||
ensureSecretReplica(ctx, cl, controlNS, minecraftNS,
|
||||
"felis-config", "felis.toml", "config", "minecraft ns", true),
|
||||
// The reaper's pre-reap warning emails authenticate with the same relay
|
||||
// password felis-api uses; the reaper pod runs in the minecraft namespace,
|
||||
// where a secretKeyRef resolves only against a local mirror. Skipped while
|
||||
// the relay is not configured yet — the "configure email" screen refreshes
|
||||
// both mirrors when it applies.
|
||||
ensureSecretReplica(ctx, cl, controlNS, minecraftNS,
|
||||
"felis-smtp", "password", "smtp", "minecraft ns", false),
|
||||
// The build namespace needs the build token: the build Job's fetch
|
||||
// initContainer reads the submission context from the internal face, and
|
||||
// that is all this token opens. Best effort — a deployment that only
|
||||
// installs the control plane simply never builds a user submission.
|
||||
ensureSecretReplica(ctx, cl, controlNS, buildNS,
|
||||
naming.BuildTokenSecretName, naming.ServiceTokenSecretKey, "build-token", "felis-build ns", true),
|
||||
}
|
||||
}
|
||||
|
||||
// ensureSecretReplica copies one Secret from the control namespace into a workload
|
||||
// namespace (minecraft — or the build namespace, whose fetch initContainer reads the
|
||||
// context from the felis-api internal face with the same token) so a pod can mount it
|
||||
// context from the felis-api internal face with the build token) so a pod can mount it
|
||||
// via secretKeyRef. A secretKeyRef is namespace-local, but those workloads do not run
|
||||
// beside the control plane — so without this replica the secretKeyRef would dangle and
|
||||
// wedge the pod in CreateContainerConfigError.
|
||||
//
|
||||
// Three Secrets need it, for different reasons: the service token (the login limbo and
|
||||
// the build Pod's context fetch — both authenticate to the felis-api internal face),
|
||||
// Several Secrets need it, for different reasons: the caller tokens of the login
|
||||
// limbo and the build Pod's context fetch (both authenticate to the felis-api
|
||||
// internal face, each with its own token),
|
||||
// the Velocity modern-forwarding secret (every backend — it is how a backend knows
|
||||
// a login really came from the proxy, and so that the player's UUID is Mojang-verified
|
||||
// rather than offline-derived), and the SMTP relay password (the reaper's pre-reap
|
||||
@@ -609,12 +645,14 @@ func phaseOrPending(p v1alpha1.Phase) string {
|
||||
// copies only Type and Data — never labels/annotations/ownerRefs — so the replica
|
||||
// carries no accidental GC owner or managed-by lineage.
|
||||
//
|
||||
// refreshExisting switches the felis-config mirror to refresh-in-place: that Secret is
|
||||
// a rendered config, never a hand-rotated credential, and the workload Jobs that mount
|
||||
// it (backup/restore/fileedit) plus the reaper silently misbehave on a stale copy —
|
||||
// e.g. after a database credential rotation the control plane moves on while every
|
||||
// backup Job keeps failing auth. Credential Secrets keep the never-overwrite rule so a
|
||||
// rotated value survives; to rotate those, delete the replica and re-run setup.
|
||||
// refreshExisting switches a replica to refresh-in-place from the control namespace.
|
||||
// The felis-config mirror uses it because that Secret is a rendered config and the
|
||||
// workload Jobs that mount it (backup/restore/fileedit) plus the reaper silently
|
||||
// misbehave on a stale copy — e.g. after a database credential rotation the control
|
||||
// plane moves on while every backup Job keeps failing auth. The caller tokens use it
|
||||
// because `felis rotate-token` replaces them in the control namespace, which makes a
|
||||
// differing replica stale by definition. The forwarding and SMTP Secrets keep the
|
||||
// never-overwrite rule.
|
||||
func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace, secretName, secretKey, label, where string, refreshExisting bool) systemServerOutcome {
|
||||
name := label + " (" + where + ")"
|
||||
validate := func(secret *corev1.Secret, location, skipped string) systemServerOutcome {
|
||||
@@ -712,7 +750,7 @@ func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace
|
||||
|
||||
func requiredProvisioningError(outcomes []systemServerOutcome) error {
|
||||
required := map[string]struct{}{
|
||||
"service-token (minecraft ns)": {},
|
||||
"limbo-token (minecraft ns)": {},
|
||||
"forwarding-secret (minecraft ns)": {},
|
||||
naming.SystemLoginServer: {},
|
||||
}
|
||||
|
||||
@@ -142,21 +142,21 @@ func TestLoginSystemServerEnv(t *testing.T) {
|
||||
// namespace (create-if-absent), so the operator's secretKeyRef on the backend pod
|
||||
// resolves. It must not overwrite an existing replica, and must degrade gracefully
|
||||
// when the source is missing or the namespaces coincide. Exercised here with the
|
||||
// service token; setup runs it a second time for the Velocity forwarding secret.
|
||||
// Velocity forwarding secret, which setup replicates in this never-overwrite mode.
|
||||
func TestEnsureSecretReplica(t *testing.T) {
|
||||
scheme := newSystemServerScheme(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srcSecret := func() *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "felis"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ForwardingSecretName, Namespace: "felis"},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte("s3cr3t")},
|
||||
Data: map[string][]byte{naming.ForwardingSecretKey: []byte("s3cr3t")},
|
||||
}
|
||||
}
|
||||
replicate := func(cl client.Client, controlNS, mcNS string) systemServerOutcome {
|
||||
return ensureSecretReplica(ctx, cl, controlNS, mcNS,
|
||||
naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey, "service-token", "minecraft ns", false)
|
||||
naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret", "minecraft ns", false)
|
||||
}
|
||||
|
||||
t.Run("replicates when absent", func(t *testing.T) {
|
||||
@@ -166,19 +166,19 @@ func TestEnsureSecretReplica(t *testing.T) {
|
||||
t.Fatalf("outcome = %+v, want created", out)
|
||||
}
|
||||
var replica corev1.Secret
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.ServiceTokenSecretName}, &replica); err != nil {
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.ForwardingSecretName}, &replica); err != nil {
|
||||
t.Fatalf("get replica: %v", err)
|
||||
}
|
||||
if string(replica.Data[naming.ServiceTokenSecretKey]) != "s3cr3t" {
|
||||
t.Errorf("replica token = %q, want s3cr3t", replica.Data[naming.ServiceTokenSecretKey])
|
||||
if string(replica.Data[naming.ForwardingSecretKey]) != "s3cr3t" {
|
||||
t.Errorf("replica token = %q, want s3cr3t", replica.Data[naming.ForwardingSecretKey])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not overwrite existing replica", func(t *testing.T) {
|
||||
existing := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "minecraft"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ForwardingSecretName, Namespace: "minecraft"},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte("rotated")},
|
||||
Data: map[string][]byte{naming.ForwardingSecretKey: []byte("rotated")},
|
||||
}
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret(), existing).Build()
|
||||
out := replicate(cl, "felis", "minecraft")
|
||||
@@ -186,11 +186,11 @@ func TestEnsureSecretReplica(t *testing.T) {
|
||||
t.Fatalf("outcome = %+v, want skipped (not clobbered)", out)
|
||||
}
|
||||
var replica corev1.Secret
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.ServiceTokenSecretName}, &replica); err != nil {
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.ForwardingSecretName}, &replica); err != nil {
|
||||
t.Fatalf("get replica: %v", err)
|
||||
}
|
||||
if string(replica.Data[naming.ServiceTokenSecretKey]) != "rotated" {
|
||||
t.Error("existing replica was overwritten — a rotated token must survive")
|
||||
if string(replica.Data[naming.ForwardingSecretKey]) != "rotated" {
|
||||
t.Error("existing replica was overwritten — a hand-set value must survive")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -204,22 +204,22 @@ func TestEnsureSecretReplica(t *testing.T) {
|
||||
|
||||
t.Run("rejects a source with an empty required key", func(t *testing.T) {
|
||||
bad := srcSecret()
|
||||
bad.Data[naming.ServiceTokenSecretKey] = nil
|
||||
bad.Data[naming.ForwardingSecretKey] = nil
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(bad).Build()
|
||||
out := replicate(cl, "felis", "minecraft")
|
||||
if out.err != nil || out.created || out.available || !strings.Contains(out.skipped, naming.ServiceTokenSecretKey) {
|
||||
if out.err != nil || out.created || out.available || !strings.Contains(out.skipped, naming.ForwardingSecretKey) {
|
||||
t.Fatalf("outcome = %+v, want unavailable required key", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects an existing replica with an empty required key", func(t *testing.T) {
|
||||
bad := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "minecraft"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ForwardingSecretName, Namespace: "minecraft"},
|
||||
Data: map[string][]byte{},
|
||||
}
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret(), bad).Build()
|
||||
out := replicate(cl, "felis", "minecraft")
|
||||
if out.err != nil || out.created || out.available || !strings.Contains(out.skipped, naming.ServiceTokenSecretKey) {
|
||||
if out.err != nil || out.created || out.available || !strings.Contains(out.skipped, naming.ForwardingSecretKey) {
|
||||
t.Fatalf("outcome = %+v, want unavailable existing replica", out)
|
||||
}
|
||||
})
|
||||
@@ -245,7 +245,7 @@ func TestEnsureSecretReplica(t *testing.T) {
|
||||
bad.Data = nil
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(bad).Build()
|
||||
out := replicate(cl, "felis", "felis")
|
||||
if out.err != nil || out.available || !strings.Contains(out.skipped, naming.ServiceTokenSecretKey) {
|
||||
if out.err != nil || out.available || !strings.Contains(out.skipped, naming.ForwardingSecretKey) {
|
||||
t.Fatalf("outcome = %+v, want unavailable required key", out)
|
||||
}
|
||||
})
|
||||
@@ -334,7 +334,7 @@ func TestEnsureSecretReplicaRefresh(t *testing.T) {
|
||||
|
||||
func TestRequiredProvisioningError(t *testing.T) {
|
||||
ready := []systemServerOutcome{
|
||||
{name: "service-token (minecraft ns)", available: true},
|
||||
{name: "limbo-token (minecraft ns)", available: true},
|
||||
{name: "forwarding-secret (minecraft ns)", available: true},
|
||||
{name: naming.SystemLoginServer, available: true},
|
||||
{name: naming.SystemLobbyServer, skipped: "image not configured"},
|
||||
@@ -349,6 +349,14 @@ func TestRequiredProvisioningError(t *testing.T) {
|
||||
t.Fatalf("missing forwarding secret = %v, want named error", err)
|
||||
}
|
||||
|
||||
// The login gate cannot reach felis-api without its token, so setup must not
|
||||
// report success while that replica is missing.
|
||||
noToken := append([]systemServerOutcome(nil), ready...)
|
||||
noToken[0] = systemServerOutcome{name: "limbo-token (minecraft ns)", skipped: "source missing"}
|
||||
if err := requiredProvisioningError(noToken); err == nil || !strings.Contains(err.Error(), "limbo-token") {
|
||||
t.Fatalf("missing limbo token = %v, want named error", err)
|
||||
}
|
||||
|
||||
failed := append([]systemServerOutcome(nil), ready...)
|
||||
failed[3] = systemServerOutcome{name: naming.SystemLobbyServer, err: context.DeadlineExceeded}
|
||||
if err := requiredProvisioningError(failed); err == nil || !strings.Contains(err.Error(), naming.SystemLobbyServer) {
|
||||
@@ -662,3 +670,62 @@ func TestEnsureSystemServersRefreshesDerivedEnv(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Setup's replicas carry each workload its own caller token and nothing more: the
|
||||
// login gate gets felis-limbo-token in the minecraft namespace, the build Jobs get
|
||||
// felis-build-token in the build namespace, a replica left stale by a rotation is
|
||||
// brought up to date, and the proxy's felis-service-token is copied nowhere.
|
||||
func TestProvisionSecretReplicasCarryCallerTokens(t *testing.T) {
|
||||
scheme := newSystemServerScheme(t)
|
||||
ctx := context.Background()
|
||||
secret := func(ns, name, key, val string) *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{key: []byte(val)},
|
||||
}
|
||||
}
|
||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
|
||||
secret("felis", "felis-service-token", "token", "proxy-tok"),
|
||||
secret("felis", "felis-limbo-token", "token", "limbo-new"),
|
||||
secret("felis", "felis-build-token", "token", "build-tok"),
|
||||
secret("felis", "felis-ops-token", "token", "ops-tok"),
|
||||
secret("felis", naming.ForwardingSecretName, naming.ForwardingSecretKey, "fwd"),
|
||||
// What a rotation leaves behind before setup runs again.
|
||||
secret("minecraft", "felis-limbo-token", "token", "limbo-old"),
|
||||
).Build()
|
||||
|
||||
outcomes := provisionSecretReplicas(ctx, cl, "felis", "minecraft", "felis-build")
|
||||
for _, o := range outcomes {
|
||||
if o.err != nil {
|
||||
t.Fatalf("%s: %v", o.name, o.err)
|
||||
}
|
||||
}
|
||||
|
||||
read := func(ns, name string) (string, bool) {
|
||||
var s corev1.Secret
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, &s); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(s.Data["token"]), true
|
||||
}
|
||||
if got, _ := read("minecraft", "felis-limbo-token"); got != "limbo-new" {
|
||||
t.Errorf("minecraft/felis-limbo-token = %q, want the rotated limbo-new", got)
|
||||
}
|
||||
if got, _ := read("felis-build", "felis-build-token"); got != "build-tok" {
|
||||
t.Errorf("felis-build/felis-build-token = %q, want build-tok", got)
|
||||
}
|
||||
for _, ns := range []string{"minecraft", "felis-build"} {
|
||||
for _, name := range []string{"felis-service-token", "felis-ops-token"} {
|
||||
if _, ok := read(ns, name); ok {
|
||||
t.Errorf("%s/%s was replicated; only the control namespace holds it", ns, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := read("minecraft", "felis-build-token"); ok {
|
||||
t.Error("the build token was copied into the minecraft namespace")
|
||||
}
|
||||
if _, ok := read("felis-build", "felis-limbo-token"); ok {
|
||||
t.Error("the limbo token was copied into the build namespace")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user