855 lines
31 KiB
Go
855 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/pbkdf2"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
neturl "net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/config"
|
|
"felis.lolicon.best/internal/maintenance"
|
|
"felis.lolicon.best/internal/naming"
|
|
"felis.lolicon.best/internal/operator"
|
|
"felis.lolicon.best/internal/platform"
|
|
"felis.lolicon.best/internal/registrygate"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/jackc/pgx/v5"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
)
|
|
|
|
// rotate-token replaces one credential the installer generated: an internal
|
|
// caller's token (naming.CallerTokens), the registry's write tokens, the
|
|
// Velocity forwarding secret or the database password. The new value goes into
|
|
// the installer's record first, so that whatever fails later a re-run of the
|
|
// installer puts it everywhere; then into the Secrets and files that carry it;
|
|
// then whatever read the old value at start restarts. Without -yes it prints
|
|
// what it would change and what that interrupts, and changes nothing.
|
|
|
|
const (
|
|
defaultSecretsEnvPath = "/etc/felis/secrets.env"
|
|
defaultLinkPropsPath = "/opt/felis/velocity/plugins/felis-link/felis-link.properties"
|
|
defaultForwardingPath = "/opt/felis/velocity/forwarding.secret"
|
|
velocityUnit = "felis-velocity"
|
|
apiDeployment = "felis-api"
|
|
registryDeployment = "registry"
|
|
|
|
kindRegistry = "registry"
|
|
kindForwarding = "forwarding"
|
|
kindDB = "db"
|
|
|
|
// proxyReloadWait is how long the host proxy gets, once felis-api has rolled,
|
|
// to show it re-read its token: it reads the file again on its next call to
|
|
// felis-api, and it calls every 15 seconds.
|
|
proxyReloadWait = 60 * time.Second
|
|
|
|
// apiRestartNote is in every plan: felis-api runs as one replica replaced in
|
|
// place, so its restart is a short outage of everything that talks to it.
|
|
apiRestartNote = "felis-api restarts (a single replica): the panel, sign-in and the proxy's calls are unavailable for the few seconds that takes"
|
|
)
|
|
|
|
// 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",
|
|
}
|
|
|
|
// installerRegistryKeys names the registry principals' tokens in secrets.env,
|
|
// in registrygate.Principals order.
|
|
var installerRegistryKeys = map[string]string{
|
|
registrygate.PrincipalPlatform: "REGISTRY_PLATFORM_TOKEN",
|
|
registrygate.PrincipalBuild: "REGISTRY_BUILD_TOKEN",
|
|
registrygate.PrincipalPrune: "REGISTRY_PRUNE_TOKEN",
|
|
}
|
|
|
|
// installerForwardingKey and installerDBKey name the forwarding secret and the
|
|
// database password in secrets.env.
|
|
const (
|
|
installerForwardingKey = "FORWARDING_SECRET"
|
|
installerDBKey = "DB_PASSWORD"
|
|
)
|
|
|
|
type tokenRotator struct {
|
|
cl client.Client
|
|
controlNS string
|
|
minecraftNS string
|
|
buildNS string
|
|
// secretsEnv, linkProps and forwardingFile are the installer's record and the
|
|
// host proxy's felis-link.properties and forwarding.secret; a missing file is
|
|
// reported and skipped.
|
|
secretsEnv string
|
|
linkProps string
|
|
forwardingFile string
|
|
// hostTOML, podTOML and defaultTOML are the config copies that carry the
|
|
// database URL (defaultTOML only when it is a file of its own).
|
|
hostTOML string
|
|
podTOML string
|
|
defaultTOML string
|
|
newToken func() (string, error)
|
|
// rollout restarts a control-namespace Deployment and waits for it.
|
|
rollout func(ctx context.Context, deployment string) error
|
|
// restartUnit restarts a systemd unit on this host.
|
|
restartUnit func(ctx context.Context, unit string) error
|
|
// proxyLog is what the host proxy has logged since a moment, as far as it
|
|
// can be read.
|
|
proxyLog func(ctx context.Context, since time.Time) string
|
|
// alterRole stores a password verifier for a role of the database that runs
|
|
// as deployment ("namespace/name"); verifyDB connects with a URL.
|
|
alterRole func(ctx context.Context, deployment, role, verifier string) error
|
|
verifyDB func(ctx context.Context, url string) error
|
|
now func() time.Time
|
|
// reloadWait bounds the wait for the proxy to re-read its token, polled
|
|
// every pollEvery.
|
|
reloadWait time.Duration
|
|
pollEvery time.Duration
|
|
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)")
|
|
forwarding := fs.String("forwarding-secret", defaultForwardingPath, "the host proxy's forwarding secret file (forwarding only)")
|
|
yes := fs.Bool("yes", false, "rotate; without it the plan is printed and nothing changes")
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(stderr, "Usage: felis rotate-token [-yes] [flags] <%s>\n\n", strings.Join(rotationKinds(), "|"))
|
|
fmt.Fprintln(stderr, "Replaces one generated credential: the installer's record, the Secrets and files that carry it, then what reads it.")
|
|
fmt.Fprintln(stderr, "Without -yes it prints what would change and what that interrupts.")
|
|
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
|
|
}
|
|
kind := fs.Arg(0)
|
|
if !knownRotation(kind) {
|
|
fmt.Fprintf(stderr, "felis rotate-token: unknown credential %q (one of %s)\n", kind, strings.Join(rotationKinds(), ", "))
|
|
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 "+kind+")")
|
|
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
|
|
}
|
|
controlNS := platform.DefaultControlNamespace
|
|
r := tokenRotator{
|
|
cl: cl,
|
|
controlNS: controlNS,
|
|
minecraftNS: cfg.K8s.Namespace,
|
|
buildNS: buildNS,
|
|
secretsEnv: *secretsEnv,
|
|
linkProps: *linkProps,
|
|
forwardingFile: *forwarding,
|
|
hostTOML: hostSetupConfigPath,
|
|
podTOML: podSetupConfigPath,
|
|
defaultTOML: defaultSetupConfigPath,
|
|
newToken: randomToken,
|
|
rollout: func(ctx context.Context, deployment string) error {
|
|
if err := kubectl(ctx, "-n", controlNS, "rollout", "restart", "deployment/"+deployment); err != nil {
|
|
return err
|
|
}
|
|
return kubectl(ctx, "-n", controlNS, "rollout", "status", "deployment/"+deployment, "--timeout=180s")
|
|
},
|
|
restartUnit: func(ctx context.Context, unit string) error { return systemctl(ctx, "restart", unit) },
|
|
proxyLog: journalSince,
|
|
alterRole: alterRoleInPod,
|
|
verifyDB: func(ctx context.Context, url string) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
conn, err := pgx.Connect(ctx, url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return conn.Close(ctx)
|
|
},
|
|
now: time.Now,
|
|
reloadWait: proxyReloadWait,
|
|
pollEvery: 3 * time.Second,
|
|
out: stdout,
|
|
}
|
|
if err := r.rotate(context.Background(), kind, *yes); 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
|
|
}
|
|
|
|
// rotationKinds is every credential rotate-token replaces: the callers' tokens
|
|
// and the installer's other generated secrets.
|
|
func rotationKinds() []string {
|
|
return append(callerNames(), kindRegistry, kindForwarding, kindDB)
|
|
}
|
|
|
|
func knownRotation(kind string) bool {
|
|
for _, k := range rotationKinds() {
|
|
if k == kind {
|
|
return true
|
|
}
|
|
}
|
|
return 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
|
|
}
|
|
|
|
// tokenFingerprint is how the proxy names the token it reloaded in its log
|
|
// (plugins/shared FileToken.fingerprint): the first twelve hex digits of its
|
|
// SHA-256, enough to tell tokens apart and useless for finding one.
|
|
func tokenFingerprint(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
func (r tokenRotator) rotate(ctx context.Context, kind string, apply bool) error {
|
|
switch kind {
|
|
case kindRegistry:
|
|
return r.rotateRegistry(ctx, apply)
|
|
case kindForwarding:
|
|
return r.rotateForwarding(ctx, apply)
|
|
case kindDB:
|
|
return r.rotateDB(ctx, apply)
|
|
}
|
|
ct, ok := callerToken(kind)
|
|
if !ok {
|
|
return fmt.Errorf("unknown credential %q (one of %s)", kind, strings.Join(rotationKinds(), ", "))
|
|
}
|
|
return r.rotateCaller(ctx, ct, apply)
|
|
}
|
|
|
|
// confirm ends the plan: without apply it says nothing changed and how to go
|
|
// ahead, and reports false.
|
|
func (r tokenRotator) confirm(kind string, apply bool) bool {
|
|
if !apply {
|
|
fmt.Fprintf(r.out, "\nNothing was changed. To rotate: sudo felis rotate-token -yes %s\n", kind)
|
|
return false
|
|
}
|
|
fmt.Fprintln(r.out, "\nRotating:")
|
|
return true
|
|
}
|
|
|
|
// record writes new values into the installer's secrets.env. It comes first in
|
|
// every rotation: from then on, whatever fails, a re-run of the installer puts
|
|
// the new values everywhere.
|
|
func (r tokenRotator) record(kv ...[2]string) error {
|
|
keys := make([]string, len(kv))
|
|
for i, p := range kv {
|
|
keys[i] = p[0]
|
|
}
|
|
switch err := setKeyValueLines(r.secretsEnv, "=", kv); {
|
|
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 value in %s: %w", r.secretsEnv, err)
|
|
default:
|
|
fmt.Fprintf(r.out, " - %s: %s updated\n", r.secretsEnv, strings.Join(keys, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r tokenRotator) putSecret(ctx context.Context, ns, name string, data map[string][]byte) error {
|
|
if _, err := putSecretKeys(ctx, r.cl, ns, name, corev1.SecretTypeOpaque, data); err != nil {
|
|
return fmt.Errorf("write Secret %s/%s: %w", ns, name, err)
|
|
}
|
|
fmt.Fprintf(r.out, " - Secret %s/%s: updated\n", ns, name)
|
|
return nil
|
|
}
|
|
|
|
func (r tokenRotator) rollAPI(ctx context.Context) error {
|
|
if err := r.rollout(ctx, apiDeployment); err != nil {
|
|
return fmt.Errorf("roll felis-api: %w", err)
|
|
}
|
|
fmt.Fprintln(r.out, " - felis-api: rolled out on the new value")
|
|
return nil
|
|
}
|
|
|
|
// withMinecraft is the control namespace plus the minecraft namespace when that
|
|
// is a different one: where the Secrets game pods and Jobs mount are mirrored.
|
|
func (r tokenRotator) withMinecraft() []string {
|
|
if r.minecraftNS == "" || r.minecraftNS == r.controlNS {
|
|
return []string{r.controlNS}
|
|
}
|
|
return []string{r.controlNS, r.minecraftNS}
|
|
}
|
|
|
|
func (r tokenRotator) rotateCaller(ctx context.Context, ct naming.CallerToken, apply bool) error {
|
|
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)
|
|
}
|
|
key := installerTokenKeys[ct.Caller]
|
|
|
|
fmt.Fprintf(r.out, "felis rotate-token %s: a new internal token for the %s caller\n", ct.Caller, ct.Caller)
|
|
secrets := make([]string, len(namespaces))
|
|
for i, ns := range namespaces {
|
|
secrets[i] = "Secret " + ns + "/" + ct.Secret
|
|
}
|
|
writes := append([]string{r.secretsEnv + " (" + key + ")"}, secrets...)
|
|
hostProxy := ct.Caller == "velocity" && fileExists(r.linkProps)
|
|
if hostProxy {
|
|
writes = append(writes, r.linkProps+" (service-token)")
|
|
}
|
|
fmt.Fprintf(r.out, " - writes %s\n", strings.Join(writes, ", "))
|
|
fmt.Fprintf(r.out, " - %s\n", apiRestartNote)
|
|
switch ct.Caller {
|
|
case "velocity":
|
|
if hostProxy {
|
|
fmt.Fprintf(r.out, " - the proxy re-reads its token from the file and keeps its players; if it has not within %s of felis-api's restart, it is restarted, which disconnects everyone online\n", r.reloadWait)
|
|
} else {
|
|
fmt.Fprintf(r.out, " - no proxy on this host (%s): set service-token in your proxy's felis-link.properties to the value in Secret %s/%s afterwards; it re-reads the file without a restart\n",
|
|
r.linkProps, r.controlNS, ct.Secret)
|
|
}
|
|
case "limbo":
|
|
fmt.Fprintln(r.out, " - the login gate's pod restarts: a player signing in at that moment reconnects")
|
|
case "build":
|
|
fmt.Fprintln(r.out, " - a build fetching its context at that moment fails and can be submitted again")
|
|
case "ops":
|
|
fmt.Fprintln(r.out, " - felis backup-now presents the new token on its next run")
|
|
}
|
|
if !r.confirm(ct.Caller, apply) {
|
|
return nil
|
|
}
|
|
|
|
tok, err := r.newToken()
|
|
if err != nil {
|
|
return fmt.Errorf("generate a token: %w", err)
|
|
}
|
|
if err := r.record([2]string{key, tok}); err != nil {
|
|
return err
|
|
}
|
|
for _, ns := range namespaces {
|
|
if err := r.putSecret(ctx, ns, ct.Secret, map[string][]byte{naming.ServiceTokenSecretKey: []byte(tok)}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// The proxy's file changes before felis-api rolls, so the file never falls
|
|
// behind the Secret; the proxy's log is read from just before the write,
|
|
// since it may pick the new token up before the rollout ends.
|
|
since := r.now()
|
|
if hostProxy {
|
|
if err := setProxyToken(r.linkProps, tok); err != nil {
|
|
return fmt.Errorf("write the proxy's token into %s: %w", r.linkProps, err)
|
|
}
|
|
fmt.Fprintf(r.out, " - %s: service-token updated\n", r.linkProps)
|
|
}
|
|
|
|
if err := r.rollAPI(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch ct.Caller {
|
|
case "velocity":
|
|
if !hostProxy {
|
|
break
|
|
}
|
|
if r.proxyReloaded(ctx, since, tokenFingerprint(tok)) {
|
|
fmt.Fprintf(r.out, " - %s: took the new token from its properties; players stayed connected\n", velocityUnit)
|
|
break
|
|
}
|
|
if err := r.restartUnit(ctx, velocityUnit); err != nil {
|
|
return fmt.Errorf("restart %s: %w", velocityUnit, err)
|
|
}
|
|
fmt.Fprintf(r.out, " - %s: had not taken the new token within %s, restarted (players on the proxy were disconnected and can rejoin)\n", velocityUnit, r.reloadWait)
|
|
case "limbo":
|
|
// Only the operator's server pods carry its managed-by label; a backup or
|
|
// restore Job's pod carries the server label too, and app.kubernetes.io ones.
|
|
if err := r.cl.DeleteAllOf(ctx, &corev1.Pod{}, client.InNamespace(r.minecraftNS), client.MatchingLabels{
|
|
v1alpha1.LabelServer: naming.SystemLoginServer,
|
|
v1alpha1.LabelManagedBy: operator.ManagedByValue,
|
|
}); 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")
|
|
case "ops":
|
|
fmt.Fprintln(r.out, " - felis backup-now reads the new token on its next run")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// proxyReloaded waits up to reloadWait for the host proxy to log that it
|
|
// reloaded the token with this fingerprint (plugins/shared FileToken).
|
|
func (r tokenRotator) proxyReloaded(ctx context.Context, since time.Time, fingerprint string) bool {
|
|
want := "(fingerprint " + fingerprint + ")"
|
|
deadline := r.now().Add(r.reloadWait)
|
|
for {
|
|
if strings.Contains(r.proxyLog(ctx, since), want) {
|
|
return true
|
|
}
|
|
if !r.now().Before(deadline) {
|
|
return false
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-time.After(r.pollEvery):
|
|
}
|
|
}
|
|
}
|
|
|
|
// journalSince is the proxy unit's journal from the second since falls in. A
|
|
// journal that cannot be read reads as one without the line, which ends in the
|
|
// restart a rotation made before the proxy could reload.
|
|
func journalSince(ctx context.Context, since time.Time) string {
|
|
out, _ := exec.CommandContext(ctx, "journalctl", "-u", velocityUnit, "--since", "@"+strconv.FormatInt(since.Unix(), 10),
|
|
"-o", "cat", "--no-pager", "-q").Output()
|
|
return string(out)
|
|
}
|
|
|
|
// setProxyToken writes the proxy's token into felis-link.properties and puts
|
|
// the file's modification time back. The proxy re-reads its token by itself,
|
|
// while `felis domain check` and `felis domain set` read a file newer than the
|
|
// proxy's start as config it has not loaded (the installer's
|
|
// install_if_changed keeps the time for the same reason).
|
|
func setProxyToken(path, token string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := setKeyValueLine(path, "service-token", "=", token); err != nil {
|
|
return err
|
|
}
|
|
return os.Chtimes(path, time.Time{}, info.ModTime())
|
|
}
|
|
|
|
func (r tokenRotator) rotateRegistry(ctx context.Context, apply bool) error {
|
|
keys := make([]string, len(registrygate.Principals))
|
|
for i, p := range registrygate.Principals {
|
|
keys[i] = installerRegistryKeys[p]
|
|
}
|
|
fmt.Fprintf(r.out, "felis rotate-token %s: new write tokens for the image registry's principals (%s)\n", kindRegistry, strings.Join(registrygate.Principals, ", "))
|
|
fmt.Fprintf(r.out, " - writes %s (%s), Secret %s/%s, Secret %s/%s\n", r.secretsEnv, strings.Join(keys, ", "),
|
|
r.controlNS, naming.RegistryAuthSecretName, r.buildNS, naming.RegistryPushSecretName)
|
|
fmt.Fprintln(r.out, " - the registry restarts to load them: a build pushing its image at that moment fails and can be submitted again, and an image pull in that moment retries")
|
|
fmt.Fprintf(r.out, " - %s (it presents the prune token)\n", apiRestartNote)
|
|
if !r.confirm(kindRegistry, apply) {
|
|
return nil
|
|
}
|
|
|
|
tokens := map[string][]byte{}
|
|
kv := make([][2]string, 0, len(registrygate.Principals))
|
|
for _, p := range registrygate.Principals {
|
|
tok, err := r.newToken()
|
|
if err != nil {
|
|
return fmt.Errorf("generate a token: %w", err)
|
|
}
|
|
tokens[p] = []byte(tok)
|
|
kv = append(kv, [2]string{installerRegistryKeys[p], tok})
|
|
}
|
|
if err := r.record(kv...); err != nil {
|
|
return err
|
|
}
|
|
if err := r.putSecret(ctx, r.controlNS, naming.RegistryAuthSecretName, tokens); err != nil {
|
|
return err
|
|
}
|
|
if err := r.putSecret(ctx, r.buildNS, naming.RegistryPushSecretName, map[string][]byte{
|
|
naming.RegistryPushUsernameKey: []byte(registrygate.PrincipalBuild),
|
|
naming.RegistryPushPasswordKey: tokens[registrygate.PrincipalBuild],
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := r.rollout(ctx, registryDeployment); err != nil {
|
|
return fmt.Errorf("restart the registry: %w", err)
|
|
}
|
|
fmt.Fprintln(r.out, " - registry: restarted on the new tokens")
|
|
return r.rollAPI(ctx)
|
|
}
|
|
|
|
func (r tokenRotator) rotateForwarding(ctx context.Context, apply bool) error {
|
|
restart, held, err := r.gamePods(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hostProxy := fileExists(r.forwardingFile)
|
|
fmt.Fprintf(r.out, "felis rotate-token %s: a new Velocity forwarding secret, the key a server checks each player's identity with\n", kindForwarding)
|
|
secrets := []string{}
|
|
for _, ns := range r.withMinecraft() {
|
|
secrets = append(secrets, "Secret "+ns+"/"+naming.ForwardingSecretName)
|
|
}
|
|
writes := append([]string{r.secretsEnv + " (" + installerForwardingKey + ")"}, secrets...)
|
|
if hostProxy {
|
|
writes = append(writes, r.forwardingFile)
|
|
}
|
|
fmt.Fprintf(r.out, " - writes %s\n", strings.Join(writes, ", "))
|
|
fmt.Fprintf(r.out, " - every running server restarts to read it (%d now), saving its world on the way down, and the proxy restarts: everyone online is disconnected and can rejoin once their server is back\n", len(restart))
|
|
if len(held) > 0 {
|
|
fmt.Fprintf(r.out, " - left running, because a backup, restore or file write holds its world: %s. Players cannot join it until it restarts: stop and start it from the panel once that finishes\n", strings.Join(held, ", "))
|
|
}
|
|
if !hostProxy {
|
|
fmt.Fprintf(r.out, " - no proxy on this host (%s): put the value in Secret %s/%s into your proxy's forwarding secret file and restart it\n",
|
|
r.forwardingFile, r.controlNS, naming.ForwardingSecretName)
|
|
}
|
|
if !r.confirm(kindForwarding, apply) {
|
|
return nil
|
|
}
|
|
|
|
tok, err := r.newToken()
|
|
if err != nil {
|
|
return fmt.Errorf("generate a secret: %w", err)
|
|
}
|
|
if err := r.record([2]string{installerForwardingKey, tok}); err != nil {
|
|
return err
|
|
}
|
|
if hostProxy {
|
|
info, err := os.Stat(r.forwardingFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := replaceFileKeepingMode(r.forwardingFile, info, []byte(tok)); err != nil {
|
|
return fmt.Errorf("write %s: %w", r.forwardingFile, err)
|
|
}
|
|
fmt.Fprintf(r.out, " - %s: updated\n", r.forwardingFile)
|
|
}
|
|
for _, ns := range r.withMinecraft() {
|
|
if err := r.putSecret(ctx, ns, naming.ForwardingSecretName, map[string][]byte{naming.ForwardingSecretKey: []byte(tok)}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// The servers go first: their pods read the Secret as they are recreated,
|
|
// and a player who rejoins through the restarted proxy meets a server on the
|
|
// new secret, or one still starting.
|
|
for i := range restart {
|
|
p := &restart[i]
|
|
if err := r.cl.Delete(ctx, p, client.Preconditions{UID: &p.UID}); err != nil && !apierrors.IsNotFound(err) && !apierrors.IsConflict(err) {
|
|
return fmt.Errorf("restart %s: %w", p.Labels[v1alpha1.LabelServer], err)
|
|
}
|
|
}
|
|
fmt.Fprintf(r.out, " - servers: %d restarting on the new secret\n", len(restart))
|
|
if hostProxy {
|
|
if err := r.restartUnit(ctx, velocityUnit); err != nil {
|
|
return fmt.Errorf("restart %s: %w", velocityUnit, err)
|
|
}
|
|
fmt.Fprintf(r.out, " - %s: restarted on the new secret\n", velocityUnit)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// gamePods lists the running game server pods: those a rotation may restart,
|
|
// and the servers left alone because a backup, restore or file write holds
|
|
// their world (internal/maintenance), which a restart in the middle of would
|
|
// break.
|
|
func (r tokenRotator) gamePods(ctx context.Context) ([]corev1.Pod, []string, error) {
|
|
var pods corev1.PodList
|
|
// The operator's managed-by label is on its server pods alone (see rotateCaller).
|
|
if err := r.cl.List(ctx, &pods, client.InNamespace(r.minecraftNS), client.MatchingLabels{v1alpha1.LabelManagedBy: operator.ManagedByValue}); err != nil {
|
|
return nil, nil, fmt.Errorf("list the servers' pods: %w", err)
|
|
}
|
|
var jobs batchv1.JobList
|
|
if err := r.cl.List(ctx, &jobs, client.InNamespace(r.minecraftNS)); err != nil {
|
|
return nil, nil, fmt.Errorf("list the maintenance Jobs: %w", err)
|
|
}
|
|
var restart []corev1.Pod
|
|
var held []string
|
|
for _, p := range pods.Items {
|
|
if p.DeletionTimestamp != nil {
|
|
continue
|
|
}
|
|
server := p.Labels[v1alpha1.LabelServer]
|
|
var ms v1alpha1.MinecraftServer
|
|
if err := r.cl.Get(ctx, client.ObjectKey{Namespace: r.minecraftNS, Name: server}, &ms); client.IgnoreNotFound(err) != nil {
|
|
return nil, nil, fmt.Errorf("read server %s: %w", server, err)
|
|
}
|
|
if kind, ok := maintenance.Holder(server, ms.Annotations, jobs.Items, r.now()); ok {
|
|
held = append(held, server+" ("+kind+")")
|
|
continue
|
|
}
|
|
restart = append(restart, p)
|
|
}
|
|
return restart, held, nil
|
|
}
|
|
|
|
func (r tokenRotator) rotateDB(ctx context.Context, apply bool) error {
|
|
cfg, err := config.Load(r.hostTOML)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.Database.Deployment == "" {
|
|
return fmt.Errorf("[database] deployment is unset in %s, so the database is not the installer's felis-postgres: change the role's password where it runs, then in [database] url of each config copy", r.hostTOML)
|
|
}
|
|
u, err := neturl.Parse(cfg.Database.URL)
|
|
if err != nil || u.User == nil || u.User.Username() == "" {
|
|
return fmt.Errorf("[database] url in %s names no role", r.hostTOML)
|
|
}
|
|
role := u.User.Username()
|
|
targets, err := tomlTargetsOf(r.hostTOML, r.podTOML, r.defaultTOML)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
paths := make([]string, len(targets))
|
|
for i, t := range targets {
|
|
paths[i] = t.path
|
|
}
|
|
secrets := []string{}
|
|
for _, ns := range r.withMinecraft() {
|
|
secrets = append(secrets, ns+"/"+platform.ConfigSecretName)
|
|
}
|
|
fmt.Fprintf(r.out, "felis rotate-token %s: a new password for the database role %q\n", kindDB, role)
|
|
fmt.Fprintf(r.out, " - writes %s (%s), the role in %s, [database] url in %s, Secret %s\n",
|
|
r.secretsEnv, installerDBKey, cfg.Database.Deployment, strings.Join(paths, " and "), strings.Join(secrets, " and "))
|
|
fmt.Fprintf(r.out, " - %s\n", apiRestartNote)
|
|
fmt.Fprintln(r.out, " - a backup, restore or file Job that connects in the seconds between the password change and felis-api's restart fails and can be run again; the host's timers read the new config on their next run")
|
|
if !r.confirm(kindDB, apply) {
|
|
return nil
|
|
}
|
|
|
|
password, err := r.newToken()
|
|
if err != nil {
|
|
return fmt.Errorf("generate a password: %w", err)
|
|
}
|
|
// Every config copy is edited in memory first, so one this cannot edit stops
|
|
// the rotation before the role's password changes.
|
|
edited := make([][]byte, len(targets))
|
|
var hostURL string
|
|
var podConfig []byte
|
|
for i, t := range targets {
|
|
raw, err := os.ReadFile(t.real)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var doc struct {
|
|
Database struct {
|
|
URL string `toml:"url"`
|
|
} `toml:"database"`
|
|
}
|
|
if _, err := toml.Decode(string(raw), &doc); err != nil {
|
|
return fmt.Errorf("%s: %w", t.path, err)
|
|
}
|
|
next, err := withPassword(doc.Database.URL, password)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", t.path, err)
|
|
}
|
|
if edited[i], err = editTOMLStrings(raw, []tomlStringEdit{{"database", "url", next}}); err != nil {
|
|
return fmt.Errorf("%s: %w; set the password in its [database] url by hand", t.path, err)
|
|
}
|
|
switch t.path {
|
|
case r.hostTOML:
|
|
hostURL = next
|
|
case r.podTOML:
|
|
podConfig = edited[i]
|
|
}
|
|
}
|
|
if podConfig == nil {
|
|
return fmt.Errorf("%s resolves to the same file as %s; the pods reach the database at another address and need a copy of their own", r.podTOML, r.hostTOML)
|
|
}
|
|
|
|
if err := r.record([2]string{installerDBKey, password}); err != nil {
|
|
return err
|
|
}
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return err
|
|
}
|
|
verifier, err := scramVerifier(password, salt, scramIterations)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := r.alterRole(ctx, cfg.Database.Deployment, role, verifier); err != nil {
|
|
return fmt.Errorf("set the role's password: %w", err)
|
|
}
|
|
if err := r.verifyDB(ctx, hostURL); err != nil {
|
|
return fmt.Errorf("the database does not accept the new password (%v); the config copies still hold the old one: run the installer again (sudo bash deploy/bootstrap.sh), which sets the password in %s everywhere", err, r.secretsEnv)
|
|
}
|
|
fmt.Fprintf(r.out, " - role %s: password changed, and the database accepts it\n", role)
|
|
for i, t := range targets {
|
|
info, err := os.Stat(t.real)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := replaceFileKeepingMode(t.real, info, edited[i]); err != nil {
|
|
return fmt.Errorf("write %s: %w", t.path, err)
|
|
}
|
|
fmt.Fprintf(r.out, " - %s: [database] url updated\n", t.path)
|
|
}
|
|
for _, ns := range r.withMinecraft() {
|
|
if err := r.putSecret(ctx, ns, platform.ConfigSecretName, map[string][]byte{platform.ConfigSecretKey: podConfig}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return r.rollAPI(ctx)
|
|
}
|
|
|
|
// withPassword is a database URL with its password replaced.
|
|
func withPassword(raw, password string) (string, error) {
|
|
u, err := neturl.Parse(raw)
|
|
if err != nil || u.User == nil || u.User.Username() == "" {
|
|
return "", errors.New("its [database] url names no role")
|
|
}
|
|
u.User = neturl.UserPassword(u.User.Username(), password)
|
|
return u.String(), nil
|
|
}
|
|
|
|
// scramIterations is PostgreSQL's default scram_iterations.
|
|
const scramIterations = 4096
|
|
|
|
// scramVerifier is the SCRAM-SHA-256 verifier PostgreSQL stores for a password
|
|
// (RFC 5802 and RFC 7677, in the form libpq's PQencryptPasswordConn makes).
|
|
// ALTER ROLE stores a verifier as it is given, so the password itself never
|
|
// reaches the server, where a failing statement is logged with its text.
|
|
func scramVerifier(password string, salt []byte, iterations int) (string, error) {
|
|
salted, err := pbkdf2.Key(sha256.New, password, salt, iterations, sha256.Size)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mac := func(msg string) []byte {
|
|
h := hmac.New(sha256.New, salted)
|
|
h.Write([]byte(msg))
|
|
return h.Sum(nil)
|
|
}
|
|
stored := sha256.Sum256(mac("Client Key"))
|
|
b64 := base64.StdEncoding.EncodeToString
|
|
return fmt.Sprintf("SCRAM-SHA-256$%d:%s$%s:%s", iterations, b64(salt), b64(stored[:]), b64(mac("Server Key"))), nil
|
|
}
|
|
|
|
// alterRoleInPod runs ALTER ROLE as the superuser inside the database's
|
|
// container, over its socket, with the statement on stdin.
|
|
func alterRoleInPod(ctx context.Context, deployment, role, verifier string) error {
|
|
ns, name, _ := strings.Cut(deployment, "/")
|
|
return kubectlWithInput(ctx, []byte(alterRoleSQL(role, verifier)), "-n", ns, "exec", "-i", "deploy/"+name, "-c", platform.PostgresContainer, "--",
|
|
"psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", "postgres")
|
|
}
|
|
|
|
// alterRoleSQL sets role's password to a SCRAM verifier, which holds no quote.
|
|
func alterRoleSQL(role, verifier string) string {
|
|
return "ALTER ROLE \"" + strings.ReplaceAll(role, `"`, `""`) + "\" WITH PASSWORD '" + verifier + "';\n"
|
|
}
|
|
|
|
// 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 {
|
|
return setKeyValueLines(path, sep, [][2]string{{key, value}})
|
|
}
|
|
|
|
// setKeyValueLines is setKeyValueLine for several keys in one rewrite.
|
|
func setKeyValueLines(path, sep string, kv [][2]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")
|
|
for _, p := range kv {
|
|
key, value := p[0], p[1]
|
|
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)
|
|
}
|
|
}
|
|
return replaceFileKeepingMode(path, info, []byte(strings.Join(lines, "\n")+"\n"))
|
|
}
|
|
|
|
// replaceFileKeepingMode atomically replaces path with data, keeping the mode and
|
|
// owner info describes: these files are read by other users (the proxy's) and
|
|
// some hold credentials, so a rewrite must not widen or narrow who can read them.
|
|
func replaceFileKeepingMode(path string, info os.FileInfo, data []byte) error {
|
|
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.Write(data); 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)
|
|
}
|