Files
Felis/internal/platform/rbac.go
T
flyemoji 694e3cb800 feat(rcon): provision per-server RCON so the console, player list and permissions work
A server created through the panel never had RCON. CreateServer built a
MinecraftServerSpec without a Rcon block at all, so the field took its zero value
and every downstream consumer read Enabled=false. Nothing failed loudly: the
operator skips the probe when RCON is off and marks the server Ready on pod
readiness alone, so the panel showed "运行中" for a server the control plane could
not talk to. Everything that rides the write channel (spec §8 写=RCON) was dead —
the online-player list returned nothing because Status.Players is only ever
sampled by the probe, and console writes answered 503 ErrConsoleUnavailable
because internal/api/console.go refuses when Enabled is false.

The whole RCON machinery already existed — builders gate the service port,
container port, preStop save-and-stop hook and the RCON_* env on Spec.Rcon,
the reconciler probes and reports, console.go dials, the NetworkPolicy opens
25575 to {api, operator}. The only thing missing was that nobody ever turned it
on or created a password. This wires the three layers that were absent.

Provisioning lives in the operator, not in felis-api. felis-api holds secrets:get
and not create, and giving it create solely to mint a password it immediately
stops caring about (console.go re-reads the Secret at command time) would widen
the API's powers for nothing. The operator already reads every Secret in the
namespace, so adding create there grants no read it did not have. It also makes
provisioning declarative: a Secret deleted by hand comes back on the next pass, a
controller reference garbage-collects it with the server so no delete path has to
remember it, and a server that predates RCON only needs spec.rcon filled in for
the password to appear. The name comes from naming.RconSecretName so felis-api,
`felis setup` and the operator cannot drift apart on it.

RCON is enabled per system service rather than by default, because enabling it on
a backend that serves no RCON listener is destructive rather than merely useless:
the operator gates readiness on the probe, so such a server never leaves Starting
and is eventually marked Failed. The login limbo is exactly that backend
(LOOHP/Limbo has no RCON) and it is the front door, so it stays off; the lobby
runs Paper and is administered through the panel like any other server, so it is
on.

Paper only reads RCON settings from server.properties, so the operator's injected
RCON_PASSWORD did nothing on its own — felis-lobby's entrypoint now writes the
three keys on every boot. Rewriting them each time makes the copy in the world
volume derived state rather than the source of truth, so an owner who edits them
through the panel's file editor cannot lock the control plane out of their own
server. Without a password it sets enable-rcon=false and warns rather than
refusing to start: unlike the forwarding secret, a missing RCON password degrades
the server rather than making it unsafe.

That password landing in server.properties is a §286 exposure (RCON 密码绝不下发
前端), since server.properties is readable through the file editor. It is redacted
on read rather than the file being denied outright the way config/paper-global.yml
is: the forwarding secret is cluster-wide material that merely happens to sit in
the volume, whereas server.properties is the single most-edited config an owner
has, and hiding one line should not cost them MOTD, difficulty and view-distance.
The write path is deliberately left alone — the boot-time rewrite restores the
real value, which is what makes redacting rather than denying safe here.

Also guards idle auto-stop on Rcon.Enabled. Status.Players is only meaningful
when the probe ran; with RCON off it keeps its zero value, which that branch would
have read as "empty" and used to stop a server full of people. AutoStopEnabled is
not currently settable through any path, so this is a latent footgun rather than a
live bug, but it is one line and the alternative is discovering it in production.

Checks: the operator provisions a missing Secret with a 32-hex-char password and a
controller reference, and does not rotate an existing one; idle auto-stop stays
inert without RCON; the editor redacts rcon.password from the world root's
server.properties while leaving the rest of the file (and a plugin's own nested
copy) intact; login has RCON off and lobby has it on with the shared secret name;
CreateServer sets the block. That last one departs from K8sCluster being
integration-tested against a live cluster: this defect was a struct literal
missing a field, it shipped, and a fake client is enough to pin a struct literal.

Existing servers are NOT migrated by this change — CreateServer only covers new
ones and ensureSystemServers is create-if-absent, so a `felis setup` re-run will
not touch an existing lobby. A deployed install additionally needs the
felis-lobby image rebuilt and re-imported for the entrypoint change, and its pods
recreated, before the RCON keys reach server.properties.
2026-07-21 00:12:37 +09:00

211 lines
11 KiB
Go

