mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Move setup wizard credentials to database storage
Changes setup wizard to save channel secrets (Telegram bot token, HTTP webhook secret) to PostgreSQL via SecretsStore instead of files. This enables the WASM channel credential injector to find and inject the secrets properly, since it reads from the database. Requires: - DATABASE_URL to be set - SECRETS_MASTER_KEY (will generate and display if not set) Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
c7f0e8014d
commit
4ab20ff939
+74
-112
@@ -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<SecretsCrypto>, 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<TelegramSetupResult> {
|
||||
/// 4. Saving the token to the database
|
||||
pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupResult, String> {
|
||||
println!("Telegram Setup:");
|
||||
println!();
|
||||
print_info("To create a Telegram bot:");
|
||||
@@ -54,7 +90,18 @@ pub async fn setup_telegram() -> io::Result<TelegramSetupResult> {
|
||||
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<TelegramSetupResult> {
|
||||
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<TelegramSetupResult> {
|
||||
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<HttpSetupResult> {
|
||||
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, String> {
|
||||
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<Option<SecretString>> {
|
||||
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<bool> {
|
||||
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::*;
|
||||
|
||||
+1
-1
@@ -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};
|
||||
|
||||
+105
-6
@@ -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<SecretsContext, SetupError> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user