//! Settings repository operations. use crate::error::{DbError, Result}; use crate::models::{format_datetime, parse_datetime}; use chrono::Utc; use nx9_wg_core::types::settings::{ LEGACY_SETTING_PUBLIC_ENDPOINT, LEGACY_SETTING_SERVER_ENDPOINT, SETTING_SERVER_ENDPOINT_ENABLED, SETTING_SERVER_HOST, SETTING_SERVER_PORT, ServerEndpointSettings, Setting, }; use sqlx::{Row, SqlitePool}; /// Retrieve a setting by its key. pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result> { let row = sqlx::query("SELECT key, value, is_secret, updated_at FROM settings WHERE key = ?") .bind(key) .fetch_optional(pool) .await .map_err(DbError::Sqlx)?; match row { Some(r) => { let key: String = r.try_get("key")?; let value: String = r.try_get("value")?; let is_secret_i64: i64 = r.try_get("is_secret")?; let updated_at_str: String = r.try_get("updated_at")?; Ok(Some(Setting { key, value, is_secret: is_secret_i64 != 0, updated_at: parse_datetime(&updated_at_str)?, })) } None => Ok(None), } } /// Retrieve only the string value of a setting, if present. pub async fn get_setting_value(pool: &SqlitePool, key: &str) -> Result> { let setting = get_setting(pool, key).await?; Ok(setting.map(|s| s.value)) } /// Upsert a setting key-value pair. pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str, is_secret: bool) -> Result<()> { let now = Utc::now().naive_utc(); let now_str = format_datetime(&now); sqlx::query( r#" INSERT INTO settings (key, value, is_secret, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, is_secret = excluded.is_secret, updated_at = excluded.updated_at "#, ) .bind(key) .bind(value) .bind(if is_secret { 1 } else { 0 }) .bind(&now_str) .execute(pool) .await .map_err(DbError::Sqlx)?; Ok(()) } /// Delete a setting by key. pub async fn delete_setting(pool: &SqlitePool, key: &str) -> Result<()> { sqlx::query("DELETE FROM settings WHERE key = ?") .bind(key) .execute(pool) .await .map_err(DbError::Sqlx)?; Ok(()) } /// List all settings. pub async fn list_settings(pool: &SqlitePool) -> Result> { let rows = sqlx::query("SELECT key, value, is_secret, updated_at FROM settings ORDER BY key ASC") .fetch_all(pool) .await .map_err(DbError::Sqlx)?; let mut list = Vec::with_capacity(rows.len()); for r in rows { let key: String = r.try_get("key")?; let value: String = r.try_get("value")?; let is_secret_i64: i64 = r.try_get("is_secret")?; let updated_at_str: String = r.try_get("updated_at")?; list.push(Setting { key, value, is_secret: is_secret_i64 != 0, updated_at: parse_datetime(&updated_at_str)?, }); } Ok(list) } /// Retrieve structured server endpoint settings from the database with legacy fallback. pub async fn get_server_endpoint_settings(pool: &SqlitePool) -> Result { let host_opt = get_setting_value(pool, SETTING_SERVER_HOST).await?; let port_opt = get_setting_value(pool, SETTING_SERVER_PORT).await?; let enabled_opt = get_setting_value(pool, SETTING_SERVER_ENDPOINT_ENABLED).await?; let enabled = enabled_opt .as_deref() .map(|v| { let t = v.trim(); t.parse::().unwrap_or_else(|_| t == "1") }) .unwrap_or(true); let port = port_opt .as_deref() .and_then(|v| v.trim().parse::().ok()) .filter(|&p| p > 0) .unwrap_or(51820); if let Some(host) = host_opt.filter(|h| !h.trim().is_empty()) { return Ok(ServerEndpointSettings { host: host.trim().to_string(), port, enabled, }); } // Legacy fallback: inspect server_endpoint if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_SERVER_ENDPOINT).await? { let trimmed = legacy.trim(); if !trimmed.is_empty() { let (legacy_host, legacy_port) = split_host_port(trimmed, port); return Ok(ServerEndpointSettings { host: legacy_host, port: legacy_port, enabled, }); } } // Legacy fallback: inspect public_endpoint if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await? { let trimmed = legacy.trim(); if !trimmed.is_empty() { let (legacy_host, legacy_port) = split_host_port(trimmed, port); return Ok(ServerEndpointSettings { host: legacy_host, port: legacy_port, enabled, }); } } Ok(ServerEndpointSettings { host: String::new(), port, enabled, }) } /// Persist structured server endpoint settings. pub async fn set_server_endpoint_settings( pool: &SqlitePool, settings: &ServerEndpointSettings, ) -> Result<()> { let host_trimmed = settings.host.trim(); if settings.enabled && !host_trimmed.is_empty() { nx9_wg_core::validation::validate_server_host(host_trimmed)?; nx9_wg_core::validation::validate_server_port(settings.port)?; } set_setting(pool, SETTING_SERVER_HOST, host_trimmed, false).await?; set_setting(pool, SETTING_SERVER_PORT, &settings.port.to_string(), false).await?; set_setting( pool, SETTING_SERVER_ENDPOINT_ENABLED, &settings.enabled.to_string(), false, ) .await?; // Synchronize legacy server_endpoint setting for backwards compatibility if settings.enabled && !host_trimmed.is_empty() { let formatted = nx9_wg_core::validation::format_endpoint(host_trimmed, settings.port); set_setting(pool, LEGACY_SETTING_SERVER_ENDPOINT, &formatted, false).await?; } else { delete_setting(pool, LEGACY_SETTING_SERVER_ENDPOINT).await?; delete_setting(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await?; } Ok(()) } /// Authoritative server endpoint resolver. /// /// Precedence: /// 1. Explicit endpoint override (if non-empty) /// 2. Persistent `wireguard.server_*` settings (when enabled and host non-empty) /// 3. Legacy `server_endpoint` setting (if non-empty) /// 4. Legacy `public_endpoint` setting (if non-empty) /// 5. Actionable error explaining how to configure server endpoint or provide `--endpoint`. pub async fn resolve_server_endpoint( pool: &SqlitePool, explicit_override: Option<&str>, ) -> Result { // 1. Explicit endpoint override if let Some(ep) = explicit_override { let trimmed = ep.trim(); if !trimmed.is_empty() { return parse_and_normalize_endpoint(trimmed); } } // Check enabled toggle let enabled_opt = get_setting_value(pool, SETTING_SERVER_ENDPOINT_ENABLED).await?; let enabled = enabled_opt .as_deref() .map(|v| { let t = v.trim(); t.parse::().unwrap_or_else(|_| t == "1") }) .unwrap_or(true); if !enabled { return Err(DbError::Validation( "No reachable WireGuard server endpoint is configured. Configure WireGuard Server Endpoint in Settings or provide --endpoint.".to_string(), )); } // 2. Persistent wireguard.server_* settings let host_opt = get_setting_value(pool, SETTING_SERVER_HOST).await?; let port_opt = get_setting_value(pool, SETTING_SERVER_PORT).await?; let port = port_opt .as_deref() .and_then(|v| v.trim().parse::().ok()) .filter(|&p| p > 0) .unwrap_or(51820); if let Some(host) = host_opt.filter(|h| !h.trim().is_empty()) { let validated_host = nx9_wg_core::validation::validate_server_host(&host)?; let validated_port = nx9_wg_core::validation::validate_server_port(port)?; return Ok(nx9_wg_core::validation::format_endpoint( &validated_host, validated_port, )); } // 3. Legacy server_endpoint fallback if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_SERVER_ENDPOINT).await? { let trimmed = legacy.trim(); if !trimmed.is_empty() { return parse_and_normalize_endpoint(trimmed); } } // 4. Legacy public_endpoint fallback if let Some(legacy) = get_setting_value(pool, LEGACY_SETTING_PUBLIC_ENDPOINT).await? { let trimmed = legacy.trim(); if !trimmed.is_empty() { return parse_and_normalize_endpoint(trimmed); } } // 5. Actionable error Err(DbError::Validation( "No reachable WireGuard server endpoint is configured. Configure WireGuard Server Endpoint in Settings or provide --endpoint.".to_string(), )) } fn split_host_port(s: &str, default_port: u16) -> (String, u16) { let trimmed = s.trim(); if trimmed.starts_with('[') && let Some(closing) = trimmed.find(']') { let host_part = &trimmed[1..closing]; let rest = &trimmed[closing + 1..]; if let Some(port_str) = rest.strip_prefix(':') && let Ok(port) = port_str.parse::() && port > 0 { return (host_part.to_string(), port); } return (host_part.to_string(), default_port); } if let Ok(ipv6) = trimmed.parse::() { return (ipv6.to_string(), default_port); } if let Some(last_colon) = trimmed.rfind(':') { let host_part = &trimmed[..last_colon]; let port_part = &trimmed[last_colon + 1..]; if let Ok(port) = port_part.parse::() && port > 0 { return (host_part.to_string(), port); } } (trimmed.to_string(), default_port) } fn parse_and_normalize_endpoint(ep: &str) -> Result { let trimmed = ep.trim(); if trimmed.is_empty() { return Err(DbError::Validation("endpoint cannot be empty".into())); } if trimmed.starts_with('[') && let Some(closing) = trimmed.find(']') { let host_part = &trimmed[1..closing]; let ipv6 = host_part.parse::().map_err(|e| { DbError::Validation(format!("invalid IPv6 in endpoint '{trimmed}': {e}")) })?; let rest = &trimmed[closing + 1..]; let port = if let Some(port_str) = rest.strip_prefix(':') { port_str .parse::() .map_err(|_| DbError::Validation(format!("invalid port in endpoint '{trimmed}'")))? } else if rest.is_empty() { 51820 } else { return Err(DbError::Validation(format!( "invalid endpoint format '{trimmed}'" ))); }; if port == 0 { return Err(DbError::Validation("port must be non-zero".into())); } return Ok(format!("[{}]:{}", ipv6, port)); } if let Ok(ipv6) = trimmed.parse::() { return Ok(format!("[{}]:51820", ipv6)); } if let Some(last_colon) = trimmed.rfind(':') { let host_part = &trimmed[..last_colon]; let port_part = &trimmed[last_colon + 1..]; if let Ok(port) = port_part.parse::() { if port == 0 { return Err(DbError::Validation("port must be non-zero".into())); } let host = nx9_wg_core::validation::validate_server_host(host_part)?; return Ok(nx9_wg_core::validation::format_endpoint(&host, port)); } } let host = nx9_wg_core::validation::validate_server_host(trimmed)?; Ok(nx9_wg_core::validation::format_endpoint(&host, 51820)) }