357 lines
12 KiB
Rust
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))
|
|
}
|