Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,50 @@ impl AppConfig {
}
Ok(())
}

pub async fn validate_connectivity(&self) -> Result<(), String> {
let url = format!("{}/{}", self.stellar_horizon_url.trim_end_matches('/'), "health");
match reqwest::Client::new()
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(resp) if resp.status().is_success() => Ok(()),
Ok(resp) => Err(format!("Horizon returned status {}", resp.status())),
Err(e) => Err(format!("Horizon unreachable: {}", e)),
}
}
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConfigAuditEntry {
pub timestamp: String,
pub old_version: u32,
pub new_version: u32,
pub actor: String,
pub success: bool,
pub error: Option<String>,
}

pub struct ConfigAuditLog {
entries: std::sync::Mutex<Vec<ConfigAuditEntry>>,
}

impl ConfigAuditLog {
pub fn new() -> Self {
Self { entries: std::sync::Mutex::new(Vec::new()) }
}

pub fn record(&self, entry: ConfigAuditEntry) {
if let Ok(mut entries) = self.entries.lock() {
entries.push(entry);
}
}

pub fn entries(&self) -> Vec<ConfigAuditEntry> {
self.entries.lock().map(|e| e.clone()).unwrap_or_default()
}
}

#[cfg(test)]
Expand Down Expand Up @@ -1780,4 +1824,26 @@ mod tests {
fn config_version_constant_is_consistent() {
assert_eq!(CONFIG_VERSION, 1);
}

#[test]
fn config_audit_log_records_entries() {
let log = ConfigAuditLog::new();
log.record(ConfigAuditEntry {
timestamp: "2025-01-01T00:00:00Z".to_string(),
old_version: 1,
new_version: 2,
actor: "admin".to_string(),
success: true,
error: None,
});
let entries = log.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].new_version, 2);
}

#[test]
fn config_audit_log_starts_empty() {
let log = ConfigAuditLog::new();
assert!(log.entries().is_empty());
}
}
53 changes: 50 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ mod native {
use serde_json::json;

use proofstell_contract::cache::{CacheBackend, CacheBulkheadConfig, CacheCircuitBreakerConfig, InMemoryCache};
use proofstell_contract::config::{self, AppConfig, ConfigUpdate, ConfigWatcher};
use proofstell_contract::config::{self, AppConfig, ConfigAuditEntry, ConfigAuditLog, ConfigUpdate, ConfigWatcher};
use proofstell_contract::metrics::MetricsRegistry;
use proofstell_contract::webhook::WebhookDispatcher;

Expand All @@ -55,6 +55,7 @@ mod native {
cache: Arc<CacheBackend>,
config_watcher: ConfigWatcher,
config_version: u32,
audit_log: Arc<ConfigAuditLog>,
}

/// Build the axum router with all application routes.
Expand All @@ -68,6 +69,7 @@ mod native {
.route("/cache/stats", get(cache_stats_handler))
.route("/config/status", get(config_status_handler))
.route("/config/reload", post(config_reload_handler))
.route("/config/audit", get(config_audit_handler))
.with_state(state)
}

Expand All @@ -91,10 +93,22 @@ mod native {

/// `POST /config/reload` — triggers a config reload from environment variables.
async fn config_reload_handler(State(state): State<AppState>) -> impl IntoResponse {
let old_version = state.config_version;
match AppConfig::from_env_with_metrics(Some(Arc::clone(&state.metrics))) {
Ok(new_config) => match ConfigUpdate::new(new_config) {
Ok(update) => {
if state.config_watcher.send(update).is_ok() {
state.audit_log.record(ConfigAuditEntry {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}", d.as_secs()))
.unwrap_or_default(),
old_version,
new_version: old_version,
actor: "api".to_string(),
success: true,
error: None,
});
Json(
json!({"status": "ok", "message": "config reload triggered successfully"}),
)
Expand All @@ -104,12 +118,43 @@ mod native {
)
}
}
Err(e) => Json(json!({"status": "error", "message": e.to_string()})),
Err(e) => {
state.audit_log.record(ConfigAuditEntry {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}", d.as_secs()))
.unwrap_or_default(),
old_version,
new_version: old_version,
actor: "api".to_string(),
success: false,
error: Some(e.to_string()),
});
Json(json!({"status": "error", "message": e.to_string()}))
}
},
Err(e) => Json(json!({"status": "error", "message": e.to_string()})),
Err(e) => {
state.audit_log.record(ConfigAuditEntry {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}", d.as_secs()))
.unwrap_or_default(),
old_version,
new_version: old_version,
actor: "api".to_string(),
success: false,
error: Some(e.to_string()),
});
Json(json!({"status": "error", "message": e.to_string()}))
}
}
}

/// `GET /config/audit` — returns the config change audit log.
async fn config_audit_handler(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({ "entries": state.audit_log.entries() }))
}

/// `GET /webhooks/dlq` — returns the current DLQ depth.
async fn dlq_status_handler(State(state): State<AppState>) -> impl IntoResponse {
let depth = state.webhook.dlq_depth().await;
Expand Down Expand Up @@ -263,12 +308,14 @@ mod native {
});

// ── Router ──────────────────────────────────────────────────
let audit_log = Arc::new(ConfigAuditLog::new());
let state = AppState {
metrics: Arc::clone(&metrics),
webhook,
cache,
config_watcher,
config_version: AppConfig::version(),
audit_log,
};
let app = build_router(state);

Expand Down
Loading