package platform
import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// API groups used by the rules. The felis group is sourced from v1alpha1 so the
// CRD's identity and its RBAC can never drift apart.
const (
groupCore = "" // core/v1: secrets, services, persistentvolumeclaims
groupApps = "apps"
groupBatch = "batch"
)
var groupFelis = v1alpha1.GroupName // "felis.lolicon.best"
// RBAC is the control-plane authorization bundle: one SA per identity and the
// namespaced Roles + RoleBindings that grant each exactly the verbs its code path
// exercises. There is deliberately no ClusterRole or ClusterRoleBinding anywhere.
type RBAC struct {
ServiceAccounts []*corev1.ServiceAccount
Roles []*rbacv1.Role
RoleBindings []*rbacv1.RoleBinding
}
// ControlPlaneRBAC assembles the full RBAC bundle for p.
//
// The reaper identity (felis-reaper SA + Role + RoleBinding) is rendered ONLY when
// the retention reaper CronJob is — both gate on reaperEnabled(p), the same storage
// trio (workloads.go). This coupling is deliberate least-privilege: the reaper's
// Role is the one and only place persistentvolumeclaims:delete appears in the whole
// bundle (world reclamation) — neither felis-api nor felis-operator can delete a
// PVC. Leaving that destructive grant standing in a deployment that never runs the
// reaper would widen the blast radius of a control-plane compromise for no benefit
// (a control-namespace foothold could mount felis-reaper and destroy world PVCs),
// since nothing would consume it. So the destructive identity exists exactly as
// long as its consumer does, and the manifests command's fail-loud trio check
// guarantees the CronJob and this RBAC are always rendered together or not at all.
func ControlPlaneRBAC(p Params) RBAC {
p = p.withDefaults()
rbac := RBAC{
ServiceAccounts: []*corev1.ServiceAccount{
controlPlaneServiceAccount(p.ControlNamespace, SAAPI, ComponentAPI),
controlPlaneServiceAccount(p.ControlNamespace, SAOperator, ComponentOperator),
},
Roles: []*rbacv1.Role{
APIMinecraftRole(p),
APIBuildRole(p),
OperatorRole(p),
},
// Each binding lives in the Role's namespace and names the subject SA in the
// control namespace (a RoleBinding may reference an SA from another namespace;
// its roleRef must be a Role in the binding's own namespace).
RoleBindings: []*rbacv1.RoleBinding{
bindRole(p.MinecraftNamespace, "felis-api", p.ControlNamespace, SAAPI, ComponentAPI),
bindRole(p.BuildNamespace, "felis-api-builds", p.ControlNamespace, SAAPI, ComponentAPI),
bindRole(p.MinecraftNamespace, "felis-operator", p.ControlNamespace, SAOperator, ComponentOperator),
},
}
// The destructive fourth power is conditional on its consumer (see the doc above).
if reaperEnabled(p) {
rbac.ServiceAccounts = append(rbac.ServiceAccounts,
controlPlaneServiceAccount(p.ControlNamespace, SAReaper, ComponentReaper))
rbac.Roles = append(rbac.Roles, ReaperRole(p))
rbac.RoleBindings = append(rbac.RoleBindings,
bindRole(p.MinecraftNamespace, "felis-reaper", p.ControlNamespace, SAReaper, ComponentReaper))
}
return rbac
}
// APIMinecraftRole grants felis-api exactly what it does in the minecraft
// namespace: drive MinecraftServer specs (internal/api.k8scluster — get/list/
// create/patch, never status), read RCON passwords for console writes
// (internal/api.console — secrets:get), create the restore Job
// (internal/restore — jobs:create), and stream the live console for the read
// side (internal/api.logstream — pods:list to find the server's running pod,
// then pods/log:get to follow it; spec §8 读=pods/log follow). felis-api uses a
// DIRECT client, so it needs no list/watch beyond the explicit List calls.
//
// The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT
// pods:get — the streamer lists pods by the server label then reads the chosen
// pod's log subresource, never Gets a pod object. Keeping pods:get out is the
// least-privilege line the rbac test asserts (a pod's full object can carry more
// than its logs).
func APIMinecraftRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-api", ComponentAPI, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "create", "patch"}),
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
rule([]string{groupBatch}, []string{"jobs"}, []string{"create"}),
// Read-side console (spec §8 读=pods/log follow): list pods to find the
// server's running pod, then read its log subresource. Two separate rules so
// the verbs stay tight — list on pods, get on pods/log, and nothing else.
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
})
}
// APIBuildRole grants felis-api the build-Job lifecycle in the build namespace
// (internal/build.k8sjobs — Create/Get/Delete) plus the read-side build-log
// stream (spec §16, §416 日志流复用 §8): list build Pods to find the build Job's
// Pod by build-id label, then read its log subresource. This is a SEPARATE
// namespace from the api's minecraft powers, so it is a separate Role +
// RoleBinding; the api SA reaches across both from the control namespace. The log
// grant mirrors felis-api's minecraft-ns console read (pods:list + pods/log:get,
// no pods:get) — read-only and least-privilege; it does NOT touch the build SA
// token or any secret.
func APIBuildRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.BuildNamespace, "felis-api-builds", ComponentAPI, []rbacv1.PolicyRule{
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete"}),
// Read-side build logs (spec §16): list build Pods to find the build Job's
// Pod, then read its log subresource — and nothing wider. No pods:get (the
// streamer lists then reads pods/log, never Gets a Pod object, whose full
// spec carries more than its logs).
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
})
}
// OperatorRole grants felis-operator what the reconciler exercises through the
// manager's CACHED client (internal/operator.reconciler). Because reads go
// through informers, every watched type needs list+watch even for a single Get;
// the manager's cache is namespace-scoped (see cmd/felis/operator.go), so a
// namespaced Role is sufficient. The operator owns StatefulSets and Services
// (Get/Create/Update — never patch or delete), writes only minecraftservers
// status (Status().Update — `update` only), and reads RCON Secrets. It never
// touches pods, PVCs, Events, or finalizers, so none appear here.
func OperatorRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-operator", ComponentOperator, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch"}),
rule([]string{groupFelis}, []string{"minecraftservers/status"}, []string{"update"}),
rule([]string{groupApps}, []string{"statefulsets"}, []string{"get", "list", "watch", "create", "update"}),
rule([]string{groupCore}, []string{"services"}, []string{"get", "list", "watch", "create", "update"}),
// create is here for the per-server RCON password Secret the operator
// provisions on first reconcile (internal/operator.ensureRconSecret). It is a
// smaller grant than it looks: this identity already holds get/list/watch on
// every Secret in this namespace, so being able to add one grants no read it
// did not already have. No update/delete — the password is written once and
// removed by garbage collection through its controller reference.
rule([]string{groupCore}, []string{"secrets"}, []string{"get", "list", "watch", "create"}),
})
}
// ReaperRole grants felis-reaper its two destructive, disjoint powers
// (internal/reaper.k8scluster): patch a MinecraftServer to Stop it and delete its
// world PVC. Candidate servers come from the Postgres store, not a cluster List,
// so no list/watch is needed; the reaper uses a direct client. It can read+patch
// minecraftservers but cannot create them, and holds no power over StatefulSets,
// Services, or Secrets — those belong to the operator and api.
//
// Note no identity anywhere holds minecraftservers:delete. That is intentional, not
// a missing grant: reaping releases a server by flipping desiredState=Stopped and
// reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in
// place so a former owner can re-claim it within the retention window (spec §466).
// The MinecraftServer CR is the lifecycle source of truth and is retained, never
// hard-deleted, so the delete verb is deliberately absent from every Role.
func ReaperRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch"}),
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"delete"}),
})
}
// controlPlaneServiceAccount renders a control-plane SA. Unlike the weak
// build/restore SAs, these identities legitimately call the K8s API, so the token
// mounts (via their Deployment) — AutomountServiceAccountToken is left nil
// (cluster default = mount) rather than false.
func controlPlaneServiceAccount(ns, name, component string) *corev1.ServiceAccount {
return &corev1.ServiceAccount{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ServiceAccount"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: controlPlanePodLabels(component)},
}
}
func role(ns, name, component string, rules []rbacv1.PolicyRule) *rbacv1.Role {
return &rbacv1.Role{
TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: controlPlanePodLabels(component)},
Rules: rules,
}
}
// bindRole binds the Role named roleName (in roleNS) to the ServiceAccount saName
// in saNS. The RoleBinding lives in roleNS; the subject SA may live elsewhere.
func bindRole(roleNS, roleName, saNS, saName, component string) *rbacv1.RoleBinding {
return &rbacv1.RoleBinding{
TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"},
ObjectMeta: metav1.ObjectMeta{Name: roleName, Namespace: roleNS, Labels: controlPlanePodLabels(component)},
Subjects: []rbacv1.Subject{{
Kind: rbacv1.ServiceAccountKind,
Name: saName,
Namespace: saNS,
}},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: "Role",
Name: roleName,
},
}
}
func rule(apiGroups, resources, verbs []string) rbacv1.PolicyRule {
return rbacv1.PolicyRule{APIGroups: apiGroups, Resources: resources, Verbs: verbs}
}