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:
Illia Polosukhin
2026-02-04 09:49:11 -08:00
co-authored by Claude Opus 4.5
parent c7f0e8014d
commit 4ab20ff939
3 changed files with 180 additions and 119 deletions
+74 -112
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}