// Package config owns the on-disk layout of a node's state. // // Two YAML files live under the data directory: // // node.yaml — local identity, never replicated (id, addresses, key paths) // cluster.yaml — replicated state (peers, checks, alerts, version) // trust.yaml — local fingerprint trust store // keys/ — RSA private + public keys + self-signed cert // // A unix socket for the local CLI lives alongside (defaults to // /var/run/quptime/quptime.sock when running as root, otherwise // $XDG_RUNTIME_DIR/quptime/quptime.sock). package config import ( "errors" "os" "path/filepath" "strings" ) // Default file names. Callers should always go through DataDir() so an // override via QUPTIME_DIR is respected. const ( NodeFile = "node.yaml" ClusterFile = "cluster.yaml" TrustFile = "trust.yaml" KeysDir = "keys" PrivateKey = "private.pem" PublicKey = "public.pem" CertFile = "cert.pem" SocketName = "quptime.sock" envDataDir = "QUPTIME_DIR" ) // DataDir returns the configured data directory. Order of resolution: // 1. $QUPTIME_DIR if set // 2. /etc/quptime when running as root // 3. $XDG_CONFIG_HOME/quptime (or ~/.config/quptime) otherwise func DataDir() string { if v := os.Getenv(envDataDir); v != "" { return v } if os.Geteuid() == 0 { return "/etc/quptime" } if v := os.Getenv("XDG_CONFIG_HOME"); v != "" { return filepath.Join(v, "quptime") } home, err := os.UserHomeDir() if err != nil || home == "" { return "./quptime" } return filepath.Join(home, ".config", "quptime") } // SocketPath returns the unix socket used for local CLI ↔ daemon control. // // Resolution order: // 1. $QUPTIME_SOCKET — explicit operator override // 2. $RUNTIME_DIRECTORY — set by systemd when the unit declares // RuntimeDirectory=quptime. This is the path that matters in // practice: with User=quptime + PrivateTmp=true, the daemon's // /tmp is namespaced and invisible to the root CLI shell, so a // /tmp fallback yields "no such file" even though the daemon is // happily listening. Anchoring on $RUNTIME_DIRECTORY puts the // socket at /run/quptime/quptime.sock, which is the same inode // the root-CLI default (/var/run/quptime/…) reaches via the // /var/run → /run symlink. // 3. /var/run/quptime/… when euid is 0 (CLI side, packaged installs) // 4. $XDG_RUNTIME_DIR/quptime/… for user-mode installs // 5. /tmp/quptime-/… as a last resort func SocketPath() string { if v := os.Getenv("QUPTIME_SOCKET"); v != "" { return v } if v := os.Getenv("RUNTIME_DIRECTORY"); v != "" { // systemd may pass multiple colon-separated entries when more // than one RuntimeDirectory= is declared. Ours is single, but // be defensive in case a future unit adds more. if i := strings.IndexByte(v, ':'); i >= 0 { v = v[:i] } return filepath.Join(v, SocketName) } if os.Geteuid() == 0 { return "/var/run/quptime/" + SocketName } if v := os.Getenv("XDG_RUNTIME_DIR"); v != "" { return filepath.Join(v, "quptime", SocketName) } return filepath.Join(os.TempDir(), "quptime-"+envUserSuffix(), SocketName) } func envUserSuffix() string { if u := os.Getenv("USER"); u != "" { return u } return "default" } // NodeFilePath returns the absolute path to node.yaml. func NodeFilePath() string { return filepath.Join(DataDir(), NodeFile) } // ClusterFilePath returns the absolute path to cluster.yaml. func ClusterFilePath() string { return filepath.Join(DataDir(), ClusterFile) } // TrustFilePath returns the absolute path to trust.yaml. func TrustFilePath() string { return filepath.Join(DataDir(), TrustFile) } // PrivateKeyPath returns the absolute path to the RSA private key. func PrivateKeyPath() string { return filepath.Join(DataDir(), KeysDir, PrivateKey) } // PublicKeyPath returns the absolute path to the RSA public key. func PublicKeyPath() string { return filepath.Join(DataDir(), KeysDir, PublicKey) } // CertFilePath returns the absolute path to the self-signed cert (PEM). func CertFilePath() string { return filepath.Join(DataDir(), KeysDir, CertFile) } // EnsureDataDir creates the data directory tree if absent. func EnsureDataDir() error { dir := DataDir() if err := os.MkdirAll(filepath.Join(dir, KeysDir), 0o700); err != nil { return err } return os.MkdirAll(filepath.Dir(SocketPath()), 0o700) } // AtomicWrite writes data to path through a temp file + rename. The temp // file is created in the same directory so the rename is atomic on POSIX. func AtomicWrite(path string, data []byte, perm os.FileMode) error { if path == "" { return errors.New("empty path") } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return err } tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") if err != nil { return err } tmpName := tmp.Name() if _, err := tmp.Write(data); err != nil { tmp.Close() os.Remove(tmpName) return err } if err := tmp.Chmod(perm); err != nil { tmp.Close() os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { os.Remove(tmpName) return err } return os.Rename(tmpName, path) }