diff --git a/crates/shepherd-config/Cargo.toml b/crates/shepherd-config/Cargo.toml index 299f43a..af3a657 100644 --- a/crates/shepherd-config/Cargo.toml +++ b/crates/shepherd-config/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true description = "Configuration parsing and validation for shepherdd" +[[bin]] +name = "validate-config" +path = "src/bin/validate-config.rs" + [dependencies] shepherd-util = { workspace = true } shepherd-api = { workspace = true } diff --git a/crates/shepherd-config/README.md b/crates/shepherd-config/README.md index e28168c..6c3c809 100644 --- a/crates/shepherd-config/README.md +++ b/crates/shepherd-config/README.md @@ -86,8 +86,8 @@ max_run_seconds = 3600 # 1 hour use shepherd_config::{load_config, parse_config, Policy}; use std::path::Path; -// Load from file -let policy = load_config("/etc/shepherdd/config.toml")?; +// Load from file (typically ~/.config/shepherd/config.toml) +let policy = load_config("config.toml")?; // Parse from string let toml_content = std::fs::read_to_string("config.toml")?; diff --git a/crates/shepherd-config/src/bin/validate-config.rs b/crates/shepherd-config/src/bin/validate-config.rs new file mode 100644 index 0000000..10c27da --- /dev/null +++ b/crates/shepherd-config/src/bin/validate-config.rs @@ -0,0 +1,101 @@ +//! Config validation CLI tool +//! +//! Validates a shepherdd configuration file and reports any errors. + +use shepherd_api::EntryKind; +use shepherd_util::default_config_path; +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + + let config_path = match args.get(1) { + Some(path) => PathBuf::from(path), + None => { + let default_path = default_config_path(); + eprintln!("Usage: validate-config [config-file]"); + eprintln!(); + eprintln!("Validates a shepherdd configuration file."); + eprintln!(); + eprintln!("If no path is provided, uses: {}", default_path.display()); + eprintln!(); + eprintln!("Example:"); + eprintln!(" validate-config {}", default_path.display()); + eprintln!(" validate-config config.example.toml"); + return ExitCode::from(2); + } + }; + + // Check file exists + if !config_path.exists() { + eprintln!("Error: Configuration file not found: {}", config_path.display()); + return ExitCode::from(1); + } + + // Try to load and validate + match shepherd_config::load_config(&config_path) { + Ok(policy) => { + println!("✓ Configuration is valid"); + println!(); + println!("Summary:"); + println!(" Config version: {}", shepherd_config::CURRENT_CONFIG_VERSION); + println!(" Entries: {}", policy.entries.len()); + + // Show entry summary + if !policy.entries.is_empty() { + println!(); + println!("Entries:"); + for entry in &policy.entries { + let kind_str = match &entry.kind { + EntryKind::Process { command, .. } => { + format!("process ({})", command) + } + EntryKind::Snap { snap_name, .. } => { + format!("snap ({})", snap_name) + } + EntryKind::Vm { driver, .. } => { + format!("vm ({})", driver) + } + EntryKind::Media { library_id, .. } => { + format!("media ({})", library_id) + } + EntryKind::Custom { type_name, .. } => { + format!("custom ({})", type_name) + } + }; + println!(" - {} [{}]: {}", entry.id.as_str(), kind_str, entry.label); + } + } + + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("✗ Configuration validation failed"); + eprintln!(); + match &e { + shepherd_config::ConfigError::ReadError(io_err) => { + eprintln!("Failed to read file: {}", io_err); + } + shepherd_config::ConfigError::ParseError(parse_err) => { + eprintln!("TOML parse error:"); + eprintln!(" {}", parse_err); + } + shepherd_config::ConfigError::ValidationFailed { errors } => { + eprintln!("Validation errors ({}):", errors.len()); + for err in errors { + eprintln!(" - {}", err); + } + } + shepherd_config::ConfigError::UnsupportedVersion(ver) => { + eprintln!( + "Unsupported config version: {} (expected {})", + ver, + shepherd_config::CURRENT_CONFIG_VERSION + ); + } + } + ExitCode::from(1) + } + } +} diff --git a/crates/shepherd-util/src/paths.rs b/crates/shepherd-util/src/paths.rs index 6e26cf0..98aaa63 100644 --- a/crates/shepherd-util/src/paths.rs +++ b/crates/shepherd-util/src/paths.rs @@ -115,6 +115,37 @@ pub fn socket_dir() -> PathBuf { }) } +/// Configuration subdirectory name (uses "shepherd" not "shepherdd") +const CONFIG_APP_DIR: &str = "shepherd"; + +/// Configuration filename +const CONFIG_FILENAME: &str = "config.toml"; + +/// Get the default configuration file path. +/// +/// Returns `$XDG_CONFIG_HOME/shepherd/config.toml` or `~/.config/shepherd/config.toml` +pub fn default_config_path() -> PathBuf { + // Try XDG_CONFIG_HOME first + if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(config_home) + .join(CONFIG_APP_DIR) + .join(CONFIG_FILENAME); + } + + // Fallback to ~/.config/shepherd/config.toml + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".config") + .join(CONFIG_APP_DIR) + .join(CONFIG_FILENAME); + } + + // Last resort (unlikely to be valid, but provides a fallback) + PathBuf::from("/etc") + .join(CONFIG_APP_DIR) + .join(CONFIG_FILENAME) +} + #[cfg(test)] mod tests { use super::*; @@ -145,4 +176,11 @@ mod tests { let dir = socket_dir(); assert_eq!(socket.parent().unwrap(), dir); } + + #[test] + fn config_path_contains_shepherd() { + let path = default_config_path(); + assert!(path.to_string_lossy().contains("shepherd")); + assert!(path.to_string_lossy().ends_with("config.toml")); + } } diff --git a/crates/shepherdd/README.md b/crates/shepherdd/README.md index b861e1c..65253b2 100644 --- a/crates/shepherdd/README.md +++ b/crates/shepherdd/README.md @@ -65,7 +65,7 @@ shepherdd --log-level debug | Option | Default | Description | |--------|---------|-------------| -| `-c, --config` | `/etc/shepherdd/config.toml` | Configuration file path | +| `-c, --config` | `~/.config/shepherd/config.toml` | Configuration file path | | `-s, --socket` | From config | IPC socket path | | `-d, --data-dir` | From config | Data directory | | `-l, --log-level` | `info` | Log verbosity | diff --git a/crates/shepherdd/src/main.rs b/crates/shepherdd/src/main.rs index 96816ed..9d93733 100644 --- a/crates/shepherdd/src/main.rs +++ b/crates/shepherdd/src/main.rs @@ -21,7 +21,7 @@ use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, Volume use shepherd_host_linux::{LinuxHost, LinuxVolumeController}; use shepherd_ipc::{IpcServer, ServerMessage}; use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store}; -use shepherd_util::{ClientId, MonotonicInstant, RateLimiter}; +use shepherd_util::{default_config_path, ClientId, MonotonicInstant, RateLimiter}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -35,8 +35,8 @@ use tracing_subscriber::EnvFilter; #[command(name = "shepherdd")] #[command(about = "Policy enforcement service for child-focused computing", long_about = None)] struct Args { - /// Configuration file path - #[arg(short, long, default_value = "/etc/shepherdd/config.toml")] + /// Configuration file path (default: ~/.config/shepherd/config.toml) + #[arg(short, long, default_value_os_t = default_config_path())] config: PathBuf, /// Socket path override (or set SHEPHERD_SOCKET env var) diff --git a/scripts/README.md b/scripts/README.md index 4d7d009..06b3074 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,6 +15,9 @@ This directory contains the unified script system for shepherd-launcher. # Building ./shepherd build [--release] +# Configuration +./shepherd config validate [path] + # Development ./shepherd dev run @@ -39,6 +42,7 @@ scripts/ │ ├── common.sh # Logging, error handling, sudo helpers │ ├── deps.sh # Dependency management │ ├── build.sh # Cargo build logic +│ ├── config.sh # Configuration validation │ ├── sway.sh # Nested sway execution │ ├── install.sh # Installation logic │ └── harden.sh # User hardening/unhardening diff --git a/scripts/lib/config.sh b/scripts/lib/config.sh new file mode 100644 index 0000000..2be682b --- /dev/null +++ b/scripts/lib/config.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Configuration validation logic for shepherd-launcher +# Validates shepherdd configuration files + +# Get the directory containing this script +CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source common utilities +# shellcheck source=common.sh +source "$CONFIG_LIB_DIR/common.sh" + +# Default configuration paths +# Uses XDG_CONFIG_HOME or ~/.config/shepherd/config.toml +get_default_config_path() { + if [[ -n "${XDG_CONFIG_HOME:-}" ]]; then + echo "$XDG_CONFIG_HOME/shepherd/config.toml" + else + echo "$HOME/.config/shepherd/config.toml" + fi +} + +EXAMPLE_CONFIG_NAME="config.example.toml" + +# Get path to the validate-config binary +get_validate_binary() { + local release="${1:-false}" + local repo_root + repo_root="$(get_repo_root)" + + if [[ "$release" == "true" ]]; then + echo "$repo_root/target/release/validate-config" + else + echo "$repo_root/target/debug/validate-config" + fi +} + +# Build the validate-config binary if needed +build_validate_binary() { + local release="${1:-false}" + local repo_root + repo_root="$(get_repo_root)" + + verify_repo + require_command cargo rust + + cd "$repo_root" || die "Failed to change directory to $repo_root" + + if [[ "$release" == "true" ]]; then + info "Building validate-config (release mode)..." + cargo build --release --bin validate-config + else + info "Building validate-config..." + cargo build --bin validate-config + fi +} + +# Ensure the validate binary exists, building if necessary +ensure_validate_binary() { + local release="${1:-false}" + local binary + binary="$(get_validate_binary "$release")" + + if [[ ! -x "$binary" ]]; then + build_validate_binary "$release" + fi + + echo "$binary" +} + +# Validate a configuration file +validate_config_file() { + local config_path="$1" + local release="${2:-false}" + + # Build/find the validator + local binary + binary="$(ensure_validate_binary "$release")" + + # Run validation + "$binary" "$config_path" +} + +# Show config help +config_usage() { + cat < [options] + +Commands: + validate [path] Validate a configuration file + help Show this help message + +Options: + --release Use release build of validator + +The validate command checks a configuration file for: + - Valid TOML syntax + - Correct config_version + - Valid entry definitions + - Valid time windows and availability specs + - Warning thresholds that make sense with limits + +Examples: + # Validate the installed config + shepherd config validate + + # Validate a specific file + shepherd config validate /path/to/config.toml + + # Validate the example config in the repo + shepherd config validate config.example.toml + +Default paths: + Installed: $(get_default_config_path) + Example: \$REPO_ROOT/$EXAMPLE_CONFIG_NAME +EOF +} + +# Main entry point for config commands +config_main() { + local subcmd="${1:-}" + shift || true + + case "$subcmd" in + validate) + local config_path="" + local release="false" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --release) + release="true" + shift + ;; + -h|--help) + config_usage + return 0 + ;; + -*) + die "Unknown option: $1" + ;; + *) + if [[ -z "$config_path" ]]; then + config_path="$1" + else + die "Too many arguments" + fi + shift + ;; + esac + done + + # Determine config path + if [[ -z "$config_path" ]]; then + # Try default path first + local default_config + default_config="$(get_default_config_path)" + if [[ -f "$default_config" ]]; then + config_path="$default_config" + info "Using installed config: $config_path" + else + # Fall back to example in repo + local repo_root + repo_root="$(get_repo_root)" + local example_path="$repo_root/$EXAMPLE_CONFIG_NAME" + if [[ -f "$example_path" ]]; then + config_path="$example_path" + info "Using example config: $config_path" + else + die "No config file found. Specify a path or install shepherdd." + fi + fi + fi + + # Resolve relative paths + if [[ ! "$config_path" = /* ]]; then + config_path="$(pwd)/$config_path" + fi + + # Check file exists + if [[ ! -f "$config_path" ]]; then + die "Configuration file not found: $config_path" + fi + + # Validate + info "Validating: $config_path" + echo "" + validate_config_file "$config_path" "$release" + ;; + + ""|help|-h|--help) + config_usage + ;; + + *) + error "Unknown config command: $subcmd" + echo "" + config_usage + exit 1 + ;; + esac +} diff --git a/scripts/shepherd b/scripts/shepherd index 6cf44a5..03359d3 100755 --- a/scripts/shepherd +++ b/scripts/shepherd @@ -21,6 +21,8 @@ source "$LIB_DIR/sway.sh" source "$LIB_DIR/install.sh" # shellcheck source=lib/harden.sh source "$LIB_DIR/harden.sh" +# shellcheck source=lib/config.sh +source "$LIB_DIR/config.sh" # Version VERSION="0.1.0" @@ -35,6 +37,7 @@ Usage: shepherd [subcommand] [options] Commands: deps Manage system dependencies build Build shepherd binaries + config Configuration validation dev Development commands install Install shepherd to the system harden User hardening for kiosk mode @@ -60,6 +63,10 @@ Hardening: shepherd harden apply --user USER Apply kiosk restrictions shepherd harden revert --user USER Remove restrictions +Configuration: + shepherd config validate Validate installed config + shepherd config validate Validate a specific file + Options: -h, --help Show this help message -V, --version Show version @@ -80,6 +87,9 @@ main() { build) build_main "$@" ;; + config) + config_main "$@" + ;; dev) local subcmd="${1:-}" shift || true diff --git a/sway.conf b/sway.conf index 9a2fac8..de67a74 100644 --- a/sway.conf +++ b/sway.conf @@ -145,7 +145,8 @@ workspace 1 output * # Start shepherdd FIRST - it needs to create the socket before HUD/launcher connect # Running inside sway ensures all spawned processes use the nested compositor # Shepherdd handles SIGHUP for graceful shutdown when sway exits -exec ./target/debug/shepherdd -c ./config.example.toml +# If shepherdd fails to start (e.g., config error), exit sway to prevent a broken state +exec sh -c './target/debug/shepherdd -c ./config.example.toml || swaymsg exit' # Give shepherdd a moment to initialize, then start UI components # Start the shepherd-hud (time remaining overlay)