141 lines
5.6 KiB
Go
141 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/rcon"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
)
|
|
|
|
// Console is the synchronous RCON write channel the API depends on (spec §8,
|
|
// 读写分离: 写=RCON). RunCommand sends one command to the named server's RCON
|
|
// endpoint and returns the server's reply. It returns ErrNotFound if no server
|
|
// of that name exists, and ErrConsoleUnavailable if the RCON channel cannot be
|
|
// reached (dial timeout / refused / auth rejected) — which, because readiness
|
|
// IS an RCON probe (spec §141), is the expected outcome when the server isn't
|
|
// truly Running. The RCON password is resolved internally and is NEVER part of
|
|
// any argument or return value (spec §286: RCON 密码绝不下发前端).
|
|
//
|
|
// It is an interface so handlers are tested against a fake (api_test.go); the
|
|
// controller-runtime implementation (K8sConsole) is integration-tested only.
|
|
type Console interface {
|
|
RunCommand(ctx context.Context, name, command string) (string, error)
|
|
}
|
|
|
|
// K8sConsole is the production Console: it resolves the per-server RCON password
|
|
// from the Secret named by the CRD's spec.rcon.secretRef, dials the in-cluster
|
|
// RCON endpoint, and runs one command (spec §8 写=RCON). It mirrors the
|
|
// operator's readiness prober — the same address convention and the same
|
|
// secret-read path as internal/operator (rconAddress / reconciler.rconPassword)
|
|
// — so the single reviewed way to reach a server's RCON is the only way the API
|
|
// reaches it too.
|
|
//
|
|
// INTEGRATION-ONLY: like K8sCluster / pgRepo this needs a live cluster and a
|
|
// reachable RCON port; it compiles here but is exercised only by integration
|
|
// tests against a real cluster, never by the hermetic api_test.go suite. The
|
|
// Oracle verifies the handler layer (handleCommand) against a fake Console.
|
|
//
|
|
// Security: the resolved password authenticates the dial and is never logged
|
|
// nor placed in any return value — only Execute's reply body (the command
|
|
// output) flows back to the caller (spec §286). Port 25575 is reachable only
|
|
// from felis-api by NetworkPolicy (spec §8), so this dial is the single
|
|
// sanctioned write path.
|
|
type K8sConsole struct {
|
|
c client.Client
|
|
namespace string
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewK8sConsole builds a Console over c, scoped to namespace.
|
|
func NewK8sConsole(c client.Client, namespace string) *K8sConsole {
|
|
return &K8sConsole{c: c, namespace: namespace, timeout: 5 * time.Second}
|
|
}
|
|
|
|
// RunCommand reads the server CRD, resolves its RCON password, dials the
|
|
// in-cluster RCON endpoint, and runs command. Every unreachable/auth/secret
|
|
// failure collapses to ErrConsoleUnavailable (the handler maps it to 503) so no
|
|
// driver detail — and certainly no password — ever reaches the caller.
|
|
func (k *K8sConsole) RunCommand(ctx context.Context, name, command string) (string, error) {
|
|
var ms v1alpha1.MinecraftServer
|
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return "", ErrNotFound
|
|
}
|
|
return "", err
|
|
}
|
|
if !ms.Spec.Rcon.Enabled {
|
|
// No RCON means no write channel at all (spec §8).
|
|
return "", ErrConsoleUnavailable
|
|
}
|
|
|
|
password, err := k.rconPassword(ctx, &ms)
|
|
if err != nil {
|
|
// A missing/garbled secret is a server misconfiguration, but to the caller
|
|
// it still means the console cannot be reached — and the underlying error
|
|
// must not leak. Surface it as unavailable.
|
|
return "", ErrConsoleUnavailable
|
|
}
|
|
|
|
timeout := k.timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
if dl, ok := ctx.Deadline(); ok {
|
|
if remaining := time.Until(dl); remaining > 0 && remaining < timeout {
|
|
timeout = remaining
|
|
}
|
|
}
|
|
|
|
conn, err := rcon.Dial(rconEndpoint(&ms), password, timeout)
|
|
if err != nil {
|
|
// Dial refused / timed out / auth rejected: the channel is not reachable.
|
|
// Per spec §141 readiness IS this probe, so this is the expected "not
|
|
// actually up" outcome — surfaced as 503, not 500.
|
|
return "", ErrConsoleUnavailable
|
|
}
|
|
defer conn.Close()
|
|
|
|
out, err := conn.ExecuteContext(ctx, command)
|
|
if err != nil {
|
|
return "", ErrConsoleUnavailable
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// rconEndpoint is the in-cluster RCON address for a server, matching the
|
|
// operator's convention (internal/operator builders.rconAddress): the headless
|
|
// client Service is named after the server, in its own namespace.
|
|
func rconEndpoint(server *v1alpha1.MinecraftServer) string {
|
|
port := server.Spec.Rcon.Port
|
|
if port <= 0 {
|
|
port = rcon.DefaultPort
|
|
}
|
|
return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, port)
|
|
}
|
|
|
|
// rconPassword resolves the RCON password from the Secret named by the CRD's
|
|
// spec.rcon.secretRef. It mirrors internal/operator reconciler.rconPassword
|
|
// exactly so the API reads the credential the same reviewed way the operator
|
|
// does. The returned value is used solely to authenticate the dial.
|
|
func (k *K8sConsole) rconPassword(ctx context.Context, server *v1alpha1.MinecraftServer) (string, error) {
|
|
ref := server.Spec.Rcon.SecretRef
|
|
if ref.Name == "" || ref.Key == "" {
|
|
return "", fmt.Errorf("rcon.secretRef.name and .key are required when rcon is enabled")
|
|
}
|
|
var secret corev1.Secret
|
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: ref.Name}, &secret); err != nil {
|
|
return "", err
|
|
}
|
|
b, ok := secret.Data[ref.Key]
|
|
if !ok {
|
|
return "", fmt.Errorf("secret %q has no key %q", ref.Name, ref.Key)
|
|
}
|
|
return string(b), nil
|
|
}
|