feat(api): 内部面 token 按调用方拆分为 velocity/limbo/build/ops 并按路由限定调用方,审计来源区分调用方,安装器生成并下发各自 Secret,新增 felis rotate-token 轮换命令
This commit is contained in:
38 files changed
+1359
-209
No files matched your search
+51
-16
@@ -14,6 +14,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -352,6 +353,10 @@ type apiRoute struct {
|
||||
// since the browser calls it every few seconds while it waits.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -359,6 +364,14 @@ type apiRoute struct {
|
||||
// service-token auth, never Zero Trust. It carries both health probes and the
|
||||
// metrics scrape.
|
||||
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{
|
||||
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
|
||||
{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.
|
||||
{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
|
||||
// modpack through this route (build namespace cannot mount the uploads PVC).
|
||||
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", h: a.handleInternalSubmissionContext},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
|
||||
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", Callers: build, h: a.handleInternalSubmissionContext},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", Callers: proxy, h: a.handleReady},
|
||||
{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
|
||||
// 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;
|
||||
// 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: "GET", Pattern: "/api/v1/internal/servers/{name}/status", h: a.handleStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/wake", Callers: proxy, h: a.handleInternalWake},
|
||||
{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
|
||||
// token, so velocity drives these on its behalf — claim by online-mode UUID
|
||||
// (the lobby's `Claim & Start`, separate from the autostartPolicy-gated wake)
|
||||
// and the menu projection that adds the ownership-derived `claimable` the §11
|
||||
// list/status views never carry.
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", h: a.handleInternalClaim},
|
||||
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", h: a.handleInternalMenuStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim},
|
||||
{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
|
||||
// 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).
|
||||
{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
|
||||
// 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
|
||||
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
|
||||
// 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 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
|
||||
// (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
|
||||
// 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 —
|
||||
// velocity holds a service token, and the bar is keyed by UUID so the genuine
|
||||
// Mojang player (same name, different UUID) always passes.
|
||||
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
|
||||
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", Callers: proxy, h: a.handleReclaimUsername},
|
||||
{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
|
||||
// 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
|
||||
@@ -419,13 +432,13 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
||||
// /felis web op approve. Internal face carries the pending queue and the
|
||||
// approve action (service-token auth, no Principal); the public face carries
|
||||
// the start/status/finish the staff member's browser drives.
|
||||
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
|
||||
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", Callers: proxy, h: a.handleOpLoginPending},
|
||||
{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
|
||||
// snapshot a stopped world while the API is alive. Service-token auth (no
|
||||
// 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
|
||||
}
|
||||
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 {
|
||||
h = a.ownerOnly(rt.h)
|
||||
}
|
||||
@@ -832,6 +851,7 @@ const (
|
||||
ctxKeyRequestID ctxKey = iota
|
||||
ctxKeyPrincipal
|
||||
ctxKeyReqInfo
|
||||
ctxKeyCaller
|
||||
)
|
||||
|
||||
func requestIDFromContext(ctx context.Context) string {
|
||||
@@ -849,6 +869,21 @@ func principalFromContext(ctx context.Context) *Principal {
|
||||
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) ----
|
||||
|
||||
// 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 }
|
||||
|
||||
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 ----
|
||||
|
||||
@@ -1798,7 +1804,7 @@ func decodeErr(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||
|
||||
func TestInternalFaceRequiresServiceToken(t *testing.T) {
|
||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
||||
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
|
||||
h := api.InternalHandler()
|
||||
|
||||
// no token -> 401
|
||||
@@ -1817,7 +1823,7 @@ func TestInternalFaceRequiresServiceToken(t *testing.T) {
|
||||
|
||||
func TestHealthzIsUnauthenticated(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("healthz code = %d, want 200", w.Code)
|
||||
}
|
||||
@@ -1829,7 +1835,7 @@ func TestReadyzPingsDependencies(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
cl := newFakeCluster()
|
||||
api := newTestAPI(repo, cl)
|
||||
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
|
||||
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
|
||||
|
||||
// Both healthy.
|
||||
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
|
||||
}
|
||||
|
||||
// InternalAuth authenticates the internal face (velocity / backend callbacks):
|
||||
// a static service token presented as a Bearer credential. The internal face
|
||||
// is never wrapped in Zero Trust (spec §1.8, §14 red line).
|
||||
// Caller names the machine behind an internal-face token. Each caller holds a
|
||||
// token of its own and each internal route lists the callers it serves
|
||||
// (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 {
|
||||
Authenticate(r *http.Request) error
|
||||
Authenticate(r *http.Request) (Caller, error)
|
||||
}
|
||||
|
||||
// ExternalAuth authenticates the external face (people / panel) and returns the
|
||||
@@ -86,26 +108,45 @@ type ExternalAuth interface {
|
||||
Authenticate(r *http.Request) (*Principal, error)
|
||||
}
|
||||
|
||||
// BearerTokenAuth is the production InternalAuth: a constant-time comparison
|
||||
// against the configured service token. A zero token fails closed so a
|
||||
// misconfiguration can never silently disable internal-face auth.
|
||||
type BearerTokenAuth struct {
|
||||
Token string
|
||||
// CallerTokens is the production InternalAuth: each caller's token, compared in
|
||||
// constant time. A caller with no token cannot authenticate, so a missing
|
||||
// Secret fails closed for that caller alone.
|
||||
type CallerTokens map[Caller]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.
|
||||
func (b BearerTokenAuth) Authenticate(r *http.Request) error {
|
||||
if b.Token == "" {
|
||||
return fmt.Errorf("internal auth not configured")
|
||||
}
|
||||
// Authenticate matches the Authorization: Bearer header against every caller's
|
||||
// token, comparing each so the time taken does not say which one matched.
|
||||
func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
|
||||
got := bearerToken(r)
|
||||
if got == "" {
|
||||
return fmt.Errorf("missing bearer token")
|
||||
return "", fmt.Errorf("missing bearer token")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(b.Token)) != 1 {
|
||||
return fmt.Errorf("invalid service token")
|
||||
var match Caller
|
||||
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
|
||||
|
||||
@@ -163,8 +163,10 @@ var (
|
||||
// 503 "retry" instead of a 401 that reads as "log in again".
|
||||
errAuthUnavailable = newError(http.StatusServiceUnavailable, "auth_unavailable",
|
||||
"authentication is temporarily unavailable; retry shortly")
|
||||
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
|
||||
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
|
||||
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")
|
||||
)
|
||||
|
||||
// writeJSON writes v as an indented JSON body with the given status.
|
||||
|
||||
@@ -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'.
|
||||
a.auditEntry(r, AuditEntry{
|
||||
Actor: "mc:" + mcUUID,
|
||||
Source: "internal",
|
||||
Source: internalSource(r),
|
||||
Action: "account.migrate.start",
|
||||
})
|
||||
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
|
||||
// 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
|
||||
// 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) {
|
||||
mk := func() (*API, *fakeRepo, *fakeCluster, *fakeBackuper) {
|
||||
repo := newFakeRepo()
|
||||
@@ -282,6 +282,7 @@ func TestInternalBackup(t *testing.T) {
|
||||
Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
|
||||
backuper := &fakeBackuper{}
|
||||
api := newTestAPI(repo, cl)
|
||||
api.Internal = okInternal{caller: CallerOps}
|
||||
api.Backuper = backuper
|
||||
return api, repo, cl, backuper
|
||||
}
|
||||
@@ -301,7 +302,7 @@ func TestInternalBackup(t *testing.T) {
|
||||
backuper.calls, backuper.gotName, backuper.gotFormerOwn)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -312,7 +313,7 @@ func TestInternalBackup(t *testing.T) {
|
||||
if w.Code != http.StatusAccepted {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -375,7 +375,7 @@ func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
@@ -56,7 +56,7 @@ func (a *API) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
||||
// untouched and the next join attempt is not also throttled.
|
||||
a.limiter().record(name)
|
||||
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{
|
||||
"name": name, "desiredState": "Running",
|
||||
@@ -259,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func assertEq(t *testing.T, key string, got, want any) {
|
||||
func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
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,
|
||||
})
|
||||
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{
|
||||
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
|
||||
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",
|
||||
"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.
|
||||
payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID})
|
||||
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{
|
||||
"blacklisted": true,
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
|
||||
t.Fatalf("audits = %d, want 1 refusal row", len(repo.audits))
|
||||
}
|
||||
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)
|
||||
}
|
||||
var p map[string]string
|
||||
@@ -278,7 +278,7 @@ func TestReclaimAudited(t *testing.T) {
|
||||
t.Fatalf("audits = %d, want 1", len(repo.audits))
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// requireInternal enforces service-token auth for the internal face. It never
|
||||
// applies Zero Trust (spec §14 red line).
|
||||
// requireInternal enforces service-token auth for the internal face and stashes
|
||||
// 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 {
|
||||
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)
|
||||
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
|
||||
// resolved Principal in the request context.
|
||||
func (a *API) requireExternal(next http.Handler) http.Handler {
|
||||
|
||||
@@ -40,8 +40,9 @@ type oasDoc struct {
|
||||
}
|
||||
|
||||
type oasOp struct {
|
||||
Faces []string `json:"x-felis-face"`
|
||||
Tier string `json:"x-felis-tier"`
|
||||
Faces []string `json:"x-felis-face"`
|
||||
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
|
||||
@@ -49,6 +50,8 @@ type oasOp struct {
|
||||
type oasFacet struct {
|
||||
faces map[string]bool
|
||||
tier string
|
||||
// callers is the internal route's caller set, empty elsewhere.
|
||||
callers map[string]bool
|
||||
}
|
||||
|
||||
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
||||
@@ -80,6 +83,10 @@ func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
||||
if 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()
|
||||
a := &API{}
|
||||
out := map[string]oasFacet{}
|
||||
add := func(method, pattern, face, tier string) {
|
||||
add := func(method, pattern, face, tier string, callers ...Caller) {
|
||||
key := method + " " + pattern
|
||||
f, ok := out[key]
|
||||
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
|
||||
if f.tier != "" && f.tier != tier {
|
||||
@@ -110,7 +120,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
||||
if rt.Public {
|
||||
tier = "public"
|
||||
}
|
||||
add(rt.Method, rt.Pattern, "internal", tier)
|
||||
add(rt.Method, rt.Pattern, "internal", tier, rt.Callers...)
|
||||
}
|
||||
for _, rt := range a.externalAPIRoutes() {
|
||||
var tier string
|
||||
@@ -172,7 +182,11 @@ func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
|
||||
if _, dup := out[key]; dup {
|
||||
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
|
||||
|
||||
@@ -605,6 +605,7 @@ func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) {
|
||||
func TestInternalSubmissionContextRoute(t *testing.T) {
|
||||
newAPI := func(s SubmissionService) *API {
|
||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
api.Internal = okInternal{caller: CallerBuild}
|
||||
api.Submissions = s
|
||||
return api
|
||||
}
|
||||
|
||||
@@ -291,15 +291,17 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
Name: ContainerFetch,
|
||||
Image: p.FelisImage,
|
||||
Args: fetchArgs(p),
|
||||
// The internal face is service-token gated, and the token is read from a
|
||||
// Secret the installer materializes in THIS namespace (secretKeyRef is
|
||||
// namespace-local). It is mounted into this initContainer only: the Kaniko
|
||||
// The internal face is token gated, and the build caller's token
|
||||
// (felis-build-token, which reads a submission's context and nothing
|
||||
// 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
|
||||
// pod containers share neither environment nor PID namespace.
|
||||
Env: []corev1.EnvVar{{
|
||||
Name: "FELIS_SERVICE_TOKEN",
|
||||
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName},
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.BuildTokenSecretName},
|
||||
Key: naming.ServiceTokenSecretKey,
|
||||
}},
|
||||
}},
|
||||
|
||||
@@ -450,6 +450,11 @@ func TestBuildJobFetchesHTTPContext(t *testing.T) {
|
||||
if fetchToken.Value != "" {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
|
||||
// credential Secret (spec §7). They are one source of truth shared across
|
||||
// subsystems: the platform renderer wires this Secret into the felis-api
|
||||
// Deployment, and the operator injects it into the login system server's pod as
|
||||
// FELIS_SERVICE_TOKEN via a secretKeyRef (never a literal). The Secret itself is
|
||||
// provisioned out-of-band (deploy/bootstrap.sh) and, for the login gate, replicated
|
||||
// into the minecraft namespace by `felis setup`; these constants only name it.
|
||||
// ServiceTokenSecretName / ServiceTokenSecretKey name the proxy's internal-API
|
||||
// bearer credential Secret (spec §7); CallerTokens lists it with the tokens the
|
||||
// other internal callers hold. The Secrets are provisioned out-of-band
|
||||
// (deploy/bootstrap.sh) and replicated by `felis setup` into the namespace whose
|
||||
// pods mount them; these constants only name them.
|
||||
const (
|
||||
ServiceTokenSecretName = "felis-service-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)
|
||||
// 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.
|
||||
@@ -209,3 +216,25 @@ func ValidateHostname(host, rootDomain string) error {
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// the reserved name and the setup-owned system-role label: the label prevents
|
||||
// a legacy user server named "login" from receiving the token after upgrade.
|
||||
// The Secret must exist in this (minecraft) namespace; `felis setup` replicates
|
||||
// it there from the control namespace before creating this server.
|
||||
// The Secret must exist in this (minecraft) namespace; the installer applies it
|
||||
// there and `felis setup` replicates it from the control namespace.
|
||||
if server.Name == naming.SystemLoginServer &&
|
||||
server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLoginServer {
|
||||
env = append(env, corev1.EnvVar{
|
||||
Name: envServiceToken,
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName},
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: naming.LimboTokenSecretName},
|
||||
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
|
||||
// Secret via secretKeyRef — never a literal — so its felis-limbo plugin can
|
||||
// authenticate to the felis-api internal face.
|
||||
// The login system server (and ONLY it) receives the login gate's own token,
|
||||
// sourced from a Secret via secretKeyRef — never a literal — so its felis-limbo
|
||||
// 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) {
|
||||
s := &v1alpha1.MinecraftServer{}
|
||||
s.Name = naming.SystemLoginServer
|
||||
@@ -245,8 +246,8 @@ func TestBuildEnvInjectsServiceTokenForLogin(t *testing.T) {
|
||||
t.Fatalf("%s must be sourced from a secretKeyRef", envServiceToken)
|
||||
}
|
||||
ref := tok.ValueFrom.SecretKeyRef
|
||||
if ref.Name != naming.ServiceTokenSecretName || ref.Key != naming.ServiceTokenSecretKey {
|
||||
t.Errorf("secretKeyRef = %s/%s, want %s/%s", ref.Name, ref.Key, naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey)
|
||||
if ref.Name != "felis-limbo-token" || ref.Key != "token" {
|
||||
t.Errorf("secretKeyRef = %s/%s, want felis-limbo-token/token", ref.Name, ref.Key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,20 +50,18 @@ import (
|
||||
// kubernetes.io/hostname selector and PV node affinity) or the CronJob could
|
||||
// schedule on a node where the worlds-root is empty.
|
||||
const (
|
||||
// configSecretName / serviceTokenSecretName are referenced BY NAME and NEVER
|
||||
// rendered into the bundle: felis.toml carries the database URL (a credential)
|
||||
// and the service token is a credential, so writing either into a checked-in
|
||||
// manifest is a hard red line. The deployment provisions both Secrets
|
||||
// out-of-band before applying these workloads.
|
||||
// configSecretName and the caller token Secrets (naming.CallerTokens) are
|
||||
// referenced BY NAME and NEVER rendered into the bundle: felis.toml carries the
|
||||
// database URL (a credential) and each token is a credential, so writing any of
|
||||
// them into a checked-in manifest is a hard red line. The deployment provisions
|
||||
// these Secrets out-of-band before applying these workloads.
|
||||
configSecretName = "felis-config"
|
||||
configSecretKey = "felis.toml"
|
||||
configMountPath = "/etc/felis"
|
||||
configFilePath = "/etc/felis/felis.toml"
|
||||
felisBinaryPath = "/usr/local/bin/felis"
|
||||
// Single-sourced with the operator, which injects the same Secret into the
|
||||
// login system server's pod (see internal/naming).
|
||||
serviceTokenSecretName = naming.ServiceTokenSecretName
|
||||
serviceTokenSecretKey = naming.ServiceTokenSecretKey
|
||||
// The key every caller token Secret stores its value under (internal/naming).
|
||||
serviceTokenSecretKey = naming.ServiceTokenSecretKey
|
||||
|
||||
// Ports, single-sourced with the entrypoints (cmd/felis). The api external
|
||||
// port must match server.listen in felis.toml (default 0.0.0.0:8080); that
|
||||
@@ -323,8 +321,8 @@ func retentionEnabled(p Params) bool {
|
||||
// fence and is asserted in workloads_test.go.
|
||||
//
|
||||
// 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
|
||||
// second Secret by reference. FELIS_IMAGE is the felis image itself, so the
|
||||
// credential, so it must never be a ConfigMap); each internal caller's token
|
||||
// (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
|
||||
// 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.
|
||||
@@ -336,22 +334,29 @@ func retentionEnabled(p Params) bool {
|
||||
func APIDeployment(p Params) *appsv1.Deployment {
|
||||
p = p.withDefaults()
|
||||
|
||||
env := []corev1.EnvVar{
|
||||
{
|
||||
Name: "FELIS_SERVICE_TOKEN",
|
||||
var env []corev1.EnvVar
|
||||
// Every internal caller's token, each from its own Secret. Required: the
|
||||
// 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{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: serviceTokenSecretName},
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: ct.Secret},
|
||||
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
|
||||
// context URLs that build Pods fetch through it. Same value the login gate
|
||||
// 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 != "" {
|
||||
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) {
|
||||
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.
|
||||
tok := envVar(c.Env, "FELIS_SERVICE_TOKEN")
|
||||
if tok == nil || tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil {
|
||||
t.Fatal("FELIS_SERVICE_TOKEN must be sourced from a secretKeyRef")
|
||||
}
|
||||
if tok.Value != "" {
|
||||
t.Error("FELIS_SERVICE_TOKEN must not carry a literal value")
|
||||
// Each internal caller's token comes from its own Secret, never a literal
|
||||
// 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 {
|
||||
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 != "" {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user