Skip to content

Config and Secrets

or1k edited this page Aug 6, 2026 · 1 revision

Config and Secrets

Where config lives

src/config.rs:

pub fn config_dir() -> PathBuf   // ~/.config/admintoolkit/ (platform equivalent via `dirs::config_dir`)
pub fn config_file(name: &str) -> PathBuf  // config_dir().join(name)
pub fn expand_path(path: &str) -> String   // expands leading "~" and $VAR / ${VAR}

Every tool keeps its own JSON file inside that one shared directory — ssh_users.json, clickhouse.json, godaddy.json, mysql.json, etc. — so tools stay independent on disk while sharing one place a user (or an uninstall) can find everything. A new tool should follow the same pattern: one serde_json-backed struct, config_file("<tool>.json"), plain std::fs::read_to_string / std::fs::write wrapped in load()/save() functions in that tool's own config.rs — see mysql_mgr::config::{load, save} for the minimal version.

expand_path matters for anything that's a filesystem path a user typed into a form (SSH key paths, cert files) — always run user-entered paths through it before using them, so ~/.ssh/id_ed25519 and $HOME/... both work as typed.

Secrets at rest (src/secret.rs)

There's no cross-platform equivalent of an OS keychain without pulling in a native dependency (libsecret/dbus on Linux), so instead: AES-256-GCM under a random 256-bit key generated on first use and stored at ~/.config/admintoolkit/.godaddy.key with 0600 permissions (owner-only). The filename predates this covering more than GoDaddy — kept as-is so already-encrypted secrets from earlier installs keep decrypting; don't "fix" the filename.

secret::encrypt(plaintext: &str) -> io::Result<String>          // random nonce + AES-GCM, base64-encoded
secret::decrypt(encoded: &str) -> io::Result<String>
secret::encrypt_optional(plaintext: &str) -> io::Result<String> // "" in -> "" out, no ciphertext for nothing
secret::decrypt_optional(encoded: &str) -> io::Result<String>

Use the _optional variants for any field that's legitimately allowed to be empty (e.g. an SSH profile that uses key-only auth, no password) — this avoids emitting a zero-length ciphertext where "no value" would do.

This protects secrets from casual disk browsing/backups that don't also grab the key file — it is not a defense against another process running as the same OS user, since the key lives right next to the data. Keep that threat model in mind when documenting a new tool's secret handling; don't oversell it.

The on-disk vs. in-memory split

Every tool that stores a password/token follows the same shape (see mysql_mgr::config::{Connection, ConnectionWithSecrets} for the concrete example):

  • The struct that gets serialized to JSON stores ..._encrypted: String fields — ciphertext only, never plaintext.
  • A separate ..WithSecrets struct exists only in memory, built on demand by decrypting, and is what actually gets handed to the connect/exec code. It's never serialized.
  • On update, an empty password field in the form means "keep the existing one" (don't overwrite with blank) — upsert_connection in mysql_mgr::config only re-encrypts and replaces ..._encrypted when the incoming value is non-empty.

Follow this shape for any new tool that stores a credential — don't put a plaintext password field on the struct that gets serde_json::to_string'd, even temporarily.

Clone this wiki locally