fix(setup): SMTP 密码和上传桶密钥同时存进 /etc/felis,安装器每次从这里重建 Secret,看门狗和 breakGlass 在 k3s 宕机时也能读到

This commit is contained in:
Lemon-miaow committed 2026-09-27 00:22:22 +08:00
1 parent 8ef7112dab
commit 2d82e8cca7
13 files changed
+532 -33

No files matched your search

+1 -1
View File
@@ -156,7 +156,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
// Recovery mails its code through [smtp]; the relay is opened only if a code is // Recovery mails its code through [smtp]; the relay is opened only if a code is
// asked for. // asked for.
host, _ := os.Hostname() host, _ := os.Hostname()
recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, platform.DefaultControlNamespace), host: host} recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, hostSMTPPasswordPath, platform.DefaultControlNamespace), host: host}
res, err := runBreakGlassTUI(ctx, repo, cfg.Database, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery) res, err := runBreakGlassTUI(ctx, repo, cfg.Database, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery)
if err != nil { if err != nil {
+10 -3
View File
@@ -227,9 +227,11 @@ func maskEmail(email string) string {
} }
// hostRecoveryMailer opens the [smtp] relay from the host the way the watchdog does: // hostRecoveryMailer opens the [smtp] relay from the host the way the watchdog does:
// the password is the env var password_ref names when that is set, else the // the password is the env var password_ref names when that is set, else the host
// felis-smtp Secret, whose absence means a relay without AUTH. // copy at passwordPath, else the felis-smtp Secret, whose absence means a relay
func hostRecoveryMailer(c config.SMTPConfig, controlNS string) func(context.Context) (recoveryMailer, error) { // without AUTH. The cluster is reached only when the host copy is missing, so a
// break-glass on a host whose k3s is down still gets its code.
func hostRecoveryMailer(c config.SMTPConfig, passwordPath, controlNS string) func(context.Context) (recoveryMailer, error) {
return func(ctx context.Context) (recoveryMailer, error) { return func(ctx context.Context) (recoveryMailer, error) {
if strings.TrimSpace(c.Host) == "" { if strings.TrimSpace(c.Host) == "" {
return nil, errors.New("[smtp] is not configured in felis.toml") return nil, errors.New("[smtp] is not configured in felis.toml")
@@ -237,6 +239,11 @@ func hostRecoveryMailer(c config.SMTPConfig, controlNS string) func(context.Cont
if ref := c.PasswordRef; ref != "" && os.Getenv(ref) != "" { if ref := c.PasswordRef; ref != "" && os.Getenv(ref) != "" {
return smtpRelay(c, os.Getenv(ref)), nil return smtpRelay(c, os.Getenv(ref)), nil
} }
if password, ok, err := readHostCredential(passwordPath); err != nil {
return nil, fmt.Errorf("read the relay password: %w", err)
} else if ok {
return smtpRelay(c, password), nil
}
cl, err := buildSystemServerClient() cl, err := buildSystemServerClient()
if err != nil { if err != nil {
return nil, fmt.Errorf("reach the cluster for the relay password: %w", err) return nil, fmt.Errorf("reach the cluster for the relay password: %w", err)
+2 -2
View File
@@ -246,7 +246,7 @@ func TestHostRecoveryMailer(t *testing.T) {
ctx := context.Background() ctx := context.Background()
t.Run("no [smtp] host is no relay", func(t *testing.T) { t.Run("no [smtp] host is no relay", func(t *testing.T) {
_, err := hostRecoveryMailer(config.SMTPConfig{}, "felis")(ctx) _, err := hostRecoveryMailer(config.SMTPConfig{}, hostSMTPPasswordPath, "felis")(ctx)
if err == nil || !strings.Contains(err.Error(), "[smtp]") { if err == nil || !strings.Contains(err.Error(), "[smtp]") {
t.Fatalf("err = %v, want it to name [smtp]", err) t.Fatalf("err = %v, want it to name [smtp]", err)
} }
@@ -256,7 +256,7 @@ func TestHostRecoveryMailer(t *testing.T) {
t.Setenv("FELIS_TEST_RELAY_PW", "from-env") t.Setenv("FELIS_TEST_RELAY_PW", "from-env")
off := false off := false
c := config.SMTPConfig{Host: "mail.example.com", Port: 2525, From: "[email protected]", Username: "felis", PasswordRef: "FELIS_TEST_RELAY_PW", RequireTLS: &off} c := config.SMTPConfig{Host: "mail.example.com", Port: 2525, From: "[email protected]", Username: "felis", PasswordRef: "FELIS_TEST_RELAY_PW", RequireTLS: &off}
got, err := hostRecoveryMailer(c, "felis")(ctx) got, err := hostRecoveryMailer(c, hostSMTPPasswordPath, "felis")(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Host copies of the credentials `felis setup` takes at the keyboard: the [smtp]
// relay password and the uploads bucket's keys. The cluster reads them from the
// felis-smtp and felis-uploads-s3 Secrets, and a Secret lives in k3s's datastore,
// which a reinstall (uninstall.sh keeps /etc/felis) or a host rebuilt from a
// database bundle's state/ starts empty. Each file holds the bare value, mode
// 0600, directly in /etc/felis beside secrets.env: every installer run applies
// the Secrets from these files, and every database bundle, so the off-site copy
// too, carries them.
const (
hostSMTPPasswordPath = "/etc/felis/smtp-password"
hostUploadsS3AccessKeyPath = "/etc/felis/uploads-s3-access-key"
hostUploadsS3SecretKeyPath = "/etc/felis/uploads-s3-secret-key"
)
// writeHostCredential replaces the file at path with value, mode 0600, through a
// temporary file in the same directory, so a crash leaves the old value or the
// new one and never a partial one.
func writeHostCredential(path, value string) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
// CreateTemp already makes the file 0600; the Chmod states it rather than
// leaning on that.
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.WriteString(value); 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(tmpPath, path)
}
// readHostCredential returns the value in path; ok is false when there is no
// such file. An empty file is a value: the relay password of a relay without AUTH.
func readHostCredential(path string) (value string, ok bool, err error) {
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return string(b), true, nil
}
// relayPassword is the [smtp] relay password as the host holds it: the copy
// `felis setup` keeps at path, else, on an install from before that copy, the
// felis-smtp Secret in ns, whose absence means a relay without AUTH. cl is only
// used when the file is missing; a nil cl then reports errClusterUnreachable.
func relayPassword(ctx context.Context, path string, cl client.Client, ns string) (string, error) {
if pw, ok, err := readHostCredential(path); err != nil || ok {
return pw, err
}
if cl == nil {
return "", errClusterUnreachable
}
return smtpSecretPassword(ctx, cl, ns)
}
var errClusterUnreachable = errors.New("the cluster did not answer")
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/watchdog"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestHostCredentialFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "smtp-password")
if _, ok, err := readHostCredential(path); ok || err != nil {
t.Fatalf("a missing file read as ok=%v err=%v; want not there", ok, err)
}
// A copy an operator put there by hand, readable by everyone, is tightened.
if err := os.WriteFile(path, []byte("by hand"), 0o644); err != nil {
t.Fatal(err)
}
for _, v := range []string{"first secret", "a \"quoted\" $second\nsecret", ""} {
if err := writeHostCredential(path, v); err != nil {
t.Fatal(err)
}
got, ok, err := readHostCredential(path)
if err != nil || !ok || got != v {
t.Fatalf("read back (%q, %v, %v); want (%q, true, nil)", got, ok, err, v)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if mode := fi.Mode().Perm(); mode != 0o600 {
t.Fatalf("mode %v; want 0600", mode)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("the directory holds %d entries; want the credential alone, no leftover temporary file", len(entries))
}
if _, _, err := readHostCredential(dir); err == nil {
t.Fatal("an unreadable credential read as fine")
}
}
func smtpSecretClient(t *testing.T, password string) client.Client {
t.Helper()
return fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "felis", Name: platform.SMTPSecretName},
Data: map[string][]byte{platform.SMTPSecretPasswordKey: []byte(password)},
}).Build()
}
func TestRelayPassword(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
host := filepath.Join(dir, "smtp-password")
cl := smtpSecretClient(t, "from-secret")
if pw, err := relayPassword(ctx, host, cl, "felis"); err != nil || pw != "from-secret" {
t.Fatalf("without a host copy = (%q, %v); want the Secret's", pw, err)
}
if _, err := relayPassword(ctx, host, nil, "felis"); !errors.Is(err, errClusterUnreachable) {
t.Fatalf("without a host copy or a cluster err = %v; want errClusterUnreachable", err)
}
if err := writeHostCredential(host, "from-host"); err != nil {
t.Fatal(err)
}
for _, c := range []client.Client{cl, nil} {
if pw, err := relayPassword(ctx, host, c, "felis"); err != nil || pw != "from-host" {
t.Fatalf("with a host copy (cluster %v) = (%q, %v); want the host copy", c != nil, pw, err)
}
}
if _, err := relayPassword(ctx, dir, cl, "felis"); err == nil {
t.Fatal("an unreadable host copy fell through to the Secret")
}
}
// The watchdog mails the most while the cluster is down: the host copy must
// reach it then, and without one the password the last good run cached stays.
func TestRefreshSMTPPassword(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
host := filepath.Join(dir, "smtp-password")
var stderr bytes.Buffer
state := &watchdog.State{SMTPPassword: "cached"}
refreshSMTPPassword(ctx, host, nil, "felis", state, &stderr)
if state.SMTPPassword != "cached" || stderr.Len() != 0 {
t.Fatalf("cluster down, no host copy: password %q, stderr %q; want the cached one kept quietly", state.SMTPPassword, stderr.String())
}
refreshSMTPPassword(ctx, host, smtpSecretClient(t, "from-secret"), "felis", state, &stderr)
if state.SMTPPassword != "from-secret" {
t.Fatalf("cluster up, no host copy: password %q; want the Secret's", state.SMTPPassword)
}
if err := writeHostCredential(host, "from-host"); err != nil {
t.Fatal(err)
}
refreshSMTPPassword(ctx, host, nil, "felis", state, &stderr)
if state.SMTPPassword != "from-host" {
t.Fatalf("cluster down, host copy: password %q; want the host copy", state.SMTPPassword)
}
refreshSMTPPassword(ctx, dir, nil, "felis", state, &stderr)
if state.SMTPPassword != "from-host" || !strings.Contains(stderr.String(), "keeping the cached one") {
t.Fatalf("unreadable host copy: password %q, stderr %q; want the cached one kept and the failure said", state.SMTPPassword, stderr.String())
}
}
func TestHostRecoveryMailerHostCopy(t *testing.T) {
ctx := context.Background()
// No kubeconfig anywhere: reaching for the cluster fails, so a pass proves
// the host copy was enough.
t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "no-kubeconfig"))
dir := t.TempDir()
host := filepath.Join(dir, "smtp-password")
if err := writeHostCredential(host, "from-host"); err != nil {
t.Fatal(err)
}
off := false
c := config.SMTPConfig{Host: "mail.example.com", Port: 2525, From: "[email protected]", Username: "felis", PasswordRef: "FELIS_TEST_UNSET_RELAY_PW", RequireTLS: &off}
got, err := hostRecoveryMailer(c, host, "felis")(ctx)
if err != nil {
t.Fatalf("with the host copy: %v", err)
}
if relay, ok := got.(*mail.SMTP); !ok || relay.Password != "from-host" {
t.Fatalf("relay = %#v; want the host copy's password", got)
}
if _, err := hostRecoveryMailer(c, dir, "felis")(ctx); err == nil || !strings.Contains(err.Error(), "read the relay password") {
t.Fatalf("unreadable host copy: err = %v; want it named", err)
}
if _, err := hostRecoveryMailer(c, filepath.Join(dir, "none"), "felis")(ctx); err == nil || !strings.Contains(err.Error(), "reach the cluster") {
t.Fatalf("no host copy and no cluster: err = %v; want the cluster named", err)
}
}
// A whole watchdog run with the API server and PostgreSQL both down still
// takes the relay password from the host copy, so the outage mail can
// authenticate even when no earlier run cached it.
func TestWatchdogReadsHostCopyWhileClusterDown(t *testing.T) {
dir := t.TempDir()
t.Setenv("KUBECONFIG", filepath.Join(dir, "no-kubeconfig"))
cfgPath := filepath.Join(dir, "felis.toml")
if err := os.WriteFile(cfgPath, []byte(`[database]
url = "postgres://felis:[email protected]:1/felis?sslmode=disable&connect_timeout=2"
[server]
root_domain = "example.com"
[archive]
store = "tarLocal"
[k8s]
egress_mode = "nodeport"
[smtp]
host = "127.0.0.1"
port = 1
from = "[email protected]"
username = "felis"
password_ref = "FELIS_TEST_UNSET_RELAY_PW"
`), 0o600); err != nil {
t.Fatal(err)
}
pwPath := filepath.Join(dir, "smtp-password")
if err := writeHostCredential(pwPath, "from-host"); err != nil {
t.Fatal(err)
}
statePath := filepath.Join(dir, "state.json")
var stdout, stderr bytes.Buffer
cmdWatchdog([]string{
"-config", cfgPath, "-state", statePath, "-quiet-file", filepath.Join(dir, "quiet"),
"-backup-dir", "", "-disk-paths", dir, "-smtp-password-file", pwPath,
}, &stdout, &stderr)
if !strings.Contains(stdout.String(), "kube-api") {
t.Fatalf("the run found the API server up; the test needs it down (stdout %s)", stdout.String())
}
state, err := watchdog.LoadState(statePath)
if err != nil {
t.Fatalf("load state: %v (stderr %s)", err, stderr.String())
}
if state.SMTPPassword != "from-host" {
t.Fatalf("cached relay password %q; want the host copy (stdout %s, stderr %s)", state.SMTPPassword, stdout.String(), stderr.String())
}
}
+9 -3
View File
@@ -276,8 +276,8 @@ func validateSMTPFrom(s string) error {
} }
// currentSMTPInputs reads the relay already recorded in felis.toml so the form // currentSMTPInputs reads the relay already recorded in felis.toml so the form
// pre-fills the non-secret fields. The password lives only in the felis-smtp // pre-fills the non-secret fields. The password (the felis-smtp Secret and its
// Secret and is deliberately never read back — it must be re-entered to change. // host copy) is deliberately never read back — it must be re-entered to change.
// Any read error falls back to a blank form rather than blocking reconfig. // Any read error falls back to a blank form rather than blocking reconfig.
func currentSMTPInputs() smtpInputs { func currentSMTPInputs() smtpInputs {
cfg, err := config.Load(hostSetupConfigPath) cfg, err := config.Load(hostSetupConfigPath)
@@ -295,7 +295,8 @@ func currentSMTPInputs() smtpInputs {
// applySMTPConfig proves the relay works, then persists it and rolls felis-api: // applySMTPConfig proves the relay works, then persists it and rolls felis-api:
// Ping (a full transaction — connect/STARTTLS/AUTH/MAIL FROM/RCPT/DATA, which // Ping (a full transaction — connect/STARTTLS/AUTH/MAIL FROM/RCPT/DATA, which
// delivers one self-test message to the From address) → [smtp] into both config // delivers one self-test message to the From address) → [smtp] into both config
// files → the felis-smtp Secret → the config Secret → rollout. A failed Ping // files → the password into /etc/felis/smtp-password (hostcreds.go) → the
// felis-smtp Secret → the config Secret → rollout. A failed Ping
// leaves the install untouched, so a bad relay dies at the keyboard, not at a // leaves the install untouched, so a bad relay dies at the keyboard, not at a
// player's OTP. // player's OTP.
// //
@@ -330,6 +331,11 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
return err return err
} }
} }
// The host copy first: should the Secret fail, the next installer run applies
// it from this file.
if err := writeHostCredential(hostSMTPPasswordPath, in.password); err != nil {
return fmt.Errorf("keep the relay password in %s: %w", hostSMTPPasswordPath, err)
}
if err := applySMTPSecret(ctx, in.password); err != nil { if err := applySMTPSecret(ctx, in.password); err != nil {
return err return err
} }
+13 -3
View File
@@ -53,8 +53,17 @@ func applyStorageConfig(ctx context.Context, method storageMethod, in s3Inputs)
return err return err
} }
// S3: land the credentials in their own Secret BEFORE the roll, so the optional // S3: land the credentials in their own Secret BEFORE the roll, so the optional
// env refs resolve on the fresh pod. Local needs no Secret. // env refs resolve on the fresh pod, and on the host before that, so the next
// installer run can apply the Secret again (hostcreds.go). Local needs no Secret.
if method == storageS3 { if method == storageS3 {
for _, c := range []struct{ path, value string }{
{hostUploadsS3AccessKeyPath, in.accessKey},
{hostUploadsS3SecretKeyPath, in.secretKey},
} {
if err := writeHostCredential(c.path, c.value); err != nil {
return fmt.Errorf("keep the bucket credentials in %s: %w", c.path, err)
}
}
if err := applyUploadsS3Secret(ctx, in.accessKey, in.secretKey); err != nil { if err := applyUploadsS3Secret(ctx, in.accessKey, in.secretKey); err != nil {
return err return err
} }
@@ -70,8 +79,9 @@ func applyStorageConfig(ctx context.Context, method storageMethod, in s3Inputs)
// currentStorageInputs reads the storage backend already recorded in felis.toml so // currentStorageInputs reads the storage backend already recorded in felis.toml so
// the reconfigure flow can pre-select the method and pre-fill the non-secret S3 // the reconfigure flow can pre-select the method and pre-fill the non-secret S3
// fields (endpoint/bucket/region). Credentials live only in the felis-uploads-s3 // fields (endpoint/bucket/region). The credentials (the felis-uploads-s3 Secret
// Secret and are deliberately never read back — they must be re-entered to change. // and its host copies) are deliberately never read back — they must be re-entered
// to change.
// Any read error falls back to a blank local default rather than blocking reconfig. // Any read error falls back to a blank local default rather than blocking reconfig.
func currentStorageInputs() (storageMethod, s3Inputs) { func currentStorageInputs() (storageMethod, s3Inputs) {
cfg, err := config.Load(hostSetupConfigPath) cfg, err := config.Load(hostSetupConfigPath)
+20 -9
View File
@@ -36,6 +36,7 @@ func cmdWatchdog(args []string, stdout, stderr io.Writer) int {
fs.SetOutput(stderr) fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (the host copy, which reaches PostgreSQL on 127.0.0.1)") cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (the host copy, which reaches PostgreSQL on 127.0.0.1)")
statePath := fs.String("state", "/var/lib/felis/watchdog/state.json", "state kept between runs (root only: it caches the relay password)") statePath := fs.String("state", "/var/lib/felis/watchdog/state.json", "state kept between runs (root only: it caches the relay password)")
smtpPasswordFile := fs.String("smtp-password-file", hostSMTPPasswordPath, "the relay password `felis setup` keeps on the host; the felis-smtp Secret stands in while it is missing")
quietPath := fs.String("quiet-file", "/run/felis/watchdog-quiet-until", "Unix time before which nothing is mailed; the installer writes it while it restarts things on purpose") quietPath := fs.String("quiet-file", "/run/felis/watchdog-quiet-until", "Unix time before which nothing is mailed; the installer writes it while it restarts things on purpose")
backupDir := fs.String("backup-dir", "/var/lib/felis/db-backups", `control-plane database backups to check for freshness ("" skips the check)`) backupDir := fs.String("backup-dir", "/var/lib/felis/db-backups", `control-plane database backups to check for freshness ("" skips the check)`)
diskPaths := fs.String("disk-paths", "/,/var/lib/rancher/k3s,/var/lib/felis", "comma-separated paths whose filesystems must keep free space") diskPaths := fs.String("disk-paths", "/,/var/lib/rancher/k3s,/var/lib/felis", "comma-separated paths whose filesystems must keep free space")
@@ -88,9 +89,13 @@ func cmdWatchdog(args []string, stdout, stderr io.Writer) int {
report.Unknown = append(report.Unknown, watchdog.ClusterPrefixes...) report.Unknown = append(report.Unknown, watchdog.ClusterPrefixes...)
} else { } else {
report.Findings = append(report.Findings, found...) report.Findings = append(report.Findings, found...)
if cfg.SMTP.Host != "" {
refreshSMTPPassword(ctx, cl, *controlNS, state, stderr)
} }
if cfg.SMTP.Host != "" {
var secrets client.Client
if err == nil {
secrets = cl
}
refreshSMTPPassword(ctx, *smtpPasswordFile, secrets, *controlNS, state, stderr)
} }
if recipients, err := ownerEmails(ctx, cfg.Database.URL); err != nil { if recipients, err := ownerEmails(ctx, cfg.Database.URL); err != nil {
@@ -183,13 +188,19 @@ func usesMirroredScanDB(cfg *config.Config) bool {
return repo == "" || strings.HasPrefix(repo, cfg.Registry.URL+"/mirror/") return repo == "" || strings.HasPrefix(repo, cfg.Registry.URL+"/mirror/")
} }
// refreshSMTPPassword caches the relay password from the felis-smtp Secret, or // refreshSMTPPassword caches the relay password (relayPassword: the host copy at
// forgets it when the Secret is gone (a relay without AUTH). An env var named by // path, else the felis-smtp Secret), or forgets it when the Secret is gone (a
// [smtp] password_ref, when set, wins at send time instead. // relay without AUTH). The host copy is read even while the cluster is down, the
func refreshSMTPPassword(ctx context.Context, cl client.Client, ns string, state *watchdog.State, stderr io.Writer) { // time an alert matters most; without one, a down cluster keeps the cached
password, err := smtpSecretPassword(ctx, cl, ns) // password. An env var named by [smtp] password_ref, when set, wins at send time
if err != nil { // instead.
fmt.Fprintf(stderr, "felis watchdog: read %s/%s (keeping the cached relay password): %v\n", ns, platform.SMTPSecretName, err) func refreshSMTPPassword(ctx context.Context, path string, cl client.Client, ns string, state *watchdog.State, stderr io.Writer) {
password, err := relayPassword(ctx, path, cl, ns)
switch {
case errors.Is(err, errClusterUnreachable):
return
case err != nil:
fmt.Fprintf(stderr, "felis watchdog: read the relay password (keeping the cached one): %v\n", err)
return return
} }
state.SMTPPassword = password state.SMTPPassword = password
+56 -1
View File
@@ -414,6 +414,12 @@ UPDATE_CHECK_SERVICE="/etc/systemd/system/felis-update-check.service"
UPDATE_CHECK_TIMER="/etc/systemd/system/felis-update-check.timer" UPDATE_CHECK_TIMER="/etc/systemd/system/felis-update-check.timer"
WATCHDOG_STATE="/var/lib/felis/watchdog/state.json" WATCHDOG_STATE="/var/lib/felis/watchdog/state.json"
OFFSITE_ENV="${STATE_DIR}/offsite.env" OFFSITE_ENV="${STATE_DIR}/offsite.env"
# Host copies of the credentials `felis setup` takes at the keyboard, one bare value per
# file, mode 0600 (cmd/felis/hostcreds.go); apply_setup_credential_secrets applies their
# Secrets from them on every run.
SMTP_PASSWORD_FILE="${STATE_DIR}/smtp-password"
UPLOADS_S3_ACCESS_KEY_FILE="${STATE_DIR}/uploads-s3-access-key"
UPLOADS_S3_SECRET_KEY_FILE="${STATE_DIR}/uploads-s3-secret-key"
OFFSITE_SERVICE="/etc/systemd/system/felis-offsite.service" OFFSITE_SERVICE="/etc/systemd/system/felis-offsite.service"
OFFSITE_TIMER="/etc/systemd/system/felis-offsite.timer" OFFSITE_TIMER="/etc/systemd/system/felis-offsite.timer"
BUILD_TOOLS_SERVICE="/etc/systemd/system/felis-build-tools.service" BUILD_TOOLS_SERVICE="/etc/systemd/system/felis-build-tools.service"
@@ -666,6 +672,54 @@ apply_registry_secrets() {
rm -rf -- "$dir" rm -rf -- "$dir"
} }
# secret_key_to_file keeps one key of a Secret in path, mode 0600, when path does not
# exist yet. An install from before the host copies had the setup screens' credentials
# only in the cluster; this is the run that moves them onto the host. A missing Secret
# leaves path missing. The value goes from kubectl into the file, never into argv or
# the log.
secret_key_to_file() {
local namespace="$1" name="$2" key="$3" path="$4" encoded tmp
[ -e "$path" ] && return 0
encoded="$(kube -n "$namespace" get secret "$name" -o "jsonpath={.data.${key}}" 2>/dev/null)" || return 0
tmp="$(umask 077; mktemp "${path}.XXXXXX")"
remember_temp "$tmp"
printf '%s' "$encoded" | base64 -d > "$tmp"
mv -f -- "$tmp" "$path"
}
# apply_setup_credential_secrets applies the Secrets behind `felis setup`'s email and
# uploads-bucket screens from their host copies: felis-smtp in the control namespace and
# in the workload one (the reaper's warning mails read that copy), felis-uploads-s3 in the
# control namespace. A reinstall that kept /etc/felis, or a host rebuilt from a bundle's
# state/, starts k3s with no Secrets at all; without this, every sign-in code and alert
# would go out without AUTH and the relay would turn it away. The host copy wins over the
# cluster's: `felis setup` writes both, so they differ only after a hand edit of the
# Secret. A relay or bucket whose credentials are on neither side is reported, so the
# operator enters them again before the first code fails.
apply_setup_credential_secrets() {
local ns
secret_key_to_file "$CONTROL_NS" felis-smtp password "$SMTP_PASSWORD_FILE"
secret_key_to_file "$CONTROL_NS" felis-uploads-s3 access_key_id "$UPLOADS_S3_ACCESS_KEY_FILE"
secret_key_to_file "$CONTROL_NS" felis-uploads-s3 secret_access_key "$UPLOADS_S3_SECRET_KEY_FILE"
if [ -f "$SMTP_PASSWORD_FILE" ]; then
for ns in "$CONTROL_NS" "$MINECRAFT_NS"; do
kube -n "$ns" create secret generic felis-smtp \
--from-file=password="$SMTP_PASSWORD_FILE" \
--dry-run=client -o yaml | kube apply -f -
done
elif persisted_smtp_block | grep -Eq '^[[:space:]]*username[[:space:]]*=[[:space:]]*"[^"]'; then
warn "[smtp] signs in with a username, but its password is in neither ${SMTP_PASSWORD_FILE} nor the cluster: sign-in codes and alerts go out without AUTH until it is entered again (sudo felis setup, then e to configure email)"
fi
if [ -f "$UPLOADS_S3_ACCESS_KEY_FILE" ] && [ -f "$UPLOADS_S3_SECRET_KEY_FILE" ]; then
kube -n "$CONTROL_NS" create secret generic felis-uploads-s3 \
--from-file=access_key_id="$UPLOADS_S3_ACCESS_KEY_FILE" \
--from-file=secret_access_key="$UPLOADS_S3_SECRET_KEY_FILE" \
--dry-run=client -o yaml | kube apply -f -
elif persisted_registry_block | grep -Eq '^[[:space:]]*user_uploads_context[[:space:]]*=[[:space:]]*"[sS]3://'; then
warn "uploads go to an S3 bucket, but its keys are in neither ${UPLOADS_S3_ACCESS_KEY_FILE} / ${UPLOADS_S3_SECRET_KEY_FILE} nor the cluster: felis-api refuses modpack uploads until they are entered again (sudo felis setup, then s to change storage)"
fi
}
# node_global_cidrs prints one host-length CIDR per global address on this node. # node_global_cidrs prints one host-length CIDR per global address on this node.
# Game server egress already excludes every private range; this adds the node's # Game server egress already excludes every private range; this adds the node's
# public addresses, which would otherwise let a server dial the panel NodePort, # public addresses, which would otherwise let a server dial the panel NodePort,
@@ -4777,7 +4831,7 @@ deploy_bundle() {
kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f - kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f -
done done
log "provisioning felis-config + internal caller tokens + felis-forwarding-secret + registry credentials + panel TLS secrets (out-of-band, never in the bundle)" log "provisioning felis-config + internal caller tokens + felis-forwarding-secret + registry credentials + mail relay and uploads bucket credentials + panel TLS secrets (out-of-band, never in the bundle)"
apply_felis_config_secrets apply_felis_config_secrets
# felis-api mounts all four caller tokens from the control namespace. The login # felis-api mounts all four caller tokens from the control namespace. The login
# gate's and the build Job's are also applied into the namespace their pods run in # gate's and the build Job's are also applied into the namespace their pods run in
@@ -4797,6 +4851,7 @@ deploy_bundle() {
# "less secure", it is unjoinable. # "less secure", it is unjoinable.
apply_literal_secret "$CONTROL_NS" felis-forwarding-secret secret "$FORWARDING_SECRET" apply_literal_secret "$CONTROL_NS" felis-forwarding-secret secret "$FORWARDING_SECRET"
apply_registry_secrets apply_registry_secrets
apply_setup_credential_secrets
kube -n "$CONTROL_NS" create secret tls felis-api-tls \ kube -n "$CONTROL_NS" create secret tls felis-api-tls \
--cert="$PANEL_TLS_CERT" \ --cert="$PANEL_TLS_CERT" \
--key="$PANEL_TLS_KEY" \ --key="$PANEL_TLS_KEY" \
+106
View File
@@ -3833,6 +3833,112 @@ expect "a FELIS_ARTIFACT_DIR with SHA256SUMS is accepted" "VALID" "$(run_vs "$ad
expect "FELIS_ARTIFACT_DIR and FELIS_REF together are refused" "DIE: FELIS_ARTIFACT_DIR and FELIS_REF both name what to install" "$(run_vs "$adir" 1)" expect "FELIS_ARTIFACT_DIR and FELIS_REF together are refused" "DIE: FELIS_ARTIFACT_DIR and FELIS_REF both name what to install" "$(run_vs "$adir" 1)"
rm -rf "$adir" rm -rf "$adir"
# --- the setup screens' credentials come back from /etc/felis ---------------------------
# `felis setup` keeps the relay password and the uploads bucket's keys in /etc/felis as
# well as in their Secrets. A reinstall, or a host rebuilt from a bundle's state/, starts
# k3s empty, so every run applies the Secrets from those files; an install from before
# them moves the Secrets' values into the files first. No value may reach kubectl's argv.
for f in secret_key_to_file apply_setup_credential_secrets; do
[ -n "$(bsfn "$f")" ] || { echo "FAIL: no ${f} in $BS"; exit 1; }
[ "$(bsfn "$f" | wc -l)" -lt 45 ] \
|| { echo "FAIL: the extracted ${f} is not the function -- did its closing brace move?"; exit 1; }
done
grep -q '^ apply_setup_credential_secrets$' "$BS" \
&& echo "PASS every run applies the setup screens' Secrets" \
|| { echo "FAIL: the install no longer calls apply_setup_credential_secrets"; fails=$((fails + 1)); }
credir="$(mktemp -d)"
credcalls="$(mktemp)"
run_creds() { # secrets-in-cluster(0/1) smtp-username uploads-context
: > "$credcalls"
HAVE="$1" SMTPUSER="$2" UPCTX="$3" CALLS="$credcalls" STATE_DIR="$credir" CONTROL_NS=felis MINECRAFT_NS=minecraft bash -c '
set -Eeuo pipefail
SMTP_PASSWORD_FILE="${STATE_DIR}/smtp-password"
UPLOADS_S3_ACCESS_KEY_FILE="${STATE_DIR}/uploads-s3-access-key"
UPLOADS_S3_SECRET_KEY_FILE="${STATE_DIR}/uploads-s3-secret-key"
remember_temp() { :; }
warn() { printf "WARN: %s\n" "$*"; }
persisted_smtp_block() { printf "[smtp]\nhost = \"mail.example.com\"\nusername = \"%s\"\n" "$SMTPUSER"; }
persisted_registry_block() { printf "user_uploads_context = \"%s\"\n" "$UPCTX"; }
kube() {
case "$*" in
*" get secret "*)
printf "GET %s\n" "$*" >>"$CALLS"
[ "$HAVE" = 1 ] || return 1
case "$*" in
*felis-smtp*) printf "cmVsYXkgcHcvMSt4" ;; # relay pw/1+x
*access_key_id*) printf "QUtJQU9MRA==" ;; # AKIAOLD
*secret_access_key*) printf "b2xkL3NlY3JldCtrZXk=" ;; # old/secret+key
esac ;;
"apply -f -") cat >/dev/null; printf "APPLY\n" >>"$CALLS" ;;
*) printf "KUBE %s\n" "$*" >>"$CALLS" ;;
esac
}
'"$(bsfn secret_key_to_file)"'
'"$(bsfn apply_setup_credential_secrets)"'
apply_setup_credential_secrets' 2>&1
cat "$credcalls"
}
applies() { printf '%s\n' "$1" | grep -c '^APPLY$'; }
out="$(run_creds 1 felis s3://uploads)"
[ "$(cat "$credir/smtp-password")" = "relay pw/1+x" ] \
&& [ "$(cat "$credir/uploads-s3-access-key")" = "AKIAOLD" ] \
&& [ "$(cat "$credir/uploads-s3-secret-key")" = "old/secret+key" ] \
&& echo "PASS an install from before the host copies moves the Secrets' values onto the host" \
|| { echo "FAIL: the Secrets' values did not land in ${credir}:"; printf '%s\n' "$out"; fails=$((fails + 1)); }
for f in smtp-password uploads-s3-access-key uploads-s3-secret-key; do
case "$(ls -l "$credir/$f" | cut -c1-10)" in
-rw-------) echo "PASS ${f} is root's alone" ;;
*) echo "FAIL: ${f} is $(ls -l "$credir/$f" | cut -c1-10), want -rw-------"; fails=$((fails + 1)) ;;
esac
done
[ -z "$(find "$credir" -name '*.??????' | head -1)" ] && echo "PASS the move leaves no temporary file in /etc/felis" \
|| { echo "FAIL: a temporary file stayed behind: $(ls -a "$credir")"; fails=$((fails + 1)); }
expect "the relay password is applied to the control plane" \
"KUBE -n felis create secret generic felis-smtp --from-file=password=${credir}/smtp-password --dry-run=client -o yaml" "$out"
expect "and to the workload namespace the reaper mails from" \
"KUBE -n minecraft create secret generic felis-smtp --from-file=password=${credir}/smtp-password --dry-run=client -o yaml" "$out"
expect "the bucket keys are applied to the control plane" \
"KUBE -n felis create secret generic felis-uploads-s3 --from-file=access_key_id=${credir}/uploads-s3-access-key --from-file=secret_access_key=${credir}/uploads-s3-secret-key --dry-run=client -o yaml" "$out"
[ "$(applies "$out")" = 3 ] && echo "PASS all three Secrets are piped to kubectl apply" \
|| { echo "FAIL: expected 3 applies:"; printf '%s\n' "$out"; fails=$((fails + 1)); }
case "$out" in
*"relay pw"*|*AKIAOLD*|*"old/secret"*|*WARN*) echo "FAIL: a credential reached argv or the log, or a warning fired:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;;
*) echo "PASS no credential reaches kubectl's argv or the log" ;;
esac
printf 'new pw' > "$credir/smtp-password"
out="$(run_creds 1 felis s3://uploads)"
[ "$(cat "$credir/smtp-password")" = "new pw" ] && ! printf '%s\n' "$out" | grep -q '^GET' \
&& echo "PASS the host copy wins over the cluster's" \
|| { echo "FAIL: the cluster's value replaced the host copy:"; printf '%s\n' "$out"; fails=$((fails + 1)); }
[ "$(applies "$out")" = 3 ] && echo "PASS a re-run applies the Secrets from the host copies" \
|| { echo "FAIL: expected 3 applies on the re-run:"; printf '%s\n' "$out"; fails=$((fails + 1)); }
rm -f "$credir"/*
out="$(run_creds 0 felis s3://uploads)"
expect "a relay that signs in with no password anywhere is reported" \
"WARN: [smtp] signs in with a username, but its password is in neither ${credir}/smtp-password nor the cluster" "$out"
expect "a bucket with no keys anywhere is reported" \
"WARN: uploads go to an S3 bucket, but its keys are in neither" "$out"
[ "$(applies "$out")" = 0 ] && [ -z "$(ls -A "$credir")" ] \
&& echo "PASS nothing is applied or written without a value" \
|| { echo "FAIL: something was applied or written without a value:"; printf '%s\n' "$out"; ls -A "$credir"; fails=$((fails + 1)); }
out="$(run_creds 0 "" /var/lib/felis/uploads)"
case "$out" in
*WARN*|*APPLY*) echo "FAIL: a relay without AUTH and local uploads were reported or applied:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;;
*) echo "PASS a relay without AUTH and local uploads need no credentials" ;;
esac
printf 'AKIAONLY' > "$credir/uploads-s3-access-key"
out="$(run_creds 0 "" s3://uploads)"
case "$out" in
*"create secret generic felis-uploads-s3"*) echo "FAIL: a bucket Secret was applied from one key alone:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;;
*) expect "one bucket key alone is reported as missing keys" "WARN: uploads go to an S3 bucket" "$out" ;;
esac
rm -rf "$credir" "$credcalls"
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
if [ "$fails" -eq 0 ]; then if [ "$fails" -eq 0 ]; then
echo "ALL PASS" echo "ALL PASS"
+1 -1
View File
@@ -273,7 +273,7 @@ and the MinecraftServer CRD are deleted.
| Final database bundle | taken first (`felis db backup -label manual`); a failure stops the uninstall before anything is removed. `--no-backup` skips it | none | | Final database bundle | taken first (`felis db backup -label manual`); a failure stops the uninstall before anything is removed. `--no-backup` skips it | none |
| The database (`/var/lib/felis/postgres`) | kept; felis-postgres is stopped cleanly before k3s goes | deleted with `/var/lib/felis` | | The database (`/var/lib/felis/postgres`) | kept; felis-postgres is stopped cleanly before k3s goes | deleted with `/var/lib/felis` |
| A host PostgreSQL an earlier release ran the database on | kept as it is: stopped after the move into k3s (below, §4), with its old copy of `felis` | its `felis` database and role are dropped (the server is started for that and stopped again), and `listen_addresses` and `pg_hba.conf` go back to how they were. Checked before anything is removed: a role that still owns another database (the `felis_pgint` the PG contract tests use, CONTRIBUTING.md) or holds grants elsewhere stops the purge up front with the list and the `ALTER DATABASE … OWNER TO postgres` to run | | A host PostgreSQL an earlier release ran the database on | kept as it is: stopped after the move into k3s (below, §4), with its old copy of `felis` | its `felis` database and role are dropped (the server is started for that and stopped again), and `listen_addresses` and `pg_hba.conf` go back to how they were. Checked before anything is removed: a role that still owns another database (the `felis_pgint` the PG contract tests use, CONTRIBUTING.md) or holds grants elsewhere stops the purge up front with the list and the `ALTER DATABASE … OWNER TO postgres` to run |
| `/etc/felis` (secrets, `felis.toml`, `offsite.env`, tunnel config) | kept; `bootstrap.done` and the per-run records go | deleted, with the tunnel's credentials file | | `/etc/felis` (secrets, `felis.toml`, `offsite.env`, the mail relay password and uploads bucket keys `felis setup` took, tunnel config) | kept; `bootstrap.done` and the per-run records go | deleted, with the tunnel's credentials file |
| `/var/lib/felis` (the database, its bundles) | kept | deleted | | `/var/lib/felis` (the database, its bundles) | kept | deleted |
| Worlds, archives, registry, uploads | moved to `/var/lib/felis/retained/k3s-storage-<stamp>/` (with `--keep-k3s`: their volumes switch to `Retain` and stay in place) | deleted | | Worlds, archives, registry, uploads | moved to `/var/lib/felis/retained/k3s-storage-<stamp>/` (with `--keep-k3s`: their volumes switch to `Retain` and stay in place) | deleted |
| Felis images, Docker build cache | kept | deleted | | Felis images, Docker build cache | kept | deleted |
+28 -8
View File
@@ -1101,6 +1101,20 @@ control-namespace Secret alone is not. [GO-TESTED: the delivered/retried/
suppressed matrix in `internal/reaper`; live-drilled end to end against a local suppressed matrix in `internal/reaper`; live-drilled end to end against a local
SMTP sink.] SMTP sink.]
The screen also keeps the password in `/etc/felis/smtp-password` (mode 0600),
and the uploads screen keeps the bucket keys in `/etc/felis/uploads-s3-access-key`
and `uploads-s3-secret-key`. Secrets live in k3s's datastore, which a reinstall
or a host rebuilt from a bundle's `state/` starts empty, so **every installer
run applies `felis-smtp` (both namespaces) and `felis-uploads-s3` from these
files**. The files win: a hand edit of either Secret lasts until the next
installer run, so change a credential through `felis setup`. An install from
before these files gets them from the Secrets on its first re-run. When
`[smtp]` has a `username` but no password is on either side, or uploads go to
`s3://` without both keys, the installer says so and names the `felis setup`
screen that takes them again. [SH-TESTED: `deploy/bootstrap_test.sh`, the move
from the Secrets, the host copy winning, both warnings, no value in kubectl's
argv. GO-TESTED: the host copy's mode and replacement.]
### Genuine false-delete risk vectors ### Genuine false-delete risk vectors
- **Stale `last_active_at`.** The keep-alive is `RecordJoin`, called from the - **Stale `last_active_at`.** The keep-alive is `RecordJoin`, called from the
@@ -1510,9 +1524,12 @@ How it mails:
delay is never mailed; one that turns critical is mailed again at once. delay is never mailed; one that turns critical is mailed again at once.
- **During an install** nothing is mailed. `bootstrap.sh` writes - **During an install** nothing is mailed. `bootstrap.sh` writes
`/run/felis/watchdog-quiet-until` and removes it when it exits. `/run/felis/watchdog-quiet-until` and removes it when it exits.
- **Caching:** the relay password and the recipient list are cached in - **Caching:** the relay password comes from `/etc/felis/smtp-password`, which
`/var/lib/felis/watchdog/state.json` (root-only). An outage of PostgreSQL or the watchdog reads even while the API server is down (an install without that
of the API server can therefore still be mailed. file reads the `felis-smtp` Secret instead). It and the recipient list are
cached in `/var/lib/felis/watchdog/state.json` (root-only). An outage of
PostgreSQL or of the API server can therefore still be mailed. [GO-TESTED: a
run with the API server and PostgreSQL both down caches the host copy.]
- **No relay or no verified owner address:** each alert is written to the - **No relay or no verified owner address:** each alert is written to the
journal only. journal only.
@@ -1893,7 +1910,7 @@ along). One bundle is `felis-db-<UTC stamp>-<label>.tar`:
|---|---| |---|---|
| `MANIFEST.json` | version, schema version, `pg_dump --version`, sha256 of every member | | `MANIFEST.json` | version, schema version, `pg_dump --version`, sha256 of every member |
| `db.dump` | `pg_dump --format=custom` of the `felis` database | | `db.dump` | `pg_dump --format=custom` of the `felis` database |
| `state/etc/felis/...` | every file in `/etc/felis`: `secrets.env` (DB password, session/forwarding secrets, registry tokens), `felis.host.toml`, `felis.pod.toml`, the `felis.toml` symlink, `offsite.env` (bucket credentials and encryption key), the panel TLS pair, and the installer's own markers (`system-server-images`, `velocity.fingerprint`). `bootstrap.done` is left out on purpose | | `state/etc/felis/...` | every file in `/etc/felis`: `secrets.env` (DB password, session/forwarding secrets, registry tokens), `felis.host.toml`, `felis.pod.toml`, the `felis.toml` symlink, `offsite.env` (bucket credentials and encryption key), `smtp-password`, `uploads-s3-access-key` and `uploads-s3-secret-key` (the mail relay password and the uploads bucket keys `felis setup` took), the panel TLS pair, and the installer's own markers (`system-server-images`, `velocity.fingerprint`). `bootstrap.done` is left out on purpose |
| `k8s/minecraftservers.json` | every MinecraftServer, status and server-side metadata stripped, ready for `kubectl apply` (best effort: when the cluster did not answer, the manifest records why) | | `k8s/minecraftservers.json` | every MinecraftServer, status and server-side metadata stripped, ready for `kubectl apply` (best effort: when the cluster did not answer, the manifest records why) |
next to a `.sha256` sidecar in `sha256sum` format. **A bundle contains the next to a `.sha256` sidecar in `sha256sum` format. **A bundle contains the
@@ -2030,7 +2047,7 @@ off-site bucket (next sections) plus a fresh install. What the host holds:
| Data | On the host | In the bucket | Brought back by | Lost at most | | Data | On the host | In the bucket | Brought back by | Lost at most |
|---|---|---|---|---| |---|---|---|---|---|
| Control-plane database (accounts, passkeys, ownership, quotas, audit, submissions, the `world_backups` index) | felis-postgres, `/var/lib/felis/postgres` | every bundle, copied within the hour of being written | `fetch-db`, `db restore` | changes since the newest bundle: up to a day plus an hour with the daily timer | | Control-plane database (accounts, passkeys, ownership, quotas, audit, submissions, the `world_backups` index) | felis-postgres, `/var/lib/felis/postgres` | every bundle, copied within the hour of being written | `fetch-db`, `db restore` | changes since the newest bundle: up to a day plus an hour with the daily timer |
| Host state (`/etc/felis`: secrets, both `felis.toml` copies, `offsite.env`, panel TLS pair) | `/etc/felis` | inside every bundle | `tar -x` of the bundle's `state/` | as the database | | Host state (`/etc/felis`: secrets, both `felis.toml` copies, `offsite.env`, the mail relay password and uploads bucket keys, panel TLS pair) | `/etc/felis` | inside every bundle | `tar -x` of the bundle's `state/` | as the database |
| MinecraftServer objects | k3s | inside every bundle (`k8s/minecraftservers.json`) | `kubectl apply` | as the database | | MinecraftServer objects | k3s | inside every bundle (`k8s/minecraftservers.json`) | `kubectl apply` | as the database |
| World archives (reaper, "Back up now", pre-restore snapshots) | `felis-backups` volume | each one within the hour | `fetch-worlds` | archives written in the last hour | | World archives (reaper, "Back up now", pre-restore snapshots) | `felis-backups` volume | each one within the hour | `fetch-worlds` | archives written in the last hour |
| Live worlds | `world-*` volumes under `/var/lib/rancher/k3s/storage` | **only as their archives** | a restore from the newest archive (§10) | everything since that world's newest archive | | Live worlds | `world-*` volumes under `/var/lib/rancher/k3s/storage` | **only as their archives** | a restore from the newest archive (§10) | everything since that world's newest archive |
@@ -2088,7 +2105,8 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
`object does not decrypt with this key` and writes nothing. For a copy you `object does not decrypt with this key` and writes nothing. For a copy you
made yourself, check it with `sha256sum -c felis-db-....tar.sha256`. made yourself, check it with `sha256sum -c felis-db-....tar.sha256`.
2. Put the old host's state in place **before** installing, so the installer 2. Put the old host's state in place **before** installing, so the installer
reuses the same DB password, session secret, forwarding secret and the reuses the same DB password, session secret, forwarding secret, the mail
relay password and uploads bucket keys `felis setup` took, and the
`[offsite]` bucket with its credentials and key (`offsite.env`): `[offsite]` bucket with its credentials and key (`offsite.env`):
``` ```
@@ -2414,8 +2432,10 @@ Once a staff account exists, `sudo felis breakGlass` asks which admin or owner
is breaking the glass and mails that account's verified address a six-digit is breaking the glass and mails that account's verified address a six-digit
code through the same `[smtp]` relay as the sign-in codes. The code works for code through the same `[smtp]` relay as the sign-in codes. The code works for
10 minutes and five wrong ones end it. The console reads the relay password 10 minutes and five wrong ones end it. The console reads the relay password
the way the watchdog does (the `password_ref` env var, else the `felis-smtp` the way the watchdog does (the `password_ref` env var, else
Secret, else no AUTH), and the mail skips the API's `max_per_hour` budget. `/etc/felis/smtp-password`, else the `felis-smtp` Secret, else no AUTH), so a
host whose k3s is down still gets its code, and the mail skips the API's
`max_per_hour` budget.
Only the right code makes the run a `recovery` attributed to that account; the Only the right code makes the run a `recovery` attributed to that account; the
mail says which host and OS user asked, so an admin who did not ask learns mail says which host and OS user asked, so an admin who did not ask learns
that root there is in other hands. that root there is in other hands.
+4 -2
View File
@@ -130,7 +130,8 @@ const (
UploadsLocalPath = "/var/lib/felis/uploads" UploadsLocalPath = "/var/lib/felis/uploads"
// UploadsS3SecretName is the out-of-band Secret carrying the S3 credentials for // UploadsS3SecretName is the out-of-band Secret carrying the S3 credentials for
// an s3:// user_uploads_context. felis-api mounts its keys into env (optionally) // an s3:// user_uploads_context. felis-api mounts its keys into env (optionally)
// and the setup wizard creates it. Like configSecretName it is NEVER rendered // and the setup wizard creates it, keeping a host copy in /etc/felis that every
// installer run applies it from again. Like configSecretName it is NEVER rendered
// into the bundle — the credentials are the same red line. // into the bundle — the credentials are the same red line.
UploadsS3SecretName = "felis-uploads-s3" UploadsS3SecretName = "felis-uploads-s3"
UploadsS3SecretAccessKey = "access_key_id" UploadsS3SecretAccessKey = "access_key_id"
@@ -143,7 +144,8 @@ const (
// SMTPSecretName is the out-of-band Secret carrying the [smtp] relay password. // SMTPSecretName is the out-of-band Secret carrying the [smtp] relay password.
// Same red line as the S3 credentials: the setup wizard's "configure email" // Same red line as the S3 credentials: the setup wizard's "configure email"
// step creates it, felis-api reads it via SMTPPasswordEnv (optionally — a // step creates it (and keeps a host copy in /etc/felis that every installer run
// applies it from again), felis-api reads it via SMTPPasswordEnv (optionally — a
// mailer-less install has no such Secret and still starts, falling back to // mailer-less install has no such Secret and still starts, falling back to
// logging codes), and it is never rendered into the bundle. // logging codes), and it is never rendered into the bundle.
SMTPSecretName = "felis-smtp" SMTPSecretName = "felis-smtp"