863 lines
29 KiB
Go
863 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/config"
|
|
"felis.lolicon.best/internal/platform"
|
|
"felis.lolicon.best/internal/watchdog"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
networkingv1 "k8s.io/api/networking/v1"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
"k8s.io/client-go/kubernetes"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
// supportBundleDir is where felis support-bundle writes unless told otherwise.
|
|
const supportBundleDir = "/var/lib/felis/support"
|
|
|
|
// redacted stands in for every value the bundle leaves out.
|
|
const redacted = "<redacted>"
|
|
|
|
// minScrubLen is the shortest known secret value the bundle searches for: a
|
|
// shorter one would blank ordinary words, and every secret the installer
|
|
// generates is far longer.
|
|
const minScrubLen = 8
|
|
|
|
// hostSecretSources are the files on a Felis host that hold secrets; the
|
|
// bundle reads them only to take each value out of what it collects.
|
|
var hostSecretSources = secretSources{
|
|
envFiles: []string{"/etc/felis/secrets.env", defaultOffsiteEnvFile},
|
|
valueFiles: []string{hostSMTPPasswordPath, hostUploadsS3AccessKeyPath, hostUploadsS3SecretKeyPath, defaultHeartbeatFile, "/opt/felis/velocity/forwarding.secret"},
|
|
propsFiles: []string{"/opt/felis/velocity/plugins/felis-link/felis-link.properties"},
|
|
tokenFiles: []string{"/var/lib/rancher/k3s/server/token", "/var/lib/rancher/k3s/server/agent-token"},
|
|
}
|
|
|
|
// cmdSupportBundle collects what someone helping with this host needs into
|
|
// one tar.gz: felis status and felis doctor, the logs of the control plane,
|
|
// the builds and the systemd units, the cluster's workloads and events, and a
|
|
// summary of the configuration. It never collects a Secret, a ConfigMap, the
|
|
// contents of a configuration file, the database or a world, and takes every
|
|
// value of the host's secret files out of what it does collect. Game server
|
|
// logs, which carry player names, addresses and chat, only with -server-logs.
|
|
func cmdSupportBundle(args []string, stdout, stderr io.Writer) int {
|
|
fs := flag.NewFlagSet("support-bundle", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
outDir := fs.String("o", supportBundleDir, "directory to write the bundle to (created mode 0700 when missing)")
|
|
logLines := fs.Int64("log-lines", 2000, "lines kept from the end of each pod log and each unit's journal")
|
|
since := fs.Duration("since", 48*time.Hour, "how far back each unit's journal is read")
|
|
serverLogs := fs.Bool("server-logs", false, "also collect the game servers' own logs, which carry player names, IP addresses and chat")
|
|
unitDir := fs.String("systemd-dir", systemdUnitDir, "where the installer's systemd units are")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
if os.Geteuid() != 0 {
|
|
fmt.Fprintln(stderr, "felis support-bundle: run as root (sudo felis support-bundle): it reads root-only logs and state")
|
|
return 1
|
|
}
|
|
b := hostSupportBundle(*unitDir, *logLines, *since, *serverLogs)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
path, err := b.write(ctx, *outDir)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis support-bundle: %v\n", err)
|
|
return 1
|
|
}
|
|
size := int64(0)
|
|
if st, err := os.Stat(path); err == nil {
|
|
size = st.Size()
|
|
}
|
|
fmt.Fprintf(stdout, "wrote %s (%s, mode 0600)\n", path, humanSize(size))
|
|
fmt.Fprintln(stdout, "MANIFEST.txt inside says what it holds and what was taken out. Read it through before you send it anywhere:")
|
|
fmt.Fprintln(stdout, "redaction finds this host's known secrets and the common ways a secret is logged, and a secret logged another way stays in.")
|
|
if !*serverLogs {
|
|
fmt.Fprintln(stdout, "Game server logs are left out; -server-logs adds them (player names, IP addresses, chat).")
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func humanSize(n int64) string {
|
|
switch {
|
|
case n >= 1<<20:
|
|
return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
|
|
case n >= 1<<10:
|
|
return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10))
|
|
}
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
|
|
// shortDuration is d as Duration.String writes it, less the zero minutes and
|
|
// seconds after whole hours or minutes: 48h, and 90m as 1h30m.
|
|
func shortDuration(d time.Duration) string {
|
|
s := d.String()
|
|
if strings.HasSuffix(s, "m0s") {
|
|
s = strings.TrimSuffix(s, "0s")
|
|
}
|
|
if strings.HasSuffix(s, "h0m") {
|
|
s = strings.TrimSuffix(s, "0m")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// supportBundle is one collection and what it reads the host through.
|
|
type supportBundle struct {
|
|
host string
|
|
now time.Time
|
|
unitDir string
|
|
// w is how felis-watchdog.service runs the watchdog, wErr why it could
|
|
// not be read (w then holds the defaults).
|
|
w watchdogFlags
|
|
wErr error
|
|
cfg *config.Config // nil when cfgErr
|
|
cfgErr error
|
|
cl client.Client // nil when clErr
|
|
clErr error
|
|
logs func(ctx context.Context, ns, pod, container string, previous bool) ([]byte, error)
|
|
run func(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
backups func(ctx context.Context) (map[string]time.Time, error)
|
|
doctor func(ctx context.Context, out io.Writer)
|
|
secrets secretSources
|
|
logLines int64
|
|
since time.Duration
|
|
serverLogs bool
|
|
// Host files read whole, and the directory whose listing is kept.
|
|
meminfo, osRelease, procVersion, stateDir string
|
|
}
|
|
|
|
func hostSupportBundle(unitDir string, logLines int64, since time.Duration, serverLogs bool) *supportBundle {
|
|
host, _ := os.Hostname()
|
|
b := &supportBundle{
|
|
host: host, now: time.Now(), unitDir: unitDir, run: hostCommand, secrets: hostSecretSources,
|
|
logLines: logLines, since: since, serverLogs: serverLogs,
|
|
meminfo: "/proc/meminfo", osRelease: "/etc/os-release", procVersion: "/proc/version", stateDir: "/etc/felis",
|
|
}
|
|
var found bool
|
|
b.w, found, b.wErr = watchdogUnitFlags(filepath.Join(unitDir, "felis-watchdog.service"))
|
|
if b.wErr == nil && !found {
|
|
b.wErr = fmt.Errorf("%s is not installed; read the watchdog's defaults", filepath.Join(unitDir, "felis-watchdog.service"))
|
|
}
|
|
if b.cfg, b.cfgErr = config.Load(b.w.cfgPath); b.cfgErr == nil {
|
|
cfg := b.cfg
|
|
b.backups = func(ctx context.Context) (map[string]time.Time, error) {
|
|
return newestWorldBackups(ctx, cfg.Database.URL)
|
|
}
|
|
} else {
|
|
b.cfg = nil
|
|
}
|
|
if b.cl, b.clErr = buildSystemServerClient(); b.clErr != nil {
|
|
b.cl = nil
|
|
} else if rc, err := hostRESTConfig(); err != nil {
|
|
b.clErr = err
|
|
b.cl = nil
|
|
} else if cs, err := kubernetes.NewForConfig(rc); err != nil {
|
|
b.clErr = err
|
|
b.cl = nil
|
|
} else {
|
|
limit := int64(8 << 20)
|
|
b.logs = func(ctx context.Context, ns, pod, container string, previous bool) ([]byte, error) {
|
|
return cs.CoreV1().Pods(ns).GetLogs(pod, &corev1.PodLogOptions{
|
|
Container: container, Previous: previous, Timestamps: true, TailLines: &logLines, LimitBytes: &limit,
|
|
}).DoRaw(ctx)
|
|
}
|
|
}
|
|
b.doctor = func(ctx context.Context, out io.Writer) {
|
|
runDoctor(ctx, doctorEnv{unitDir: unitDir, run: hostCommand, now: b.now, host: host}, out)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// bundleWriter streams scrubbed files into the archive and keeps what went
|
|
// wrong while collecting, for MANIFEST.txt.
|
|
type bundleWriter struct {
|
|
tw *tar.Writer
|
|
prefix string
|
|
now time.Time
|
|
scrub *scrubber
|
|
errs []string
|
|
}
|
|
|
|
// logText adds a log, or a report that quotes errors: known secrets and
|
|
// anything logged as one come out.
|
|
func (bw *bundleWriter) logText(name string, data []byte) error {
|
|
return bw.add(name, bw.scrub.text(data))
|
|
}
|
|
|
|
// plain adds a file whose secrets were already taken out by structure (the
|
|
// cluster's objects, the configuration summary) or that names none (the
|
|
// release, disk use, addresses): known secret values still come out, and
|
|
// nothing else is rewritten.
|
|
func (bw *bundleWriter) plain(name string, data []byte) error {
|
|
return bw.add(name, bw.scrub.values(data))
|
|
}
|
|
|
|
func (bw *bundleWriter) add(name string, data []byte) error {
|
|
hdr := &tar.Header{Name: path.Join(bw.prefix, name), Mode: 0o600, Size: int64(len(data)), ModTime: bw.now, Typeflag: tar.TypeReg}
|
|
if err := bw.tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
_, err := bw.tw.Write(data)
|
|
return err
|
|
}
|
|
|
|
func (bw *bundleWriter) failed(what string, err error) {
|
|
bw.errs = append(bw.errs, string(bw.scrub.text([]byte(fmt.Sprintf("%s: %v", what, err)))))
|
|
}
|
|
|
|
// write collects the bundle into dir and returns its path.
|
|
func (b *supportBundle) write(ctx context.Context, dir string) (string, error) {
|
|
if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
stamp := b.now.UTC().Format("20060102T150405Z")
|
|
base := fmt.Sprintf("felis-support-%s-%s", safeName(b.host), stamp)
|
|
final := filepath.Join(dir, base+".tar.gz")
|
|
f, err := os.CreateTemp(dir, "."+base+".*.partial") // mode 0600
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
keep := false
|
|
defer func() {
|
|
if !keep {
|
|
f.Close()
|
|
os.Remove(f.Name())
|
|
}
|
|
}()
|
|
gz := gzip.NewWriter(f)
|
|
bw := &bundleWriter{tw: tar.NewWriter(gz), prefix: base, now: b.now, scrub: b.scrubber()}
|
|
if err := b.collect(ctx, bw); err != nil {
|
|
return "", err
|
|
}
|
|
if err := bw.tw.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Rename(f.Name(), final); err != nil {
|
|
return "", err
|
|
}
|
|
keep = true
|
|
return final, nil
|
|
}
|
|
|
|
// safeName keeps a host name usable in a file name.
|
|
func safeName(s string) string {
|
|
s = strings.Map(func(r rune) rune {
|
|
if r == '-' || r == '.' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
return r
|
|
}
|
|
return '_'
|
|
}, s)
|
|
if s == "" {
|
|
return "host"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (b *supportBundle) collect(ctx context.Context, bw *bundleWriter) error {
|
|
var buf bytes.Buffer
|
|
cmdVersion(nil, &buf, io.Discard)
|
|
for _, p := range []string{b.osRelease, b.procVersion} {
|
|
if raw, err := os.ReadFile(p); err == nil {
|
|
fmt.Fprintf(&buf, "\n# %s\n%s", p, raw)
|
|
}
|
|
}
|
|
if err := bw.plain("version.txt", buf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
|
|
buf.Reset()
|
|
switch {
|
|
case b.cfgErr != nil:
|
|
fmt.Fprintf(&buf, "felis status: the configuration did not load: %v\n", b.cfgErr)
|
|
default:
|
|
printStatus(ctx, statusEnv{
|
|
cfg: b.cfg, w: b.w, cl: b.cl, clErr: b.clErr, backups: b.backups, run: b.run,
|
|
unitDir: b.unitDir, meminfo: b.meminfo, host: b.host, now: b.now,
|
|
}, &buf)
|
|
}
|
|
if err := bw.logText("status.txt", buf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
buf.Reset()
|
|
b.doctor(ctx, &buf)
|
|
if err := bw.logText("doctor.txt", buf.Bytes()); err != nil {
|
|
return err
|
|
}
|
|
if b.cfg != nil {
|
|
if err := bw.plain("config.txt", configSummary(b.cfg, b.w.cfgPath)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := b.collectHost(ctx, bw); err != nil {
|
|
return err
|
|
}
|
|
if err := b.collectJournal(ctx, bw); err != nil {
|
|
return err
|
|
}
|
|
if b.cl == nil {
|
|
bw.failed("cluster", b.clErr)
|
|
} else if err := b.collectCluster(ctx, bw); err != nil {
|
|
return err
|
|
}
|
|
return bw.add("MANIFEST.txt", b.manifest(bw))
|
|
}
|
|
|
|
func (b *supportBundle) collectHost(ctx context.Context, bw *bundleWriter) error {
|
|
commands := []struct {
|
|
name string
|
|
argv []string
|
|
}{
|
|
{"host/systemd-units.txt", []string{"systemctl", "list-units", "--all", "--no-pager", "--plain", "felis-*", "k3s.service"}},
|
|
{"host/systemd-timers.txt", []string{"systemctl", "list-timers", "--all", "--no-pager", "felis-*"}},
|
|
{"host/df.txt", []string{"df", "-h"}},
|
|
{"host/addresses.txt", []string{"ip", "-brief", "address"}},
|
|
}
|
|
for _, c := range commands {
|
|
out, err := b.run(ctx, c.argv[0], c.argv[1:]...)
|
|
if err != nil && len(out) == 0 {
|
|
bw.failed(strings.Join(c.argv, " "), err)
|
|
continue
|
|
}
|
|
if err := bw.plain(c.name, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if raw, err := os.ReadFile(b.meminfo); err == nil {
|
|
if err := bw.plain("host/meminfo.txt", raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
listing, err := dirListing(b.stateDir)
|
|
if err != nil {
|
|
bw.failed("list "+b.stateDir, err)
|
|
return nil
|
|
}
|
|
return bw.plain("host/etc-felis.txt", listing)
|
|
}
|
|
|
|
// dirListing names every file under dir with its mode, size and time, and
|
|
// holds nothing of what is in them.
|
|
func dirListing(dir string) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
tw := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintf(tw, "# %s: names, modes, sizes and times only; no contents\n", dir)
|
|
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(tw, "%s\t%d\t%s\t%s\n", info.Mode(), info.Size(), info.ModTime().UTC().Format(time.RFC3339), p)
|
|
return nil
|
|
})
|
|
tw.Flush()
|
|
return buf.Bytes(), err
|
|
}
|
|
|
|
func (b *supportBundle) collectJournal(ctx context.Context, bw *bundleWriter) error {
|
|
units, _ := filepath.Glob(filepath.Join(b.unitDir, "felis-*.service"))
|
|
if _, err := os.Stat(filepath.Join(b.unitDir, "k3s.service")); err == nil {
|
|
units = append(units, filepath.Join(b.unitDir, "k3s.service"))
|
|
}
|
|
since := "@" + strconv.FormatInt(b.now.Add(-b.since).Unix(), 10)
|
|
for _, u := range units {
|
|
unit := filepath.Base(u)
|
|
out, err := b.run(ctx, "journalctl", "-u", unit, "--since", since, "-n", strconv.FormatInt(b.logLines, 10), "--no-pager", "-o", "short-iso")
|
|
if err != nil && len(out) == 0 {
|
|
bw.failed("journalctl -u "+unit, err)
|
|
continue
|
|
}
|
|
if err := bw.logText("journal/"+strings.TrimSuffix(unit, ".service")+".log", out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// bundleNamespaces are the namespaces whose objects and logs the bundle
|
|
// collects: the control plane, the builds and the game servers.
|
|
func (b *supportBundle) bundleNamespaces() (control, build, minecraft string) {
|
|
control, build, minecraft = b.w.controlNS, platform.DefaultBuildNamespace, platform.DefaultMinecraftNamespace
|
|
if b.cfg != nil {
|
|
if b.cfg.Registry.BuildNamespace != "" {
|
|
build = b.cfg.Registry.BuildNamespace
|
|
}
|
|
if b.cfg.K8s.Namespace != "" {
|
|
minecraft = b.cfg.K8s.Namespace
|
|
}
|
|
}
|
|
return control, build, minecraft
|
|
}
|
|
|
|
func (b *supportBundle) collectCluster(ctx context.Context, bw *bundleWriter) error {
|
|
control, build, minecraft := b.bundleNamespaces()
|
|
dump := func(name string, list client.ObjectList, opts ...client.ListOption) error {
|
|
if err := b.cl.List(ctx, list, opts...); err != nil {
|
|
bw.failed("list "+name, err)
|
|
return nil
|
|
}
|
|
redactList(list)
|
|
out, err := yaml.Marshal(list)
|
|
if err != nil {
|
|
bw.failed("encode "+name, err)
|
|
return nil
|
|
}
|
|
return bw.plain(name, out)
|
|
}
|
|
if err := dump("cluster/nodes.yaml", &corev1.NodeList{}); err != nil {
|
|
return err
|
|
}
|
|
if err := dump("cluster/persistentvolumes.yaml", &corev1.PersistentVolumeList{}); err != nil {
|
|
return err
|
|
}
|
|
if err := dump("cluster/minecraftservers.yaml", &v1alpha1.MinecraftServerList{}, client.InNamespace(minecraft)); err != nil {
|
|
return err
|
|
}
|
|
for _, ns := range []string{control, build, minecraft} {
|
|
for _, k := range []struct {
|
|
name string
|
|
list client.ObjectList
|
|
}{
|
|
{"pods", &corev1.PodList{}},
|
|
{"deployments", &appsv1.DeploymentList{}},
|
|
{"statefulsets", &appsv1.StatefulSetList{}},
|
|
{"jobs", &batchv1.JobList{}},
|
|
{"cronjobs", &batchv1.CronJobList{}},
|
|
{"services", &corev1.ServiceList{}},
|
|
{"persistentvolumeclaims", &corev1.PersistentVolumeClaimList{}},
|
|
{"networkpolicies", &networkingv1.NetworkPolicyList{}},
|
|
{"events", &corev1.EventList{}},
|
|
} {
|
|
if err := dump("cluster/"+ns+"/"+k.name+".yaml", k.list, client.InNamespace(ns)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
var all corev1.PodList
|
|
if err := b.cl.List(ctx, &all); err != nil {
|
|
bw.failed("list every pod", err)
|
|
} else if err := bw.plain("cluster/pods-all-namespaces.txt", podTable(all.Items, b.now)); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, ns := range []string{control, build, minecraft} {
|
|
var pods corev1.PodList
|
|
if err := b.cl.List(ctx, &pods, client.InNamespace(ns)); err != nil {
|
|
continue // already recorded by the dump above
|
|
}
|
|
for _, p := range pods.Items {
|
|
if err := b.collectPodLogs(ctx, bw, p, ns == minecraft && !b.serverLogs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// collectPodLogs keeps the tail of each container's log, and of its previous
|
|
// run when it restarted. initOnly keeps only the init containers: a game
|
|
// server's own log carries player names, addresses and chat.
|
|
func (b *supportBundle) collectPodLogs(ctx context.Context, bw *bundleWriter, p corev1.Pod, initOnly bool) error {
|
|
type ctr struct {
|
|
name string
|
|
restarts int32
|
|
}
|
|
var ctrs []ctr
|
|
restarts := map[string]int32{}
|
|
for _, cs := range append(append([]corev1.ContainerStatus(nil), p.Status.InitContainerStatuses...), p.Status.ContainerStatuses...) {
|
|
restarts[cs.Name] = cs.RestartCount
|
|
}
|
|
for _, c := range p.Spec.InitContainers {
|
|
ctrs = append(ctrs, ctr{c.Name, restarts[c.Name]})
|
|
}
|
|
if !initOnly {
|
|
for _, c := range p.Spec.Containers {
|
|
ctrs = append(ctrs, ctr{c.Name, restarts[c.Name]})
|
|
}
|
|
}
|
|
for _, c := range ctrs {
|
|
for _, previous := range []bool{false, true} {
|
|
if previous && c.restarts == 0 {
|
|
continue
|
|
}
|
|
name := fmt.Sprintf("logs/%s/%s/%s.log", p.Namespace, p.Name, c.name)
|
|
if previous {
|
|
name = fmt.Sprintf("logs/%s/%s/%s.previous.log", p.Namespace, p.Name, c.name)
|
|
}
|
|
out, err := b.logs(ctx, p.Namespace, p.Name, c.name, previous)
|
|
if err != nil {
|
|
bw.failed(name, err)
|
|
continue
|
|
}
|
|
if err := bw.logText(name, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// podTable is every pod on the node, one line each.
|
|
func podTable(pods []corev1.Pod, now time.Time) []byte {
|
|
sort.Slice(pods, func(i, j int) bool {
|
|
if pods[i].Namespace != pods[j].Namespace {
|
|
return pods[i].Namespace < pods[j].Namespace
|
|
}
|
|
return pods[i].Name < pods[j].Name
|
|
})
|
|
var buf bytes.Buffer
|
|
tw := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "NAMESPACE\tNAME\tPHASE\tREADY\tRESTARTS\tAGE")
|
|
for _, p := range pods {
|
|
var ready, restarts int32
|
|
for _, cs := range p.Status.ContainerStatuses {
|
|
if cs.Ready {
|
|
ready++
|
|
}
|
|
restarts += cs.RestartCount
|
|
}
|
|
age := "-"
|
|
if !p.CreationTimestamp.IsZero() {
|
|
age = now.Sub(p.CreationTimestamp.Time).Round(time.Minute).String()
|
|
}
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%d/%d\t%d\t%s\n", p.Namespace, p.Name, p.Status.Phase, ready, len(p.Spec.Containers), restarts, age)
|
|
}
|
|
tw.Flush()
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// redactList takes out of every object what the bundle must not carry: the
|
|
// literal env values of pod specs and of MinecraftServers (valueFrom
|
|
// references stay, naming the Secret without its contents), the
|
|
// last-applied-configuration annotation that repeats them, and managedFields.
|
|
func redactList(list client.ObjectList) {
|
|
items, err := meta.ExtractList(list)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, it := range items {
|
|
if acc, err := meta.Accessor(it); err == nil {
|
|
acc.SetManagedFields(nil)
|
|
if ann := acc.GetAnnotations(); ann != nil {
|
|
delete(ann, corev1.LastAppliedConfigAnnotation)
|
|
acc.SetAnnotations(ann)
|
|
}
|
|
}
|
|
switch o := it.(type) {
|
|
case *corev1.Pod:
|
|
redactPodSpec(&o.Spec)
|
|
case *appsv1.Deployment:
|
|
redactPodSpec(&o.Spec.Template.Spec)
|
|
case *appsv1.StatefulSet:
|
|
redactPodSpec(&o.Spec.Template.Spec)
|
|
case *batchv1.Job:
|
|
redactPodSpec(&o.Spec.Template.Spec)
|
|
case *batchv1.CronJob:
|
|
redactPodSpec(&o.Spec.JobTemplate.Spec.Template.Spec)
|
|
case *v1alpha1.MinecraftServer:
|
|
for i := range o.Spec.Env {
|
|
if o.Spec.Env[i].Value != "" {
|
|
o.Spec.Env[i].Value = redacted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func redactPodSpec(s *corev1.PodSpec) {
|
|
blank := func(cs []corev1.Container) {
|
|
for i := range cs {
|
|
for j := range cs[i].Env {
|
|
if cs[i].Env[j].Value != "" {
|
|
cs[i].Env[j].Value = redacted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
blank(s.InitContainers)
|
|
blank(s.Containers)
|
|
for i := range s.EphemeralContainers {
|
|
for j := range s.EphemeralContainers[i].Env {
|
|
if s.EphemeralContainers[i].Env[j].Value != "" {
|
|
s.EphemeralContainers[i].Env[j].Value = redacted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// configSummary is felis.toml without a single credential: the hostnames,
|
|
// namespaces and which features are on. Fields are picked one by one, so a
|
|
// field added later stays out until someone decides it is safe.
|
|
func configSummary(c *config.Config, path string) []byte {
|
|
var buf bytes.Buffer
|
|
line := func(k string, v any) { fmt.Fprintf(&buf, "%-34s %v\n", k, v) }
|
|
fmt.Fprintf(&buf, "# a summary of %s; no password, key or token is in it\n", path)
|
|
line("server.root_domain", c.Server.RootDomain)
|
|
line("server.listen", c.Server.Listen)
|
|
db := "(unparsable)"
|
|
if u, err := url.Parse(c.Database.URL); err == nil {
|
|
db = u.Scheme + "://" + u.User.Username() + "@" + u.Host + u.Path
|
|
}
|
|
line("database.url (no password)", db)
|
|
line("database.deployment", c.Database.Deployment)
|
|
line("velocity.public_ip", c.Velocity.PublicIP)
|
|
line("velocity.game_port", c.Velocity.GamePort)
|
|
line("velocity.login_image", c.Velocity.LoginImage)
|
|
line("velocity.lobby_image", c.Velocity.LobbyImage)
|
|
line("auth.panel_hostname", c.Auth.PanelHostname)
|
|
line("auth.admin_hostname", c.Auth.AdminHostname)
|
|
line("auth.client_ip_header", c.Auth.ClientIPHeader)
|
|
line("k8s.namespace", c.K8s.Namespace)
|
|
line("k8s.egress_mode", c.K8s.EgressMode)
|
|
line("k8s.metallb_pool", c.K8s.MetalLBPool)
|
|
line("registry.url", c.Registry.URL)
|
|
line("registry.build_namespace", c.Registry.BuildNamespace)
|
|
line("registry.trivy_db_repository", c.Registry.TrivyDBRepository)
|
|
line("registry.build_user_namespaces", c.Registry.BuildUserNamespaces)
|
|
line("registry.build_runtime_class", c.Registry.BuildRuntimeClass)
|
|
line("registry.max_concurrent_builds", c.Registry.MaxConcurrentBuilds)
|
|
line("registry.user_uploads_context", c.Registry.UserUploadsContext)
|
|
line("archive.store", c.Archive.Store)
|
|
line("archive.local_path", c.Archive.LocalPath)
|
|
line("archive.retention", c.Archive.Retention)
|
|
line("archive.scheduled_every", c.Archive.ScheduledEvery)
|
|
line("archive.scheduled_keep", c.Archive.ScheduledKeep)
|
|
line("offsite (configured)", c.Offsite.Enabled())
|
|
if c.Offsite.Enabled() {
|
|
line("offsite.endpoint", c.Offsite.Endpoint)
|
|
line("offsite.bucket", c.Offsite.Bucket)
|
|
line("offsite.prefix", c.Offsite.Prefix)
|
|
}
|
|
line("smtp.host", c.SMTP.Host)
|
|
if c.SMTP.Host != "" {
|
|
line("smtp.port", c.SMTP.Port)
|
|
line("smtp.require_tls", c.SMTP.TLSRequired())
|
|
line("smtp.max_per_hour", c.SMTP.MaxPerHour)
|
|
}
|
|
for i, s := range c.AuthSources {
|
|
line(fmt.Sprintf("auth_source[%d]", i), s.Tag+" "+s.Prefix+" "+s.URL)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func (b *supportBundle) manifest(bw *bundleWriter) []byte {
|
|
control, build, minecraft := b.bundleNamespaces()
|
|
var buf bytes.Buffer
|
|
fmt.Fprintf(&buf, "Felis support bundle\nhost %s, collected %s, felis %s\n\n", b.host, b.now.UTC().Format(time.RFC3339), resolvedVersion())
|
|
fmt.Fprintf(&buf, `What it holds:
|
|
status.txt, doctor.txt felis status and felis doctor at collection time
|
|
version.txt the release, the OS and the kernel
|
|
config.txt a summary of felis.toml: hostnames, namespaces, which features are on
|
|
host/ systemd units and timers, disk use, memory, addresses, and the names,
|
|
modes, sizes and times of the files under %s (not what is in them)
|
|
journal/ up to %d lines per Felis unit and k3s, from the last %s
|
|
logs/ up to %d lines of each container of the pods in %s and %s, and of the
|
|
init containers of the game server pods in %s; the previous run too
|
|
where a container restarted
|
|
cluster/ nodes, volumes, the MinecraftServers, and the pods, workloads, services,
|
|
volume claims, network policies and events of %s, %s and %s
|
|
`, b.stateDir, b.logLines, shortDuration(b.since), b.logLines, control, build, minecraft, control, build, minecraft)
|
|
if b.serverLogs {
|
|
fmt.Fprintln(&buf, "\nThe game servers' own logs are in logs/ (-server-logs): they carry player names, IP addresses and chat.")
|
|
} else {
|
|
fmt.Fprintln(&buf, "\nThe game servers' own logs are left out (they carry player names, IP addresses and chat; -server-logs adds them).")
|
|
}
|
|
fmt.Fprint(&buf, `
|
|
Never collected: Kubernetes Secrets and ConfigMaps, what is in /etc/felis or any configuration
|
|
file, the database, worlds, uploads.
|
|
|
|
Taken out:
|
|
`)
|
|
if len(bw.scrub.sources) > 0 {
|
|
fmt.Fprintf(&buf, " - %d secret values, wherever they appear, read from:\n", len(bw.scrub.vals))
|
|
for _, s := range bw.scrub.sources {
|
|
fmt.Fprintf(&buf, " %s\n", s)
|
|
}
|
|
} else {
|
|
fmt.Fprintln(&buf, " - no secret file was found on this host to take values from")
|
|
}
|
|
fmt.Fprint(&buf, ` - passwords in URLs, private keys, Bearer and Basic credentials
|
|
- in logs and command output, whatever follows password=, secret=, token=, api_key=,
|
|
access_key=, private_key= or credentials= (and the same with a colon)
|
|
- every literal env value in pod specs and MinecraftServers (valueFrom references stay)
|
|
|
|
Read it through before you send it anywhere: redaction finds this host's known secrets and the
|
|
common ways a secret is logged, and a secret logged another way stays in.
|
|
`)
|
|
if b.wErr != nil {
|
|
fmt.Fprintf(&buf, "\nThe watchdog's settings: %v\n", b.wErr)
|
|
}
|
|
if len(bw.errs) > 0 {
|
|
fmt.Fprintln(&buf, "\nNot collected:")
|
|
for _, e := range bw.errs {
|
|
fmt.Fprintf(&buf, " - %s\n", e)
|
|
}
|
|
}
|
|
// Its own words name what is redacted, and would be redacted themselves;
|
|
// what it quotes was scrubbed as it was recorded.
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// secretSources are files that hold secrets, by how each is laid out.
|
|
type secretSources struct {
|
|
envFiles []string // KEY=VALUE lines, every value a secret (a commented-out one too)
|
|
valueFiles []string // one secret, the whole file
|
|
propsFiles []string // key=value lines; the keys naming a token, secret, password or key hold one
|
|
tokenFiles []string // k3s join tokens: the whole token and its secret part after the last ':'
|
|
}
|
|
|
|
// scrubber takes secrets out of what the bundle collects.
|
|
type scrubber struct {
|
|
vals []string // longest first, so a secret that contains another goes whole
|
|
sources []string // the files vals came from
|
|
}
|
|
|
|
var (
|
|
pemPrivateKey = regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`)
|
|
urlUserinfo = regexp.MustCompile(`([A-Za-z][A-Za-z0-9+.-]*://[^/\s:@]*:)[^/\s@]+@`)
|
|
authScheme = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}`)
|
|
secretAssign = regexp.MustCompile(`(?i)((?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credentials?)[A-Za-z0-9_.-]*"?[ \t]*[=:][ \t]*"?)([^\s"',;&]{4,})`)
|
|
secretPropKey = regexp.MustCompile(`(?i)token|secret|password|key`)
|
|
)
|
|
|
|
func (b *supportBundle) scrubber() *scrubber {
|
|
s := &scrubber{}
|
|
s.readSources(b.secrets)
|
|
if b.cfg != nil {
|
|
if u, err := url.Parse(b.cfg.Database.URL); err == nil {
|
|
if pw, ok := u.User.Password(); ok {
|
|
s.add(pw)
|
|
}
|
|
}
|
|
for _, ref := range []string{
|
|
b.cfg.Velocity.ServiceTokenRef, b.cfg.SMTP.PasswordRef,
|
|
b.cfg.Offsite.AccessKeyRef, b.cfg.Offsite.SecretKeyRef, b.cfg.Offsite.KeyRef,
|
|
b.cfg.Registry.S3.AccessKeyRef, b.cfg.Registry.S3.SecretKeyRef,
|
|
b.cfg.Archive.S3.AccessKeyRef, b.cfg.Archive.S3.SecretKeyRef,
|
|
} {
|
|
if ref != "" {
|
|
s.add(os.Getenv(ref))
|
|
}
|
|
}
|
|
}
|
|
if st, err := watchdog.LoadState(watchdog.NewestState(b.w.statePath, b.w.fallbackState)); err == nil {
|
|
s.add(st.SMTPPassword)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *scrubber) add(v string) bool {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) < minScrubLen {
|
|
return false
|
|
}
|
|
for _, have := range s.vals {
|
|
if have == v {
|
|
return true
|
|
}
|
|
}
|
|
s.vals = append(s.vals, v)
|
|
sort.SliceStable(s.vals, func(i, j int) bool { return len(s.vals[i]) > len(s.vals[j]) })
|
|
return true
|
|
}
|
|
|
|
func (s *scrubber) readSources(src secretSources) {
|
|
read := func(p string, take func(content string) bool) {
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if take(string(raw)) {
|
|
s.sources = append(s.sources, p)
|
|
}
|
|
}
|
|
keyValues := func(content string, keep func(key string) bool) bool {
|
|
found := false
|
|
for _, line := range strings.Split(content, "\n") {
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok || !keep(strings.TrimSpace(k)) {
|
|
continue
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
if len(v) >= 2 && (v[0] == '\'' || v[0] == '"') && v[len(v)-1] == v[0] {
|
|
v = v[1 : len(v)-1]
|
|
}
|
|
found = s.add(v) || found
|
|
}
|
|
return found
|
|
}
|
|
for _, p := range src.envFiles {
|
|
read(p, func(c string) bool { return keyValues(c, func(string) bool { return true }) })
|
|
}
|
|
for _, p := range src.propsFiles {
|
|
read(p, func(c string) bool { return keyValues(c, secretPropKey.MatchString) })
|
|
}
|
|
for _, p := range src.valueFiles {
|
|
read(p, func(c string) bool { return s.add(c) })
|
|
}
|
|
for _, p := range src.tokenFiles {
|
|
read(p, func(c string) bool {
|
|
c = strings.TrimSpace(c)
|
|
whole := s.add(c)
|
|
part := false
|
|
if i := strings.LastIndex(c, ":"); i >= 0 {
|
|
part = s.add(c[i+1:])
|
|
}
|
|
return whole || part
|
|
})
|
|
}
|
|
}
|
|
|
|
// values takes every known secret value out of data.
|
|
func (s *scrubber) values(data []byte) []byte {
|
|
t := pemPrivateKey.ReplaceAllString(string(data), "<redacted private key>")
|
|
for _, v := range s.vals {
|
|
t = strings.ReplaceAll(t, v, redacted)
|
|
}
|
|
t = urlUserinfo.ReplaceAllString(t, "${1}"+redacted+"@")
|
|
t = authScheme.ReplaceAllString(t, "${1} "+redacted)
|
|
return []byte(t)
|
|
}
|
|
|
|
// text is values, and whatever a log names as a secret as well.
|
|
func (s *scrubber) text(data []byte) []byte {
|
|
return secretAssign.ReplaceAll(s.values(data), []byte("${1}"+redacted))
|
|
}
|