Files
nx9-wg/crates/nx9-wg-db/src/settings.rs
T

357 lines
12 KiB
Rust

//! 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<Option<Setting>> {
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<Option<String>> {
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<Vec<Setting>> {
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<ServerEndpointSettings> {
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::<bool>().unwrap_or_else(|_| t == "1")
})
.unwrap_or(true);
let port = port_opt
.as_deref()
.and_then(|v| v.trim().parse::<u16>().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<String> {
// 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::<bool>().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::<u16>().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::<u16>()
&& port > 0
{
return (host_part.to_string(), port);
}
return (host_part.to_string(), default_port);
}
if let Ok(ipv6) = trimmed.parse::<std::net::Ipv6Addr>() {
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::<u16>()
&& port > 0
{
return (host_part.to_string(), port);
}
}
(trimmed.to_string(), default_port)
}
fn parse_and_normalize_endpoint(ep: &str) -> Result<String> {
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::<std::net::Ipv6Addr>().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::<u16>()
.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::<std::net::Ipv6Addr>() {
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::<u16>() {
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))
}