diff --git a/.env.sample b/.env.sample index 7718696..7399392 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,8 @@ +# Stackdog configuration. +# Copy to .env in the working directory, or to ~/.config/stackdog/.env +# (`stackdog init` writes this file there). Real environment variables and +# the nearer file win; everything here is optional and defaults are safe. + #APP_HOST=localhost # in container APP_HOST=0.0.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf3044..b9d879a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Configuration is also read from `~/.config/stackdog/.env` + (`$XDG_CONFIG_HOME/stackdog/.env` when set), behind the working-directory + `.env`: the nearest file wins and real environment variables win over both. + A missing file is no longer a warning — every setting has a default. +- `stackdog init` writes the starter config to that directory and never + overwrites an existing file (it holds credentials). - Discord webhook notifications (`STACKDOG_DISCORD_WEBHOOK_URL`), routed exactly like Slack: Critical, High and Medium alerts arrive as plain messages under the webhook's own name and avatar, truncated to Discord's 2000 character diff --git a/README.md b/README.md index 7d29bfb..8a06c8c 100644 --- a/README.md +++ b/README.md @@ -862,7 +862,7 @@ stackdog/ # 1. Clone and setup git clone https://github.com/trydirect/stackdog cd stackdog -cp .env.sample .env +cp .env.sample .env # or run `stackdog init` once built # 2. Build cargo build @@ -891,6 +891,14 @@ bpftool version uname -r # Should be 4.19+ ``` +### Where the config lives + +`stackdog` reads settings from three places, nearest first: real environment +variables, then `.env` in the working directory, then `~/.config/stackdog/.env` +(`$XDG_CONFIG_HOME/stackdog/.env` when set). Every setting has a default, so +the app runs with no config at all. `stackdog init` writes a starter file to +the config directory and never overwrites an existing one. + --- ## 🤝 Contributing diff --git a/src/cli.rs b/src/cli.rs index d1a5391..98d7b1e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -37,6 +37,9 @@ pub enum Command { /// Inspect and repair the Stackdog firewall rules Firewall(FirewallCommand), + + /// Write a starter config to ~/.config/stackdog/.env + Init, } #[derive(Args, Debug, Clone)] diff --git a/src/main.rs b/src/main.rs index 00c050f..6be7b40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,13 +32,11 @@ use tracing_subscriber::FmtSubscriber; #[actix_rt::main] async fn main() -> io::Result<()> { - // Load environment - if let Err(err) = dotenvy::dotenv() { - eprintln!( - "Warning: could not load .env file ({}). Continuing with existing environment.", - err - ); - } + // Parse CLI first, then load the environment: real variables always + // win (dotenvy never overrides them), then ./.env for project-local + // setups, then the service-style config in ~/.config/stackdog/. + let cli = Cli::parse(); + load_environment(!matches!(cli.command, Some(Command::Init))); // Parse CLI arguments let cli = Cli::parse(); @@ -103,11 +101,62 @@ async fn main() -> io::Result<()> { cli::FirewallSubcommand::List => run_firewall_list().await, cli::FirewallSubcommand::Reconcile => run_firewall_reconcile().await, }, + Some(Command::Init) => run_init(), // Default: serve (backward compatible) Some(Command::Serve) | None => run_serve().await, } } +/// Where the service-style config lives: `$XDG_CONFIG_HOME/stackdog`, +/// falling back to `~/.config/stackdog`. +fn config_dir() -> Option { + if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return Some(std::path::PathBuf::from(xdg).join("stackdog")); + } + } + env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".config").join("stackdog")) +} + +/// Load `.env` from the working directory and from [`config_dir`]. The +/// nearest file wins: dotenvy never overrides a variable that is already +/// set, and the working directory is loaded first. Real environment +/// variables win over both. With `quiet`, a missing file is silent — used +/// by `stackdog init`, which exists to create one. +fn load_environment(quiet: bool) { + let local = dotenvy::dotenv(); + let home = config_dir().map(|dir| dotenvy::from_path(dir.join(".env"))); + if local.is_err() && home.as_ref().is_none_or(Result::is_err) && !quiet { + eprintln!( + "Note: no .env in the working directory or {:?}. Using the existing environment; every setting has a default. Run `stackdog init` to write a starter config.", + config_dir() + ); + } +} + +/// Write a starter config to [`config_dir`]. An existing file is never +/// overwritten: it holds credentials. +fn run_init() -> io::Result<()> { + let dir = config_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "cannot determine the config directory (no HOME or XDG_CONFIG_HOME)", + ) + })?; + std::fs::create_dir_all(&dir)?; + let path = dir.join(".env"); + if path.exists() { + println!("{} already exists; not overwriting it.", path.display()); + return Ok(()); + } + std::fs::write(&path, include_str!("../.env.sample"))?; + println!("Wrote {}.", path.display()); + println!( + "Edit it to set notification channels and log paths, then start stackdog again." + ); + Ok(()) +} + async fn run_serve() -> io::Result<()> { let app_host = env::var("APP_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let app_port = env::var("APP_PORT").unwrap_or_else(|_| "5000".to_string());