Files

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)
}