cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,215 @@
|
||||
//! Administrator repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::auth::Admin;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
/// Retrieve the single administrator record, if initialized.
|
||||
pub async fn get_admin(pool: &SqlitePool) -> Result<Option<Admin>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, username, password_hash, totp_secret, totp_enabled,
|
||||
last_login_at, last_login_ip, created_at, updated_at
|
||||
FROM admin
|
||||
WHERE id = 1
|
||||
"#,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let id: i64 = r.try_get("id")?;
|
||||
let username: String = r.try_get("username")?;
|
||||
let password_hash: String = r.try_get("password_hash")?;
|
||||
let totp_secret: Option<String> = r.try_get("totp_secret")?;
|
||||
let totp_enabled_int: i64 = r.try_get("totp_enabled")?;
|
||||
let last_login_at_str: Option<String> = r.try_get("last_login_at")?;
|
||||
let last_login_ip: Option<String> = r.try_get("last_login_ip")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let last_login_at = match last_login_at_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Some(Admin {
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
totp_secret,
|
||||
totp_enabled: totp_enabled_int != 0,
|
||||
last_login_at,
|
||||
last_login_ip,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the administrator record by username.
|
||||
pub async fn get_admin_by_username(pool: &SqlitePool, username: &str) -> Result<Option<Admin>> {
|
||||
let admin = get_admin(pool).await?;
|
||||
match admin {
|
||||
Some(a) if a.username == username => Ok(Some(a)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the single administrator has already been initialized.
|
||||
pub async fn admin_exists(pool: &SqlitePool) -> Result<bool> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as count FROM admin WHERE id = 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let count: i64 = row.try_get("count")?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Create the single administrator record.
|
||||
///
|
||||
/// Fails if an administrator already exists.
|
||||
pub async fn create_admin(pool: &SqlitePool, username: &str, password_hash: &str) -> Result<Admin> {
|
||||
if admin_exists(pool).await? {
|
||||
return Err(DbError::Conflict(
|
||||
"Administrator has already been initialized".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO admin (id, username, password_hash, totp_secret, totp_enabled, created_at, updated_at)
|
||||
VALUES (1, ?, ?, NULL, 0, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(&now_str)
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||
DbError::Conflict("Administrator already exists or username conflict".to_string())
|
||||
}
|
||||
_ => DbError::Sqlx(e),
|
||||
})?;
|
||||
|
||||
Ok(Admin {
|
||||
id: 1,
|
||||
username: username.to_string(),
|
||||
password_hash: password_hash.to_string(),
|
||||
totp_secret: None,
|
||||
totp_enabled: false,
|
||||
last_login_at: None,
|
||||
last_login_ip: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the administrator's password hash.
|
||||
pub async fn update_admin_password(pool: &SqlitePool, new_password_hash: &str) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE admin
|
||||
SET password_hash = ?, updated_at = ?
|
||||
WHERE id = 1
|
||||
"#,
|
||||
)
|
||||
.bind(new_password_hash)
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(
|
||||
"Administrator record does not exist".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update administrator TOTP configuration.
|
||||
pub async fn update_admin_totp(
|
||||
pool: &SqlitePool,
|
||||
totp_secret: Option<&str>,
|
||||
totp_enabled: bool,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE admin
|
||||
SET totp_secret = ?, totp_enabled = ?, updated_at = ?
|
||||
WHERE id = 1
|
||||
"#,
|
||||
)
|
||||
.bind(totp_secret)
|
||||
.bind(if totp_enabled { 1 } else { 0 })
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(
|
||||
"Administrator record does not exist".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a successful administrator login timestamp and IP address.
|
||||
pub async fn record_admin_login(pool: &SqlitePool, ip_address: Option<&str>) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE admin
|
||||
SET last_login_at = ?, last_login_ip = ?, updated_at = ?
|
||||
WHERE id = 1
|
||||
"#,
|
||||
)
|
||||
.bind(&now_str)
|
||||
.bind(ip_address)
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(
|
||||
"Administrator record does not exist".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete administrator record (if explicitly supported).
|
||||
pub async fn delete_admin(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query("DELETE FROM admin WHERE id = 1")
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Operational Audit log repository operations (append-only).
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use nx9_wg_core::types::audit::{AuditEvent, AuditEventType};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Filter options for querying audit records.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct AuditFilter {
|
||||
pub event_type: Option<AuditEventType>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<String>,
|
||||
pub since: Option<NaiveDateTime>,
|
||||
pub until: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
/// Append a new audit event to the log.
|
||||
pub async fn create_audit_event(pool: &SqlitePool, event: &AuditEvent) -> Result<i64> {
|
||||
let created_at_str = format_datetime(&event.created_at);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_events (
|
||||
event_type, actor, resource_type, resource_id,
|
||||
message, metadata, ip_address, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(event.event_type.as_str())
|
||||
.bind(&event.actor)
|
||||
.bind(&event.resource_type)
|
||||
.bind(&event.resource_id)
|
||||
.bind(&event.message)
|
||||
.bind(&event.metadata)
|
||||
.bind(&event.ip_address)
|
||||
.bind(&created_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Convenience function to record an audit entry.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn record_audit(
|
||||
pool: &SqlitePool,
|
||||
event_type: AuditEventType,
|
||||
actor: &str,
|
||||
resource_type: Option<&str>,
|
||||
resource_id: Option<&str>,
|
||||
message: Option<&str>,
|
||||
metadata: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let event = AuditEvent {
|
||||
id: 0,
|
||||
event_type,
|
||||
actor: actor.to_string(),
|
||||
resource_type: resource_type.map(|s| s.to_string()),
|
||||
resource_id: resource_id.map(|s| s.to_string()),
|
||||
message: message.map(|s| s.to_string()),
|
||||
metadata: metadata.map(|s| s.to_string()),
|
||||
ip_address: ip_address.map(|s| s.to_string()),
|
||||
created_at: now,
|
||||
};
|
||||
create_audit_event(pool, &event).await
|
||||
}
|
||||
|
||||
/// Query audit events with filtering and pagination.
|
||||
pub async fn list_audit_events(
|
||||
pool: &SqlitePool,
|
||||
filter: &AuditFilter,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<AuditEvent>> {
|
||||
let event_type_str = filter.event_type.map(|et| et.as_str().to_string());
|
||||
let since_str = filter.since.as_ref().map(format_datetime);
|
||||
let until_str = filter.until.as_ref().map(format_datetime);
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, event_type, actor, resource_type, resource_id,
|
||||
message, metadata, ip_address, created_at
|
||||
FROM audit_events
|
||||
WHERE (?1 IS NULL OR event_type = ?1)
|
||||
AND (?2 IS NULL OR resource_type = ?2)
|
||||
AND (?3 IS NULL OR resource_id = ?3)
|
||||
AND (?4 IS NULL OR created_at >= ?4)
|
||||
AND (?5 IS NULL OR created_at <= ?5)
|
||||
ORDER BY id DESC
|
||||
LIMIT ?6 OFFSET ?7
|
||||
"#,
|
||||
)
|
||||
.bind(event_type_str)
|
||||
.bind(&filter.resource_type)
|
||||
.bind(&filter.resource_id)
|
||||
.bind(since_str)
|
||||
.bind(until_str)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut events = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let id: i64 = r.try_get("id")?;
|
||||
let event_type_str: String = r.try_get("event_type")?;
|
||||
let actor: String = r.try_get("actor")?;
|
||||
let resource_type: Option<String> = r.try_get("resource_type")?;
|
||||
let resource_id: Option<String> = r.try_get("resource_id")?;
|
||||
let message: Option<String> = r.try_get("message")?;
|
||||
let metadata: Option<String> = r.try_get("metadata")?;
|
||||
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
|
||||
let event_type = AuditEventType::from_str(&event_type_str)?;
|
||||
|
||||
events.push(AuditEvent {
|
||||
id,
|
||||
event_type,
|
||||
actor,
|
||||
resource_type,
|
||||
resource_id,
|
||||
message,
|
||||
metadata,
|
||||
ip_address,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Retrieve a single audit event by ID.
|
||||
pub async fn get_audit_event(pool: &SqlitePool, id: i64) -> Result<Option<AuditEvent>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, event_type, actor, resource_type, resource_id,
|
||||
message, metadata, ip_address, created_at
|
||||
FROM audit_events
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let event_type_str: String = r.try_get("event_type")?;
|
||||
let actor: String = r.try_get("actor")?;
|
||||
let resource_type: Option<String> = r.try_get("resource_type")?;
|
||||
let resource_id: Option<String> = r.try_get("resource_id")?;
|
||||
let message: Option<String> = r.try_get("message")?;
|
||||
let metadata: Option<String> = r.try_get("metadata")?;
|
||||
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
|
||||
let event_type = AuditEventType::from_str(&event_type_str)?;
|
||||
|
||||
Ok(Some(AuditEvent {
|
||||
id,
|
||||
event_type,
|
||||
actor,
|
||||
resource_type,
|
||||
resource_id,
|
||||
message,
|
||||
metadata,
|
||||
ip_address,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Count total audit events matching a filter.
|
||||
pub async fn count_audit_events(pool: &SqlitePool, filter: &AuditFilter) -> Result<i64> {
|
||||
let event_type_str = filter.event_type.map(|et| et.as_str().to_string());
|
||||
let since_str = filter.since.as_ref().map(format_datetime);
|
||||
let until_str = filter.until.as_ref().map(format_datetime);
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*) as count
|
||||
FROM audit_events
|
||||
WHERE (?1 IS NULL OR event_type = ?1)
|
||||
AND (?2 IS NULL OR resource_type = ?2)
|
||||
AND (?3 IS NULL OR resource_id = ?3)
|
||||
AND (?4 IS NULL OR created_at >= ?4)
|
||||
AND (?5 IS NULL OR created_at <= ?5)
|
||||
"#,
|
||||
)
|
||||
.bind(event_type_str)
|
||||
.bind(&filter.resource_type)
|
||||
.bind(&filter.resource_id)
|
||||
.bind(since_str)
|
||||
.bind(until_str)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let count: i64 = row.try_get("count")?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Backup metadata repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use nx9_wg_core::types::backup::BackupMeta;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper to convert a database row into a `BackupMeta` domain struct.
|
||||
fn row_to_backup_meta(r: &sqlx::sqlite::SqliteRow) -> Result<BackupMeta> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let filename: String = r.try_get("filename")?;
|
||||
let size_i64: i64 = r.try_get("size")?;
|
||||
let checksum: String = r.try_get("checksum")?;
|
||||
let encrypted_i64: i64 = r.try_get("encrypted")?;
|
||||
let schema_version: String = r.try_get("schema_version")?;
|
||||
let description: Option<String> = r.try_get("description")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid backup UUID '{id_str}': {e}")))?;
|
||||
|
||||
Ok(BackupMeta {
|
||||
id,
|
||||
filename,
|
||||
size_bytes: size_i64,
|
||||
checksum,
|
||||
schema_version,
|
||||
encrypted: encrypted_i64 != 0,
|
||||
description,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record metadata for a new backup file.
|
||||
pub async fn create_backup_meta(pool: &SqlitePool, meta: &BackupMeta) -> Result<()> {
|
||||
let id_str = meta.id.to_string();
|
||||
let created_at_str = format_datetime(&meta.created_at);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO backups (id, filename, size, checksum, encrypted, schema_version, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&meta.filename)
|
||||
.bind(meta.size_bytes)
|
||||
.bind(&meta.checksum)
|
||||
.bind(if meta.encrypted { 1 } else { 0 })
|
||||
.bind(&meta.schema_version)
|
||||
.bind(&meta.description)
|
||||
.bind(&created_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve backup metadata by UUID.
|
||||
pub async fn get_backup_meta(pool: &SqlitePool, id: Uuid) -> Result<Option<BackupMeta>> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM backups WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_backup_meta(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all backup records ordered by creation date descending.
|
||||
pub async fn list_backups(pool: &SqlitePool) -> Result<Vec<BackupMeta>> {
|
||||
let rows = sqlx::query("SELECT * FROM backups ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_backup_meta(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Delete a backup record by UUID.
|
||||
pub async fn delete_backup_meta(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM backups WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"Backup record '{id_str}' not found"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a consistent, atomic file snapshot of the database using SQLite VACUUM INTO.
|
||||
pub async fn vacuum_into(pool: &SqlitePool, target_file_path: &str) -> Result<()> {
|
||||
// Check if target file already exists, remove it if so since VACUUM INTO fails if target exists
|
||||
let path = std::path::Path::new(target_file_path);
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
if let Some(parent) = path.parent().filter(|p| !p.exists()) {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| DbError::Internal(format!("Failed to create backup directory: {e}")))?;
|
||||
}
|
||||
|
||||
// SQLite VACUUM INTO requires a string literal filename
|
||||
let escaped_path = target_file_path.replace('\'', "''");
|
||||
let query_str = format!("VACUUM INTO '{escaped_path}'");
|
||||
sqlx::query(&query_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Client Profile repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, DeviceCategory, NatType};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Helper to convert a database row into a `ClientProfile` domain struct.
|
||||
fn row_to_profile(r: &sqlx::sqlite::SqliteRow) -> Result<ClientProfile> {
|
||||
let id: String = r.try_get("id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let provider: Option<String> = r.try_get("provider")?;
|
||||
let device_str: Option<String> = r.try_get("device")?;
|
||||
let connection_type_str: String = r.try_get("connection_type")?;
|
||||
let nat_type_str: String = r.try_get("nat_type")?;
|
||||
let mtu_i64: i64 = r.try_get("mtu")?;
|
||||
let dns: Option<String> = r.try_get("dns")?;
|
||||
let keepalive_i64: Option<i64> = r.try_get("persistent_keepalive")?;
|
||||
let is_builtin_i64: i64 = r.try_get("is_builtin")?;
|
||||
let description: Option<String> = r.try_get("description")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let device = match device_str {
|
||||
Some(s) if !s.trim().is_empty() => Some(
|
||||
DeviceCategory::from_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid device category '{s}': {e}")))?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let connection_type = ConnectionType::from_str(&connection_type_str).map_err(|e| {
|
||||
DbError::Validation(format!(
|
||||
"invalid connection type '{connection_type_str}': {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let nat_type = NatType::from_str(&nat_type_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid nat type '{nat_type_str}': {e}")))?;
|
||||
|
||||
Ok(ClientProfile {
|
||||
id,
|
||||
name,
|
||||
provider,
|
||||
device,
|
||||
connection_type,
|
||||
nat_type,
|
||||
mtu: mtu_i64 as u16,
|
||||
dns,
|
||||
persistent_keepalive: keepalive_i64.map(|k| k as u16),
|
||||
is_builtin: is_builtin_i64 != 0,
|
||||
description,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new client profile.
|
||||
pub async fn create_client_profile(pool: &SqlitePool, profile: &ClientProfile) -> Result<()> {
|
||||
let now = format_datetime(&Utc::now().naive_utc());
|
||||
let device_str = profile.device.map(|d| d.as_str().to_string());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO client_profiles (
|
||||
id, name, provider, device, connection_type, nat_type,
|
||||
mtu, dns, persistent_keepalive, is_builtin, description,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.provider)
|
||||
.bind(&device_str)
|
||||
.bind(profile.connection_type.as_str())
|
||||
.bind(profile.nat_type.as_str())
|
||||
.bind(profile.mtu as i64)
|
||||
.bind(&profile.dns)
|
||||
.bind(profile.persistent_keepalive.map(|k| k as i64))
|
||||
.bind(if profile.is_builtin { 1i64 } else { 0i64 })
|
||||
.bind(&profile.description)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
|
||||
DbError::Conflict(format!("client profile '{}' already exists", profile.id))
|
||||
}
|
||||
other => DbError::Sqlx(other),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a client profile by ID.
|
||||
pub async fn get_client_profile(pool: &SqlitePool, id: &str) -> Result<Option<ClientProfile>> {
|
||||
let row = sqlx::query("SELECT * FROM client_profiles WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
row.map(|r| row_to_profile(&r)).transpose()
|
||||
}
|
||||
|
||||
/// List all client profiles ordered by built-in status (built-in first) then name.
|
||||
pub async fn list_client_profiles(pool: &SqlitePool) -> Result<Vec<ClientProfile>> {
|
||||
let rows = sqlx::query("SELECT * FROM client_profiles ORDER BY is_builtin DESC, name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
rows.iter().map(row_to_profile).collect()
|
||||
}
|
||||
|
||||
/// Update a custom client profile. Built-in profiles cannot be modified.
|
||||
pub async fn update_client_profile(pool: &SqlitePool, profile: &ClientProfile) -> Result<()> {
|
||||
let existing = get_client_profile(pool, &profile.id)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::NotFound(format!("client profile '{}' not found", profile.id)))?;
|
||||
|
||||
if existing.is_builtin {
|
||||
return Err(DbError::Validation(format!(
|
||||
"built-in client profile '{}' cannot be modified",
|
||||
profile.id
|
||||
)));
|
||||
}
|
||||
|
||||
let now = format_datetime(&Utc::now().naive_utc());
|
||||
let device_str = profile.device.map(|d| d.as_str().to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE client_profiles SET
|
||||
name = ?,
|
||||
provider = ?,
|
||||
device = ?,
|
||||
connection_type = ?,
|
||||
nat_type = ?,
|
||||
mtu = ?,
|
||||
dns = ?,
|
||||
persistent_keepalive = ?,
|
||||
description = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND is_builtin = 0
|
||||
"#,
|
||||
)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.provider)
|
||||
.bind(&device_str)
|
||||
.bind(profile.connection_type.as_str())
|
||||
.bind(profile.nat_type.as_str())
|
||||
.bind(profile.mtu as i64)
|
||||
.bind(&profile.dns)
|
||||
.bind(profile.persistent_keepalive.map(|k| k as i64))
|
||||
.bind(&profile.description)
|
||||
.bind(&now)
|
||||
.bind(&profile.id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"client profile '{}' not found or is built-in",
|
||||
profile.id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a custom client profile. Built-in profiles cannot be deleted.
|
||||
pub async fn delete_client_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
let existing = get_client_profile(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::NotFound(format!("client profile '{id}' not found")))?;
|
||||
|
||||
if existing.is_builtin {
|
||||
return Err(DbError::Validation(format!(
|
||||
"built-in client profile '{id}' cannot be deleted"
|
||||
)));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM client_profiles WHERE id = ? AND is_builtin = 0")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"client profile '{id}' not found or is built-in"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all distinct non-null provider identifiers.
|
||||
pub async fn list_distinct_providers(pool: &SqlitePool) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT DISTINCT provider FROM client_profiles WHERE provider IS NOT NULL AND provider != '' ORDER BY provider ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut providers = Vec::new();
|
||||
for r in rows {
|
||||
let p: Option<String> = r.try_get("provider")?;
|
||||
if let Some(name) = p.filter(|s| !s.trim().is_empty() && !providers.contains(s)) {
|
||||
providers.push(name);
|
||||
}
|
||||
}
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// Find matching profiles from database given criteria.
|
||||
pub async fn find_matching_profiles(
|
||||
pool: &SqlitePool,
|
||||
provider: Option<&str>,
|
||||
device: Option<DeviceCategory>,
|
||||
connection: Option<ConnectionType>,
|
||||
nat: Option<NatType>,
|
||||
) -> Result<Vec<ClientProfile>> {
|
||||
let all = list_client_profiles(pool).await?;
|
||||
let mut filtered = Vec::new();
|
||||
|
||||
for p in all {
|
||||
if let Some(req_p) = provider {
|
||||
match p.provider {
|
||||
Some(ref prof_p) if prof_p.eq_ignore_ascii_case(req_p) => {}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
if device.is_some_and(|req_d| p.device.is_some_and(|d| d != req_d)) {
|
||||
continue;
|
||||
}
|
||||
if connection.is_some_and(|req_c| p.connection_type != req_c) {
|
||||
continue;
|
||||
}
|
||||
if nat.is_some_and(|req_n| p.nat_type != NatType::Unknown && p.nat_type != req_n) {
|
||||
continue;
|
||||
}
|
||||
filtered.push(p);
|
||||
}
|
||||
|
||||
Ok(filtered)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Database error types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type for database operations.
|
||||
pub type Result<T> = std::result::Result<T, DbError>;
|
||||
|
||||
/// Database-specific errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
/// Entity was not found.
|
||||
#[error("entity not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// Unique or foreign key constraint violation.
|
||||
#[error("constraint violation: {0}")]
|
||||
ConstraintViolation(String),
|
||||
|
||||
/// Conflict, e.g. entity already exists.
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// Validation error when converting from raw database values.
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
/// SQLx database error.
|
||||
#[error("database error: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
/// Migration failure.
|
||||
#[error("migration error: {0}")]
|
||||
Migration(String),
|
||||
|
||||
/// Internal or unexpected error.
|
||||
#[error("internal database error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<nx9_wg_core::error::Nx9Error> for DbError {
|
||||
fn from(err: nx9_wg_core::error::Nx9Error) -> Self {
|
||||
Self::Validation(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Firewall Rule repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::firewall::{
|
||||
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper to convert a database row into a `FirewallRule` domain struct.
|
||||
fn row_to_rule(r: &sqlx::sqlite::SqliteRow) -> Result<FirewallRule> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let interface_id_str: Option<String> = r.try_get("interface_id")?;
|
||||
let peer_id_str: Option<String> = r.try_get("peer_id").unwrap_or(None);
|
||||
let direction_str: String = r.try_get("direction")?;
|
||||
let action_str: String = r.try_get("action")?;
|
||||
let protocol_str: String = r.try_get("protocol")?;
|
||||
let source: Option<String> = r.try_get("source")?;
|
||||
let destination: Option<String> = r.try_get("destination")?;
|
||||
let source_port_i64: Option<i64> = r.try_get("source_port")?;
|
||||
let destination_port_i64: Option<i64> = r.try_get("destination_port")?;
|
||||
let port_range: Option<String> = r.try_get("port_range").unwrap_or(None);
|
||||
let priority_i64: i64 = r.try_get("priority")?;
|
||||
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||
let description: Option<String> = r.try_get("description")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid firewall rule UUID '{id_str}': {e}")))?;
|
||||
|
||||
let interface_id = match interface_id_str {
|
||||
Some(s) => Some(
|
||||
Uuid::parse_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{s}': {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let peer_id = match peer_id_str {
|
||||
Some(s) => Some(
|
||||
Uuid::parse_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid peer UUID '{s}': {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let direction = FirewallDirection::from_str(&direction_str)?;
|
||||
let action = FirewallAction::from_str(&action_str)?;
|
||||
let protocol = FirewallProtocol::from_str(&protocol_str)?;
|
||||
|
||||
Ok(FirewallRule {
|
||||
id,
|
||||
name,
|
||||
interface_id,
|
||||
peer_id,
|
||||
direction,
|
||||
action,
|
||||
protocol,
|
||||
source,
|
||||
destination,
|
||||
source_port: source_port_i64.map(|p| p as u16),
|
||||
destination_port: destination_port_i64.map(|p| p as u16),
|
||||
port_range,
|
||||
priority: priority_i64 as i32,
|
||||
enabled: enabled_i64 != 0,
|
||||
description,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new firewall rule record.
|
||||
pub async fn create_rule(pool: &SqlitePool, rule: &FirewallRule) -> Result<()> {
|
||||
let id_str = rule.id.to_string();
|
||||
let interface_id_str = rule.interface_id.map(|id| id.to_string());
|
||||
let peer_id_str = rule.peer_id.map(|id| id.to_string());
|
||||
let created_at_str = format_datetime(&rule.created_at);
|
||||
let updated_at_str = format_datetime(&rule.updated_at);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO firewall_rules (
|
||||
id, name, interface_id, peer_id, direction, action, protocol,
|
||||
source, destination, source_port, destination_port, port_range,
|
||||
priority, enabled, description, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&rule.name)
|
||||
.bind(interface_id_str)
|
||||
.bind(peer_id_str)
|
||||
.bind(rule.direction.as_str())
|
||||
.bind(rule.action.as_str())
|
||||
.bind(rule.protocol.as_str())
|
||||
.bind(&rule.source)
|
||||
.bind(&rule.destination)
|
||||
.bind(rule.source_port.map(|p| p as i64))
|
||||
.bind(rule.destination_port.map(|p| p as i64))
|
||||
.bind(&rule.port_range)
|
||||
.bind(rule.priority as i64)
|
||||
.bind(if rule.enabled { 1 } else { 0 })
|
||||
.bind(&rule.description)
|
||||
.bind(&created_at_str)
|
||||
.bind(&updated_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a firewall rule by UUID.
|
||||
pub async fn get_rule(pool: &SqlitePool, id: Uuid) -> Result<Option<FirewallRule>> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM firewall_rules WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_rule(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all firewall rules ordered by priority ascending.
|
||||
pub async fn list_rules(pool: &SqlitePool) -> Result<Vec<FirewallRule>> {
|
||||
let rows = sqlx::query("SELECT * FROM firewall_rules ORDER BY priority ASC, name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_rule(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// List firewall rules for a given interface ordered by priority ascending.
|
||||
pub async fn list_rules_for_interface(
|
||||
pool: &SqlitePool,
|
||||
interface_id: Uuid,
|
||||
) -> Result<Vec<FirewallRule>> {
|
||||
let iface_id_str = interface_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT * FROM firewall_rules WHERE interface_id = ? ORDER BY priority ASC, name ASC",
|
||||
)
|
||||
.bind(&iface_id_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_rule(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// List firewall rules for a given peer ordered by priority ascending.
|
||||
pub async fn list_rules_for_peer(pool: &SqlitePool, peer_id: Uuid) -> Result<Vec<FirewallRule>> {
|
||||
let peer_id_str = peer_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT * FROM firewall_rules WHERE peer_id = ? ORDER BY priority ASC, name ASC",
|
||||
)
|
||||
.bind(&peer_id_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_rule(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Update a firewall rule record.
|
||||
pub async fn update_rule(pool: &SqlitePool, rule: &FirewallRule) -> Result<()> {
|
||||
let id_str = rule.id.to_string();
|
||||
let interface_id_str = rule.interface_id.map(|id| id.to_string());
|
||||
let peer_id_str = rule.peer_id.map(|id| id.to_string());
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE firewall_rules
|
||||
SET name = ?, interface_id = ?, peer_id = ?, direction = ?, action = ?, protocol = ?,
|
||||
source = ?, destination = ?, source_port = ?, destination_port = ?, port_range = ?,
|
||||
priority = ?, enabled = ?, description = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&rule.name)
|
||||
.bind(interface_id_str)
|
||||
.bind(peer_id_str)
|
||||
.bind(rule.direction.as_str())
|
||||
.bind(rule.action.as_str())
|
||||
.bind(rule.protocol.as_str())
|
||||
.bind(&rule.source)
|
||||
.bind(&rule.destination)
|
||||
.bind(rule.source_port.map(|p| p as i64))
|
||||
.bind(rule.destination_port.map(|p| p as i64))
|
||||
.bind(&rule.port_range)
|
||||
.bind(rule.priority as i64)
|
||||
.bind(if rule.enabled { 1 } else { 0 })
|
||||
.bind(&rule.description)
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"Firewall rule '{id_str}' not found"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a firewall rule by UUID.
|
||||
pub async fn delete_rule(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM firewall_rules WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"Firewall rule '{id_str}' not found"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable or disable a firewall rule.
|
||||
pub async fn set_rule_enabled(pool: &SqlitePool, id: Uuid, enabled: bool) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE firewall_rules
|
||||
SET enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(if enabled { 1 } else { 0 })
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"Firewall rule '{id_str}' not found"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! WireGuard Interface repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::wireguard::{Interface, WireGuardPrivateKey, WireGuardPublicKey};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper to convert a database row into an `Interface` domain struct.
|
||||
fn row_to_interface(r: &sqlx::sqlite::SqliteRow) -> Result<Interface> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let private_key_str: String = r.try_get("private_key")?;
|
||||
let public_key_str: String = r.try_get("public_key")?;
|
||||
let listen_port_i64: i64 = r.try_get("listen_port")?;
|
||||
let ipv4_cidr_str: String = r.try_get("ipv4_cidr")?;
|
||||
let ipv6_cidr_str: Option<String> = r.try_get("ipv6_cidr")?;
|
||||
let mtu_i64: Option<i64> = r.try_get("mtu")?;
|
||||
let dns: Option<String> = r.try_get("dns")?;
|
||||
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||
let pre_up: Option<String> = r.try_get("pre_up")?;
|
||||
let post_up: Option<String> = r.try_get("post_up")?;
|
||||
let pre_down: Option<String> = r.try_get("pre_down")?;
|
||||
let post_down: Option<String> = r.try_get("post_down")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{id_str}': {e}")))?;
|
||||
|
||||
let address_v4 = IpNet::from_str(&ipv4_cidr_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid ipv4_cidr '{ipv4_cidr_str}': {e}")))?;
|
||||
|
||||
let address_v6 = match ipv6_cidr_str {
|
||||
Some(s) => Some(
|
||||
IpNet::from_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid ipv6_cidr '{s}': {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Interface {
|
||||
id,
|
||||
name,
|
||||
private_key: WireGuardPrivateKey::new(private_key_str),
|
||||
public_key: WireGuardPublicKey::new(public_key_str),
|
||||
listen_port: listen_port_i64 as u16,
|
||||
address_v4,
|
||||
address_v6,
|
||||
mtu: mtu_i64.map(|m| m as u16),
|
||||
dns,
|
||||
enabled: enabled_i64 != 0,
|
||||
pre_up,
|
||||
post_up,
|
||||
pre_down,
|
||||
post_down,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new WireGuard interface desired configuration record.
|
||||
pub async fn create_interface(pool: &SqlitePool, iface: &Interface) -> Result<()> {
|
||||
let id_str = iface.id.to_string();
|
||||
let ipv4_str = iface.address_v4.to_string();
|
||||
let ipv6_str = iface.address_v6.as_ref().map(|ip| ip.to_string());
|
||||
let created_at_str = format_datetime(&iface.created_at);
|
||||
let updated_at_str = format_datetime(&iface.updated_at);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO interfaces (
|
||||
id, name, private_key, public_key, listen_port, ipv4_cidr, ipv6_cidr,
|
||||
mtu, dns, enabled, pre_up, post_up, pre_down, post_down, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&iface.name)
|
||||
.bind(iface.private_key.as_str())
|
||||
.bind(iface.public_key.as_str())
|
||||
.bind(iface.listen_port as i64)
|
||||
.bind(&ipv4_str)
|
||||
.bind(ipv6_str)
|
||||
.bind(iface.mtu.map(|m| m as i64))
|
||||
.bind(&iface.dns)
|
||||
.bind(if iface.enabled { 1 } else { 0 })
|
||||
.bind(&iface.pre_up)
|
||||
.bind(&iface.post_up)
|
||||
.bind(&iface.pre_down)
|
||||
.bind(&iface.post_down)
|
||||
.bind(&created_at_str)
|
||||
.bind(&updated_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => DbError::Conflict(format!(
|
||||
"Interface with name '{}' already exists",
|
||||
iface.name
|
||||
)),
|
||||
_ => DbError::Sqlx(e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve an interface by its UUID.
|
||||
pub async fn get_interface(pool: &SqlitePool, id: Uuid) -> Result<Option<Interface>> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM interfaces WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_interface(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve an interface by its name.
|
||||
pub async fn get_interface_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Interface>> {
|
||||
let row = sqlx::query("SELECT * FROM interfaces WHERE name = ?")
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_interface(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all interfaces.
|
||||
pub async fn list_interfaces(pool: &SqlitePool) -> Result<Vec<Interface>> {
|
||||
let rows = sqlx::query("SELECT * FROM interfaces ORDER BY name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_interface(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Update an interface record.
|
||||
pub async fn update_interface(pool: &SqlitePool, iface: &Interface) -> Result<()> {
|
||||
let id_str = iface.id.to_string();
|
||||
let ipv4_str = iface.address_v4.to_string();
|
||||
let ipv6_str = iface.address_v6.as_ref().map(|ip| ip.to_string());
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE interfaces
|
||||
SET name = ?, private_key = ?, public_key = ?, listen_port = ?,
|
||||
ipv4_cidr = ?, ipv6_cidr = ?, mtu = ?, dns = ?, enabled = ?,
|
||||
pre_up = ?, post_up = ?, pre_down = ?, post_down = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&iface.name)
|
||||
.bind(iface.private_key.as_str())
|
||||
.bind(iface.public_key.as_str())
|
||||
.bind(iface.listen_port as i64)
|
||||
.bind(&ipv4_str)
|
||||
.bind(ipv6_str)
|
||||
.bind(iface.mtu.map(|m| m as i64))
|
||||
.bind(&iface.dns)
|
||||
.bind(if iface.enabled { 1 } else { 0 })
|
||||
.bind(&iface.pre_up)
|
||||
.bind(&iface.post_up)
|
||||
.bind(&iface.pre_down)
|
||||
.bind(&iface.post_down)
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Interface '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an interface by UUID. Peers are deleted automatically via ON DELETE CASCADE.
|
||||
pub async fn delete_interface(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM interfaces WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Interface '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable or disable an interface.
|
||||
pub async fn set_interface_enabled(pool: &SqlitePool, id: Uuid, enabled: bool) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE interfaces
|
||||
SET enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(if enabled { 1 } else { 0 })
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Interface '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! SQLite persistence layer for nx9-wg.
|
||||
//!
|
||||
//! Provides the authoritative desired-state storage, administrator identity,
|
||||
//! authentication state, interfaces, peers, networks, routes, firewall rules,
|
||||
//! system settings, backup metadata, and audit records.
|
||||
|
||||
pub mod admin;
|
||||
pub mod audit;
|
||||
pub mod backups;
|
||||
pub mod client_profiles;
|
||||
pub mod error;
|
||||
pub mod firewall;
|
||||
pub mod interfaces;
|
||||
pub mod login_attempts;
|
||||
pub mod migrations;
|
||||
pub mod models;
|
||||
pub mod networks;
|
||||
pub mod peers;
|
||||
pub mod routes;
|
||||
pub mod sessions;
|
||||
pub mod settings;
|
||||
pub mod store;
|
||||
pub mod tokens;
|
||||
|
||||
pub use audit::AuditFilter;
|
||||
pub use error::{DbError, Result};
|
||||
pub use migrations::run_migrations;
|
||||
pub use store::Store;
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Login attempt tracking repository for brute-force protection.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::{Duration, Utc};
|
||||
use nx9_wg_core::types::auth::LoginAttempt;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
/// Record a login attempt (successful or failed).
|
||||
pub async fn record_login_attempt(
|
||||
pool: &SqlitePool,
|
||||
ip_address: &str,
|
||||
success: bool,
|
||||
) -> Result<i64> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO login_attempts (ip_address, attempted_at, success)
|
||||
VALUES (?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(ip_address)
|
||||
.bind(&now_str)
|
||||
.bind(if success { 1 } else { 0 })
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Count failed login attempts from a given IP address within the last `window_minutes`.
|
||||
pub async fn count_recent_failed_attempts(
|
||||
pool: &SqlitePool,
|
||||
ip_address: &str,
|
||||
window_minutes: i64,
|
||||
) -> Result<i64> {
|
||||
let cutoff = Utc::now().naive_utc() - Duration::minutes(window_minutes);
|
||||
let cutoff_str = format_datetime(&cutoff);
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*) as count
|
||||
FROM login_attempts
|
||||
WHERE ip_address = ? AND success = 0 AND attempted_at >= ?
|
||||
"#,
|
||||
)
|
||||
.bind(ip_address)
|
||||
.bind(&cutoff_str)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let count: i64 = row.try_get("count")?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Clear login attempts for an IP (e.g. after successful login).
|
||||
pub async fn clear_login_attempts(pool: &SqlitePool, ip_address: &str) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM login_attempts WHERE ip_address = ?")
|
||||
.bind(ip_address)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Purge old login attempts older than `retention_hours`.
|
||||
pub async fn purge_old_login_attempts(pool: &SqlitePool, retention_hours: i64) -> Result<u64> {
|
||||
let cutoff = Utc::now().naive_utc() - Duration::hours(retention_hours);
|
||||
let cutoff_str = format_datetime(&cutoff);
|
||||
|
||||
let result = sqlx::query("DELETE FROM login_attempts WHERE attempted_at < ?")
|
||||
.bind(&cutoff_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List recent login attempts for diagnostics.
|
||||
pub async fn list_recent_login_attempts(
|
||||
pool: &SqlitePool,
|
||||
limit: u32,
|
||||
) -> Result<Vec<LoginAttempt>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, ip_address, attempted_at, success
|
||||
FROM login_attempts
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let id: i64 = r.try_get("id")?;
|
||||
let ip_address: String = r.try_get("ip_address")?;
|
||||
let attempted_at_str: String = r.try_get("attempted_at")?;
|
||||
let success_i64: i64 = r.try_get("success")?;
|
||||
|
||||
list.push(LoginAttempt {
|
||||
id,
|
||||
ip_address,
|
||||
attempted_at: parse_datetime(&attempted_at_str)?,
|
||||
success: success_i64 != 0,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Database migration runner.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Embed migrations from the `migrations` directory.
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// Run all pending SQLite database migrations.
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
MIGRATOR
|
||||
.run(pool)
|
||||
.await
|
||||
.map_err(|e| DbError::Migration(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Database row models and conversion utilities.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use chrono::NaiveDateTime;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Parse a string into a `NaiveDateTime` supporting multiple common SQLite date formats.
|
||||
pub fn parse_datetime(s: &str) -> Result<NaiveDateTime> {
|
||||
// Try standard formats: "YYYY-MM-DD HH:MM:SS", "YYYY-MM-DDTHH:MM:SS", RFC3339
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
||||
return Ok(dt);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Ok(dt);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
|
||||
return Ok(dt);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||
return Ok(dt);
|
||||
}
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return Ok(dt.naive_utc());
|
||||
}
|
||||
NaiveDateTime::from_str(s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid datetime string '{s}': {e}")))
|
||||
}
|
||||
|
||||
/// Format a `NaiveDateTime` to standard SQLite string format: "YYYY-MM-DD HH:MM:SS".
|
||||
pub fn format_datetime(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Network repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::network::Network;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper to convert a database row into a `Network` domain struct.
|
||||
fn row_to_network(r: &sqlx::sqlite::SqliteRow) -> Result<Network> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let cidr_str: String = r.try_get("cidr")?;
|
||||
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||
let description: Option<String> = r.try_get("description")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid network UUID '{id_str}': {e}")))?;
|
||||
|
||||
let cidr = IpNet::from_str(&cidr_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid network CIDR '{cidr_str}': {e}")))?;
|
||||
|
||||
Ok(Network {
|
||||
id,
|
||||
name,
|
||||
cidr,
|
||||
enabled: enabled_i64 != 0,
|
||||
description,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new network record.
|
||||
pub async fn create_network(pool: &SqlitePool, net: &Network) -> Result<()> {
|
||||
let id_str = net.id.to_string();
|
||||
let cidr_str = net.cidr.to_string();
|
||||
let created_at_str = format_datetime(&net.created_at);
|
||||
let updated_at_str = format_datetime(&net.updated_at);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO networks (id, name, cidr, enabled, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&net.name)
|
||||
.bind(&cidr_str)
|
||||
.bind(if net.enabled { 1 } else { 0 })
|
||||
.bind(&net.description)
|
||||
.bind(&created_at_str)
|
||||
.bind(&updated_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||
DbError::Conflict(format!("Network with name '{}' already exists", net.name))
|
||||
}
|
||||
_ => DbError::Sqlx(e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a network by UUID.
|
||||
pub async fn get_network(pool: &SqlitePool, id: Uuid) -> Result<Option<Network>> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM networks WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_network(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a network by name.
|
||||
pub async fn get_network_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Network>> {
|
||||
let row = sqlx::query("SELECT * FROM networks WHERE name = ?")
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_network(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all networks.
|
||||
pub async fn list_networks(pool: &SqlitePool) -> Result<Vec<Network>> {
|
||||
let rows = sqlx::query("SELECT * FROM networks ORDER BY name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_network(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Update a network record.
|
||||
pub async fn update_network(pool: &SqlitePool, net: &Network) -> Result<()> {
|
||||
let id_str = net.id.to_string();
|
||||
let cidr_str = net.cidr.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE networks
|
||||
SET name = ?, cidr = ?, enabled = ?, description = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&net.name)
|
||||
.bind(&cidr_str)
|
||||
.bind(if net.enabled { 1 } else { 0 })
|
||||
.bind(&net.description)
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Network '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a network by UUID.
|
||||
pub async fn delete_network(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM networks WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Network '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! WireGuard Peer repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey, WireGuardPrivateKey,
|
||||
WireGuardPublicKey,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper to convert a database row into a `Peer` domain struct.
|
||||
fn row_to_peer(r: &sqlx::sqlite::SqliteRow) -> Result<Peer> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let interface_id_str: String = r.try_get("interface_id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let peer_type_str: String = r.try_get("peer_type")?;
|
||||
let state_str: String = r.try_get("state")?;
|
||||
let profile_str: String = r.try_get("profile")?;
|
||||
let public_key_str: String = r.try_get("public_key")?;
|
||||
let private_key_str: Option<String> = r.try_get("private_key")?;
|
||||
let preshared_key_str: Option<String> = r.try_get("preshared_key")?;
|
||||
let endpoint: Option<String> = r.try_get("endpoint")?;
|
||||
let allowed_ips: String = r.try_get("allowed_ips")?;
|
||||
let server_allowed_ips: Option<String> = r.try_get("server_allowed_ips")?;
|
||||
let address_ipv4_str: Option<String> = r.try_get("address_ipv4")?;
|
||||
let address_ipv6_str: Option<String> = r.try_get("address_ipv6")?;
|
||||
let dns: Option<String> = r.try_get("dns")?;
|
||||
let mtu_i64: Option<i64> = r.try_get("mtu")?;
|
||||
let persistent_keepalive_i64: Option<i64> = r.try_get("persistent_keepalive")?;
|
||||
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||
let last_handshake_at_str: Option<String> = r.try_get("last_handshake_at")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid peer UUID '{id_str}': {e}")))?;
|
||||
|
||||
let interface_id = Uuid::parse_str(&interface_id_str).map_err(|e| {
|
||||
DbError::Validation(format!("invalid interface UUID '{interface_id_str}': {e}"))
|
||||
})?;
|
||||
|
||||
let peer_type = PeerType::from_str(&peer_type_str)?;
|
||||
let state = PeerState::from_str(&state_str)?;
|
||||
let profile = PeerProfile::from_str(&profile_str)?;
|
||||
|
||||
let address_v4 =
|
||||
match address_ipv4_str {
|
||||
Some(s) => Some(IpNet::from_str(&s).map_err(|e| {
|
||||
DbError::Validation(format!("invalid peer address_ipv4 '{s}': {e}"))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let address_v6 =
|
||||
match address_ipv6_str {
|
||||
Some(s) => Some(IpNet::from_str(&s).map_err(|e| {
|
||||
DbError::Validation(format!("invalid peer address_ipv6 '{s}': {e}"))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let expires_at = match expires_at_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let last_handshake_at = match last_handshake_at_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Peer {
|
||||
id,
|
||||
interface_id,
|
||||
name,
|
||||
peer_type,
|
||||
state,
|
||||
public_key: WireGuardPublicKey::new(public_key_str),
|
||||
private_key: private_key_str.map(WireGuardPrivateKey::new),
|
||||
preshared_key: preshared_key_str.map(WireGuardPresharedKey::new),
|
||||
endpoint,
|
||||
allowed_ips,
|
||||
server_allowed_ips,
|
||||
address_v4,
|
||||
address_v6,
|
||||
dns,
|
||||
mtu: mtu_i64.map(|m| m as u16),
|
||||
persistent_keepalive: persistent_keepalive_i64.map(|k| k as u16),
|
||||
profile,
|
||||
expires_at,
|
||||
last_handshake_at,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new WireGuard peer record.
|
||||
pub async fn create_peer(pool: &SqlitePool, peer: &Peer) -> Result<()> {
|
||||
let id_str = peer.id.to_string();
|
||||
let interface_id_str = peer.interface_id.to_string();
|
||||
let ipv4_str = peer.address_v4.as_ref().map(|ip| ip.to_string());
|
||||
let ipv6_str = peer.address_v6.as_ref().map(|ip| ip.to_string());
|
||||
let expires_at_str = peer.expires_at.as_ref().map(format_datetime);
|
||||
let last_handshake_str = peer.last_handshake_at.as_ref().map(format_datetime);
|
||||
let created_at_str = format_datetime(&peer.created_at);
|
||||
let updated_at_str = format_datetime(&peer.updated_at);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO peers (
|
||||
id, interface_id, name, peer_type, state, profile, public_key, private_key, preshared_key,
|
||||
endpoint, allowed_ips, server_allowed_ips, address_ipv4, address_ipv6, dns, mtu,
|
||||
persistent_keepalive, expires_at, last_handshake_at, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&interface_id_str)
|
||||
.bind(&peer.name)
|
||||
.bind(peer.peer_type.as_str())
|
||||
.bind(peer.state.as_str())
|
||||
.bind(peer.profile.as_str())
|
||||
.bind(peer.public_key.as_str())
|
||||
.bind(peer.private_key.as_ref().map(|pk| pk.as_str()))
|
||||
.bind(peer.preshared_key.as_ref().map(|psk| psk.as_str()))
|
||||
.bind(&peer.endpoint)
|
||||
.bind(&peer.allowed_ips)
|
||||
.bind(&peer.server_allowed_ips)
|
||||
.bind(ipv4_str)
|
||||
.bind(ipv6_str)
|
||||
.bind(&peer.dns)
|
||||
.bind(peer.mtu.map(|m| m as i64))
|
||||
.bind(peer.persistent_keepalive.map(|k| k as i64))
|
||||
.bind(expires_at_str)
|
||||
.bind(last_handshake_str)
|
||||
.bind(&created_at_str)
|
||||
.bind(&updated_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||
DbError::Conflict(format!("Peer with name '{}' or public key already exists for this interface", peer.name))
|
||||
}
|
||||
sqlx::Error::Database(dbe) if dbe.is_foreign_key_violation() => {
|
||||
DbError::ConstraintViolation(format!("Referenced interface '{}' does not exist", peer.interface_id))
|
||||
}
|
||||
_ => DbError::Sqlx(e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a peer by its UUID.
|
||||
pub async fn get_peer(pool: &SqlitePool, id: Uuid) -> Result<Option<Peer>> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM peers WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_peer(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a peer by name within an interface.
|
||||
pub async fn get_peer_by_name(
|
||||
pool: &SqlitePool,
|
||||
interface_id: Uuid,
|
||||
name: &str,
|
||||
) -> Result<Option<Peer>> {
|
||||
let iface_id_str = interface_id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM peers WHERE interface_id = ? AND name = ?")
|
||||
.bind(&iface_id_str)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_peer(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a peer by public key within an interface.
|
||||
pub async fn get_peer_by_public_key(
|
||||
pool: &SqlitePool,
|
||||
interface_id: Uuid,
|
||||
public_key: &str,
|
||||
) -> Result<Option<Peer>> {
|
||||
let iface_id_str = interface_id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM peers WHERE interface_id = ? AND public_key = ?")
|
||||
.bind(&iface_id_str)
|
||||
.bind(public_key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_peer(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all peers for a given interface.
|
||||
pub async fn list_peers_for_interface(pool: &SqlitePool, interface_id: Uuid) -> Result<Vec<Peer>> {
|
||||
let iface_id_str = interface_id.to_string();
|
||||
let rows = sqlx::query("SELECT * FROM peers WHERE interface_id = ? ORDER BY name ASC")
|
||||
.bind(&iface_id_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_peer(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// List all peers across all interfaces.
|
||||
pub async fn list_all_peers(pool: &SqlitePool) -> Result<Vec<Peer>> {
|
||||
let rows = sqlx::query("SELECT * FROM peers ORDER BY name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_peer(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Update a peer record.
|
||||
pub async fn update_peer(pool: &SqlitePool, peer: &Peer) -> Result<()> {
|
||||
let id_str = peer.id.to_string();
|
||||
let interface_id_str = peer.interface_id.to_string();
|
||||
let ipv4_str = peer.address_v4.as_ref().map(|ip| ip.to_string());
|
||||
let ipv6_str = peer.address_v6.as_ref().map(|ip| ip.to_string());
|
||||
let expires_at_str = peer.expires_at.as_ref().map(format_datetime);
|
||||
let last_handshake_str = peer.last_handshake_at.as_ref().map(format_datetime);
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE peers
|
||||
SET interface_id = ?, name = ?, peer_type = ?, state = ?, profile = ?,
|
||||
public_key = ?, private_key = ?, preshared_key = ?, endpoint = ?,
|
||||
allowed_ips = ?, server_allowed_ips = ?, address_ipv4 = ?, address_ipv6 = ?,
|
||||
dns = ?, mtu = ?, persistent_keepalive = ?, expires_at = ?,
|
||||
last_handshake_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&interface_id_str)
|
||||
.bind(&peer.name)
|
||||
.bind(peer.peer_type.as_str())
|
||||
.bind(peer.state.as_str())
|
||||
.bind(peer.profile.as_str())
|
||||
.bind(peer.public_key.as_str())
|
||||
.bind(peer.private_key.as_ref().map(|pk| pk.as_str()))
|
||||
.bind(peer.preshared_key.as_ref().map(|psk| psk.as_str()))
|
||||
.bind(&peer.endpoint)
|
||||
.bind(&peer.allowed_ips)
|
||||
.bind(&peer.server_allowed_ips)
|
||||
.bind(ipv4_str)
|
||||
.bind(ipv6_str)
|
||||
.bind(&peer.dns)
|
||||
.bind(peer.mtu.map(|m| m as i64))
|
||||
.bind(peer.persistent_keepalive.map(|k| k as i64))
|
||||
.bind(expires_at_str)
|
||||
.bind(last_handshake_str)
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update peer state (active, disabled, revoked, expired).
|
||||
pub async fn set_peer_state(pool: &SqlitePool, id: Uuid, state: PeerState) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE peers
|
||||
SET state = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(state.as_str())
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update operational last_handshake_at timestamp.
|
||||
pub async fn update_peer_handshake(
|
||||
pool: &SqlitePool,
|
||||
id: Uuid,
|
||||
handshake_at: NaiveDateTime,
|
||||
) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let handshake_str = format_datetime(&handshake_at);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE peers
|
||||
SET last_handshake_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&handshake_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a peer by UUID.
|
||||
pub async fn delete_peer(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM peers WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Peer '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve all allocated IP addresses (CIDR strings) for an interface or across all interfaces.
|
||||
pub async fn get_allocated_ips(
|
||||
pool: &SqlitePool,
|
||||
interface_id: Option<Uuid>,
|
||||
) -> Result<Vec<String>> {
|
||||
let rows = match interface_id {
|
||||
Some(iface_id) => {
|
||||
let iface_id_str = iface_id.to_string();
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT address_ipv4, address_ipv6
|
||||
FROM peers
|
||||
WHERE interface_id = ? AND state != 'revoked'
|
||||
"#,
|
||||
)
|
||||
.bind(&iface_id_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?
|
||||
}
|
||||
None => sqlx::query(
|
||||
r#"
|
||||
SELECT address_ipv4, address_ipv6
|
||||
FROM peers
|
||||
WHERE state != 'revoked'
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?,
|
||||
};
|
||||
|
||||
let mut allocated = Vec::new();
|
||||
for r in rows {
|
||||
let v4: Option<String> = r.try_get("address_ipv4")?;
|
||||
let v6: Option<String> = r.try_get("address_ipv6")?;
|
||||
if let Some(ip) = v4.as_ref().filter(|s| !s.trim().is_empty()) {
|
||||
allocated.push(ip.clone());
|
||||
}
|
||||
if let Some(ip) = v6.as_ref().filter(|s| !s.trim().is_empty()) {
|
||||
allocated.push(ip.clone());
|
||||
}
|
||||
}
|
||||
Ok(allocated)
|
||||
}
|
||||
|
||||
/// Find active peers whose expiration timestamp has passed.
|
||||
pub async fn get_expired_active_peers(pool: &SqlitePool, now: NaiveDateTime) -> Result<Vec<Peer>> {
|
||||
let now_str = format_datetime(&now);
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT * FROM peers
|
||||
WHERE state = 'active' AND expires_at IS NOT NULL AND expires_at <= ?
|
||||
"#,
|
||||
)
|
||||
.bind(&now_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_peer(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Mark a peer as expired.
|
||||
pub async fn mark_peer_expired(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
set_peer_state(pool, id, PeerState::Expired).await
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Route repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::types::network::Route;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper to convert a database row into a `Route` domain struct.
|
||||
fn row_to_route(r: &sqlx::sqlite::SqliteRow) -> Result<Route> {
|
||||
let id_str: String = r.try_get("id")?;
|
||||
let network_id_str: Option<String> = r.try_get("network_id")?;
|
||||
let interface_id_str: Option<String> = r.try_get("interface_id")?;
|
||||
let destination_str: String = r.try_get("destination")?;
|
||||
let gateway_str: Option<String> = r.try_get("gateway")?;
|
||||
let metric_i64: Option<i64> = r.try_get("metric")?;
|
||||
let enabled_i64: i64 = r.try_get("enabled")?;
|
||||
let description: Option<String> = r.try_get("description")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let updated_at_str: String = r.try_get("updated_at")?;
|
||||
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DbError::Validation(format!("invalid route UUID '{id_str}': {e}")))?;
|
||||
|
||||
let network_id = match network_id_str {
|
||||
Some(s) => Some(
|
||||
Uuid::parse_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid network UUID '{s}': {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let interface_id = match interface_id_str {
|
||||
Some(s) => Some(
|
||||
Uuid::parse_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid interface UUID '{s}': {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let destination = IpNet::from_str(&destination_str).map_err(|e| {
|
||||
DbError::Validation(format!("invalid destination CIDR '{destination_str}': {e}"))
|
||||
})?;
|
||||
|
||||
let gateway = match gateway_str {
|
||||
Some(s) => Some(
|
||||
IpAddr::from_str(&s)
|
||||
.map_err(|e| DbError::Validation(format!("invalid gateway IP '{s}': {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Route {
|
||||
id,
|
||||
network_id,
|
||||
interface_id,
|
||||
destination,
|
||||
gateway,
|
||||
interface_name: None,
|
||||
metric: metric_i64.map(|m| m as u32),
|
||||
enabled: enabled_i64 != 0,
|
||||
description,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
updated_at: parse_datetime(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new route record.
|
||||
pub async fn create_route(pool: &SqlitePool, route: &Route) -> Result<()> {
|
||||
let id_str = route.id.to_string();
|
||||
let network_id_str = route.network_id.map(|id| id.to_string());
|
||||
let interface_id_str = route.interface_id.map(|id| id.to_string());
|
||||
let dest_str = route.destination.to_string();
|
||||
let gateway_str = route.gateway.map(|g| g.to_string());
|
||||
let created_at_str = format_datetime(&route.created_at);
|
||||
let updated_at_str = format_datetime(&route.updated_at);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO routes (
|
||||
id, network_id, interface_id, destination, gateway,
|
||||
metric, enabled, description, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(network_id_str)
|
||||
.bind(interface_id_str)
|
||||
.bind(&dest_str)
|
||||
.bind(gateway_str)
|
||||
.bind(route.metric.map(|m| m as i64))
|
||||
.bind(if route.enabled { 1 } else { 0 })
|
||||
.bind(&route.description)
|
||||
.bind(&created_at_str)
|
||||
.bind(&updated_at_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a route by UUID.
|
||||
pub async fn get_route(pool: &SqlitePool, id: Uuid) -> Result<Option<Route>> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query("SELECT * FROM routes WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(row_to_route(&r)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all routes.
|
||||
pub async fn list_routes(pool: &SqlitePool) -> Result<Vec<Route>> {
|
||||
let rows = sqlx::query("SELECT * FROM routes ORDER BY destination ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_route(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// List routes referencing a given network.
|
||||
pub async fn list_routes_for_network(pool: &SqlitePool, network_id: Uuid) -> Result<Vec<Route>> {
|
||||
let net_id_str = network_id.to_string();
|
||||
let rows = sqlx::query("SELECT * FROM routes WHERE network_id = ? ORDER BY destination ASC")
|
||||
.bind(&net_id_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_route(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// List routes referencing a given interface.
|
||||
pub async fn list_routes_for_interface(
|
||||
pool: &SqlitePool,
|
||||
interface_id: Uuid,
|
||||
) -> Result<Vec<Route>> {
|
||||
let iface_id_str = interface_id.to_string();
|
||||
let rows = sqlx::query("SELECT * FROM routes WHERE interface_id = ? ORDER BY destination ASC")
|
||||
.bind(&iface_id_str)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut list = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
list.push(row_to_route(&r)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Update a route record.
|
||||
pub async fn update_route(pool: &SqlitePool, route: &Route) -> Result<()> {
|
||||
let id_str = route.id.to_string();
|
||||
let network_id_str = route.network_id.map(|id| id.to_string());
|
||||
let interface_id_str = route.interface_id.map(|id| id.to_string());
|
||||
let dest_str = route.destination.to_string();
|
||||
let gateway_str = route.gateway.map(|g| g.to_string());
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE routes
|
||||
SET network_id = ?, interface_id = ?, destination = ?, gateway = ?,
|
||||
metric = ?, enabled = ?, description = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(network_id_str)
|
||||
.bind(interface_id_str)
|
||||
.bind(&dest_str)
|
||||
.bind(gateway_str)
|
||||
.bind(route.metric.map(|m| m as i64))
|
||||
.bind(if route.enabled { 1 } else { 0 })
|
||||
.bind(&route.description)
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Route '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a route by UUID.
|
||||
pub async fn delete_route(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM routes WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Route '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable or disable a route.
|
||||
pub async fn set_route_enabled(pool: &SqlitePool, id: Uuid, enabled: bool) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE routes
|
||||
SET enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(if enabled { 1 } else { 0 })
|
||||
.bind(&now_str)
|
||||
.bind(&id_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Route '{id_str}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Session repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::auth::Session;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
/// Create a new session.
|
||||
pub async fn create_session(pool: &SqlitePool, session: &Session) -> Result<()> {
|
||||
let created_at_str = format_datetime(&session.created_at);
|
||||
let expires_at_str = format_datetime(&session.expires_at);
|
||||
let last_seen_str = session.last_seen_at.as_ref().map(format_datetime);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sessions (id, admin_id, ip_address, user_agent, created_at, expires_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&session.id)
|
||||
.bind(session.admin_id)
|
||||
.bind(&session.ip_address)
|
||||
.bind(&session.user_agent)
|
||||
.bind(&created_at_str)
|
||||
.bind(&expires_at_str)
|
||||
.bind(last_seen_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a session by its ID.
|
||||
pub async fn get_session(pool: &SqlitePool, id: &str) -> Result<Option<Session>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, admin_id, ip_address, user_agent, created_at, expires_at, last_seen_at
|
||||
FROM sessions
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let id: String = r.try_get("id")?;
|
||||
let admin_id: i64 = r.try_get("admin_id")?;
|
||||
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||
let user_agent: Option<String> = r.try_get("user_agent")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let expires_at_str: String = r.try_get("expires_at")?;
|
||||
let last_seen_str: Option<String> = r.try_get("last_seen_at")?;
|
||||
|
||||
let last_seen_at = match last_seen_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Some(Session {
|
||||
id,
|
||||
admin_id,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
expires_at: parse_datetime(&expires_at_str)?,
|
||||
last_seen_at,
|
||||
ip_address,
|
||||
user_agent,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch a session by updating its `last_seen_at` to the current time.
|
||||
pub async fn touch_session(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE sessions
|
||||
SET last_seen_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&now_str)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("Session '{id}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a session by ID (logout).
|
||||
pub async fn delete_session(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all expired sessions. Returns the count of deleted sessions.
|
||||
pub async fn delete_expired_sessions(pool: &SqlitePool) -> Result<u64> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Delete all sessions for the given administrator (e.g. after password reset).
|
||||
pub async fn delete_all_admin_sessions(pool: &SqlitePool, admin_id: i64) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE admin_id = ?")
|
||||
.bind(admin_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List all active and unexpired sessions.
|
||||
pub async fn list_sessions(pool: &SqlitePool) -> Result<Vec<Session>> {
|
||||
let rows = sqlx::query("SELECT id, admin_id, ip_address, user_agent, created_at, expires_at, last_seen_at FROM sessions ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut sessions = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let id: String = r.try_get("id")?;
|
||||
let admin_id: i64 = r.try_get("admin_id")?;
|
||||
let ip_address: Option<String> = r.try_get("ip_address")?;
|
||||
let user_agent: Option<String> = r.try_get("user_agent")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let expires_at_str: String = r.try_get("expires_at")?;
|
||||
let last_seen_str: Option<String> = r.try_get("last_seen_at")?;
|
||||
|
||||
let last_seen_at = match last_seen_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
sessions.push(Session {
|
||||
id,
|
||||
admin_id,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
expires_at: parse_datetime(&expires_at_str)?,
|
||||
last_seen_at,
|
||||
ip_address,
|
||||
user_agent,
|
||||
});
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Delete all sessions unconditionally.
|
||||
pub async fn delete_all_sessions(pool: &SqlitePool) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM sessions")
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Settings repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::settings::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)
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
//! Central database `Store` providing connection lifecycle, migrations, and repository access.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::migrations::run_migrations;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Central database store handle wrapping the SQLite connection pool.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Store {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Connect to a SQLite database by path, ensuring directory creation and setting WAL/foreign keys.
|
||||
pub async fn connect_path<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty() && !p.exists())
|
||||
{
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
DbError::Internal(format!(
|
||||
"failed to create database parent directory '{}': {e}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let opts = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_millis(5000));
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(1)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Connect to a SQLite database using a connection string URL (e.g. `sqlite:///var/lib/nx9-wg/nx9-wg.db`).
|
||||
pub async fn connect(database_url: &str) -> Result<Self> {
|
||||
let opts = SqliteConnectOptions::from_str(database_url)
|
||||
.map_err(|e| {
|
||||
DbError::Validation(format!("invalid database URL '{database_url}': {e}"))
|
||||
})?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_millis(5000));
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(1)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Create an in-memory SQLite database store (useful for tests).
|
||||
pub async fn connect_in_memory() -> Result<Self> {
|
||||
let opts = SqliteConnectOptions::new()
|
||||
.filename(":memory:")
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_millis(5000));
|
||||
|
||||
// In-memory SQLite databases require max_connections=1 so the same DB is shared across queries
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Run all pending SQLx migrations.
|
||||
pub async fn migrate(&self) -> Result<()> {
|
||||
run_migrations(&self.pool).await
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying `SqlitePool`.
|
||||
pub fn pool(&self) -> &SqlitePool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Check database connectivity with a simple SELECT 1 query.
|
||||
pub async fn health_check(&self) -> Result<()> {
|
||||
sqlx::query("SELECT 1")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the connection pool gracefully.
|
||||
pub async fn close(&self) {
|
||||
self.pool.close().await;
|
||||
}
|
||||
|
||||
// ── Repository convenience accessors ───────────────────────────────
|
||||
|
||||
// Administrator
|
||||
pub async fn get_admin(&self) -> Result<Option<nx9_wg_core::types::auth::Admin>> {
|
||||
crate::admin::get_admin(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn get_admin_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::auth::Admin>> {
|
||||
crate::admin::get_admin_by_username(&self.pool, username).await
|
||||
}
|
||||
|
||||
pub async fn admin_exists(&self) -> Result<bool> {
|
||||
crate::admin::admin_exists(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn create_admin(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<nx9_wg_core::types::auth::Admin> {
|
||||
crate::admin::create_admin(&self.pool, username, password_hash).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_password(&self, new_password_hash: &str) -> Result<()> {
|
||||
crate::admin::update_admin_password(&self.pool, new_password_hash).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_totp(&self, secret: Option<&str>, enabled: bool) -> Result<()> {
|
||||
crate::admin::update_admin_totp(&self.pool, secret, enabled).await
|
||||
}
|
||||
|
||||
pub async fn record_admin_login(&self, ip_address: Option<&str>) -> Result<()> {
|
||||
crate::admin::record_admin_login(&self.pool, ip_address).await
|
||||
}
|
||||
|
||||
// Sessions
|
||||
pub async fn create_session(&self, session: &nx9_wg_core::types::auth::Session) -> Result<()> {
|
||||
crate::sessions::create_session(&self.pool, session).await
|
||||
}
|
||||
|
||||
pub async fn get_session(&self, id: &str) -> Result<Option<nx9_wg_core::types::auth::Session>> {
|
||||
crate::sessions::get_session(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn touch_session(&self, id: &str) -> Result<()> {
|
||||
crate::sessions::touch_session(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_session(&self, id: &str) -> Result<()> {
|
||||
crate::sessions::delete_session(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_expired_sessions(&self) -> Result<u64> {
|
||||
crate::sessions::delete_expired_sessions(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn delete_all_admin_sessions(&self, admin_id: i64) -> Result<u64> {
|
||||
crate::sessions::delete_all_admin_sessions(&self.pool, admin_id).await
|
||||
}
|
||||
|
||||
pub async fn list_sessions(&self) -> Result<Vec<nx9_wg_core::types::auth::Session>> {
|
||||
crate::sessions::list_sessions(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn delete_all_sessions(&self) -> Result<u64> {
|
||||
crate::sessions::delete_all_sessions(&self.pool).await
|
||||
}
|
||||
|
||||
// Login Attempts (Rate Limiting)
|
||||
pub async fn record_login_attempt(&self, ip_address: &str, success: bool) -> Result<i64> {
|
||||
crate::login_attempts::record_login_attempt(&self.pool, ip_address, success).await
|
||||
}
|
||||
|
||||
pub async fn count_recent_failed_attempts(
|
||||
&self,
|
||||
ip_address: &str,
|
||||
window_minutes: i64,
|
||||
) -> Result<i64> {
|
||||
crate::login_attempts::count_recent_failed_attempts(&self.pool, ip_address, window_minutes)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn clear_login_attempts(&self, ip_address: &str) -> Result<u64> {
|
||||
crate::login_attempts::clear_login_attempts(&self.pool, ip_address).await
|
||||
}
|
||||
|
||||
pub async fn purge_old_login_attempts(&self, retention_hours: i64) -> Result<u64> {
|
||||
crate::login_attempts::purge_old_login_attempts(&self.pool, retention_hours).await
|
||||
}
|
||||
|
||||
pub async fn list_recent_login_attempts(
|
||||
&self,
|
||||
limit: u32,
|
||||
) -> Result<Vec<nx9_wg_core::types::auth::LoginAttempt>> {
|
||||
crate::login_attempts::list_recent_login_attempts(&self.pool, limit).await
|
||||
}
|
||||
|
||||
// API Tokens
|
||||
pub async fn create_token(&self, token: &nx9_wg_core::types::auth::ApiToken) -> Result<()> {
|
||||
crate::tokens::create_token(&self.pool, token).await
|
||||
}
|
||||
|
||||
pub async fn list_tokens(&self) -> Result<Vec<nx9_wg_core::types::auth::ApiToken>> {
|
||||
crate::tokens::list_tokens(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn get_token(&self, id: &str) -> Result<Option<nx9_wg_core::types::auth::ApiToken>> {
|
||||
crate::tokens::get_token(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn find_token_by_hash(
|
||||
&self,
|
||||
hash: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::auth::ApiToken>> {
|
||||
crate::tokens::find_token_by_hash(&self.pool, hash).await
|
||||
}
|
||||
|
||||
pub async fn mark_token_used(&self, id: &str) -> Result<()> {
|
||||
crate::tokens::mark_token_used(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn revoke_token(&self, id: &str) -> Result<()> {
|
||||
crate::tokens::revoke_token(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_token(&self, id: &str) -> Result<()> {
|
||||
crate::tokens::delete_token(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_expired_tokens(&self) -> Result<u64> {
|
||||
crate::tokens::delete_expired_tokens(&self.pool).await
|
||||
}
|
||||
|
||||
// Interfaces
|
||||
pub async fn create_interface(
|
||||
&self,
|
||||
iface: &nx9_wg_core::types::wireguard::Interface,
|
||||
) -> Result<()> {
|
||||
crate::interfaces::create_interface(&self.pool, iface).await
|
||||
}
|
||||
|
||||
pub async fn get_interface(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<nx9_wg_core::types::wireguard::Interface>> {
|
||||
crate::interfaces::get_interface(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn get_interface_by_name(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::wireguard::Interface>> {
|
||||
crate::interfaces::get_interface_by_name(&self.pool, name).await
|
||||
}
|
||||
|
||||
pub async fn list_interfaces(&self) -> Result<Vec<nx9_wg_core::types::wireguard::Interface>> {
|
||||
crate::interfaces::list_interfaces(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn update_interface(
|
||||
&self,
|
||||
iface: &nx9_wg_core::types::wireguard::Interface,
|
||||
) -> Result<()> {
|
||||
crate::interfaces::update_interface(&self.pool, iface).await
|
||||
}
|
||||
|
||||
pub async fn delete_interface(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::interfaces::delete_interface(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn set_interface_enabled(&self, id: uuid::Uuid, enabled: bool) -> Result<()> {
|
||||
crate::interfaces::set_interface_enabled(&self.pool, id, enabled).await
|
||||
}
|
||||
|
||||
// Peers
|
||||
pub async fn create_peer(&self, peer: &nx9_wg_core::types::wireguard::Peer) -> Result<()> {
|
||||
crate::peers::create_peer(&self.pool, peer).await
|
||||
}
|
||||
|
||||
pub async fn get_peer(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<nx9_wg_core::types::wireguard::Peer>> {
|
||||
crate::peers::get_peer(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn get_peer_by_name(
|
||||
&self,
|
||||
iface_id: uuid::Uuid,
|
||||
name: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::wireguard::Peer>> {
|
||||
crate::peers::get_peer_by_name(&self.pool, iface_id, name).await
|
||||
}
|
||||
|
||||
pub async fn get_peer_by_public_key(
|
||||
&self,
|
||||
iface_id: uuid::Uuid,
|
||||
pub_key: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::wireguard::Peer>> {
|
||||
crate::peers::get_peer_by_public_key(&self.pool, iface_id, pub_key).await
|
||||
}
|
||||
|
||||
pub async fn list_peers_for_interface(
|
||||
&self,
|
||||
iface_id: uuid::Uuid,
|
||||
) -> Result<Vec<nx9_wg_core::types::wireguard::Peer>> {
|
||||
crate::peers::list_peers_for_interface(&self.pool, iface_id).await
|
||||
}
|
||||
|
||||
pub async fn list_all_peers(&self) -> Result<Vec<nx9_wg_core::types::wireguard::Peer>> {
|
||||
crate::peers::list_all_peers(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn update_peer(&self, peer: &nx9_wg_core::types::wireguard::Peer) -> Result<()> {
|
||||
crate::peers::update_peer(&self.pool, peer).await
|
||||
}
|
||||
|
||||
pub async fn set_peer_state(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
state: nx9_wg_core::types::wireguard::PeerState,
|
||||
) -> Result<()> {
|
||||
crate::peers::set_peer_state(&self.pool, id, state).await
|
||||
}
|
||||
|
||||
pub async fn update_peer_handshake(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
handshake_at: chrono::NaiveDateTime,
|
||||
) -> Result<()> {
|
||||
crate::peers::update_peer_handshake(&self.pool, id, handshake_at).await
|
||||
}
|
||||
|
||||
pub async fn delete_peer(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::peers::delete_peer(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn get_allocated_ips(&self, interface_id: Option<uuid::Uuid>) -> Result<Vec<String>> {
|
||||
crate::peers::get_allocated_ips(&self.pool, interface_id).await
|
||||
}
|
||||
|
||||
pub async fn get_expired_active_peers(
|
||||
&self,
|
||||
now: chrono::NaiveDateTime,
|
||||
) -> Result<Vec<nx9_wg_core::types::wireguard::Peer>> {
|
||||
crate::peers::get_expired_active_peers(&self.pool, now).await
|
||||
}
|
||||
|
||||
pub async fn mark_peer_expired(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::peers::mark_peer_expired(&self.pool, id).await
|
||||
}
|
||||
|
||||
// Networks
|
||||
pub async fn create_network(&self, net: &nx9_wg_core::types::network::Network) -> Result<()> {
|
||||
crate::networks::create_network(&self.pool, net).await
|
||||
}
|
||||
|
||||
pub async fn get_network(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<nx9_wg_core::types::network::Network>> {
|
||||
crate::networks::get_network(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn get_network_by_name(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::network::Network>> {
|
||||
crate::networks::get_network_by_name(&self.pool, name).await
|
||||
}
|
||||
|
||||
pub async fn list_networks(&self) -> Result<Vec<nx9_wg_core::types::network::Network>> {
|
||||
crate::networks::list_networks(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn update_network(&self, net: &nx9_wg_core::types::network::Network) -> Result<()> {
|
||||
crate::networks::update_network(&self.pool, net).await
|
||||
}
|
||||
|
||||
pub async fn delete_network(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::networks::delete_network(&self.pool, id).await
|
||||
}
|
||||
|
||||
// Routes
|
||||
pub async fn create_route(&self, route: &nx9_wg_core::types::network::Route) -> Result<()> {
|
||||
crate::routes::create_route(&self.pool, route).await
|
||||
}
|
||||
|
||||
pub async fn get_route(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<nx9_wg_core::types::network::Route>> {
|
||||
crate::routes::get_route(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn list_routes(&self) -> Result<Vec<nx9_wg_core::types::network::Route>> {
|
||||
crate::routes::list_routes(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn list_routes_for_network(
|
||||
&self,
|
||||
network_id: uuid::Uuid,
|
||||
) -> Result<Vec<nx9_wg_core::types::network::Route>> {
|
||||
crate::routes::list_routes_for_network(&self.pool, network_id).await
|
||||
}
|
||||
|
||||
pub async fn list_routes_for_interface(
|
||||
&self,
|
||||
interface_id: uuid::Uuid,
|
||||
) -> Result<Vec<nx9_wg_core::types::network::Route>> {
|
||||
crate::routes::list_routes_for_interface(&self.pool, interface_id).await
|
||||
}
|
||||
|
||||
pub async fn update_route(&self, route: &nx9_wg_core::types::network::Route) -> Result<()> {
|
||||
crate::routes::update_route(&self.pool, route).await
|
||||
}
|
||||
|
||||
pub async fn delete_route(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::routes::delete_route(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn set_route_enabled(&self, id: uuid::Uuid, enabled: bool) -> Result<()> {
|
||||
crate::routes::set_route_enabled(&self.pool, id, enabled).await
|
||||
}
|
||||
|
||||
// Firewall
|
||||
pub async fn create_firewall_rule(
|
||||
&self,
|
||||
rule: &nx9_wg_core::types::firewall::FirewallRule,
|
||||
) -> Result<()> {
|
||||
crate::firewall::create_rule(&self.pool, rule).await
|
||||
}
|
||||
|
||||
pub async fn get_firewall_rule(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||
crate::firewall::get_rule(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn list_firewall_rules(
|
||||
&self,
|
||||
) -> Result<Vec<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||
crate::firewall::list_rules(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn list_firewall_rules_for_interface(
|
||||
&self,
|
||||
interface_id: uuid::Uuid,
|
||||
) -> Result<Vec<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||
crate::firewall::list_rules_for_interface(&self.pool, interface_id).await
|
||||
}
|
||||
|
||||
pub async fn list_firewall_rules_for_peer(
|
||||
&self,
|
||||
peer_id: uuid::Uuid,
|
||||
) -> Result<Vec<nx9_wg_core::types::firewall::FirewallRule>> {
|
||||
crate::firewall::list_rules_for_peer(&self.pool, peer_id).await
|
||||
}
|
||||
|
||||
pub async fn update_firewall_rule(
|
||||
&self,
|
||||
rule: &nx9_wg_core::types::firewall::FirewallRule,
|
||||
) -> Result<()> {
|
||||
crate::firewall::update_rule(&self.pool, rule).await
|
||||
}
|
||||
|
||||
pub async fn delete_firewall_rule(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::firewall::delete_rule(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn set_firewall_rule_enabled(&self, id: uuid::Uuid, enabled: bool) -> Result<()> {
|
||||
crate::firewall::set_rule_enabled(&self.pool, id, enabled).await
|
||||
}
|
||||
|
||||
// Settings
|
||||
pub async fn get_setting(
|
||||
&self,
|
||||
key: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::settings::Setting>> {
|
||||
crate::settings::get_setting(&self.pool, key).await
|
||||
}
|
||||
|
||||
pub async fn get_setting_value(&self, key: &str) -> Result<Option<String>> {
|
||||
crate::settings::get_setting_value(&self.pool, key).await
|
||||
}
|
||||
|
||||
pub async fn set_setting(&self, key: &str, value: &str, is_secret: bool) -> Result<()> {
|
||||
crate::settings::set_setting(&self.pool, key, value, is_secret).await
|
||||
}
|
||||
|
||||
pub async fn delete_setting(&self, key: &str) -> Result<()> {
|
||||
crate::settings::delete_setting(&self.pool, key).await
|
||||
}
|
||||
|
||||
pub async fn list_settings(&self) -> Result<Vec<nx9_wg_core::types::settings::Setting>> {
|
||||
crate::settings::list_settings(&self.pool).await
|
||||
}
|
||||
|
||||
// Audit
|
||||
pub async fn create_audit_event(
|
||||
&self,
|
||||
event: &nx9_wg_core::types::audit::AuditEvent,
|
||||
) -> Result<i64> {
|
||||
crate::audit::create_audit_event(&self.pool, event).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn record_audit(
|
||||
&self,
|
||||
event_type: nx9_wg_core::types::audit::AuditEventType,
|
||||
actor: &str,
|
||||
resource_type: Option<&str>,
|
||||
resource_id: Option<&str>,
|
||||
message: Option<&str>,
|
||||
metadata: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
crate::audit::record_audit(
|
||||
&self.pool,
|
||||
event_type,
|
||||
actor,
|
||||
resource_type,
|
||||
resource_id,
|
||||
message,
|
||||
metadata,
|
||||
ip_address,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_audit_events(
|
||||
&self,
|
||||
filter: &crate::audit::AuditFilter,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<nx9_wg_core::types::audit::AuditEvent>> {
|
||||
crate::audit::list_audit_events(&self.pool, filter, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn get_audit_event(
|
||||
&self,
|
||||
id: i64,
|
||||
) -> Result<Option<nx9_wg_core::types::audit::AuditEvent>> {
|
||||
crate::audit::get_audit_event(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn count_audit_events(&self, filter: &crate::audit::AuditFilter) -> Result<i64> {
|
||||
crate::audit::count_audit_events(&self.pool, filter).await
|
||||
}
|
||||
|
||||
// Backups
|
||||
pub async fn create_backup_meta(
|
||||
&self,
|
||||
meta: &nx9_wg_core::types::backup::BackupMeta,
|
||||
) -> Result<()> {
|
||||
crate::backups::create_backup_meta(&self.pool, meta).await
|
||||
}
|
||||
|
||||
pub async fn get_backup_meta(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<nx9_wg_core::types::backup::BackupMeta>> {
|
||||
crate::backups::get_backup_meta(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn list_backups(&self) -> Result<Vec<nx9_wg_core::types::backup::BackupMeta>> {
|
||||
crate::backups::list_backups(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn delete_backup_meta(&self, id: uuid::Uuid) -> Result<()> {
|
||||
crate::backups::delete_backup_meta(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn vacuum_into(&self, target_file_path: &str) -> Result<()> {
|
||||
crate::backups::vacuum_into(&self.pool, target_file_path).await
|
||||
}
|
||||
|
||||
// Client Profiles
|
||||
pub async fn create_client_profile(
|
||||
&self,
|
||||
profile: &nx9_wg_core::types::client_profile::ClientProfile,
|
||||
) -> Result<()> {
|
||||
crate::client_profiles::create_client_profile(&self.pool, profile).await
|
||||
}
|
||||
|
||||
pub async fn get_client_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Option<nx9_wg_core::types::client_profile::ClientProfile>> {
|
||||
crate::client_profiles::get_client_profile(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn list_client_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<nx9_wg_core::types::client_profile::ClientProfile>> {
|
||||
crate::client_profiles::list_client_profiles(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn update_client_profile(
|
||||
&self,
|
||||
profile: &nx9_wg_core::types::client_profile::ClientProfile,
|
||||
) -> Result<()> {
|
||||
crate::client_profiles::update_client_profile(&self.pool, profile).await
|
||||
}
|
||||
|
||||
pub async fn delete_client_profile(&self, id: &str) -> Result<()> {
|
||||
crate::client_profiles::delete_client_profile(&self.pool, id).await
|
||||
}
|
||||
|
||||
pub async fn list_distinct_providers(&self) -> Result<Vec<String>> {
|
||||
crate::client_profiles::list_distinct_providers(&self.pool).await
|
||||
}
|
||||
|
||||
pub async fn find_matching_client_profiles(
|
||||
&self,
|
||||
provider: Option<&str>,
|
||||
device: Option<nx9_wg_core::types::client_profile::DeviceCategory>,
|
||||
connection: Option<nx9_wg_core::types::client_profile::ConnectionType>,
|
||||
nat: Option<nx9_wg_core::types::client_profile::NatType>,
|
||||
) -> Result<Vec<nx9_wg_core::types::client_profile::ClientProfile>> {
|
||||
crate::client_profiles::find_matching_profiles(
|
||||
&self.pool, provider, device, connection, nat,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! API Token repository operations.
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::models::{format_datetime, parse_datetime};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::auth::ApiToken;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
/// Create a new API token record. Only the token hash is stored.
|
||||
pub async fn create_token(pool: &SqlitePool, token: &ApiToken) -> Result<()> {
|
||||
let created_at_str = format_datetime(&token.created_at);
|
||||
let expires_at_str = token.expires_at.as_ref().map(format_datetime);
|
||||
let last_used_str = token.last_used_at.as_ref().map(format_datetime);
|
||||
let revoked_str = token.revoked_at.as_ref().map(format_datetime);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_tokens (id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&token.id)
|
||||
.bind(token.admin_id)
|
||||
.bind(&token.name)
|
||||
.bind(&token.token_hash)
|
||||
.bind(&created_at_str)
|
||||
.bind(expires_at_str)
|
||||
.bind(last_used_str)
|
||||
.bind(revoked_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(dbe) if dbe.is_unique_violation() => {
|
||||
DbError::Conflict("API token with this hash already exists".to_string())
|
||||
}
|
||||
_ => DbError::Sqlx(e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve all API tokens.
|
||||
pub async fn list_tokens(pool: &SqlitePool) -> Result<Vec<ApiToken>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
let mut tokens = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let id: String = r.try_get("id")?;
|
||||
let admin_id: i64 = r.try_get("admin_id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let token_hash: String = r.try_get("token_hash")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||
let last_used_str: Option<String> = r.try_get("last_used_at")?;
|
||||
let revoked_str: Option<String> = r.try_get("revoked_at")?;
|
||||
|
||||
let expires_at = match expires_at_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let last_used_at = match last_used_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let revoked_at = match revoked_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
tokens.push(ApiToken {
|
||||
id,
|
||||
admin_id,
|
||||
name,
|
||||
token_hash,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
revoked: revoked_at.is_some(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// Retrieve an API token by ID.
|
||||
pub async fn get_token(pool: &SqlitePool, id: &str) -> Result<Option<ApiToken>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let id: String = r.try_get("id")?;
|
||||
let admin_id: i64 = r.try_get("admin_id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let token_hash: String = r.try_get("token_hash")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||
let last_used_str: Option<String> = r.try_get("last_used_at")?;
|
||||
let revoked_str: Option<String> = r.try_get("revoked_at")?;
|
||||
|
||||
let expires_at = match expires_at_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let last_used_at = match last_used_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let revoked_at = match revoked_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Some(ApiToken {
|
||||
id,
|
||||
admin_id,
|
||||
name,
|
||||
token_hash,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
revoked: revoked_at.is_some(),
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up an active (non-revoked) API token by its SHA-256 hash.
|
||||
pub async fn find_token_by_hash(pool: &SqlitePool, token_hash: &str) -> Result<Option<ApiToken>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, admin_id, name, token_hash, created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE token_hash = ?
|
||||
"#,
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let id: String = r.try_get("id")?;
|
||||
let admin_id: i64 = r.try_get("admin_id")?;
|
||||
let name: String = r.try_get("name")?;
|
||||
let token_hash: String = r.try_get("token_hash")?;
|
||||
let created_at_str: String = r.try_get("created_at")?;
|
||||
let expires_at_str: Option<String> = r.try_get("expires_at")?;
|
||||
let last_used_str: Option<String> = r.try_get("last_used_at")?;
|
||||
let revoked_str: Option<String> = r.try_get("revoked_at")?;
|
||||
|
||||
let expires_at = match expires_at_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let last_used_at = match last_used_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let revoked_at = match revoked_str {
|
||||
Some(s) => Some(parse_datetime(&s)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Some(ApiToken {
|
||||
id,
|
||||
admin_id,
|
||||
name,
|
||||
token_hash,
|
||||
created_at: parse_datetime(&created_at_str)?,
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
revoked: revoked_at.is_some(),
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a token as used at current timestamp.
|
||||
pub async fn mark_token_used(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET last_used_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&now_str)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!("API token '{id}' not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke an API token.
|
||||
pub async fn revoke_token(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET revoked_at = ?
|
||||
WHERE id = ? AND revoked_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(&now_str)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"API token '{id}' not found or already revoked"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an API token.
|
||||
pub async fn delete_token(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM api_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete expired tokens. Returns count deleted.
|
||||
pub async fn delete_expired_tokens(pool: &SqlitePool) -> Result<u64> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_str = format_datetime(&now);
|
||||
|
||||
let result =
|
||||
sqlx::query("DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at < ?")
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(DbError::Sqlx)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
Reference in new issue
Block a user