1415 lines
42 KiB
Rust
1415 lines
42 KiB
Rust
use axum::body::Body;
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Extension;
|
|
use myfsio_common::types::Principal;
|
|
use myfsio_storage::traits::StorageEngine;
|
|
|
|
use crate::services::site_registry::{PeerSite, SiteInfo};
|
|
use crate::services::website_domains::{is_valid_domain, normalize_domain};
|
|
use crate::state::AppState;
|
|
|
|
fn json_response(status: StatusCode, value: serde_json::Value) -> Response {
|
|
(
|
|
status,
|
|
[("content-type", "application/json")],
|
|
value.to_string(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn json_error(code: &str, message: &str, status: StatusCode) -> Response {
|
|
json_response(
|
|
status,
|
|
serde_json::json!({"error": {"code": code, "message": message}}),
|
|
)
|
|
}
|
|
|
|
fn push_issue(result: &mut serde_json::Value, issue: serde_json::Value) {
|
|
if let Some(items) = result
|
|
.get_mut("issues")
|
|
.and_then(|value| value.as_array_mut())
|
|
{
|
|
items.push(issue);
|
|
}
|
|
}
|
|
|
|
fn require_admin(principal: &Principal) -> Option<Response> {
|
|
if !principal.is_admin {
|
|
return Some(json_error(
|
|
"AccessDenied",
|
|
"Admin access required",
|
|
StatusCode::FORBIDDEN,
|
|
));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn require_iam_action(state: &AppState, principal: &Principal, action: &str) -> Option<Response> {
|
|
if !state.iam.authorize(principal, None, action, None) {
|
|
return Some(json_error(
|
|
"AccessDenied",
|
|
&format!("Requires {} permission", action),
|
|
StatusCode::FORBIDDEN,
|
|
));
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn read_json_body(body: Body) -> Option<serde_json::Value> {
|
|
let bytes = http_body_util::BodyExt::collect(body)
|
|
.await
|
|
.ok()?
|
|
.to_bytes();
|
|
serde_json::from_slice(&bytes).ok()
|
|
}
|
|
|
|
fn validate_site_id(site_id: &str) -> Option<String> {
|
|
if site_id.is_empty() || site_id.len() > 63 {
|
|
return Some("site_id must be 1-63 characters".to_string());
|
|
}
|
|
let first = site_id.chars().next().unwrap();
|
|
if !first.is_ascii_alphanumeric() {
|
|
return Some("site_id must start with alphanumeric".to_string());
|
|
}
|
|
if !site_id
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
{
|
|
return Some("site_id must contain only alphanumeric, hyphens, underscores".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn validate_endpoint(endpoint: &str) -> Option<String> {
|
|
if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
|
|
return Some("Endpoint must be http or https URL".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn validate_region(region: &str) -> Option<String> {
|
|
let re = regex::Regex::new(r"^[a-z]{2,}-[a-z]+-\d+$").unwrap();
|
|
if !re.is_match(region) {
|
|
return Some("Region must match format like us-east-1".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn validate_priority(priority: i64) -> Option<String> {
|
|
if priority < 0 || priority > 1000 {
|
|
return Some("Priority must be between 0 and 1000".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn get_local_site(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
|
|
if let Some(ref registry) = state.site_registry {
|
|
if let Some(local) = registry.get_local_site() {
|
|
return json_response(StatusCode::OK, serde_json::to_value(&local).unwrap());
|
|
}
|
|
}
|
|
|
|
json_error(
|
|
"NotFound",
|
|
"Local site not configured",
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
|
|
pub async fn update_local_site(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Site registry not available",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let payload = match read_json_body(body).await {
|
|
Some(v) => v,
|
|
None => {
|
|
return json_error(
|
|
"MalformedJSON",
|
|
"Invalid JSON body",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let site_id = match payload.get("site_id").and_then(|v| v.as_str()) {
|
|
Some(s) => s.to_string(),
|
|
None => {
|
|
return json_error(
|
|
"ValidationError",
|
|
"site_id is required",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
if let Some(err) = validate_site_id(&site_id) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let endpoint = payload
|
|
.get("endpoint")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if !endpoint.is_empty() {
|
|
if let Some(err) = validate_endpoint(&endpoint) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
if let Some(p) = payload.get("priority").and_then(|v| v.as_i64()) {
|
|
if let Some(err) = validate_priority(p) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
if let Some(r) = payload.get("region").and_then(|v| v.as_str()) {
|
|
if let Some(err) = validate_region(r) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
let existing = registry.get_local_site();
|
|
let site = SiteInfo {
|
|
site_id: site_id.clone(),
|
|
endpoint,
|
|
region: payload
|
|
.get("region")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("us-east-1")
|
|
.to_string(),
|
|
priority: payload
|
|
.get("priority")
|
|
.and_then(|v| v.as_i64())
|
|
.unwrap_or(100) as i32,
|
|
display_name: payload
|
|
.get("display_name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&site_id)
|
|
.to_string(),
|
|
created_at: existing.and_then(|e| e.created_at),
|
|
};
|
|
|
|
registry.set_local_site(site.clone());
|
|
json_response(StatusCode::OK, serde_json::to_value(&site).unwrap())
|
|
}
|
|
|
|
pub async fn list_all_sites(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"local": null, "peers": [], "total_peers": 0}),
|
|
)
|
|
}
|
|
};
|
|
|
|
let local = registry.get_local_site();
|
|
let peers = registry.list_peers();
|
|
|
|
json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({
|
|
"local": local,
|
|
"peers": peers,
|
|
"total_peers": peers.len(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub async fn register_peer_site(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Site registry not available",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let payload = match read_json_body(body).await {
|
|
Some(v) => v,
|
|
None => {
|
|
return json_error(
|
|
"MalformedJSON",
|
|
"Invalid JSON body",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let site_id = match payload.get("site_id").and_then(|v| v.as_str()) {
|
|
Some(s) => s.to_string(),
|
|
None => {
|
|
return json_error(
|
|
"ValidationError",
|
|
"site_id is required",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
if let Some(err) = validate_site_id(&site_id) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let endpoint = match payload.get("endpoint").and_then(|v| v.as_str()) {
|
|
Some(e) => e.to_string(),
|
|
None => {
|
|
return json_error(
|
|
"ValidationError",
|
|
"endpoint is required",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
if let Some(err) = validate_endpoint(&endpoint) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let region = payload
|
|
.get("region")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("us-east-1")
|
|
.to_string();
|
|
if let Some(err) = validate_region(®ion) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let priority = payload
|
|
.get("priority")
|
|
.and_then(|v| v.as_i64())
|
|
.unwrap_or(100);
|
|
if let Some(err) = validate_priority(priority) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
if registry.get_peer(&site_id).is_some() {
|
|
return json_error(
|
|
"AlreadyExists",
|
|
&format!("Peer site '{}' already exists", site_id),
|
|
StatusCode::CONFLICT,
|
|
);
|
|
}
|
|
|
|
let peer = PeerSite {
|
|
site_id: site_id.clone(),
|
|
endpoint,
|
|
region,
|
|
priority: priority as i32,
|
|
display_name: payload
|
|
.get("display_name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&site_id)
|
|
.to_string(),
|
|
connection_id: payload
|
|
.get("connection_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string()),
|
|
created_at: Some(chrono::Utc::now().to_rfc3339()),
|
|
is_healthy: false,
|
|
last_health_check: None,
|
|
};
|
|
|
|
registry.add_peer(peer.clone());
|
|
json_response(StatusCode::CREATED, serde_json::to_value(&peer).unwrap())
|
|
}
|
|
|
|
pub async fn get_peer_site(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"NotFound",
|
|
"Site registry not available",
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
};
|
|
|
|
match registry.get_peer(&site_id) {
|
|
Some(peer) => json_response(StatusCode::OK, serde_json::to_value(&peer).unwrap()),
|
|
None => json_error(
|
|
"NotFound",
|
|
&format!("Peer site '{}' not found", site_id),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn update_peer_site(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(site_id): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"NotFound",
|
|
"Site registry not available",
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
};
|
|
|
|
let existing = match registry.get_peer(&site_id) {
|
|
Some(p) => p,
|
|
None => {
|
|
return json_error(
|
|
"NotFound",
|
|
&format!("Peer site '{}' not found", site_id),
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
};
|
|
|
|
let payload = match read_json_body(body).await {
|
|
Some(v) => v,
|
|
None => {
|
|
return json_error(
|
|
"MalformedJSON",
|
|
"Invalid JSON body",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
if let Some(ep) = payload.get("endpoint").and_then(|v| v.as_str()) {
|
|
if let Some(err) = validate_endpoint(ep) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
if let Some(p) = payload.get("priority").and_then(|v| v.as_i64()) {
|
|
if let Some(err) = validate_priority(p) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
if let Some(r) = payload.get("region").and_then(|v| v.as_str()) {
|
|
if let Some(err) = validate_region(r) {
|
|
return json_error("ValidationError", &err, StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
let peer = PeerSite {
|
|
site_id: site_id.clone(),
|
|
endpoint: payload
|
|
.get("endpoint")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&existing.endpoint)
|
|
.to_string(),
|
|
region: payload
|
|
.get("region")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&existing.region)
|
|
.to_string(),
|
|
priority: payload
|
|
.get("priority")
|
|
.and_then(|v| v.as_i64())
|
|
.unwrap_or(existing.priority as i64) as i32,
|
|
display_name: payload
|
|
.get("display_name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&existing.display_name)
|
|
.to_string(),
|
|
connection_id: payload
|
|
.get("connection_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.or(existing.connection_id),
|
|
created_at: existing.created_at,
|
|
is_healthy: existing.is_healthy,
|
|
last_health_check: existing.last_health_check,
|
|
};
|
|
|
|
registry.update_peer(peer.clone());
|
|
json_response(StatusCode::OK, serde_json::to_value(&peer).unwrap())
|
|
}
|
|
|
|
pub async fn delete_peer_site(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"NotFound",
|
|
"Site registry not available",
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
};
|
|
|
|
if !registry.delete_peer(&site_id) {
|
|
return json_error(
|
|
"NotFound",
|
|
&format!("Peer site '{}' not found", site_id),
|
|
StatusCode::NOT_FOUND,
|
|
);
|
|
}
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
pub async fn check_peer_health(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"NotFound",
|
|
"Site registry not available",
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
};
|
|
|
|
if registry.get_peer(&site_id).is_none() {
|
|
return json_error(
|
|
"NotFound",
|
|
&format!("Peer site '{}' not found", site_id),
|
|
StatusCode::NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
let peer = registry.get_peer(&site_id).unwrap();
|
|
let checked_at = chrono::Utc::now().timestamp_millis() as f64 / 1000.0;
|
|
let mut is_healthy = false;
|
|
let mut error: Option<String> = None;
|
|
|
|
if let Some(connection_id) = peer.connection_id.as_deref() {
|
|
if let Some(connection) = state.connections.get(connection_id) {
|
|
is_healthy = state.replication.check_endpoint(&connection).await;
|
|
if !is_healthy {
|
|
error = Some(format!(
|
|
"Cannot reach endpoint: {}",
|
|
connection.endpoint_url
|
|
));
|
|
}
|
|
} else {
|
|
error = Some(format!("Connection '{}' not found", connection_id));
|
|
}
|
|
} else {
|
|
error = Some("No connection configured for this peer".to_string());
|
|
}
|
|
|
|
registry.update_health(&site_id, is_healthy);
|
|
|
|
json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({
|
|
"site_id": site_id,
|
|
"is_healthy": is_healthy,
|
|
"error": error,
|
|
"checked_at": checked_at,
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub async fn get_topology(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"sites": [], "total": 0, "healthy_count": 0}),
|
|
)
|
|
}
|
|
};
|
|
|
|
let local = registry.get_local_site();
|
|
let peers = registry.list_peers();
|
|
|
|
let mut sites: Vec<serde_json::Value> = Vec::new();
|
|
if let Some(l) = local {
|
|
let mut v = serde_json::to_value(&l).unwrap();
|
|
v.as_object_mut()
|
|
.unwrap()
|
|
.insert("is_local".to_string(), serde_json::json!(true));
|
|
v.as_object_mut()
|
|
.unwrap()
|
|
.insert("is_healthy".to_string(), serde_json::json!(true));
|
|
sites.push(v);
|
|
}
|
|
for p in &peers {
|
|
let mut v = serde_json::to_value(p).unwrap();
|
|
v.as_object_mut()
|
|
.unwrap()
|
|
.insert("is_local".to_string(), serde_json::json!(false));
|
|
sites.push(v);
|
|
}
|
|
|
|
sites.sort_by_key(|s| s.get("priority").and_then(|v| v.as_i64()).unwrap_or(100));
|
|
|
|
let healthy_count = sites
|
|
.iter()
|
|
.filter(|s| {
|
|
s.get("is_healthy")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
})
|
|
.count();
|
|
|
|
json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({
|
|
"sites": sites,
|
|
"total": sites.len(),
|
|
"healthy_count": healthy_count,
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub async fn check_bidirectional_status(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let registry = match &state.site_registry {
|
|
Some(r) => r,
|
|
None => {
|
|
return json_error(
|
|
"NotFound",
|
|
"Site registry not available",
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
};
|
|
|
|
if registry.get_peer(&site_id).is_none() {
|
|
return json_error(
|
|
"NotFound",
|
|
&format!("Peer site '{}' not found", site_id),
|
|
StatusCode::NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
let local = registry.get_local_site();
|
|
let peer = registry.get_peer(&site_id).unwrap();
|
|
let local_bidirectional_rules: Vec<serde_json::Value> = state
|
|
.replication
|
|
.list_rules()
|
|
.into_iter()
|
|
.filter(|rule| {
|
|
peer.connection_id
|
|
.as_deref()
|
|
.map(|connection_id| rule.target_connection_id == connection_id)
|
|
.unwrap_or(false)
|
|
&& rule.mode == crate::services::replication::MODE_BIDIRECTIONAL
|
|
})
|
|
.map(|rule| {
|
|
serde_json::json!({
|
|
"bucket_name": rule.bucket_name,
|
|
"target_bucket": rule.target_bucket,
|
|
"enabled": rule.enabled,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let mut result = serde_json::json!({
|
|
"site_id": site_id,
|
|
"local_site_id": local.as_ref().map(|l| l.site_id.clone()),
|
|
"local_endpoint": local.as_ref().map(|l| l.endpoint.clone()),
|
|
"local_bidirectional_rules": local_bidirectional_rules,
|
|
"local_site_sync_enabled": state.config.site_sync_enabled,
|
|
"remote_status": null,
|
|
"issues": Vec::<serde_json::Value>::new(),
|
|
"is_fully_configured": false,
|
|
});
|
|
|
|
if local
|
|
.as_ref()
|
|
.map(|site| site.site_id.trim().is_empty())
|
|
.unwrap_or(true)
|
|
{
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "NO_LOCAL_SITE_ID",
|
|
"message": "Local site identity not configured",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
}
|
|
if local
|
|
.as_ref()
|
|
.map(|site| site.endpoint.trim().is_empty())
|
|
.unwrap_or(true)
|
|
{
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "NO_LOCAL_ENDPOINT",
|
|
"message": "Local site endpoint not configured (remote site cannot reach back)",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
}
|
|
|
|
let Some(connection_id) = peer.connection_id.as_deref() else {
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "NO_CONNECTION",
|
|
"message": "No connection configured for this peer",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
return json_response(StatusCode::OK, result);
|
|
};
|
|
|
|
let Some(connection) = state.connections.get(connection_id) else {
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "CONNECTION_NOT_FOUND",
|
|
"message": format!("Connection '{}' not found", connection_id),
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
return json_response(StatusCode::OK, result);
|
|
};
|
|
|
|
if result["local_bidirectional_rules"]
|
|
.as_array()
|
|
.map(|rules| rules.is_empty())
|
|
.unwrap_or(true)
|
|
{
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "NO_LOCAL_BIDIRECTIONAL_RULES",
|
|
"message": "No bidirectional replication rules configured on this site",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
if !state.config.site_sync_enabled {
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "SITE_SYNC_DISABLED",
|
|
"message": "Site sync worker is disabled (SITE_SYNC_ENABLED=false). Pull operations will not work.",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
if !state.replication.check_endpoint(&connection).await {
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_UNREACHABLE",
|
|
"message": "Remote endpoint is not reachable",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
return json_response(StatusCode::OK, result);
|
|
}
|
|
|
|
let admin_url = format!(
|
|
"{}/admin/sites",
|
|
connection.endpoint_url.trim_end_matches('/')
|
|
);
|
|
match reqwest::Client::new()
|
|
.get(&admin_url)
|
|
.header("accept", "application/json")
|
|
.header("x-access-key", &connection.access_key)
|
|
.header("x-secret-key", &connection.secret_key)
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) if resp.status().is_success() => match resp.json::<serde_json::Value>().await {
|
|
Ok(remote_data) => {
|
|
let remote_local = remote_data
|
|
.get("local")
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let remote_peers = remote_data
|
|
.get("peers")
|
|
.and_then(|value| value.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let mut has_peer_for_us = false;
|
|
let mut peer_connection_configured = false;
|
|
|
|
for remote_peer in &remote_peers {
|
|
let matches_site = local
|
|
.as_ref()
|
|
.map(|site| {
|
|
remote_peer.get("site_id").and_then(|v| v.as_str())
|
|
== Some(site.site_id.as_str())
|
|
|| remote_peer.get("endpoint").and_then(|v| v.as_str())
|
|
== Some(site.endpoint.as_str())
|
|
})
|
|
.unwrap_or(false);
|
|
if matches_site {
|
|
has_peer_for_us = true;
|
|
peer_connection_configured = remote_peer
|
|
.get("connection_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(|v| !v.trim().is_empty())
|
|
.unwrap_or(false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
result["remote_status"] = serde_json::json!({
|
|
"reachable": true,
|
|
"local_site": remote_local,
|
|
"site_sync_enabled": serde_json::Value::Null,
|
|
"has_peer_for_us": has_peer_for_us,
|
|
"peer_connection_configured": peer_connection_configured,
|
|
"has_bidirectional_rules_for_us": serde_json::Value::Null,
|
|
});
|
|
|
|
if !has_peer_for_us {
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_NO_PEER_FOR_US",
|
|
"message": "Remote site does not have this site registered as a peer",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
} else if !peer_connection_configured {
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_NO_CONNECTION_FOR_US",
|
|
"message": "Remote site has us as peer but no connection configured (cannot push back)",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
Err(_) => {
|
|
result["remote_status"] = serde_json::json!({
|
|
"reachable": true,
|
|
"invalid_response": true,
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_INVALID_RESPONSE",
|
|
"message": "Remote admin API returned invalid JSON",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
Ok(resp)
|
|
if resp.status() == StatusCode::UNAUTHORIZED
|
|
|| resp.status() == StatusCode::FORBIDDEN =>
|
|
{
|
|
result["remote_status"] = serde_json::json!({
|
|
"reachable": true,
|
|
"admin_access_denied": true,
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_ADMIN_ACCESS_DENIED",
|
|
"message": "Cannot verify remote configuration (admin access denied)",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
Ok(resp) => {
|
|
result["remote_status"] = serde_json::json!({
|
|
"reachable": true,
|
|
"admin_api_error": resp.status().as_u16(),
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_ADMIN_API_ERROR",
|
|
"message": format!("Remote admin API returned status {}", resp.status().as_u16()),
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
result["remote_status"] = serde_json::json!({
|
|
"reachable": false,
|
|
"error": "Connection failed",
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
serde_json::json!({
|
|
"code": "REMOTE_ADMIN_UNREACHABLE",
|
|
"message": "Could not reach remote admin API",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
let has_errors = result["issues"]
|
|
.as_array()
|
|
.map(|items| {
|
|
items.iter().any(|issue| {
|
|
issue.get("severity").and_then(|value| value.as_str()) == Some("error")
|
|
})
|
|
})
|
|
.unwrap_or(true);
|
|
result["is_fully_configured"] = serde_json::json!(
|
|
!has_errors
|
|
&& result["local_bidirectional_rules"]
|
|
.as_array()
|
|
.map(|rules| !rules.is_empty())
|
|
.unwrap_or(false)
|
|
);
|
|
|
|
json_response(StatusCode::OK, result)
|
|
}
|
|
|
|
pub async fn iam_list_users(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:list_users") {
|
|
return err;
|
|
}
|
|
let users = state.iam.list_users().await;
|
|
json_response(StatusCode::OK, serde_json::json!({"users": users}))
|
|
}
|
|
|
|
pub async fn iam_get_user(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(identifier): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:get_user") {
|
|
return err;
|
|
}
|
|
match state.iam.get_user(&identifier).await {
|
|
Some(user) => json_response(StatusCode::OK, user),
|
|
None => json_error(
|
|
"NotFound",
|
|
&format!("User '{}' not found", identifier),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn iam_get_user_policies(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(identifier): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:get_policy") {
|
|
return err;
|
|
}
|
|
match state.iam.get_user_policies(&identifier) {
|
|
Some(policies) => json_response(StatusCode::OK, serde_json::json!({"policies": policies})),
|
|
None => json_error(
|
|
"NotFound",
|
|
&format!("User '{}' not found", identifier),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn iam_create_access_key(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(identifier): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:create_key") {
|
|
return err;
|
|
}
|
|
match state.iam.create_access_key(&identifier) {
|
|
Ok(result) => json_response(StatusCode::CREATED, result),
|
|
Err(e) => json_error("InvalidRequest", &e, StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
pub async fn iam_delete_access_key(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((_identifier, access_key)): Path<(String, String)>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:delete_key") {
|
|
return err;
|
|
}
|
|
match state.iam.delete_access_key(&access_key) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => json_error("InvalidRequest", &e, StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
pub async fn iam_disable_user(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(identifier): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:disable_user") {
|
|
return err;
|
|
}
|
|
match state.iam.set_user_enabled(&identifier, false).await {
|
|
Ok(()) => json_response(StatusCode::OK, serde_json::json!({"status": "disabled"})),
|
|
Err(e) => json_error("InvalidRequest", &e, StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
pub async fn iam_enable_user(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(identifier): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_iam_action(&state, &principal, "iam:disable_user") {
|
|
return err;
|
|
}
|
|
match state.iam.set_user_enabled(&identifier, true).await {
|
|
Ok(()) => json_response(StatusCode::OK, serde_json::json!({"status": "enabled"})),
|
|
Err(e) => json_error("InvalidRequest", &e, StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
pub async fn list_website_domains(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let store = match &state.website_domains {
|
|
Some(s) => s,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Website hosting is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
json_response(StatusCode::OK, serde_json::json!(store.list_all()))
|
|
}
|
|
|
|
pub async fn create_website_domain(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let store = match &state.website_domains {
|
|
Some(s) => s,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Website hosting is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let payload = match read_json_body(body).await {
|
|
Some(v) => v,
|
|
None => {
|
|
return json_error(
|
|
"MalformedJSON",
|
|
"Invalid JSON body",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let domain = normalize_domain(payload.get("domain").and_then(|v| v.as_str()).unwrap_or(""));
|
|
if domain.is_empty() {
|
|
return json_error(
|
|
"ValidationError",
|
|
"domain is required",
|
|
StatusCode::BAD_REQUEST,
|
|
);
|
|
}
|
|
if !is_valid_domain(&domain) {
|
|
return json_error(
|
|
"ValidationError",
|
|
&format!("Invalid domain: '{}'", domain),
|
|
StatusCode::BAD_REQUEST,
|
|
);
|
|
}
|
|
|
|
let bucket = payload
|
|
.get("bucket")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_string();
|
|
if bucket.is_empty() {
|
|
return json_error(
|
|
"ValidationError",
|
|
"bucket is required",
|
|
StatusCode::BAD_REQUEST,
|
|
);
|
|
}
|
|
|
|
match state.storage.bucket_exists(&bucket).await {
|
|
Ok(true) => {}
|
|
_ => {
|
|
return json_error(
|
|
"NoSuchBucket",
|
|
&format!("Bucket '{}' does not exist", bucket),
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
}
|
|
|
|
if store.get_bucket(&domain).is_some() {
|
|
return json_error(
|
|
"Conflict",
|
|
&format!("Domain '{}' is already mapped", domain),
|
|
StatusCode::CONFLICT,
|
|
);
|
|
}
|
|
|
|
store.set_mapping(&domain, &bucket);
|
|
json_response(
|
|
StatusCode::CREATED,
|
|
serde_json::json!({"domain": domain, "bucket": bucket}),
|
|
)
|
|
}
|
|
|
|
pub async fn get_website_domain(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(domain): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let store = match &state.website_domains {
|
|
Some(s) => s,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Website hosting is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let domain = normalize_domain(&domain);
|
|
match store.get_bucket(&domain) {
|
|
Some(bucket) => json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"domain": domain, "bucket": bucket}),
|
|
),
|
|
None => json_error(
|
|
"NotFound",
|
|
&format!("No mapping found for domain '{}'", domain),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn update_website_domain(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(domain): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let store = match &state.website_domains {
|
|
Some(s) => s,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Website hosting is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let domain = normalize_domain(&domain);
|
|
let payload = match read_json_body(body).await {
|
|
Some(v) => v,
|
|
None => {
|
|
return json_error(
|
|
"MalformedJSON",
|
|
"Invalid JSON body",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let bucket = payload
|
|
.get("bucket")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_string();
|
|
if bucket.is_empty() {
|
|
return json_error(
|
|
"ValidationError",
|
|
"bucket is required",
|
|
StatusCode::BAD_REQUEST,
|
|
);
|
|
}
|
|
|
|
match state.storage.bucket_exists(&bucket).await {
|
|
Ok(true) => {}
|
|
_ => {
|
|
return json_error(
|
|
"NoSuchBucket",
|
|
&format!("Bucket '{}' does not exist", bucket),
|
|
StatusCode::NOT_FOUND,
|
|
)
|
|
}
|
|
}
|
|
|
|
if store.get_bucket(&domain).is_none() {
|
|
return json_error(
|
|
"NotFound",
|
|
&format!("No mapping found for domain '{}'", domain),
|
|
StatusCode::NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
store.set_mapping(&domain, &bucket);
|
|
json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"domain": domain, "bucket": bucket}),
|
|
)
|
|
}
|
|
|
|
pub async fn delete_website_domain(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(domain): Path<String>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let store = match &state.website_domains {
|
|
Some(s) => s,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Website hosting is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let domain = normalize_domain(&domain);
|
|
if !store.delete_mapping(&domain) {
|
|
return json_error(
|
|
"NotFound",
|
|
&format!("No mapping found for domain '{}'", domain),
|
|
StatusCode::NOT_FOUND,
|
|
);
|
|
}
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
#[derive(serde::Deserialize, Default)]
|
|
pub struct PaginationQuery {
|
|
pub limit: Option<usize>,
|
|
pub offset: Option<usize>,
|
|
}
|
|
|
|
pub async fn gc_status(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
match &state.gc {
|
|
Some(gc) => json_response(StatusCode::OK, gc.status().await),
|
|
None => json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"enabled": false, "message": "GC is not enabled. Set GC_ENABLED=true to enable."}),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn gc_run(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let gc = match &state.gc {
|
|
Some(gc) => gc,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"GC is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let payload = read_json_body(body).await.unwrap_or(serde_json::json!({}));
|
|
let dry_run = payload
|
|
.get("dry_run")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
match gc.run_now(dry_run).await {
|
|
Ok(result) => json_response(StatusCode::OK, result),
|
|
Err(e) => json_error("Conflict", &e, StatusCode::CONFLICT),
|
|
}
|
|
}
|
|
|
|
pub async fn gc_history(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
match &state.gc {
|
|
Some(gc) => json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"executions": gc.history().await}),
|
|
),
|
|
None => json_response(StatusCode::OK, serde_json::json!({"executions": []})),
|
|
}
|
|
}
|
|
|
|
pub async fn integrity_status(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
match &state.integrity {
|
|
Some(checker) => json_response(StatusCode::OK, checker.status().await),
|
|
None => json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"enabled": false, "message": "Integrity checker is not enabled. Set INTEGRITY_ENABLED=true to enable."}),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub async fn integrity_run(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
body: Body,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
let checker = match &state.integrity {
|
|
Some(c) => c,
|
|
None => {
|
|
return json_error(
|
|
"InvalidRequest",
|
|
"Integrity checker is not enabled",
|
|
StatusCode::BAD_REQUEST,
|
|
)
|
|
}
|
|
};
|
|
|
|
let payload = read_json_body(body).await.unwrap_or(serde_json::json!({}));
|
|
let dry_run = payload
|
|
.get("dry_run")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
let auto_heal = payload
|
|
.get("auto_heal")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
match checker.run_now(dry_run, auto_heal).await {
|
|
Ok(result) => json_response(StatusCode::OK, result),
|
|
Err(e) => json_error("Conflict", &e, StatusCode::CONFLICT),
|
|
}
|
|
}
|
|
|
|
pub async fn integrity_history(
|
|
State(state): State<AppState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Response {
|
|
if let Some(err) = require_admin(&principal) {
|
|
return err;
|
|
}
|
|
match &state.integrity {
|
|
Some(checker) => json_response(
|
|
StatusCode::OK,
|
|
serde_json::json!({"executions": checker.history().await}),
|
|
),
|
|
None => json_response(StatusCode::OK, serde_json::json!({"executions": []})),
|
|
}
|
|
}
|