feat(api): 内部面 token 按调用方拆分为 velocity/limbo/build/ops 并按路由限定调用方,审计来源区分调用方,安装器生成并下发各自 Secret,新增 felis rotate-token 轮换命令
This commit is contained in:
38 files changed
+1350
-200
No files matched your search
+21
-4
@@ -119,9 +119,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
|
|
||||||
metrics.SetBuildInfo("api", resolvedVersion())
|
metrics.SetBuildInfo("api", resolvedVersion())
|
||||||
|
|
||||||
token := os.Getenv("FELIS_SERVICE_TOKEN")
|
internalAuth, err := internalCallerTokens(os.Getenv)
|
||||||
if token == "" {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, "felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers")
|
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
|
// 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
|
// 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.
|
// value the Builder renders Jobs into — so it follows where build Pods run.
|
||||||
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
|
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
|
||||||
Internal: api.BearerTokenAuth{Token: token},
|
Internal: internalAuth,
|
||||||
Builder: builder,
|
Builder: builder,
|
||||||
Images: imagePinner(cfg.Registry.URL),
|
Images: imagePinner(cfg.Registry.URL),
|
||||||
Restorer: restorer,
|
Restorer: restorer,
|
||||||
@@ -800,3 +806,14 @@ func imagePinner(registry string) api.ImagePinner {
|
|||||||
}
|
}
|
||||||
return imagepin.Resolver{Registry: registry}
|
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 —
|
// 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
|
// 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
|
// (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
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// token. Both live in the control namespace.
|
||||||
func resolveInternalAPI(ctx context.Context, cl client.Client, controlNamespace string) (baseURL, token string, err error) {
|
func resolveInternalAPI(ctx context.Context, cl client.Client, controlNamespace string) (baseURL, token string, err error) {
|
||||||
var svc corev1.Service
|
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)
|
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
|
var sec corev1.Secret
|
||||||
if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNamespace, Name: naming.ServiceTokenSecretName}, &sec); err != nil {
|
if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNamespace, Name: naming.OpsTokenSecretName}, &sec); err != nil {
|
||||||
return "", "", fmt.Errorf("get %s Secret: %w", naming.ServiceTokenSecretName, err)
|
return "", "", fmt.Errorf("get %s Secret (re-run the installer to create it): %w", naming.OpsTokenSecretName, err)
|
||||||
}
|
}
|
||||||
token = string(sec.Data[naming.ServiceTokenSecretKey])
|
token = string(sec.Data[naming.ServiceTokenSecretKey])
|
||||||
if token == "" {
|
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
|
return fmt.Sprintf("http://%s:%d", ip, platform.APIInternalPort), token, nil
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
"felis.lolicon.best/internal/platform"
|
"felis.lolicon.best/internal/platform"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
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},
|
ObjectMeta: metav1.ObjectMeta{Name: platform.APIInternalServiceName, Namespace: bgControlNS},
|
||||||
Spec: corev1.ServiceSpec{ClusterIP: clusterIP},
|
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{
|
&corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: bgControlNS},
|
ObjectMeta: metav1.ObjectMeta{Name: "felis-ops-token", Namespace: bgControlNS},
|
||||||
Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte(token)},
|
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)
|
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
|
||||||
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
|
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
|
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)
|
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
|
version Print the build stamp of this binary
|
||||||
update Report which platform components have updates available
|
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,
|
"apply": cmdApply,
|
||||||
"setup": cmdSetup,
|
"setup": cmdSetup,
|
||||||
"converge": cmdConverge,
|
"converge": cmdConverge,
|
||||||
|
"rotate-token": cmdRotateToken,
|
||||||
"breakGlass": cmdBreakGlass,
|
"breakGlass": cmdBreakGlass,
|
||||||
"bootstrap-assets": cmdBootstrapAssets,
|
"bootstrap-assets": cmdBootstrapAssets,
|
||||||
"init-forwarding": cmdInitForwarding,
|
"init-forwarding": cmdInitForwarding,
|
||||||
|
|||||||
+8
-33
@@ -13,7 +13,6 @@ import (
|
|||||||
"felis.lolicon.best/internal/api"
|
"felis.lolicon.best/internal/api"
|
||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
"felis.lolicon.best/internal/naming"
|
|
||||||
"felis.lolicon.best/internal/platform"
|
"felis.lolicon.best/internal/platform"
|
||||||
"felis.lolicon.best/internal/store"
|
"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)
|
"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
|
// 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
|
// internal base URL, the root domain (to link players at the console), and its
|
||||||
// service token. The first two are plain env baked into the pod here; the token
|
// own token (felis-limbo-token). The first two are plain env baked into the pod
|
||||||
// is a Secret the operator injects by reference — but a secretKeyRef is
|
// here; the token is a Secret the operator injects by reference — but a
|
||||||
// namespace-local, so first replicate the token Secret from the control namespace
|
// secretKeyRef is namespace-local, so first replicate the token Secret from the
|
||||||
// into the minecraft namespace where the login pod runs. The control namespace is
|
// 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
|
// the platform default (there is no felis.toml override for it); a deployment that
|
||||||
// renamed it must replicate the Secret by hand.
|
// renamed it must replicate the Secret by hand.
|
||||||
controlNS := platform.DefaultControlNamespace
|
controlNS := platform.DefaultControlNamespace
|
||||||
apiBaseURL := platform.InternalAPIBaseURL(controlNS)
|
apiBaseURL := platform.InternalAPIBaseURL(controlNS)
|
||||||
// These Secrets must land in the minecraft namespace before the pods that
|
// These Secrets must land in the minecraft namespace before the pods that
|
||||||
// mount them are created: the service token (login authenticates to felis-api
|
// mount them are created: the login gate's token (login authenticates to
|
||||||
// with it), the Velocity forwarding secret (every backend verifies the proxy's
|
// 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
|
// 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
|
// 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
|
// (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 == "" {
|
if buildNS == "" {
|
||||||
buildNS = platform.DefaultBuildNamespace
|
buildNS = platform.DefaultBuildNamespace
|
||||||
}
|
}
|
||||||
secretOutcomes := []systemServerOutcome{
|
secretOutcomes := provisionSecretReplicas(ctx, cl, controlNS, cfg.K8s.Namespace, buildNS)
|
||||||
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),
|
|
||||||
}
|
|
||||||
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 := 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...)
|
outcomes = append(secretOutcomes, outcomes...)
|
||||||
fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):")
|
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)
|
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
|
// ensureSecretReplica copies one Secret from the control namespace into a workload
|
||||||
// namespace (minecraft — or the build namespace, whose fetch initContainer reads the
|
// 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
|
// 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
|
// beside the control plane — so without this replica the secretKeyRef would dangle and
|
||||||
// wedge the pod in CreateContainerConfigError.
|
// wedge the pod in CreateContainerConfigError.
|
||||||
//
|
//
|
||||||
// Three Secrets need it, for different reasons: the service token (the login limbo and
|
// Several Secrets need it, for different reasons: the caller tokens of the login
|
||||||
// the build Pod's context fetch — both authenticate to the felis-api internal face),
|
// 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
|
// 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
|
// 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
|
// 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
|
// copies only Type and Data — never labels/annotations/ownerRefs — so the replica
|
||||||
// carries no accidental GC owner or managed-by lineage.
|
// carries no accidental GC owner or managed-by lineage.
|
||||||
//
|
//
|
||||||
// refreshExisting switches the felis-config mirror to refresh-in-place: that Secret is
|
// refreshExisting switches a replica to refresh-in-place from the control namespace.
|
||||||
// a rendered config, never a hand-rotated credential, and the workload Jobs that mount
|
// The felis-config mirror uses it because that Secret is a rendered config and the
|
||||||
// it (backup/restore/fileedit) plus the reaper silently misbehave on a stale copy —
|
// workload Jobs that mount it (backup/restore/fileedit) plus the reaper silently
|
||||||
// e.g. after a database credential rotation the control plane moves on while every
|
// misbehave on a stale copy — e.g. after a database credential rotation the control
|
||||||
// backup Job keeps failing auth. Credential Secrets keep the never-overwrite rule so a
|
// plane moves on while every backup Job keeps failing auth. The caller tokens use it
|
||||||
// rotated value survives; to rotate those, delete the replica and re-run setup.
|
// 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 {
|
func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace, minecraftNamespace, secretName, secretKey, label, where string, refreshExisting bool) systemServerOutcome {
|
||||||
name := label + " (" + where + ")"
|
name := label + " (" + where + ")"
|
||||||
validate := func(secret *corev1.Secret, location, skipped string) systemServerOutcome {
|
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 {
|
func requiredProvisioningError(outcomes []systemServerOutcome) error {
|
||||||
required := map[string]struct{}{
|
required := map[string]struct{}{
|
||||||
"service-token (minecraft ns)": {},
|
"limbo-token (minecraft ns)": {},
|
||||||
"forwarding-secret (minecraft ns)": {},
|
"forwarding-secret (minecraft ns)": {},
|
||||||
naming.SystemLoginServer: {},
|
naming.SystemLoginServer: {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,21 +142,21 @@ func TestLoginSystemServerEnv(t *testing.T) {
|
|||||||
// namespace (create-if-absent), so the operator's secretKeyRef on the backend pod
|
// 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
|
// 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
|
// 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) {
|
func TestEnsureSecretReplica(t *testing.T) {
|
||||||
scheme := newSystemServerScheme(t)
|
scheme := newSystemServerScheme(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
srcSecret := func() *corev1.Secret {
|
srcSecret := func() *corev1.Secret {
|
||||||
return &corev1.Secret{
|
return &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "felis"},
|
ObjectMeta: metav1.ObjectMeta{Name: naming.ForwardingSecretName, Namespace: "felis"},
|
||||||
Type: corev1.SecretTypeOpaque,
|
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 {
|
replicate := func(cl client.Client, controlNS, mcNS string) systemServerOutcome {
|
||||||
return ensureSecretReplica(ctx, cl, controlNS, mcNS,
|
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) {
|
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)
|
t.Fatalf("outcome = %+v, want created", out)
|
||||||
}
|
}
|
||||||
var replica corev1.Secret
|
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)
|
t.Fatalf("get replica: %v", err)
|
||||||
}
|
}
|
||||||
if string(replica.Data[naming.ServiceTokenSecretKey]) != "s3cr3t" {
|
if string(replica.Data[naming.ForwardingSecretKey]) != "s3cr3t" {
|
||||||
t.Errorf("replica token = %q, want s3cr3t", replica.Data[naming.ServiceTokenSecretKey])
|
t.Errorf("replica token = %q, want s3cr3t", replica.Data[naming.ForwardingSecretKey])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("does not overwrite existing replica", func(t *testing.T) {
|
t.Run("does not overwrite existing replica", func(t *testing.T) {
|
||||||
existing := &corev1.Secret{
|
existing := &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "minecraft"},
|
ObjectMeta: metav1.ObjectMeta{Name: naming.ForwardingSecretName, Namespace: "minecraft"},
|
||||||
Type: corev1.SecretTypeOpaque,
|
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()
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret(), existing).Build()
|
||||||
out := replicate(cl, "felis", "minecraft")
|
out := replicate(cl, "felis", "minecraft")
|
||||||
@@ -186,11 +186,11 @@ func TestEnsureSecretReplica(t *testing.T) {
|
|||||||
t.Fatalf("outcome = %+v, want skipped (not clobbered)", out)
|
t.Fatalf("outcome = %+v, want skipped (not clobbered)", out)
|
||||||
}
|
}
|
||||||
var replica corev1.Secret
|
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)
|
t.Fatalf("get replica: %v", err)
|
||||||
}
|
}
|
||||||
if string(replica.Data[naming.ServiceTokenSecretKey]) != "rotated" {
|
if string(replica.Data[naming.ForwardingSecretKey]) != "rotated" {
|
||||||
t.Error("existing replica was overwritten — a rotated token must survive")
|
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) {
|
t.Run("rejects a source with an empty required key", func(t *testing.T) {
|
||||||
bad := srcSecret()
|
bad := srcSecret()
|
||||||
bad.Data[naming.ServiceTokenSecretKey] = nil
|
bad.Data[naming.ForwardingSecretKey] = nil
|
||||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(bad).Build()
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(bad).Build()
|
||||||
out := replicate(cl, "felis", "minecraft")
|
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.Fatalf("outcome = %+v, want unavailable required key", out)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("rejects an existing replica with an empty required key", func(t *testing.T) {
|
t.Run("rejects an existing replica with an empty required key", func(t *testing.T) {
|
||||||
bad := &corev1.Secret{
|
bad := &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: "minecraft"},
|
ObjectMeta: metav1.ObjectMeta{Name: naming.ForwardingSecretName, Namespace: "minecraft"},
|
||||||
Data: map[string][]byte{},
|
Data: map[string][]byte{},
|
||||||
}
|
}
|
||||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret(), bad).Build()
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(srcSecret(), bad).Build()
|
||||||
out := replicate(cl, "felis", "minecraft")
|
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)
|
t.Fatalf("outcome = %+v, want unavailable existing replica", out)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -245,7 +245,7 @@ func TestEnsureSecretReplica(t *testing.T) {
|
|||||||
bad.Data = nil
|
bad.Data = nil
|
||||||
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(bad).Build()
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(bad).Build()
|
||||||
out := replicate(cl, "felis", "felis")
|
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)
|
t.Fatalf("outcome = %+v, want unavailable required key", out)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -334,7 +334,7 @@ func TestEnsureSecretReplicaRefresh(t *testing.T) {
|
|||||||
|
|
||||||
func TestRequiredProvisioningError(t *testing.T) {
|
func TestRequiredProvisioningError(t *testing.T) {
|
||||||
ready := []systemServerOutcome{
|
ready := []systemServerOutcome{
|
||||||
{name: "service-token (minecraft ns)", available: true},
|
{name: "limbo-token (minecraft ns)", available: true},
|
||||||
{name: "forwarding-secret (minecraft ns)", available: true},
|
{name: "forwarding-secret (minecraft ns)", available: true},
|
||||||
{name: naming.SystemLoginServer, available: true},
|
{name: naming.SystemLoginServer, available: true},
|
||||||
{name: naming.SystemLobbyServer, skipped: "image not configured"},
|
{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)
|
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 := append([]systemServerOutcome(nil), ready...)
|
||||||
failed[3] = systemServerOutcome{name: naming.SystemLobbyServer, err: context.DeadlineExceeded}
|
failed[3] = systemServerOutcome{name: naming.SystemLobbyServer, err: context.DeadlineExceeded}
|
||||||
if err := requiredProvisioningError(failed); err == nil || !strings.Contains(err.Error(), naming.SystemLobbyServer) {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-5
@@ -2819,7 +2819,15 @@ load_or_make_secrets() {
|
|||||||
ok "reusing persisted secrets from ${SECRETS_ENV}"
|
ok "reusing persisted secrets from ${SECRETS_ENV}"
|
||||||
fi
|
fi
|
||||||
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -hex 24)}"
|
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -hex 24)}"
|
||||||
|
# One felis-api internal token per caller (naming.CallerTokens), so each is scoped
|
||||||
|
# to its own routes and a leak is contained to that caller: SERVICE_TOKEN is the
|
||||||
|
# proxy's (felis-link.properties), LIMBO_TOKEN the login gate's, BUILD_TOKEN what a
|
||||||
|
# build Job fetches its context with, OPS_TOKEN what `felis backup-now` presents.
|
||||||
|
# `felis rotate-token <caller>` rewrites the matching line here.
|
||||||
SERVICE_TOKEN="${SERVICE_TOKEN:-$(openssl rand -hex 32)}"
|
SERVICE_TOKEN="${SERVICE_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
LIMBO_TOKEN="${LIMBO_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
BUILD_TOKEN="${BUILD_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
OPS_TOKEN="${OPS_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
|
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
|
||||||
# The Velocity modern-forwarding key. It is what makes a backend's UUID trustworthy:
|
# The Velocity modern-forwarding key. It is what makes a backend's UUID trustworthy:
|
||||||
# the proxy does the Mojang handshake and HMACs the resulting profile with this key,
|
# the proxy does the Mojang handshake and HMACs the resulting profile with this key,
|
||||||
@@ -2840,6 +2848,9 @@ load_or_make_secrets() {
|
|||||||
cat > "$SECRETS_ENV" <<EOF
|
cat > "$SECRETS_ENV" <<EOF
|
||||||
DB_PASSWORD=${DB_PASSWORD}
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
SERVICE_TOKEN=${SERVICE_TOKEN}
|
SERVICE_TOKEN=${SERVICE_TOKEN}
|
||||||
|
LIMBO_TOKEN=${LIMBO_TOKEN}
|
||||||
|
BUILD_TOKEN=${BUILD_TOKEN}
|
||||||
|
OPS_TOKEN=${OPS_TOKEN}
|
||||||
SESSION_SECRET=${SESSION_SECRET}
|
SESSION_SECRET=${SESSION_SECRET}
|
||||||
FORWARDING_SECRET=${FORWARDING_SECRET}
|
FORWARDING_SECRET=${FORWARDING_SECRET}
|
||||||
REGISTRY_PLATFORM_TOKEN=${REGISTRY_PLATFORM_TOKEN}
|
REGISTRY_PLATFORM_TOKEN=${REGISTRY_PLATFORM_TOKEN}
|
||||||
@@ -3475,13 +3486,19 @@ deploy_bundle() {
|
|||||||
kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f -
|
kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f -
|
||||||
done
|
done
|
||||||
|
|
||||||
log "provisioning felis-config + felis-service-token + felis-forwarding-secret + registry credentials + panel TLS secrets (out-of-band, never in the bundle)"
|
log "provisioning felis-config + internal caller tokens + felis-forwarding-secret + registry credentials + panel TLS secrets (out-of-band, never in the bundle)"
|
||||||
apply_felis_config_secrets
|
apply_felis_config_secrets
|
||||||
|
# felis-api mounts all four caller tokens from the control namespace. The login
|
||||||
|
# gate's and the build Job's are also applied into the namespace their pods run in
|
||||||
|
# (a secretKeyRef is namespace-local); applying them here rather than leaving it to
|
||||||
|
# `felis setup` means an upgrade has them in place before the new operator points
|
||||||
|
# the login pod at felis-limbo-token. The proxy's and the ops token stay here only.
|
||||||
apply_literal_secret "$CONTROL_NS" felis-service-token token "$SERVICE_TOKEN"
|
apply_literal_secret "$CONTROL_NS" felis-service-token token "$SERVICE_TOKEN"
|
||||||
# The build namespace needs the same token: the build Job's fetch initContainer
|
apply_literal_secret "$CONTROL_NS" felis-limbo-token token "$LIMBO_TOKEN"
|
||||||
# streams a submission's build context from the felis-api internal face, and a
|
apply_literal_secret "$MINECRAFT_NS" felis-limbo-token token "$LIMBO_TOKEN"
|
||||||
# secretKeyRef is namespace-local (a PVC cannot carry it across either).
|
apply_literal_secret "$CONTROL_NS" felis-build-token token "$BUILD_TOKEN"
|
||||||
apply_literal_secret "$BUILD_NS" felis-service-token token "$SERVICE_TOKEN"
|
apply_literal_secret "$BUILD_NS" felis-build-token token "$BUILD_TOKEN"
|
||||||
|
apply_literal_secret "$CONTROL_NS" felis-ops-token token "$OPS_TOKEN"
|
||||||
# The forwarding key every backend verifies the proxy's handshake with. `felis setup`
|
# The forwarding key every backend verifies the proxy's handshake with. `felis setup`
|
||||||
# replicates it into the minecraft namespace (ensureSecretReplica) before it creates
|
# replicates it into the minecraft namespace (ensureSecretReplica) before it creates
|
||||||
# the pods that mount it; the operator injects it into EVERY backend, because Velocity's
|
# the pods that mount it; the operator injects it into EVERY backend, because Velocity's
|
||||||
@@ -3559,6 +3576,12 @@ deploy_bundle() {
|
|||||||
die "control-plane rollout did not complete: ${d}"
|
die "control-plane rollout did not complete: ${d}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
# Before per-caller tokens the proxy's token was replicated into the workload
|
||||||
|
# namespaces for the login gate and the build Jobs. The new operator and api no
|
||||||
|
# longer reference those copies; leaving them would keep the proxy's credential
|
||||||
|
# readable from namespaces that have no business with it.
|
||||||
|
kube -n "$MINECRAFT_NS" delete secret felis-service-token --ignore-not-found
|
||||||
|
kube -n "$BUILD_NS" delete secret felis-service-token --ignore-not-found
|
||||||
}
|
}
|
||||||
|
|
||||||
# pvc_size <namespace> <claim> <wanted> <env name> prints the size to render the claim
|
# pvc_size <namespace> <claim> <wanted> <env name> prints the size to render the claim
|
||||||
|
|||||||
@@ -132,10 +132,13 @@ set them by hand:
|
|||||||
`spec.env` by `felis setup` (`cmd/felis` derives the internal API URL from the
|
`spec.env` by `felis setup` (`cmd/felis` derives the internal API URL from the
|
||||||
control namespace — the platform default `felis`; a renamed control namespace must
|
control namespace — the platform default `felis`; a renamed control namespace must
|
||||||
be reflected by hand — and the root domain from `felis.toml`).
|
be reflected by hand — and the root domain from `felis.toml`).
|
||||||
- `FELIS_SERVICE_TOKEN` is a **secret**, so it is never written into the CRD. `felis
|
- `FELIS_SERVICE_TOKEN` is a **secret**, so it is never written into the CRD. The
|
||||||
setup` replicates the `felis-service-token` Secret from the control namespace into
|
login gate has its own internal-API token, `felis-limbo-token`, which may only mint
|
||||||
the minecraft namespace, and the operator injects it into the `login` pod (only)
|
link codes, poll link status and check the blacklist. The installer applies it into
|
||||||
via a `secretKeyRef`, keyed off the reserved `login` name. Until the token is
|
the minecraft namespace (and `felis setup` refreshes that replica from the control
|
||||||
|
namespace), and the operator injects it into the `login` pod (only) as
|
||||||
|
`FELIS_SERVICE_TOKEN` via a `secretKeyRef`, keyed off the reserved `login` name.
|
||||||
|
`sudo felis rotate-token limbo` replaces it and restarts the pod. Until the token is
|
||||||
present the plugin fail-safes to readiness-only, so the gate is never broken — it
|
present the plugin fail-safes to readiness-only, so the gate is never broken — it
|
||||||
simply does not authenticate yet.
|
simply does not authenticate yet.
|
||||||
- **Service:** the login pod dials `FELIS_API_BASE_URL`, which resolves to the
|
- **Service:** the login pod dials `FELIS_API_BASE_URL`, which resolves to the
|
||||||
|
|||||||
+23
-1
@@ -98,7 +98,13 @@ components:
|
|||||||
serviceToken:
|
serviceToken:
|
||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
description: Static service token presented by velocity / backend callers (internal face).
|
description: >-
|
||||||
|
Static per-caller token (internal face). Each machine holds its own —
|
||||||
|
velocity (felis-service-token), limbo (felis-limbo-token), build
|
||||||
|
(felis-build-token), ops (felis-ops-token) — and each operation lists the
|
||||||
|
callers it serves in x-felis-callers. A genuine token for a caller the
|
||||||
|
operation does not list is refused with 403 wrong_caller. `felis
|
||||||
|
rotate-token <caller>` replaces one.
|
||||||
accessJWT:
|
accessJWT:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
in: header
|
in: header
|
||||||
@@ -758,6 +764,7 @@ paths:
|
|||||||
summary: List all servers (velocity route table).
|
summary: List all servers (velocity route table).
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -838,6 +845,7 @@ paths:
|
|||||||
summary: Backend readiness callback — the server reports it is accepting players.
|
summary: Backend readiness callback — the server reports it is accepting players.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
@@ -861,6 +869,7 @@ paths:
|
|||||||
the internal face (service token, no Zero Trust).
|
the internal face (service token, no Zero Trust).
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [build]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
@@ -884,6 +893,7 @@ paths:
|
|||||||
summary: Player-join event by online-mode UUID (activity tracking / idle reset).
|
summary: Player-join event by online-mode UUID (activity tracking / idle reset).
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
@@ -917,6 +927,7 @@ paths:
|
|||||||
server's autostartPolicy and the per-server wake cooldown.
|
server's autostartPolicy and the per-server wake cooldown.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
@@ -968,6 +979,7 @@ paths:
|
|||||||
summary: Server status projection (velocity polls this after a wake).
|
summary: Server status projection (velocity polls this after a wake).
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
@@ -992,6 +1004,7 @@ paths:
|
|||||||
binding the unowned server to the player's linked account.
|
binding the unowned server to the player's linked account.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
@@ -1035,6 +1048,7 @@ paths:
|
|||||||
summary: Lobby menu projection — status plus the ownership-derived `claimable`.
|
summary: Lobby menu projection — status plus the ownership-derived `claimable`.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
@@ -1067,6 +1081,7 @@ paths:
|
|||||||
description: Internal-only — the code is born from a UUID the web never holds.
|
description: Internal-only — the code is born from a UUID the web never holds.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity, limbo]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1124,6 +1139,7 @@ paths:
|
|||||||
UUID it already holds, so no identity detail crosses back.
|
UUID it already holds, so no identity detail crosses back.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity, limbo]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
@@ -1154,6 +1170,7 @@ paths:
|
|||||||
web credentials — so this endpoint starts a flow, it does not move anything.
|
web credentials — so this endpoint starts a flow, it does not move anything.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1203,6 +1220,7 @@ paths:
|
|||||||
never the contested name, so the genuine Mojang player always passes.
|
never the contested name, so the genuine Mojang player always passes.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1248,6 +1266,7 @@ paths:
|
|||||||
different UUID — is never on the list and always passes.
|
different UUID — is never on the list and always passes.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity, limbo]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
@@ -1277,6 +1296,7 @@ paths:
|
|||||||
is secret to the operator crew.
|
is secret to the operator crew.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -1314,6 +1334,7 @@ paths:
|
|||||||
factor distinct from the mailbox.
|
factor distinct from the mailbox.
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [velocity]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
@@ -1368,6 +1389,7 @@ paths:
|
|||||||
and the action is audited to "break-glass".
|
and the action is audited to "break-glass".
|
||||||
x-felis-face: [internal]
|
x-felis-face: [internal]
|
||||||
x-felis-tier: service
|
x-felis-tier: service
|
||||||
|
x-felis-callers: [ops]
|
||||||
security: [{ serviceToken: [] }]
|
security: [{ serviceToken: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: name, in: path, required: true, schema: { type: string } }
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
+43
-12
@@ -364,8 +364,21 @@ is intended. [GO-TESTED for the session/QR-login logic.]
|
|||||||
## 6. Internal API rejects Velocity / proxy callers (service-token)
|
## 6. Internal API rejects Velocity / proxy callers (service-token)
|
||||||
|
|
||||||
The internal face (`--internal-addr :8081`, routes under
|
The internal face (`--internal-addr :8081`, routes under
|
||||||
`/api/v1/internal/...`) is **never** Zero-Trust; it authenticates a single
|
`/api/v1/internal/...`) is **never** Zero-Trust; it authenticates a bearer token
|
||||||
service token via `Authorization: Bearer <token>`, compared in constant time.
|
(`Authorization: Bearer <token>`, compared in constant time). Each internal caller
|
||||||
|
has its own token, and each route serves only the callers listed for it
|
||||||
|
(`x-felis-callers` in `docs/openapi.yaml`):
|
||||||
|
|
||||||
|
| Caller | Secret (key `token`) | API env | Where the caller reads it | Routes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `velocity` (proxy felis-link) | `felis/felis-service-token` | `FELIS_SERVICE_TOKEN` | `service-token` in the host's `felis-link.properties` | server list, wake/claim/ready/status, join events, menu, op-login, migrate, reclaim, link codes, blacklist |
|
||||||
|
| `limbo` (login gate) | `felis/felis-limbo-token`, replica in `minecraft` | `FELIS_LIMBO_TOKEN` | login pod env `FELIS_SERVICE_TOKEN` | link codes, link status, blacklist |
|
||||||
|
| `build` (build Job fetch) | `felis/felis-build-token`, replica in `felis-build` | `FELIS_BUILD_TOKEN` | fetch initContainer env | submission build context |
|
||||||
|
| `ops` (`felis backup-now`) | `felis/felis-ops-token` | `FELIS_OPS_TOKEN` | read from the Secret on each run | break-glass backup |
|
||||||
|
|
||||||
|
The installer generates all four into `/etc/felis/secrets.env` (`SERVICE_TOKEN`,
|
||||||
|
`LIMBO_TOKEN`, `BUILD_TOKEN`, `OPS_TOKEN`) and applies them on every run. The audit
|
||||||
|
log records internal actions with the source `internal:<caller>`. [GO-TESTED]
|
||||||
|
|
||||||
In-cluster it is reached through the ClusterIP Service `felis-api-internal` (port
|
In-cluster it is reached through the ClusterIP Service `felis-api-internal` (port
|
||||||
8081), which is separate from the external NodePort `felis-api` (443) precisely so
|
8081), which is separate from the external NodePort `felis-api` (443) precisely so
|
||||||
@@ -378,24 +391,42 @@ break-glass console reaches it by resolving that Service's ClusterIP and dialing
|
|||||||
svc felis-api-internal` must show a ClusterIP with 8081; a bare `felis-api` name
|
svc felis-api-internal` must show a ClusterIP with 8081; a bare `felis-api` name
|
||||||
serves only 443 and every internal call would hang/refuse.
|
serves only 443 and every internal call would hang/refuse.
|
||||||
|
|
||||||
- **All internal calls 401** → the token is unset or wrong. The API reads env
|
- **One caller's calls all 401** → its token is unset or differs from the api's
|
||||||
`FELIS_SERVICE_TOKEN`. If unset, startup logs:
|
copy. The api logs one line per unset token at startup:
|
||||||
|
|
||||||
```
|
```
|
||||||
felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers
|
felis api: warning: FELIS_LIMBO_TOKEN unset — the internal face turns the limbo caller away
|
||||||
```
|
```
|
||||||
|
|
||||||
and wires an empty token, which rejects **everyone** (no bypass). [GO-TESTED
|
An unset token never matches anything (no bypass). Compare the caller's value
|
||||||
for the constant-time compare / empty-token rejection.]
|
with its Secret, e.g. for the proxy:
|
||||||
|
|
||||||
In-cluster, the token's source of truth is the Secret `felis-service-token`
|
|
||||||
(key `token`), injected as `FELIS_SERVICE_TOKEN` on the API Deployment. Fix:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
kubectl get secret felis-service-token -o jsonpath='{.data.token}' | base64 -d
|
kubectl -n felis get secret felis-service-token -o jsonpath='{.data.token}' | base64 -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Ensure the proxy is configured with the identical value.
|
- **`403 wrong_caller`** → the token is valid but belongs to a caller that route
|
||||||
|
does not serve, e.g. the build token calling a proxy route. Configure the caller
|
||||||
|
with its own token from the table above.
|
||||||
|
|
||||||
|
- **api pods stuck in `CreateContainerConfigError`** → one of the four Secrets is
|
||||||
|
missing in `felis`. Re-run the installer; it applies them before the bundle.
|
||||||
|
|
||||||
|
- **Two tokens with the same value** → `felis api` refuses to start with
|
||||||
|
`the X and Y tokens are the same value; each caller needs its own`, since a shared
|
||||||
|
value would make the caller ambiguous. Rotate one of them.
|
||||||
|
|
||||||
|
### Rotating a token
|
||||||
|
|
||||||
|
`sudo felis rotate-token <velocity|limbo|build|ops>` replaces one caller's token:
|
||||||
|
it writes the new value to `secrets.env` (so a later installer run keeps it), the
|
||||||
|
Secret and its replica, rolls felis-api so only the new value is accepted, then
|
||||||
|
restarts the caller — the `felis-velocity` unit when the proxy runs on this host,
|
||||||
|
or the login pod. Build Jobs and `felis backup-now` pick the new value up on their
|
||||||
|
next run. The old value stops working as soon as felis-api has rolled; the caller
|
||||||
|
is turned away for the few seconds until it restarts. For a proxy on another host,
|
||||||
|
the command leaves the host alone and tells you to copy the new value from the
|
||||||
|
Secret into that proxy's `felis-link.properties` and restart it. [GO-TESTED]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+51
-16
@@ -14,6 +14,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -352,6 +353,10 @@ type apiRoute struct {
|
|||||||
// since the browser calls it every few seconds while it waits.
|
// since the browser calls it every few seconds while it waits.
|
||||||
AuthDoor bool
|
AuthDoor bool
|
||||||
|
|
||||||
|
// Callers lists the machines an internal-face route serves; every
|
||||||
|
// authenticated internal route names at least one, and external routes none.
|
||||||
|
Callers []Caller
|
||||||
|
|
||||||
h http.HandlerFunc
|
h http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +364,14 @@ type apiRoute struct {
|
|||||||
// service-token auth, never Zero Trust. It carries both health probes and the
|
// service-token auth, never Zero Trust. It carries both health probes and the
|
||||||
// metrics scrape.
|
// metrics scrape.
|
||||||
func (a *API) internalAPIRoutes() []apiRoute {
|
func (a *API) internalAPIRoutes() []apiRoute {
|
||||||
|
// Who may call what (Caller). The proxy drives the game-facing routes; the
|
||||||
|
// login gate only checks a joining player's bar and link and mints their bind
|
||||||
|
// code; the build Job only reads the context of the submission it builds; the
|
||||||
|
// on-node console only asks for a break-glass backup.
|
||||||
|
proxy := []Caller{CallerVelocity}
|
||||||
|
gate := []Caller{CallerVelocity, CallerLimbo}
|
||||||
|
build := []Caller{CallerBuild}
|
||||||
|
ops := []Caller{CallerOps}
|
||||||
return []apiRoute{
|
return []apiRoute{
|
||||||
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
|
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
|
||||||
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
|
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
|
||||||
@@ -366,47 +379,47 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
|||||||
// no token, internal-only so it is never exposed off-cluster.
|
// no token, internal-only so it is never exposed off-cluster.
|
||||||
{Method: "GET", Pattern: "/metrics", Public: true, h: a.handleMetrics},
|
{Method: "GET", Pattern: "/metrics", Public: true, h: a.handleMetrics},
|
||||||
|
|
||||||
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
|
{Method: "GET", Pattern: "/api/v1/servers", Callers: proxy, h: a.handleListServers},
|
||||||
// The build Pod's context-fetch initContainer streams a submission's stored
|
// The build Pod's context-fetch initContainer streams a submission's stored
|
||||||
// modpack through this route (build namespace cannot mount the uploads PVC).
|
// modpack through this route (build namespace cannot mount the uploads PVC).
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", h: a.handleInternalSubmissionContext},
|
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", Callers: build, h: a.handleInternalSubmissionContext},
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
|
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", Callers: proxy, h: a.handleReady},
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
|
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", Callers: proxy, h: a.handleJoinEvent},
|
||||||
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
|
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
|
||||||
// status with its service token, identifying the joining player by online-mode
|
// status with its service token, identifying the joining player by online-mode
|
||||||
// UUID. These live on the internal face because velocity holds no web Principal;
|
// UUID. These live on the internal face because velocity holds no web Principal;
|
||||||
// the external face keeps its own Principal-gated wake/status for the panel.
|
// the external face keeps its own Principal-gated wake/status for the panel.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/wake", h: a.handleInternalWake},
|
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/wake", Callers: proxy, h: a.handleInternalWake},
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/status", h: a.handleStatus},
|
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/status", Callers: proxy, h: a.handleStatus},
|
||||||
// Lobby `/menu` (spec §12): the felis-paper lobby is a pure UI face holding no
|
// Lobby `/menu` (spec §12): the felis-paper lobby is a pure UI face holding no
|
||||||
// token, so velocity drives these on its behalf — claim by online-mode UUID
|
// token, so velocity drives these on its behalf — claim by online-mode UUID
|
||||||
// (the lobby's `Claim & Start`, separate from the autostartPolicy-gated wake)
|
// (the lobby's `Claim & Start`, separate from the autostartPolicy-gated wake)
|
||||||
// and the menu projection that adds the ownership-derived `claimable` the §11
|
// and the menu projection that adds the ownership-derived `claimable` the §11
|
||||||
// list/status views never carry.
|
// list/status views never carry.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", h: a.handleInternalClaim},
|
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim},
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", h: a.handleInternalMenuStatus},
|
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", Callers: proxy, h: a.handleInternalMenuStatus},
|
||||||
// Account linking (spec §10): the in-game /link side mints a one-time code for a
|
// Account linking (spec §10): the in-game /link side mints a one-time code for a
|
||||||
// verified UUID. Internal-only — the code is born from an online-mode UUID the
|
// verified UUID. Internal-only — the code is born from an online-mode UUID the
|
||||||
// web never holds (account_link_codes has no user_id column).
|
// web never holds (account_link_codes has no user_id column).
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode},
|
{Method: "POST", Pattern: "/api/v1/internal/account/link/code", Callers: gate, h: a.handleCreateLinkCode},
|
||||||
// QR scan-to-login completion poll (spec §B3 player game-login). After the player
|
// QR scan-to-login completion poll (spec §B3 player game-login). After the player
|
||||||
// scans the QR-encoded code and the web verify writes the durable link, velocity
|
// scans the QR-encoded code and the web verify writes the durable link, velocity
|
||||||
// polls this for the UUID it minted against and admits on {linked:true}. Read-only
|
// polls this for the UUID it minted against and admits on {linked:true}. Read-only
|
||||||
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
|
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
|
||||||
// and is safe to poll repeatedly.
|
// and is safe to poll repeatedly.
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", h: a.handleLinkStatus},
|
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", Callers: gate, h: a.handleLinkStatus},
|
||||||
// Account migration (spec §B3 inherit), in-game side: /felis migrate puts the
|
// Account migration (spec §B3 inherit), in-game side: /felis migrate puts the
|
||||||
// account linked to the running player's verified UUID into migrate mode. Internal
|
// account linked to the running player's verified UUID into migrate mode. Internal
|
||||||
// only — the initiator is proven by online-mode auth, and the sensitive proof
|
// only — the initiator is proven by online-mode auth, and the sensitive proof
|
||||||
// (step-up) still happens web-side before anything transfers.
|
// (step-up) still happens web-side before anything transfers.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/account/migrate/start", h: a.handleMigrateStart},
|
{Method: "POST", Pattern: "/api/v1/internal/account/migrate/start", Callers: proxy, h: a.handleMigrateStart},
|
||||||
// Username-collision reclaim (spec §B3): velocity records a Mojang-priority
|
// Username-collision reclaim (spec §B3): velocity records a Mojang-priority
|
||||||
// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the
|
// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the
|
||||||
// limbo login by checking whether a connecting UUID was barred. Internal-only —
|
// limbo login by checking whether a connecting UUID was barred. Internal-only —
|
||||||
// velocity holds a service token, and the bar is keyed by UUID so the genuine
|
// velocity holds a service token, and the bar is keyed by UUID so the genuine
|
||||||
// Mojang player (same name, different UUID) always passes.
|
// Mojang player (same name, different UUID) always passes.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
|
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", Callers: proxy, h: a.handleReclaimUsername},
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
|
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", Callers: gate, h: a.handleCheckBlacklist},
|
||||||
// Felis-nano multi-source session verifier, behind player game-login. Velocity is
|
// Felis-nano multi-source session verifier, behind player game-login. Velocity is
|
||||||
// pointed here with -Dmojang.sessionserver and issues the request itself; it speaks
|
// pointed here with -Dmojang.sessionserver and issues the request itself; it speaks
|
||||||
// the vanilla sessionserver protocol and carries no token, so this is Public. It
|
// the vanilla sessionserver protocol and carries no token, so this is Public. It
|
||||||
@@ -419,13 +432,13 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
|||||||
// /felis web op approve. Internal face carries the pending queue and the
|
// /felis web op approve. Internal face carries the pending queue and the
|
||||||
// approve action (service-token auth, no Principal); the public face carries
|
// approve action (service-token auth, no Principal); the public face carries
|
||||||
// the start/status/finish the staff member's browser drives.
|
// the start/status/finish the staff member's browser drives.
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
|
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", Callers: proxy, h: a.handleOpLoginPending},
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
|
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", Callers: proxy, h: a.handleOpLoginApprove},
|
||||||
|
|
||||||
// Break-glass backup (spec §B4 "Sync"): the on-node console POSTs here to
|
// Break-glass backup (spec §B4 "Sync"): the on-node console POSTs here to
|
||||||
// snapshot a stopped world while the API is alive. Service-token auth (no
|
// snapshot a stopped world while the API is alive. Service-token auth (no
|
||||||
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
|
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
|
||||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", h: a.handleInternalBackup},
|
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,6 +718,12 @@ func (a *API) buildFace(face string, routes []apiRoute, guard func(http.Handler)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
h := rt.h
|
h := rt.h
|
||||||
|
if (face == "internal") != (len(rt.Callers) > 0) {
|
||||||
|
panic(fmt.Sprintf("%s route %s %s: internal routes list their callers, external ones none", face, rt.Method, rt.Pattern))
|
||||||
|
}
|
||||||
|
if len(rt.Callers) > 0 {
|
||||||
|
h = callersOnly(rt.Callers, h)
|
||||||
|
}
|
||||||
if rt.Owner {
|
if rt.Owner {
|
||||||
h = a.ownerOnly(rt.h)
|
h = a.ownerOnly(rt.h)
|
||||||
}
|
}
|
||||||
@@ -832,6 +851,7 @@ const (
|
|||||||
ctxKeyRequestID ctxKey = iota
|
ctxKeyRequestID ctxKey = iota
|
||||||
ctxKeyPrincipal
|
ctxKeyPrincipal
|
||||||
ctxKeyReqInfo
|
ctxKeyReqInfo
|
||||||
|
ctxKeyCaller
|
||||||
)
|
)
|
||||||
|
|
||||||
func requestIDFromContext(ctx context.Context) string {
|
func requestIDFromContext(ctx context.Context) string {
|
||||||
@@ -849,6 +869,21 @@ func principalFromContext(ctx context.Context) *Principal {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// callerFromContext returns the internal-face caller, or "" off that face.
|
||||||
|
func callerFromContext(ctx context.Context) Caller {
|
||||||
|
c, _ := ctx.Value(ctxKeyCaller).(Caller)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalSource is the audit Source for an action taken on the internal face,
|
||||||
|
// naming the caller whose token asked for it ("internal:velocity").
|
||||||
|
func internalSource(r *http.Request) string {
|
||||||
|
if c := callerFromContext(r.Context()); c != "" {
|
||||||
|
return "internal:" + string(c)
|
||||||
|
}
|
||||||
|
return "internal"
|
||||||
|
}
|
||||||
|
|
||||||
// ---- per-key cooldown (wake + OTP) ----
|
// ---- per-key cooldown (wake + OTP) ----
|
||||||
|
|
||||||
// cooldownLimiter is an in-memory per-key cooldown. It backs two throttles with
|
// cooldownLimiter is an in-memory per-key cooldown. It backs two throttles with
|
||||||
|
|||||||
@@ -1752,9 +1752,15 @@ type staticExternal struct {
|
|||||||
|
|
||||||
func (s staticExternal) Authenticate(*http.Request) (*Principal, error) { return s.p, s.err }
|
func (s staticExternal) Authenticate(*http.Request) (*Principal, error) { return s.p, s.err }
|
||||||
|
|
||||||
type okInternal struct{}
|
// okInternal admits every request as one caller, the proxy unless set.
|
||||||
|
type okInternal struct{ caller Caller }
|
||||||
|
|
||||||
func (okInternal) Authenticate(*http.Request) error { return nil }
|
func (o okInternal) Authenticate(*http.Request) (Caller, error) {
|
||||||
|
if o.caller == "" {
|
||||||
|
return CallerVelocity, nil
|
||||||
|
}
|
||||||
|
return o.caller, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
@@ -1798,7 +1804,7 @@ func decodeErr(t *testing.T, w *httptest.ResponseRecorder) string {
|
|||||||
|
|
||||||
func TestInternalFaceRequiresServiceToken(t *testing.T) {
|
func TestInternalFaceRequiresServiceToken(t *testing.T) {
|
||||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
|
||||||
h := api.InternalHandler()
|
h := api.InternalHandler()
|
||||||
|
|
||||||
// no token -> 401
|
// no token -> 401
|
||||||
@@ -1817,7 +1823,7 @@ func TestInternalFaceRequiresServiceToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestHealthzIsUnauthenticated(t *testing.T) {
|
func TestHealthzIsUnauthenticated(t *testing.T) {
|
||||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
|
||||||
if w := do(api.InternalHandler(), "GET", "/healthz", "", nil); w.Code != http.StatusOK {
|
if w := do(api.InternalHandler(), "GET", "/healthz", "", nil); w.Code != http.StatusOK {
|
||||||
t.Fatalf("healthz code = %d, want 200", w.Code)
|
t.Fatalf("healthz code = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
@@ -1829,7 +1835,7 @@ func TestReadyzPingsDependencies(t *testing.T) {
|
|||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
cl := newFakeCluster()
|
cl := newFakeCluster()
|
||||||
api := newTestAPI(repo, cl)
|
api := newTestAPI(repo, cl)
|
||||||
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
|
||||||
|
|
||||||
// Both healthy.
|
// Both healthy.
|
||||||
if w := do(api.InternalHandler(), "GET", "/readyz", "", nil); w.Code != http.StatusOK {
|
if w := do(api.InternalHandler(), "GET", "/readyz", "", nil); w.Code != http.StatusOK {
|
||||||
|
|||||||
+59
-18
@@ -71,11 +71,33 @@ func (p *Principal) IsOwner() bool {
|
|||||||
return p != nil && p.Role == "owner" && p.ViaAdminAccess
|
return p != nil && p.Role == "owner" && p.ViaAdminAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// InternalAuth authenticates the internal face (velocity / backend callbacks):
|
// Caller names the machine behind an internal-face token. Each caller holds a
|
||||||
// a static service token presented as a Bearer credential. The internal face
|
// token of its own and each internal route lists the callers it serves
|
||||||
// is never wrapped in Zero Trust (spec §1.8, §14 red line).
|
// (apiRoute.Callers), so a token copied out of one namespace opens only what
|
||||||
|
// that caller needs: the build Job's token reads a submission's context and
|
||||||
|
// nothing else, and only the proxy and the login gate can mint link codes.
|
||||||
|
type Caller string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CallerVelocity is the proxy's felis-link plugin (felis-service-token,
|
||||||
|
// written into felis-link.properties on the host).
|
||||||
|
CallerVelocity Caller = "velocity"
|
||||||
|
// CallerLimbo is the login gate's felis-limbo plugin (felis-limbo-token,
|
||||||
|
// injected into the login pod only).
|
||||||
|
CallerLimbo Caller = "limbo"
|
||||||
|
// CallerBuild is the build Job's context-fetch initContainer
|
||||||
|
// (felis-build-token in the build namespace).
|
||||||
|
CallerBuild Caller = "build"
|
||||||
|
// CallerOps is the on-node console, `felis backup-now` (felis-ops-token,
|
||||||
|
// control namespace only).
|
||||||
|
CallerOps Caller = "ops"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InternalAuth authenticates the internal face: a per-caller static token
|
||||||
|
// presented as a Bearer credential, answered with the caller it belongs to. The
|
||||||
|
// internal face is never wrapped in Zero Trust (spec §1.8, §14 red line).
|
||||||
type InternalAuth interface {
|
type InternalAuth interface {
|
||||||
Authenticate(r *http.Request) error
|
Authenticate(r *http.Request) (Caller, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExternalAuth authenticates the external face (people / panel) and returns the
|
// ExternalAuth authenticates the external face (people / panel) and returns the
|
||||||
@@ -86,26 +108,45 @@ type ExternalAuth interface {
|
|||||||
Authenticate(r *http.Request) (*Principal, error)
|
Authenticate(r *http.Request) (*Principal, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BearerTokenAuth is the production InternalAuth: a constant-time comparison
|
// CallerTokens is the production InternalAuth: each caller's token, compared in
|
||||||
// against the configured service token. A zero token fails closed so a
|
// constant time. A caller with no token cannot authenticate, so a missing
|
||||||
// misconfiguration can never silently disable internal-face auth.
|
// Secret fails closed for that caller alone.
|
||||||
type BearerTokenAuth struct {
|
type CallerTokens map[Caller]string
|
||||||
Token string
|
|
||||||
|
// NewCallerTokens refuses a set that would make the caller ambiguous: two
|
||||||
|
// callers sharing a value, which is also what an install whose callers all
|
||||||
|
// still hold the one old service token would look like.
|
||||||
|
func NewCallerTokens(tokens map[Caller]string) (CallerTokens, error) {
|
||||||
|
seen := map[string]Caller{}
|
||||||
|
for caller, tok := range tokens {
|
||||||
|
if tok == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if other, dup := seen[tok]; dup {
|
||||||
|
return nil, fmt.Errorf("the %s and %s tokens are the same value; each caller needs its own", other, caller)
|
||||||
|
}
|
||||||
|
seen[tok] = caller
|
||||||
|
}
|
||||||
|
return CallerTokens(tokens), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate checks the Authorization: Bearer header against the token.
|
// Authenticate matches the Authorization: Bearer header against every caller's
|
||||||
func (b BearerTokenAuth) Authenticate(r *http.Request) error {
|
// token, comparing each so the time taken does not say which one matched.
|
||||||
if b.Token == "" {
|
func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
|
||||||
return fmt.Errorf("internal auth not configured")
|
|
||||||
}
|
|
||||||
got := bearerToken(r)
|
got := bearerToken(r)
|
||||||
if got == "" {
|
if got == "" {
|
||||||
return fmt.Errorf("missing bearer token")
|
return "", fmt.Errorf("missing bearer token")
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(got), []byte(b.Token)) != 1 {
|
var match Caller
|
||||||
return fmt.Errorf("invalid service token")
|
for caller, tok := range c {
|
||||||
|
if tok != "" && subtle.ConstantTimeCompare([]byte(got), []byte(tok)) == 1 {
|
||||||
|
match = caller
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
|
if match == "" {
|
||||||
|
return "", fmt.Errorf("invalid service token")
|
||||||
|
}
|
||||||
|
return match, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
|
// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ var (
|
|||||||
errAuthUnavailable = newError(http.StatusServiceUnavailable, "auth_unavailable",
|
errAuthUnavailable = newError(http.StatusServiceUnavailable, "auth_unavailable",
|
||||||
"authentication is temporarily unavailable; retry shortly")
|
"authentication is temporarily unavailable; retry shortly")
|
||||||
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
|
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
|
||||||
|
// errWrongCaller: a valid internal token for a caller this route does not serve.
|
||||||
|
errWrongCaller = newError(http.StatusForbidden, "wrong_caller", "this token's caller may not use this route")
|
||||||
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
|
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Internal-face event: attribute to the in-game initiator, Source 'internal'.
|
// Internal-face event: attribute to the in-game initiator, Source 'internal'.
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: "mc:" + mcUUID,
|
Actor: "mc:" + mcUUID,
|
||||||
Source: "internal",
|
Source: internalSource(r),
|
||||||
Action: "account.migrate.start",
|
Action: "account.migrate.start",
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"started": true, "state": "initiated"})
|
writeJSON(w, http.StatusCreated, map[string]any{"started": true, "state": "initiated"})
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ func TestBackupNow(t *testing.T) {
|
|||||||
// the stopped-gate / 503 / async-202 behaviour is proven there; here the focus is the
|
// the stopped-gate / 503 / async-202 behaviour is proven there; here the focus is the
|
||||||
// internal-face difference: no Principal (service-token auth), no owner gate — even a
|
// internal-face difference: no Principal (service-token auth), no owner gate — even a
|
||||||
// server owned by someone else backs up (the on-node operator is trusted) — and the
|
// server owned by someone else backs up (the on-node operator is trusted) — and the
|
||||||
// audit is attributed to "break-glass"/"internal", not an email/"external".
|
// audit is attributed to "break-glass"/"internal:ops", not an email/"external".
|
||||||
func TestInternalBackup(t *testing.T) {
|
func TestInternalBackup(t *testing.T) {
|
||||||
mk := func() (*API, *fakeRepo, *fakeCluster, *fakeBackuper) {
|
mk := func() (*API, *fakeRepo, *fakeCluster, *fakeBackuper) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
@@ -282,6 +282,7 @@ func TestInternalBackup(t *testing.T) {
|
|||||||
Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
|
Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
|
||||||
backuper := &fakeBackuper{}
|
backuper := &fakeBackuper{}
|
||||||
api := newTestAPI(repo, cl)
|
api := newTestAPI(repo, cl)
|
||||||
|
api.Internal = okInternal{caller: CallerOps}
|
||||||
api.Backuper = backuper
|
api.Backuper = backuper
|
||||||
return api, repo, cl, backuper
|
return api, repo, cl, backuper
|
||||||
}
|
}
|
||||||
@@ -301,7 +302,7 @@ func TestInternalBackup(t *testing.T) {
|
|||||||
backuper.calls, backuper.gotName, backuper.gotFormerOwn)
|
backuper.calls, backuper.gotName, backuper.gotFormerOwn)
|
||||||
}
|
}
|
||||||
if len(repo.audits) != 1 || repo.audits[0].Action != "backup.create" ||
|
if len(repo.audits) != 1 || repo.audits[0].Action != "backup.create" ||
|
||||||
repo.audits[0].Actor != "break-glass" || repo.audits[0].Source != "internal" {
|
repo.audits[0].Actor != "break-glass" || repo.audits[0].Source != "internal:ops" {
|
||||||
t.Fatalf("audit not attributed to break-glass/internal: %+v", repo.audits)
|
t.Fatalf("audit not attributed to break-glass/internal: %+v", repo.audits)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -312,7 +313,7 @@ func TestInternalBackup(t *testing.T) {
|
|||||||
if w.Code != http.StatusAccepted {
|
if w.Code != http.StatusAccepted {
|
||||||
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
|
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if len(repo.audits) != 1 || repo.audits[0].Actor != "alice" || repo.audits[0].Source != "internal" {
|
if len(repo.audits) != 1 || repo.audits[0].Actor != "alice" || repo.audits[0].Source != "internal:ops" {
|
||||||
t.Fatalf("audit actor should be the os_user, not break-glass: %+v", repo.audits)
|
t.Fatalf("audit actor should be the os_user, not break-glass: %+v", repo.audits)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.enqueueBackup(w, r, name, rec, actor, "internal")
|
a.enqueueBackup(w, r, name, rec, actor, internalSource(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueBackup is the shared tail of both backup faces: the RWO stopped-gate, the
|
// enqueueBackup is the shared tail of both backup faces: the RWO stopped-gate, the
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (a *API) handleReady(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: "backend", Source: "internal", Action: "ready", ServerName: name,
|
Actor: "backend", Source: internalSource(r), Action: "ready", ServerName: name,
|
||||||
})
|
})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
|||||||
// untouched and the next join attempt is not also throttled.
|
// untouched and the next join attempt is not also throttled.
|
||||||
a.limiter().record(name)
|
a.limiter().record(name)
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: "velocity", Source: "internal", Action: "wake", ServerName: name,
|
Actor: "velocity", Source: internalSource(r), Action: "wake", ServerName: name,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"name": name, "desiredState": "Running",
|
"name": name, "desiredState": "Running",
|
||||||
@@ -259,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: "velocity", Source: "internal", Action: "claim", ServerName: name,
|
Actor: "velocity", Source: internalSource(r), Action: "claim", ServerName: name,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "claimed": true})
|
writeJSON(w, http.StatusOK, map[string]any{"name": name, "claimed": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func assertEq(t *testing.T, key string, got, want any) {
|
|||||||
func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
|
func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, e := range f.audits {
|
for _, e := range f.audits {
|
||||||
if e.Action == "claim" && e.ServerName == name && e.Actor == "velocity" && e.Source == "internal" {
|
if e.Action == "claim" && e.ServerName == name && e.Actor == "velocity" && e.Source == "internal:velocity" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
|
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: approver.Username, ActorUserID: approverID, Source: "internal",
|
Actor: approver.Username, ActorUserID: approverID, Source: internalSource(r),
|
||||||
Action: "auth.op_login.approved", Payload: payload,
|
Action: "auth.op_login.approved", Payload: payload,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
|
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
|
|||||||
payload, _ := json.Marshal(map[string]string{
|
payload, _ := json.Marshal(map[string]string{
|
||||||
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
|
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: "velocity", Source: "internal", Action: "player.reclaim.refused", Payload: payload,
|
Actor: "velocity", Source: internalSource(r), Action: "player.reclaim.refused", Payload: payload,
|
||||||
})
|
})
|
||||||
writeError(w, r, newError(http.StatusConflict, "protected_admin",
|
writeError(w, r, newError(http.StatusConflict, "protected_admin",
|
||||||
"that username belongs to a linked administrator on the login server and cannot be reclaimed"))
|
"that username belongs to a linked administrator on the login server and cannot be reclaimed"))
|
||||||
@@ -126,7 +126,7 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
|
|||||||
// internal since velocity, not a human, drives it.
|
// internal since velocity, not a human, drives it.
|
||||||
payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID})
|
payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID})
|
||||||
a.auditEntry(r, AuditEntry{
|
a.auditEntry(r, AuditEntry{
|
||||||
Actor: "velocity", Source: "internal", Action: "player.reclaim", Payload: payload,
|
Actor: "velocity", Source: internalSource(r), Action: "player.reclaim", Payload: payload,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"blacklisted": true,
|
"blacklisted": true,
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
|
|||||||
t.Fatalf("audits = %d, want 1 refusal row", len(repo.audits))
|
t.Fatalf("audits = %d, want 1 refusal row", len(repo.audits))
|
||||||
}
|
}
|
||||||
a := repo.audits[0]
|
a := repo.audits[0]
|
||||||
if a.Action != "player.reclaim.refused" || a.Actor != "velocity" || a.Source != "internal" {
|
if a.Action != "player.reclaim.refused" || a.Actor != "velocity" || a.Source != "internal:velocity" {
|
||||||
t.Fatalf("audit = %+v, want player.reclaim.refused/velocity/internal", a)
|
t.Fatalf("audit = %+v, want player.reclaim.refused/velocity/internal", a)
|
||||||
}
|
}
|
||||||
var p map[string]string
|
var p map[string]string
|
||||||
@@ -278,7 +278,7 @@ func TestReclaimAudited(t *testing.T) {
|
|||||||
t.Fatalf("audits = %d, want 1", len(repo.audits))
|
t.Fatalf("audits = %d, want 1", len(repo.audits))
|
||||||
}
|
}
|
||||||
a := repo.audits[0]
|
a := repo.audits[0]
|
||||||
if a.Action != "player.reclaim" || a.Actor != "velocity" || a.Source != "internal" {
|
if a.Action != "player.reclaim" || a.Actor != "velocity" || a.Source != "internal:velocity" {
|
||||||
t.Fatalf("audit = %+v, want player.reclaim/velocity/internal", a)
|
t.Fatalf("audit = %+v, want player.reclaim/velocity/internal", a)
|
||||||
}
|
}
|
||||||
var p map[string]string
|
var p map[string]string
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func callerTokensForTest() CallerTokens {
|
||||||
|
return CallerTokens{
|
||||||
|
CallerVelocity: "tok-velocity",
|
||||||
|
CallerLimbo: "tok-limbo",
|
||||||
|
CallerBuild: "tok-build",
|
||||||
|
CallerOps: "tok-ops",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearer(tok string) map[string]string {
|
||||||
|
return map[string]string{"Authorization": "Bearer " + tok, "Content-Type": "application/json"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each token answers with its own caller, and nothing else gets in.
|
||||||
|
func TestCallerTokensNameTheCaller(t *testing.T) {
|
||||||
|
c := callerTokensForTest()
|
||||||
|
for tok, want := range map[string]Caller{
|
||||||
|
"tok-velocity": CallerVelocity,
|
||||||
|
"tok-limbo": CallerLimbo,
|
||||||
|
"tok-build": CallerBuild,
|
||||||
|
"tok-ops": CallerOps,
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
got, err := c.Authenticate(r)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("%s: got (%q, %v), want %q", tok, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, header := range []string{"", "Bearer tok-velocityX", "Bearer tok-veloci", "Basic tok-ops"} {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
if header != "" {
|
||||||
|
r.Header.Set("Authorization", header)
|
||||||
|
}
|
||||||
|
if got, err := c.Authenticate(r); err == nil {
|
||||||
|
t.Errorf("%q authenticated as %q", header, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A caller whose Secret is missing has an empty token; that must not turn into
|
||||||
|
// "any empty-ish credential passes".
|
||||||
|
partial := CallerTokens{CallerVelocity: "tok-velocity", CallerBuild: ""}
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer ")
|
||||||
|
if got, err := partial.Authenticate(r); err == nil {
|
||||||
|
t.Fatalf("blank bearer authenticated as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCallerTokensRefusesAmbiguousSets(t *testing.T) {
|
||||||
|
if _, err := NewCallerTokens(map[Caller]string{CallerVelocity: "a", CallerLimbo: "b", CallerBuild: "", CallerOps: "c"}); err != nil {
|
||||||
|
t.Fatalf("distinct tokens refused: %v", err)
|
||||||
|
}
|
||||||
|
_, err := NewCallerTokens(map[Caller]string{CallerVelocity: "same", CallerBuild: "same"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "same value") {
|
||||||
|
t.Fatalf("shared value: err = %v, want a same-value refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller scopes the gap asked for, spelled out rather than read back from the
|
||||||
|
// route table: the build token reads a submission's context and nothing else, only
|
||||||
|
// the proxy and the login gate mint link codes, only the proxy approves an
|
||||||
|
// op-login, and only the on-node console asks for a break-glass backup.
|
||||||
|
func TestInternalRoutesServeOnlyTheirCallers(t *testing.T) {
|
||||||
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
a.Internal = callerTokensForTest()
|
||||||
|
h := a.InternalHandler()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
method, path string
|
||||||
|
allowed []Caller
|
||||||
|
}{
|
||||||
|
{"GET", "/api/v1/servers", []Caller{CallerVelocity}},
|
||||||
|
{"GET", "/api/v1/internal/submissions/sub-1/context", []Caller{CallerBuild}},
|
||||||
|
{"POST", "/api/v1/internal/account/link/code", []Caller{CallerVelocity, CallerLimbo}},
|
||||||
|
{"GET", "/api/v1/internal/account/link/status/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
|
||||||
|
{"GET", "/api/v1/internal/player/blacklist/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
|
||||||
|
{"POST", "/api/v1/internal/op-login/req-1/approve", []Caller{CallerVelocity}},
|
||||||
|
{"GET", "/api/v1/internal/op-login/pending", []Caller{CallerVelocity}},
|
||||||
|
{"POST", "/api/v1/internal/account/migrate/start", []Caller{CallerVelocity}},
|
||||||
|
{"POST", "/api/v1/internal/player/reclaim", []Caller{CallerVelocity}},
|
||||||
|
{"POST", "/api/v1/internal/servers/survival/wake", []Caller{CallerVelocity}},
|
||||||
|
{"POST", "/api/v1/internal/servers/survival/claim", []Caller{CallerVelocity}},
|
||||||
|
{"POST", "/api/v1/internal/servers/survival/backup", []Caller{CallerOps}},
|
||||||
|
}
|
||||||
|
tokens := map[Caller]string{CallerVelocity: "tok-velocity", CallerLimbo: "tok-limbo", CallerBuild: "tok-build", CallerOps: "tok-ops"}
|
||||||
|
for _, tc := range cases {
|
||||||
|
for caller, tok := range tokens {
|
||||||
|
allowed := false
|
||||||
|
for _, c := range tc.allowed {
|
||||||
|
allowed = allowed || c == caller
|
||||||
|
}
|
||||||
|
w := do(h, tc.method, tc.path, "{}", bearer(tok))
|
||||||
|
refused := w.Code == http.StatusForbidden && decodeErr(t, w) == "wrong_caller"
|
||||||
|
if allowed && (refused || w.Code == http.StatusUnauthorized) {
|
||||||
|
t.Errorf("%s %s as %s: refused (%d %s), want it served", tc.method, tc.path, caller, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !allowed && !refused {
|
||||||
|
t.Errorf("%s %s as %s: got %d %s, want 403 wrong_caller", tc.method, tc.path, caller, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit names the caller whose token asked for the action.
|
||||||
|
func TestInternalAuditNamesTheCaller(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
a := newTestAPI(repo, newFakeCluster())
|
||||||
|
a.Internal = callerTokensForTest()
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
if got := internalSource(r); got != "internal" {
|
||||||
|
t.Fatalf("no caller: source = %q, want internal", got)
|
||||||
|
}
|
||||||
|
var seen string
|
||||||
|
probe := a.requireInternal(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = internalSource(r)
|
||||||
|
}))
|
||||||
|
r.Header.Set("Authorization", "Bearer tok-limbo")
|
||||||
|
probe.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
if seen != "internal:limbo" {
|
||||||
|
t.Fatalf("source = %q, want internal:limbo", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A route added to the internal table without saying who calls it would be open
|
||||||
|
// to every token; the face refuses to build instead. Callers on an external route
|
||||||
|
// would mean nothing, so that is refused too.
|
||||||
|
func TestBuildFaceRequiresCallersOnInternalRoutes(t *testing.T) {
|
||||||
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
noop := func(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
pass := func(h http.Handler) http.Handler { return h }
|
||||||
|
mustPanic := func(name string, fn func()) {
|
||||||
|
t.Helper()
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Errorf("%s: built without complaint", name)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
mustPanic("internal route without callers", func() {
|
||||||
|
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/api/v1/internal/x", h: noop}}, pass)
|
||||||
|
})
|
||||||
|
mustPanic("external route with callers", func() {
|
||||||
|
a.buildFace("external", []apiRoute{{Method: "GET", Pattern: "/api/v1/x", Callers: []Caller{CallerOps}, h: noop}}, pass)
|
||||||
|
})
|
||||||
|
// Public internal routes (probes, hasJoined) carry no token and list no callers.
|
||||||
|
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/readyz", Public: true, h: noop}}, pass)
|
||||||
|
}
|
||||||
@@ -210,18 +210,35 @@ func (b *deadlineBody) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireInternal enforces service-token auth for the internal face. It never
|
// requireInternal enforces service-token auth for the internal face and stashes
|
||||||
// applies Zero Trust (spec §14 red line).
|
// the caller the token belongs to, which callersOnly checks against the route.
|
||||||
|
// It never applies Zero Trust (spec §14 red line).
|
||||||
func (a *API) requireInternal(next http.Handler) http.Handler {
|
func (a *API) requireInternal(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := a.Internal.Authenticate(r); err != nil {
|
caller, err := a.Internal.Authenticate(r)
|
||||||
|
if err != nil {
|
||||||
writeError(w, r, errUnauthorized)
|
writeError(w, r, errUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyCaller, caller)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// callersOnly refuses an internal route to a caller it does not list: the token
|
||||||
|
// is genuine, it just belongs to a machine this route does not serve.
|
||||||
|
func callersOnly(callers []Caller, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller := callerFromContext(r.Context())
|
||||||
|
for _, c := range callers {
|
||||||
|
if c == caller {
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeError(w, r, errWrongCaller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requireExternal enforces Access-JWT auth for the external face and stashes the
|
// requireExternal enforces Access-JWT auth for the external face and stashes the
|
||||||
// resolved Principal in the request context.
|
// resolved Principal in the request context.
|
||||||
func (a *API) requireExternal(next http.Handler) http.Handler {
|
func (a *API) requireExternal(next http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type oasDoc struct {
|
|||||||
type oasOp struct {
|
type oasOp struct {
|
||||||
Faces []string `json:"x-felis-face"`
|
Faces []string `json:"x-felis-face"`
|
||||||
Tier string `json:"x-felis-tier"`
|
Tier string `json:"x-felis-tier"`
|
||||||
|
Callers []string `json:"x-felis-callers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// oasFacet is the classification of one {method, path}: which face(s) serve it
|
// oasFacet is the classification of one {method, path}: which face(s) serve it
|
||||||
@@ -49,6 +50,8 @@ type oasOp struct {
|
|||||||
type oasFacet struct {
|
type oasFacet struct {
|
||||||
faces map[string]bool
|
faces map[string]bool
|
||||||
tier string
|
tier string
|
||||||
|
// callers is the internal route's caller set, empty elsewhere.
|
||||||
|
callers map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
||||||
@@ -80,6 +83,10 @@ func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
|||||||
if s.tier != d.tier {
|
if s.tier != d.tier {
|
||||||
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier)
|
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier)
|
||||||
}
|
}
|
||||||
|
if !oasSameSet(s.callers, d.callers) {
|
||||||
|
t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v",
|
||||||
|
key, oasSortedKeys(s.callers), oasSortedKeys(d.callers))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +99,14 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
a := &API{}
|
a := &API{}
|
||||||
out := map[string]oasFacet{}
|
out := map[string]oasFacet{}
|
||||||
add := func(method, pattern, face, tier string) {
|
add := func(method, pattern, face, tier string, callers ...Caller) {
|
||||||
key := method + " " + pattern
|
key := method + " " + pattern
|
||||||
f, ok := out[key]
|
f, ok := out[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
f = oasFacet{faces: map[string]bool{}}
|
f = oasFacet{faces: map[string]bool{}, callers: map[string]bool{}}
|
||||||
|
}
|
||||||
|
for _, c := range callers {
|
||||||
|
f.callers[string(c)] = true
|
||||||
}
|
}
|
||||||
f.faces[face] = true
|
f.faces[face] = true
|
||||||
if f.tier != "" && f.tier != tier {
|
if f.tier != "" && f.tier != tier {
|
||||||
@@ -110,7 +120,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
if rt.Public {
|
if rt.Public {
|
||||||
tier = "public"
|
tier = "public"
|
||||||
}
|
}
|
||||||
add(rt.Method, rt.Pattern, "internal", tier)
|
add(rt.Method, rt.Pattern, "internal", tier, rt.Callers...)
|
||||||
}
|
}
|
||||||
for _, rt := range a.externalAPIRoutes() {
|
for _, rt := range a.externalAPIRoutes() {
|
||||||
var tier string
|
var tier string
|
||||||
@@ -172,7 +182,11 @@ func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
if _, dup := out[key]; dup {
|
if _, dup := out[key]; dup {
|
||||||
t.Errorf("%s: documented more than once", key)
|
t.Errorf("%s: documented more than once", key)
|
||||||
}
|
}
|
||||||
out[key] = oasFacet{faces: faces, tier: op.Tier}
|
callers := map[string]bool{}
|
||||||
|
for _, c := range op.Callers {
|
||||||
|
callers[c] = true
|
||||||
|
}
|
||||||
|
out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) {
|
|||||||
func TestInternalSubmissionContextRoute(t *testing.T) {
|
func TestInternalSubmissionContextRoute(t *testing.T) {
|
||||||
newAPI := func(s SubmissionService) *API {
|
newAPI := func(s SubmissionService) *API {
|
||||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
api.Internal = okInternal{caller: CallerBuild}
|
||||||
api.Submissions = s
|
api.Submissions = s
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,15 +291,17 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
|||||||
Name: ContainerFetch,
|
Name: ContainerFetch,
|
||||||
Image: p.FelisImage,
|
Image: p.FelisImage,
|
||||||
Args: fetchArgs(p),
|
Args: fetchArgs(p),
|
||||||
// The internal face is service-token gated, and the token is read from a
|
// The internal face is token gated, and the build caller's token
|
||||||
// Secret the installer materializes in THIS namespace (secretKeyRef is
|
// (felis-build-token, which reads a submission's context and nothing
|
||||||
// namespace-local). It is mounted into this initContainer only: the Kaniko
|
// else) is read from a Secret the installer materializes in THIS
|
||||||
|
// namespace (secretKeyRef is namespace-local). It is mounted into this
|
||||||
|
// initContainer only: the Kaniko
|
||||||
// container executes the untrusted Dockerfile and must never hold it, and
|
// container executes the untrusted Dockerfile and must never hold it, and
|
||||||
// pod containers share neither environment nor PID namespace.
|
// pod containers share neither environment nor PID namespace.
|
||||||
Env: []corev1.EnvVar{{
|
Env: []corev1.EnvVar{{
|
||||||
Name: "FELIS_SERVICE_TOKEN",
|
Name: "FELIS_SERVICE_TOKEN",
|
||||||
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
|
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
|
||||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName},
|
LocalObjectReference: corev1.LocalObjectReference{Name: naming.BuildTokenSecretName},
|
||||||
Key: naming.ServiceTokenSecretKey,
|
Key: naming.ServiceTokenSecretKey,
|
||||||
}},
|
}},
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ func TestBuildJobFetchesHTTPContext(t *testing.T) {
|
|||||||
if fetchToken.Value != "" {
|
if fetchToken.Value != "" {
|
||||||
t.Error("fetch container must not carry a literal token")
|
t.Error("fetch container must not carry a literal token")
|
||||||
}
|
}
|
||||||
|
// The build token reads a submission's context and nothing else; the proxy's
|
||||||
|
// felis-service-token must never be copied into the build namespace.
|
||||||
|
if ref := fetchToken.ValueFrom.SecretKeyRef; ref.Name != "felis-build-token" || ref.Key != "token" {
|
||||||
|
t.Errorf("fetch token reads %s/%s, want felis-build-token/token", ref.Name, ref.Key)
|
||||||
|
}
|
||||||
if len(kaniko.Env) != 0 {
|
if len(kaniko.Env) != 0 {
|
||||||
t.Errorf("kaniko must carry no env (especially no token), got %v", kaniko.Env)
|
t.Errorf("kaniko must carry no env (especially no token), got %v", kaniko.Env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,16 +55,23 @@ func IsSystemServer(name string) bool {
|
|||||||
return name == SystemLoginServer || name == SystemLobbyServer
|
return name == SystemLoginServer || name == SystemLobbyServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
|
// ServiceTokenSecretName / ServiceTokenSecretKey name the proxy's internal-API
|
||||||
// credential Secret (spec §7). They are one source of truth shared across
|
// bearer credential Secret (spec §7); CallerTokens lists it with the tokens the
|
||||||
// subsystems: the platform renderer wires this Secret into the felis-api
|
// other internal callers hold. The Secrets are provisioned out-of-band
|
||||||
// Deployment, and the operator injects it into the login system server's pod as
|
// (deploy/bootstrap.sh) and replicated by `felis setup` into the namespace whose
|
||||||
// FELIS_SERVICE_TOKEN via a secretKeyRef (never a literal). The Secret itself is
|
// pods mount them; these constants only name them.
|
||||||
// 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 (
|
const (
|
||||||
ServiceTokenSecretName = "felis-service-token"
|
ServiceTokenSecretName = "felis-service-token"
|
||||||
ServiceTokenSecretKey = "token"
|
ServiceTokenSecretKey = "token"
|
||||||
|
// LimboTokenSecretName is the login gate's token, replicated into the
|
||||||
|
// minecraft namespace; the operator injects it into the login pod only.
|
||||||
|
LimboTokenSecretName = "felis-limbo-token"
|
||||||
|
// BuildTokenSecretName is the build Job's token, replicated into the build
|
||||||
|
// namespace for the context-fetch initContainer.
|
||||||
|
BuildTokenSecretName = "felis-build-token"
|
||||||
|
// OpsTokenSecretName is the on-node console's token (`felis backup-now`); it
|
||||||
|
// stays in the control namespace.
|
||||||
|
OpsTokenSecretName = "felis-ops-token"
|
||||||
// EnvAPIBaseURL carries the internal-face base URL (platform.InternalAPIBaseURL)
|
// EnvAPIBaseURL carries the internal-face base URL (platform.InternalAPIBaseURL)
|
||||||
// into a pod: the login gate dials it, and the api reads it to derive the build
|
// into a pod: the login gate dials it, and the api reads it to derive the build
|
||||||
// contexts' fetch URLs, so both sides name the same address for the same face.
|
// contexts' fetch URLs, so both sides name the same address for the same face.
|
||||||
@@ -209,3 +216,25 @@ func ValidateHostname(host, rootDomain string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallerToken ties one internal-face caller (api.Caller) to the Secret holding
|
||||||
|
// its token, the env var felis-api reads that token from, and the namespace a
|
||||||
|
// replica of the Secret must reach for the caller's pods ("" when the caller
|
||||||
|
// runs outside the cluster or in the control namespace).
|
||||||
|
type CallerToken struct {
|
||||||
|
Caller string
|
||||||
|
Secret string
|
||||||
|
APIEnv string
|
||||||
|
// Replica names where the caller's pods run: "minecraft" or "build".
|
||||||
|
Replica string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallerTokens is every internal caller, one token each. felis-api refuses to
|
||||||
|
// start when two share a value, so a token copied from one namespace opens only
|
||||||
|
// the routes that caller is listed on.
|
||||||
|
var CallerTokens = []CallerToken{
|
||||||
|
{Caller: "velocity", Secret: ServiceTokenSecretName, APIEnv: "FELIS_SERVICE_TOKEN"},
|
||||||
|
{Caller: "limbo", Secret: LimboTokenSecretName, APIEnv: "FELIS_LIMBO_TOKEN", Replica: "minecraft"},
|
||||||
|
{Caller: "build", Secret: BuildTokenSecretName, APIEnv: "FELIS_BUILD_TOKEN", Replica: "build"},
|
||||||
|
{Caller: "ops", Secret: OpsTokenSecretName, APIEnv: "FELIS_OPS_TOKEN"},
|
||||||
|
}
|
||||||
@@ -323,21 +323,22 @@ func buildEnv(server *v1alpha1.MinecraftServer) []corev1.EnvVar {
|
|||||||
}
|
}
|
||||||
// The login system server is the ONE workload that authenticates to the
|
// 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
|
// 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
|
// link status), so it — and only it — receives a token: felis-limbo-token,
|
||||||
|
// which the api serves on those routes alone. Injected
|
||||||
// from a Secret in this namespace, never inlined into the CRD (the same
|
// 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
|
// discipline as RCON_PASSWORD above; the CRD's EnvVar type has no valueFrom
|
||||||
// precisely so a user server cannot mount an arbitrary secret). Require both
|
// 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
|
// 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.
|
// a legacy user server named "login" from receiving the token after upgrade.
|
||||||
// The Secret must exist in this (minecraft) namespace; `felis setup` replicates
|
// The Secret must exist in this (minecraft) namespace; the installer applies it
|
||||||
// it there from the control namespace before creating this server.
|
// there and `felis setup` replicates it from the control namespace.
|
||||||
if server.Name == naming.SystemLoginServer &&
|
if server.Name == naming.SystemLoginServer &&
|
||||||
server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLoginServer {
|
server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLoginServer {
|
||||||
env = append(env, corev1.EnvVar{
|
env = append(env, corev1.EnvVar{
|
||||||
Name: envServiceToken,
|
Name: envServiceToken,
|
||||||
ValueFrom: &corev1.EnvVarSource{
|
ValueFrom: &corev1.EnvVarSource{
|
||||||
SecretKeyRef: &corev1.SecretKeySelector{
|
SecretKeyRef: &corev1.SecretKeySelector{
|
||||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName},
|
LocalObjectReference: corev1.LocalObjectReference{Name: naming.LimboTokenSecretName},
|
||||||
Key: naming.ServiceTokenSecretKey,
|
Key: naming.ServiceTokenSecretKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -227,9 +227,10 @@ func TestBuildStatefulSetAddsHealthPort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The login system server (and ONLY it) receives the service token, sourced from a
|
// The login system server (and ONLY it) receives the login gate's own token,
|
||||||
// Secret via secretKeyRef — never a literal — so its felis-limbo plugin can
|
// sourced from a Secret via secretKeyRef — never a literal — so its felis-limbo
|
||||||
// authenticate to the felis-api internal face.
|
// plugin can authenticate to the felis-api internal face as the limbo caller. It
|
||||||
|
// must not be the proxy's felis-service-token, which opens every game route.
|
||||||
func TestBuildEnvInjectsServiceTokenForLogin(t *testing.T) {
|
func TestBuildEnvInjectsServiceTokenForLogin(t *testing.T) {
|
||||||
s := &v1alpha1.MinecraftServer{}
|
s := &v1alpha1.MinecraftServer{}
|
||||||
s.Name = naming.SystemLoginServer
|
s.Name = naming.SystemLoginServer
|
||||||
@@ -245,8 +246,8 @@ func TestBuildEnvInjectsServiceTokenForLogin(t *testing.T) {
|
|||||||
t.Fatalf("%s must be sourced from a secretKeyRef", envServiceToken)
|
t.Fatalf("%s must be sourced from a secretKeyRef", envServiceToken)
|
||||||
}
|
}
|
||||||
ref := tok.ValueFrom.SecretKeyRef
|
ref := tok.ValueFrom.SecretKeyRef
|
||||||
if ref.Name != naming.ServiceTokenSecretName || ref.Key != naming.ServiceTokenSecretKey {
|
if ref.Name != "felis-limbo-token" || ref.Key != "token" {
|
||||||
t.Errorf("secretKeyRef = %s/%s, want %s/%s", ref.Name, ref.Key, naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey)
|
t.Errorf("secretKeyRef = %s/%s, want felis-limbo-token/token", ref.Name, ref.Key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,19 +50,17 @@ import (
|
|||||||
// kubernetes.io/hostname selector and PV node affinity) or the CronJob could
|
// kubernetes.io/hostname selector and PV node affinity) or the CronJob could
|
||||||
// schedule on a node where the worlds-root is empty.
|
// schedule on a node where the worlds-root is empty.
|
||||||
const (
|
const (
|
||||||
// configSecretName / serviceTokenSecretName are referenced BY NAME and NEVER
|
// configSecretName and the caller token Secrets (naming.CallerTokens) are
|
||||||
// rendered into the bundle: felis.toml carries the database URL (a credential)
|
// referenced BY NAME and NEVER rendered into the bundle: felis.toml carries the
|
||||||
// and the service token is a credential, so writing either into a checked-in
|
// database URL (a credential) and each token is a credential, so writing any of
|
||||||
// manifest is a hard red line. The deployment provisions both Secrets
|
// them into a checked-in manifest is a hard red line. The deployment provisions
|
||||||
// out-of-band before applying these workloads.
|
// these Secrets out-of-band before applying these workloads.
|
||||||
configSecretName = "felis-config"
|
configSecretName = "felis-config"
|
||||||
configSecretKey = "felis.toml"
|
configSecretKey = "felis.toml"
|
||||||
configMountPath = "/etc/felis"
|
configMountPath = "/etc/felis"
|
||||||
configFilePath = "/etc/felis/felis.toml"
|
configFilePath = "/etc/felis/felis.toml"
|
||||||
felisBinaryPath = "/usr/local/bin/felis"
|
felisBinaryPath = "/usr/local/bin/felis"
|
||||||
// Single-sourced with the operator, which injects the same Secret into the
|
// The key every caller token Secret stores its value under (internal/naming).
|
||||||
// login system server's pod (see internal/naming).
|
|
||||||
serviceTokenSecretName = naming.ServiceTokenSecretName
|
|
||||||
serviceTokenSecretKey = naming.ServiceTokenSecretKey
|
serviceTokenSecretKey = naming.ServiceTokenSecretKey
|
||||||
|
|
||||||
// Ports, single-sourced with the entrypoints (cmd/felis). The api external
|
// Ports, single-sourced with the entrypoints (cmd/felis). The api external
|
||||||
@@ -323,8 +321,8 @@ func retentionEnabled(p Params) bool {
|
|||||||
// fence and is asserted in workloads_test.go.
|
// fence and is asserted in workloads_test.go.
|
||||||
//
|
//
|
||||||
// felis.toml is mounted read-only from a Secret (it carries the database URL, a
|
// felis.toml is mounted read-only from a Secret (it carries the database URL, a
|
||||||
// credential, so it must never be a ConfigMap); FELIS_SERVICE_TOKEN comes from a
|
// credential, so it must never be a ConfigMap); each internal caller's token
|
||||||
// second Secret by reference. FELIS_IMAGE is the felis image itself, so the
|
// (naming.CallerTokens) comes from its own Secret by reference. FELIS_IMAGE is the felis image itself, so the
|
||||||
// restore executor launches `felis restore` with the same image. FELIS_BACKUP_PVC
|
// restore executor launches `felis restore` with the same image. FELIS_BACKUP_PVC
|
||||||
// is rendered only when a backup PVC is named — otherwise the restore endpoint
|
// is rendered only when a backup PVC is named — otherwise the restore endpoint
|
||||||
// degrades to 503 rather than enqueuing a Job that cannot mount its backup.
|
// degrades to 503 rather than enqueuing a Job that cannot mount its backup.
|
||||||
@@ -336,22 +334,29 @@ func retentionEnabled(p Params) bool {
|
|||||||
func APIDeployment(p Params) *appsv1.Deployment {
|
func APIDeployment(p Params) *appsv1.Deployment {
|
||||||
p = p.withDefaults()
|
p = p.withDefaults()
|
||||||
|
|
||||||
env := []corev1.EnvVar{
|
var env []corev1.EnvVar
|
||||||
{
|
// Every internal caller's token, each from its own Secret. Required: the
|
||||||
Name: "FELIS_SERVICE_TOKEN",
|
// installer applies all four before this Deployment, and a missing one should
|
||||||
|
// stall the rollout on the old pods rather than start an api that turns that
|
||||||
|
// caller away.
|
||||||
|
for _, ct := range naming.CallerTokens {
|
||||||
|
env = append(env, corev1.EnvVar{
|
||||||
|
Name: ct.APIEnv,
|
||||||
ValueFrom: &corev1.EnvVarSource{
|
ValueFrom: &corev1.EnvVarSource{
|
||||||
SecretKeyRef: &corev1.SecretKeySelector{
|
SecretKeyRef: &corev1.SecretKeySelector{
|
||||||
LocalObjectReference: corev1.LocalObjectReference{Name: serviceTokenSecretName},
|
LocalObjectReference: corev1.LocalObjectReference{Name: ct.Secret},
|
||||||
Key: serviceTokenSecretKey,
|
Key: serviceTokenSecretKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
{Name: "FELIS_IMAGE", Value: p.FelisImage},
|
}
|
||||||
|
env = append(env,
|
||||||
|
corev1.EnvVar{Name: "FELIS_IMAGE", Value: p.FelisImage},
|
||||||
// The api's own internal-face base URL, so it derives the submission
|
// The api's own internal-face base URL, so it derives the submission
|
||||||
// context URLs that build Pods fetch through it. Same value the login gate
|
// context URLs that build Pods fetch through it. Same value the login gate
|
||||||
// is handed; one address for one face.
|
// is handed; one address for one face.
|
||||||
{Name: naming.EnvAPIBaseURL, Value: InternalAPIBaseURL(p.ControlNamespace)},
|
corev1.EnvVar{Name: naming.EnvAPIBaseURL, Value: InternalAPIBaseURL(p.ControlNamespace)},
|
||||||
}
|
)
|
||||||
if p.BackupPVC != "" {
|
if p.BackupPVC != "" {
|
||||||
env = append(env, corev1.EnvVar{Name: "FELIS_BACKUP_PVC", Value: p.BackupPVC})
|
env = append(env, corev1.EnvVar{Name: "FELIS_BACKUP_PVC", Value: p.BackupPVC})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,13 +233,28 @@ func TestAPIDeployment_Wiring(t *testing.T) {
|
|||||||
if v := envValue(c.Env, "FELIS_API_BASE_URL"); v != InternalAPIBaseURL(p.ControlNamespace) {
|
if v := envValue(c.Env, "FELIS_API_BASE_URL"); v != InternalAPIBaseURL(p.ControlNamespace) {
|
||||||
t.Errorf("FELIS_API_BASE_URL = %q, want %q", v, InternalAPIBaseURL(p.ControlNamespace))
|
t.Errorf("FELIS_API_BASE_URL = %q, want %q", v, InternalAPIBaseURL(p.ControlNamespace))
|
||||||
}
|
}
|
||||||
// FELIS_SERVICE_TOKEN must come from a Secret, never a literal value.
|
// Each internal caller's token comes from its own Secret, never a literal
|
||||||
tok := envVar(c.Env, "FELIS_SERVICE_TOKEN")
|
// value, and none is optional: a missing Secret must hold the rollout back.
|
||||||
|
for env, secret := range map[string]string{
|
||||||
|
"FELIS_SERVICE_TOKEN": "felis-service-token",
|
||||||
|
"FELIS_LIMBO_TOKEN": "felis-limbo-token",
|
||||||
|
"FELIS_BUILD_TOKEN": "felis-build-token",
|
||||||
|
"FELIS_OPS_TOKEN": "felis-ops-token",
|
||||||
|
} {
|
||||||
|
tok := envVar(c.Env, env)
|
||||||
if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil {
|
if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil {
|
||||||
t.Fatal("FELIS_SERVICE_TOKEN must be sourced from a secretKeyRef")
|
t.Fatalf("%s must be sourced from a secretKeyRef", env)
|
||||||
|
}
|
||||||
|
ref := tok.ValueFrom.SecretKeyRef
|
||||||
|
if ref.Name != secret || ref.Key != "token" {
|
||||||
|
t.Errorf("%s reads %s/%s, want %s/token", env, ref.Name, ref.Key, secret)
|
||||||
|
}
|
||||||
|
if ref.Optional != nil && *ref.Optional {
|
||||||
|
t.Errorf("%s is optional; the api must not start without it", env)
|
||||||
}
|
}
|
||||||
if tok.Value != "" {
|
if tok.Value != "" {
|
||||||
t.Error("FELIS_SERVICE_TOKEN must not carry a literal value")
|
t.Errorf("%s must not carry a literal value", env)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// felis.toml carries the DB URL, so its volume must be a Secret (NOT a
|
// felis.toml carries the DB URL, so its volume must be a Secret (NOT a
|
||||||
|
|||||||
+5
-2
@@ -229,8 +229,11 @@ Drop the matching jar into the server/proxy mods or plugins directory, start
|
|||||||
once to generate `config/felis-link.properties` (or `plugins/felis-link/…` on
|
once to generate `config/felis-link.properties` (or `plugins/felis-link/…` on
|
||||||
Velocity), then set `api-base-url` and `service-token` — or provide
|
Velocity), then set `api-base-url` and `service-token` — or provide
|
||||||
`FELIS_API_BASE_URL` and `FELIS_SERVICE_TOKEN` in the environment, which take
|
`FELIS_API_BASE_URL` and `FELIS_SERVICE_TOKEN` in the environment, which take
|
||||||
precedence. The service token is the same one felis-api compares for its
|
precedence. The token is one of felis-api's per-caller internal tokens: the
|
||||||
internal endpoints; treat it as a secret.
|
Velocity proxy uses the `velocity` token (Secret `felis/felis-service-token`), the
|
||||||
|
login gate the `limbo` token (`felis-limbo-token`), and each serves only its own
|
||||||
|
routes (a token on another caller's route gets `403 wrong_caller`). Treat it as a
|
||||||
|
secret; `sudo felis rotate-token <caller>` replaces it.
|
||||||
|
|
||||||
On **Velocity**, also set `root-domain` (and optionally `lobby-server`) in the
|
On **Velocity**, also set `root-domain` (and optionally `lobby-server`) in the
|
||||||
same file to turn on §11 routing, and make sure `online-mode=true` in
|
same file to turn on §11 routing, and make sure `online-mode=true` in
|
||||||
|
|||||||
Reference in new issue
Block a user