diff --git a/src/setup/channels.rs b/src/setup/channels.rs index c8b753f4..095e09c5 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -4,19 +4,55 @@ //! 1. Displays setup instructions //! 2. Collects configuration (tokens, ports, etc.) //! 3. Validates the configuration -//! 4. Saves secrets securely +//! 4. Saves secrets to the database -use std::io; -use std::path::PathBuf; +use std::sync::Arc; use reqwest::Client; use secrecy::{ExposeSecret, SecretString}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore}; use crate::setup::prompts::{ confirm, optional_input, print_error, print_info, print_success, secret_input, }; +/// Context for saving secrets during setup. +pub struct SecretsContext { + store: PostgresSecretsStore, + user_id: String, +} + +impl SecretsContext { + /// Create a new secrets context. + pub fn new(pool: deadpool_postgres::Pool, crypto: Arc, user_id: &str) -> Self { + Self { + store: PostgresSecretsStore::new(pool, crypto), + user_id: user_id.to_string(), + } + } + + /// Save a secret to the database. + pub async fn save_secret(&self, name: &str, value: &SecretString) -> Result<(), String> { + let params = CreateSecretParams::new(name, value.expose_secret()); + + self.store + .create(&self.user_id, params) + .await + .map_err(|e| format!("Failed to save secret: {}", e))?; + + Ok(()) + } + + /// Check if a secret exists. + pub async fn secret_exists(&self, name: &str) -> bool { + self.store + .exists(&self.user_id, name) + .await + .unwrap_or(false) + } +} + /// Result of Telegram setup. #[derive(Debug, Clone)] pub struct TelegramSetupResult { @@ -44,8 +80,8 @@ struct TelegramUser { /// 1. Creating a bot with @BotFather /// 2. Entering the bot token /// 3. Validating the token -/// 4. Saving the token to secrets -pub async fn setup_telegram() -> io::Result { +/// 4. Saving the token to the database +pub async fn setup_telegram(secrets: &SecretsContext) -> Result { println!("Telegram Setup:"); println!(); print_info("To create a Telegram bot:"); @@ -54,7 +90,18 @@ pub async fn setup_telegram() -> io::Result { print_info("3. Copy the bot token (looks like 123456:ABC-DEF...)"); println!(); - let token = secret_input("Bot token (from @BotFather)")?; + // Check if token already exists + if secrets.secret_exists("telegram_bot_token").await { + print_info("Existing Telegram token found in database."); + if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? { + return Ok(TelegramSetupResult { + enabled: true, + bot_username: None, + }); + } + } + + let token = secret_input("Bot token (from @BotFather)").map_err(|e| e.to_string())?; // Validate the token print_info("Validating bot token..."); @@ -66,13 +113,9 @@ pub async fn setup_telegram() -> io::Result { username.as_deref().unwrap_or("unknown") )); - // Save to secrets file - if let Err(e) = save_channel_secret("telegram_bot_token", &token) { - print_error(&format!("Failed to save token: {}", e)); - return Err(io::Error::new(io::ErrorKind::Other, e.to_string())); - } - - print_success("Token saved to ~/.near-agent/secrets/telegram_bot_token"); + // Save to database + secrets.save_secret("telegram_bot_token", &token).await?; + print_success("Token saved to database"); Ok(TelegramSetupResult { enabled: true, @@ -82,9 +125,8 @@ pub async fn setup_telegram() -> io::Result { Err(e) => { print_error(&format!("Token validation failed: {}", e)); - if confirm("Try again?", true)? { - // Recursive retry - Box::pin(setup_telegram()).await + if confirm("Try again?", true).map_err(|e| e.to_string())? { + Box::pin(setup_telegram(secrets)).await } else { Ok(TelegramSetupResult { enabled: false, @@ -140,30 +182,34 @@ pub struct HttpSetupResult { } /// Set up HTTP webhook channel. -pub fn setup_http() -> io::Result { +pub async fn setup_http(secrets: &SecretsContext) -> Result { println!("HTTP Webhook Setup:"); println!(); print_info("The HTTP webhook allows external services to send messages to the agent."); println!(); - let port_str = optional_input("Port", Some("default: 8080"))?; - let port: u16 = - port_str.as_deref().unwrap_or("8080").parse().map_err(|e| { - io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid port: {}", e)) - })?; + let port_str = optional_input("Port", Some("default: 8080")).map_err(|e| e.to_string())?; + let port: u16 = port_str + .as_deref() + .unwrap_or("8080") + .parse() + .map_err(|e| format!("Invalid port: {}", e))?; if port < 1024 { print_info("Note: Ports below 1024 may require root privileges"); } - let host = - optional_input("Host", Some("default: 0.0.0.0"))?.unwrap_or_else(|| "0.0.0.0".to_string()); + let host = optional_input("Host", Some("default: 0.0.0.0")) + .map_err(|e| e.to_string())? + .unwrap_or_else(|| "0.0.0.0".to_string()); // Generate a webhook secret - if confirm("Generate a webhook secret for authentication?", true)? { + if confirm("Generate a webhook secret for authentication?", true).map_err(|e| e.to_string())? { let secret = generate_webhook_secret(); - save_channel_secret("http_webhook_secret", &SecretString::from(secret.clone()))?; - print_success("Webhook secret generated and saved"); + secrets + .save_secret("http_webhook_secret", &SecretString::from(secret.clone())) + .await?; + print_success("Webhook secret generated and saved to database"); print_info(&format!( "Secret: {} (store this for your webhook clients)", secret @@ -185,93 +231,9 @@ fn generate_webhook_secret() -> String { let mut rng = rand::thread_rng(); let mut bytes = [0u8; 32]; rng.fill_bytes(&mut bytes); - // Encode as hex manually (avoid adding hex crate dependency) bytes.iter().map(|b| format!("{:02x}", b)).collect() } -/// Get the secrets directory path. -pub fn secrets_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") - .join("secrets") -} - -/// Save a channel secret to the secrets directory. -/// -/// Secrets are stored as individual files with restricted permissions. -pub fn save_channel_secret(name: &str, value: &SecretString) -> io::Result<()> { - let dir = secrets_dir(); - std::fs::create_dir_all(&dir)?; - - let path = dir.join(name); - - // Write the secret - std::fs::write(&path, value.expose_secret())?; - - // Set restrictive permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&path)?.permissions(); - perms.set_mode(0o600); // Owner read/write only - std::fs::set_permissions(&path, perms)?; - } - - Ok(()) -} - -/// Load a channel secret from the secrets directory. -#[allow(dead_code)] -pub fn load_channel_secret(name: &str) -> io::Result> { - let path = secrets_dir().join(name); - - if !path.exists() { - return Ok(None); - } - - let contents = std::fs::read_to_string(&path)?; - Ok(Some(SecretString::from(contents.trim().to_string()))) -} - -/// Check if a channel secret exists. -#[allow(dead_code)] -pub fn has_channel_secret(name: &str) -> bool { - secrets_dir().join(name).exists() -} - -/// Delete a channel secret. -#[allow(dead_code)] -pub fn delete_channel_secret(name: &str) -> io::Result { - let path = secrets_dir().join(name); - if path.exists() { - std::fs::remove_file(&path)?; - Ok(true) - } else { - Ok(false) - } -} - -/// Channel secrets configuration (persisted to settings). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ChannelSecretsConfig { - /// Whether Telegram has a saved token. - pub telegram_configured: bool, - /// Whether HTTP webhook has a saved secret. - pub http_configured: bool, -} - -impl ChannelSecretsConfig { - /// Load from the secrets directory. - #[allow(dead_code)] - pub fn from_secrets_dir() -> Self { - Self { - telegram_configured: has_channel_secret("telegram_bot_token"), - http_configured: has_channel_secret("http_webhook_secret"), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 34417f82..3beb13c0 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -18,6 +18,6 @@ mod channels; mod prompts; mod wizard; -pub use channels::{setup_http, setup_telegram, validate_telegram_token}; +pub use channels::{SecretsContext, setup_http, setup_telegram, validate_telegram_token}; pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one}; pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index c41ac592..cdc65175 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -7,9 +7,14 @@ use std::sync::Arc; +use deadpool_postgres::{Config as PoolConfig, Runtime}; +use secrecy::SecretString; +use tokio_postgres::NoTls; + use crate::llm::{SessionConfig, SessionManager}; +use crate::secrets::SecretsCrypto; use crate::settings::Settings; -use crate::setup::channels::{setup_http, setup_telegram}; +use crate::setup::channels::{SecretsContext, setup_http, setup_telegram}; use crate::setup::prompts::{ input, print_header, print_info, print_step, print_success, select_many, select_one, }; @@ -23,6 +28,15 @@ pub enum SetupError { #[error("Authentication error: {0}")] Auth(String), + #[error("Database error: {0}")] + Database(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Channel setup error: {0}")] + Channel(String), + #[error("User cancelled")] Cancelled, } @@ -242,6 +256,64 @@ impl SetupWizard { } } + /// Initialize secrets context for channel setup. + async fn init_secrets_context(&self) -> Result { + // Get DATABASE_URL + let database_url = std::env::var("DATABASE_URL").map_err(|_| { + SetupError::Config( + "DATABASE_URL not set. Please set it in .env or environment.".to_string(), + ) + })?; + + // Get or generate SECRETS_MASTER_KEY + let master_key = match std::env::var("SECRETS_MASTER_KEY") { + Ok(key) => { + if key.len() < 32 { + return Err(SetupError::Config( + "SECRETS_MASTER_KEY must be at least 32 characters".to_string(), + )); + } + key + } + Err(_) => { + // Generate a new master key + print_info("SECRETS_MASTER_KEY not set. Generating a new one..."); + let key = generate_master_key(); + print_info(&format!( + "Generated master key. Add to your .env file:\nSECRETS_MASTER_KEY={}", + key + )); + key + } + }; + + // Create database pool + let mut cfg = PoolConfig::new(); + cfg.url = Some(database_url); + cfg.pool = Some(deadpool_postgres::PoolConfig { + max_size: 5, + ..Default::default() + }); + + let pool = cfg + .create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; + + // Test connection + let _ = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Failed to connect to database: {}", e)))?; + + print_success("Connected to database"); + + // Create crypto + let crypto = SecretsCrypto::new(SecretString::from(master_key)) + .map_err(|e| SetupError::Config(format!("Invalid master key: {}", e)))?; + + Ok(SecretsContext::new(pool, Arc::new(crypto), "default")) + } + /// Step 3: Channel configuration. async fn step_channels(&mut self) -> Result<(), SetupError> { let options = [ @@ -252,12 +324,22 @@ impl SetupWizard { let selected = select_many("Which channels do you want to enable?", &options)?; + // Only initialize secrets context if we need it (HTTP or Telegram selected) + let needs_secrets = selected.contains(&1) || selected.contains(&2); + let secrets = if needs_secrets { + Some(self.init_secrets_context().await?) + } else { + None + }; + // HTTP is index 1 if selected.contains(&1) { println!(); - let result = setup_http()?; - self.settings.channels.http_enabled = result.enabled; - self.settings.channels.http_port = Some(result.port); + if let Some(ref ctx) = secrets { + let result = setup_http(ctx).await.map_err(SetupError::Channel)?; + self.settings.channels.http_enabled = result.enabled; + self.settings.channels.http_port = Some(result.port); + } } else { self.settings.channels.http_enabled = false; } @@ -265,8 +347,10 @@ impl SetupWizard { // Telegram is index 2 if selected.contains(&2) { println!(); - let result = setup_telegram().await?; - self.settings.channels.telegram_enabled = result.enabled; + if let Some(ref ctx) = secrets { + let result = setup_telegram(ctx).await.map_err(SetupError::Channel)?; + self.settings.channels.telegram_enabled = result.enabled; + } } else { self.settings.channels.telegram_enabled = false; } @@ -318,6 +402,15 @@ impl SetupWizard { } } +/// Generate a random 32-byte master key as hex string. +fn generate_master_key() -> String { + use rand::RngCore; + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + impl Default for SetupWizard { fn default() -> Self { Self::new() @@ -344,4 +437,10 @@ mod tests { let wizard = SetupWizard::with_config(config); assert!(wizard.config.skip_auth); } + + #[test] + fn test_generate_master_key() { + let key = generate_master_key(); + assert_eq!(key.len(), 64); // 32 bytes = 64 hex chars + } }