diff --git a/Cargo.lock b/Cargo.lock index d6458bb..a102a5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -557,6 +557,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.3.11" @@ -599,6 +605,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_filter" version = "0.1.0" @@ -690,6 +708,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -706,6 +739,23 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.30" @@ -735,10 +785,13 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1006,6 +1059,16 @@ dependencies = [ "cc", ] +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -1026,6 +1089,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1064,13 +1139,14 @@ dependencies = [ [[package]] name = "khm" -version = "0.6.1" +version = "0.6.2" dependencies = [ "actix-web", "base64 0.21.7", "chrono", "clap", "env_logger", + "futures", "hostname", "log", "regex", @@ -1081,6 +1157,7 @@ dependencies = [ "tokio", "tokio-postgres", "tokio-util", + "trust-dns-resolver", ] [[package]] @@ -1095,6 +1172,12 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1134,6 +1217,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -1558,9 +1650,15 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "winreg 0.52.0", ] +[[package]] +name = "resolv-conf" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" + [[package]] name = "ring" version = "0.17.8" @@ -1873,9 +1971,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.68" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -1921,6 +2019,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.3.36" @@ -2092,9 +2210,21 @@ checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.32" @@ -2104,6 +2234,52 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trust-dns-proto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.4.0", + "ipnet", + "once_cell", + "rand", + "smallvec", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "tracing", + "trust-dns-proto", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2156,7 +2332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -2296,6 +2472,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" @@ -2475,6 +2657,16 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 7fb3486..6e1375a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "khm" -version = "0.6.2" +version = "0.6.3" edition = "2021" authors = ["AB "] diff --git a/src/db.rs b/src/db.rs index 735ce3a..626e1be 100644 --- a/src/db.rs +++ b/src/db.rs @@ -467,6 +467,71 @@ impl DbClient { Ok(affected) } + pub async fn bulk_deprecate_keys_by_servers( + &self, + server_names: &[String], + flow_name: &str, + ) -> Result { + if server_names.is_empty() { + return Ok(0); + } + + // Update keys to deprecated status for multiple servers in one query + let result = self + .client + .execute( + "UPDATE public.keys + SET deprecated = TRUE, updated = NOW() + WHERE host = ANY($1) + AND key_id IN ( + SELECT key_id FROM public.flows WHERE name = $2 + )", + &[&server_names, &flow_name], + ) + .await; + let affected = Self::handle_db_error(result, "bulk deprecating keys")?; + + info!( + "Bulk deprecated {} key(s) for {} servers in flow '{}'", + affected, server_names.len(), flow_name + ); + + Ok(affected) + } + + pub async fn bulk_restore_keys_by_servers( + &self, + server_names: &[String], + flow_name: &str, + ) -> Result { + if server_names.is_empty() { + return Ok(0); + } + + // Update keys to active status for multiple servers in one query + let result = self + .client + .execute( + "UPDATE public.keys + SET deprecated = FALSE, updated = NOW() + WHERE host = ANY($1) + AND deprecated = TRUE + AND key_id IN ( + SELECT key_id FROM public.flows WHERE name = $2 + )", + &[&server_names, &flow_name], + ) + .await; + let affected = Self::handle_db_error(result, "bulk restoring keys")?; + + info!( + "Bulk restored {} key(s) for {} servers in flow '{}'", + affected, server_names.len(), flow_name + ); + + Ok(affected) + } + pub async fn restore_key_by_server( &self, server_name: &str, @@ -648,6 +713,36 @@ impl ReconnectingDbClient { } } + pub async fn bulk_deprecate_keys_by_servers_reconnecting( + &self, + server_names: Vec, + flow_name: String, + ) -> Result { + match &self.inner { + Some(client) => { + client + .bulk_deprecate_keys_by_servers(&server_names, &flow_name) + .await + } + None => panic!("Database client not initialized"), + } + } + + pub async fn bulk_restore_keys_by_servers_reconnecting( + &self, + server_names: Vec, + flow_name: String, + ) -> Result { + match &self.inner { + Some(client) => { + client + .bulk_restore_keys_by_servers(&server_names, &flow_name) + .await + } + None => panic!("Database client not initialized"), + } + } + pub async fn restore_key_by_server_reconnecting( &self, server_name: String, diff --git a/src/server.rs b/src/server.rs index d1d2f8a..0bf4d7d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -306,6 +306,18 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> { .app_data(allowed_flows.clone()) // API routes .route("/api/flows", web::get().to(crate::web::get_flows_api)) + .route( + "/{flow_id}/scan-dns", + web::post().to(crate::web::scan_dns_resolution), + ) + .route( + "/{flow_id}/bulk-deprecate", + web::post().to(crate::web::bulk_deprecate_servers), + ) + .route( + "/{flow_id}/bulk-restore", + web::post().to(crate::web::bulk_restore_servers), + ) .route( "/{flow_id}/keys/{server}", web::delete().to(crate::web::delete_key_by_server), diff --git a/src/web.rs b/src/web.rs index 137087a..47e85b1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -3,6 +3,12 @@ use log::info; use rust_embed::RustEmbed; use serde_json::json; use std::sync::Arc; +use trust_dns_resolver::TokioAsyncResolver; +use trust_dns_resolver::config::*; +use serde::{Deserialize, Serialize}; +use futures::future; +use tokio::sync::Semaphore; +use tokio::time::{timeout, Duration}; use crate::db::ReconnectingDbClient; use crate::server::Flows; @@ -11,12 +17,231 @@ use crate::server::Flows; #[folder = "static/"] struct StaticAssets; +#[derive(Serialize, Deserialize, Debug)] +pub struct DnsResolutionResult { + pub server: String, + pub resolved: bool, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct BulkDeprecateRequest { + pub servers: Vec, +} + +async fn check_dns_resolution(hostname: String, semaphore: Arc) -> DnsResolutionResult { + let _permit = match semaphore.acquire().await { + Ok(permit) => permit, + Err(_) => { + return DnsResolutionResult { + server: hostname, + resolved: false, + error: Some("Failed to acquire semaphore".to_string()), + }; + } + }; + + let resolver = TokioAsyncResolver::tokio( + ResolverConfig::default(), + ResolverOpts::default(), + ); + + let lookup_result = timeout(Duration::from_secs(5), resolver.lookup_ip(&hostname)).await; + + match lookup_result { + Ok(Ok(_)) => DnsResolutionResult { + server: hostname, + resolved: true, + error: None, + }, + Ok(Err(e)) => DnsResolutionResult { + server: hostname, + resolved: false, + error: Some(e.to_string()), + }, + Err(_) => DnsResolutionResult { + server: hostname, + resolved: false, + error: Some("DNS lookup timeout (5s)".to_string()), + }, + } +} + // API endpoint to get list of available flows pub async fn get_flows_api(allowed_flows: web::Data>) -> Result { info!("API request for available flows"); Ok(HttpResponse::Ok().json(&**allowed_flows)) } +// API endpoint to scan DNS resolution for all hosts in a flow +pub async fn scan_dns_resolution( + flows: web::Data, + path: web::Path, + allowed_flows: web::Data>, +) -> Result { + let flow_id_str = path.into_inner(); + + info!("API request to scan DNS resolution for flow '{}'" , flow_id_str); + + if !allowed_flows.contains(&flow_id_str) { + return Ok(HttpResponse::Forbidden().json(json!({ + "error": "Flow ID not allowed" + }))); + } + + let flows_guard = flows.lock().unwrap(); + let flow = match flows_guard.iter().find(|flow| flow.name == flow_id_str) { + Some(flow) => flow, + None => { + return Ok(HttpResponse::NotFound().json(json!({ + "error": "Flow ID not found" + }))); + } + }; + + // Get unique hostnames + let mut hostnames: std::collections::HashSet = std::collections::HashSet::new(); + for key in &flow.servers { + hostnames.insert(key.server.clone()); + } + + drop(flows_guard); + + info!("Scanning DNS resolution for {} unique hosts", hostnames.len()); + + // Limit concurrent DNS requests to prevent "too many open files" error + let semaphore = Arc::new(Semaphore::new(20)); + + // Scan all hostnames concurrently with rate limiting + let mut scan_futures = Vec::new(); + for hostname in hostnames { + scan_futures.push(check_dns_resolution(hostname, semaphore.clone())); + } + + let results = future::join_all(scan_futures).await; + + let unresolved_count = results.iter().filter(|r| !r.resolved).count(); + info!("DNS scan complete: {} unresolved out of {} hosts", unresolved_count, results.len()); + + Ok(HttpResponse::Ok().json(json!({ + "results": results, + "total": results.len(), + "unresolved": unresolved_count + }))) +} + +// API endpoint to bulk deprecate multiple servers +pub async fn bulk_deprecate_servers( + flows: web::Data, + path: web::Path, + request: web::Json, + db_client: web::Data>, + allowed_flows: web::Data>, +) -> Result { + let flow_id_str = path.into_inner(); + + info!("API request to bulk deprecate {} servers in flow '{}'", request.servers.len(), flow_id_str); + + if !allowed_flows.contains(&flow_id_str) { + return Ok(HttpResponse::Forbidden().json(json!({ + "error": "Flow ID not allowed" + }))); + } + + // Use single bulk operation instead of loop + let total_deprecated = match db_client + .bulk_deprecate_keys_by_servers_reconnecting(request.servers.clone(), flow_id_str.clone()) + .await + { + Ok(count) => { + info!("Bulk deprecated {} key(s) for {} servers", count, request.servers.len()); + count + } + Err(e) => { + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to bulk deprecate keys: {}", e) + }))); + } + }; + + // Refresh the in-memory flows + let updated_flows = match db_client.get_keys_from_db_reconnecting().await { + Ok(flows) => flows, + Err(e) => { + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to refresh flows: {}", e) + }))); + } + }; + + let mut flows_guard = flows.lock().unwrap(); + *flows_guard = updated_flows; + + let response = json!({ + "message": format!("Successfully deprecated {} key(s) for {} server(s)", total_deprecated, request.servers.len()), + "deprecated_count": total_deprecated, + "servers_processed": request.servers.len() + }); + + Ok(HttpResponse::Ok().json(response)) +} + +// API endpoint to bulk restore multiple servers +pub async fn bulk_restore_servers( + flows: web::Data, + path: web::Path, + request: web::Json, + db_client: web::Data>, + allowed_flows: web::Data>, +) -> Result { + let flow_id_str = path.into_inner(); + + info!("API request to bulk restore {} servers in flow '{}'", request.servers.len(), flow_id_str); + + if !allowed_flows.contains(&flow_id_str) { + return Ok(HttpResponse::Forbidden().json(json!({ + "error": "Flow ID not allowed" + }))); + } + + // Use single bulk operation + let total_restored = match db_client + .bulk_restore_keys_by_servers_reconnecting(request.servers.clone(), flow_id_str.clone()) + .await + { + Ok(count) => { + info!("Bulk restored {} key(s) for {} servers", count, request.servers.len()); + count + } + Err(e) => { + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to bulk restore keys: {}", e) + }))); + } + }; + + // Refresh the in-memory flows + let updated_flows = match db_client.get_keys_from_db_reconnecting().await { + Ok(flows) => flows, + Err(e) => { + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": format!("Failed to refresh flows: {}", e) + }))); + } + }; + + let mut flows_guard = flows.lock().unwrap(); + *flows_guard = updated_flows; + + let response = json!({ + "message": format!("Successfully restored {} key(s) for {} server(s)", total_restored, request.servers.len()), + "restored_count": total_restored, + "servers_processed": request.servers.len() + }); + + Ok(HttpResponse::Ok().json(response)) +} + // API endpoint to deprecate a specific key by server name pub async fn delete_key_by_server( flows: web::Data, diff --git a/static/index.html b/static/index.html index 17c8311..e296358 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,14 @@ 0 Total Keys +
+ 0 + Active Keys +
+
+ 0 + Deprecated Keys +
0 Unique Servers @@ -34,7 +42,9 @@
+ +
@@ -129,6 +139,30 @@
+ + +
diff --git a/static/script.js b/static/script.js index 9357eb9..e907797 100644 --- a/static/script.js +++ b/static/script.js @@ -39,11 +39,21 @@ class SSHKeyManager { this.showAddKeyModal(); }); + // Scan DNS button + document.getElementById('scanDnsBtn').addEventListener('click', () => { + this.scanDnsResolution(); + }); + // Bulk delete button document.getElementById('bulkDeleteBtn').addEventListener('click', () => { this.deleteSelectedKeys(); }); + // Bulk restore button + document.getElementById('bulkRestoreBtn').addEventListener('click', () => { + this.restoreSelectedKeys(); + }); + // Bulk permanent delete button document.getElementById('bulkPermanentDeleteBtn').addEventListener('click', () => { this.permanentlyDeleteSelectedKeys(); @@ -110,6 +120,19 @@ class SSHKeyManager { this.copyKeyToClipboard(); }); + // DNS scan modal + document.getElementById('closeDnsScan').addEventListener('click', () => { + this.hideModal('dnsScanModal'); + }); + + document.getElementById('selectAllUnresolved').addEventListener('click', () => { + this.toggleSelectAllUnresolved(); + }); + + document.getElementById('deprecateUnresolved').addEventListener('click', () => { + this.deprecateSelectedUnresolved(); + }); + // Close modals when clicking on close button or outside document.querySelectorAll('.modal .close').forEach(closeBtn => { closeBtn.addEventListener('click', (e) => { @@ -219,9 +242,14 @@ class SSHKeyManager { } updateStats() { - document.getElementById('totalKeys').textContent = this.keys.length; - + const totalKeys = this.keys.length; + const deprecatedKeys = this.keys.filter(key => key.deprecated).length; + const activeKeys = totalKeys - deprecatedKeys; const uniqueServers = new Set(this.keys.map(key => key.server)); + + document.getElementById('totalKeys').textContent = totalKeys; + document.getElementById('activeKeys').textContent = activeKeys; + document.getElementById('deprecatedKeys').textContent = deprecatedKeys; document.getElementById('uniqueServers').textContent = uniqueServers.size; } @@ -456,12 +484,15 @@ class SSHKeyManager { updateBulkDeleteButton() { const bulkDeleteBtn = document.getElementById('bulkDeleteBtn'); + const bulkRestoreBtn = document.getElementById('bulkRestoreBtn'); const bulkPermanentDeleteBtn = document.getElementById('bulkPermanentDeleteBtn'); if (this.selectedKeys.size === 0) { - // No keys selected - hide both buttons + // No keys selected - hide all buttons bulkDeleteBtn.disabled = true; bulkDeleteBtn.textContent = 'Deprecate Selected'; + bulkRestoreBtn.style.display = 'none'; + bulkRestoreBtn.disabled = true; bulkPermanentDeleteBtn.style.display = 'none'; bulkPermanentDeleteBtn.disabled = true; return; @@ -491,6 +522,16 @@ class SSHKeyManager { bulkDeleteBtn.textContent = 'Deprecate Selected'; } + // Show/hide restore button + if (deprecatedCount > 0) { + bulkRestoreBtn.style.display = 'inline-flex'; + bulkRestoreBtn.disabled = false; + bulkRestoreBtn.textContent = `Restore Selected (${deprecatedCount})`; + } else { + bulkRestoreBtn.style.display = 'none'; + bulkRestoreBtn.disabled = true; + } + // Show/hide permanent delete button if (deprecatedCount > 0) { bulkPermanentDeleteBtn.style.display = 'inline-flex'; @@ -703,6 +744,56 @@ class SSHKeyManager { } } + async restoreSelectedKeys() { + if (this.selectedKeys.size === 0) return; + + // Filter only deprecated keys + const deprecatedKeys = Array.from(this.selectedKeys).filter(keyId => { + const key = this.findKeyById(keyId); + return key && key.deprecated; + }); + + if (deprecatedKeys.length === 0) { + this.showToast('No deprecated keys selected', 'warning'); + return; + } + + if (!confirm(`Are you sure you want to restore ${deprecatedKeys.length} deprecated SSH keys?`)) { + return; + } + + // Get unique server names + const serverNames = [...new Set(deprecatedKeys.map(keyId => { + const key = this.findKeyById(keyId); + return key ? key.server : null; + }).filter(Boolean))]; + + try { + this.showLoading(); + + const response = await fetch(`/${this.currentFlow}/bulk-restore`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ servers: serverNames }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to restore keys'); + } + + const result = await response.json(); + this.showToast(result.message, 'success'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to restore selected keys: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + async permanentlyDeleteSelectedKeys() { if (this.selectedKeys.size === 0) return; @@ -805,6 +896,8 @@ class SSHKeyManager { document.getElementById('keysTableBody').innerHTML = ''; document.getElementById('noKeysMessage').style.display = 'block'; document.getElementById('totalKeys').textContent = '0'; + document.getElementById('activeKeys').textContent = '0'; + document.getElementById('deprecatedKeys').textContent = '0'; document.getElementById('uniqueServers').textContent = '0'; this.selectedKeys.clear(); this.updateBulkDeleteButton(); @@ -849,6 +942,150 @@ class SSHKeyManager { }, 300); }, 4000); } + + // DNS Resolution Scanning + async scanDnsResolution() { + if (!this.currentFlow) { + this.showToast('Please select a flow first', 'warning'); + return; + } + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/scan-dns`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to scan DNS resolution'); + } + + const scanResults = await response.json(); + this.showDnsScanResults(scanResults); + } catch (error) { + this.showToast('Failed to scan DNS resolution: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + + showDnsScanResults(scanResults) { + const { results, total, unresolved } = scanResults; + + // Update stats + const statsDiv = document.getElementById('dnsScanStats'); + statsDiv.innerHTML = ` +
+ ${total} + Total Hosts +
+
+ ${total - unresolved} + Resolved +
+
+ ${unresolved} + Unresolved +
+ `; + + // Show unresolved hosts + const unresolvedHosts = results.filter(r => !r.resolved); + const unresolvedList = document.getElementById('unresolvedList'); + + if (unresolvedHosts.length === 0) { + unresolvedList.innerHTML = '
🎉 All hosts resolved successfully!
'; + document.getElementById('selectAllUnresolved').style.display = 'none'; + } else { + document.getElementById('selectAllUnresolved').style.display = 'inline-flex'; + unresolvedList.innerHTML = unresolvedHosts.map(host => ` +
+ + ${host.error ? `${this.escapeHtml(host.error)}` : ''} +
+ `).join(''); + + // Add event listeners to checkboxes + unresolvedList.querySelectorAll('.unresolved-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', () => { + this.updateDeprecateUnresolvedButton(); + }); + }); + } + + this.updateDeprecateUnresolvedButton(); + this.showModal('dnsScanModal'); + } + + toggleSelectAllUnresolved() { + const checkboxes = document.querySelectorAll('.unresolved-checkbox'); + const allChecked = Array.from(checkboxes).every(cb => cb.checked); + + checkboxes.forEach(checkbox => { + checkbox.checked = !allChecked; + }); + + this.updateDeprecateUnresolvedButton(); + } + + updateDeprecateUnresolvedButton() { + const selectedCount = document.querySelectorAll('.unresolved-checkbox:checked').length; + const deprecateBtn = document.getElementById('deprecateUnresolved'); + + if (selectedCount > 0) { + deprecateBtn.disabled = false; + deprecateBtn.textContent = `Deprecate Selected (${selectedCount})`; + } else { + deprecateBtn.disabled = true; + deprecateBtn.textContent = 'Deprecate Selected'; + } + } + + async deprecateSelectedUnresolved() { + const selectedHosts = Array.from(document.querySelectorAll('.unresolved-checkbox:checked')) + .map(cb => cb.value); + + if (selectedHosts.length === 0) { + this.showToast('No hosts selected', 'warning'); + return; + } + + if (!confirm(`Are you sure you want to deprecate SSH keys for ${selectedHosts.length} unresolved hosts?`)) { + return; + } + + try { + this.showLoading(); + const response = await fetch(`/${this.currentFlow}/bulk-deprecate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ servers: selectedHosts }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to deprecate hosts'); + } + + const result = await response.json(); + this.showToast(result.message, 'success'); + this.hideModal('dnsScanModal'); + await this.loadKeys(); + } catch (error) { + this.showToast('Failed to deprecate hosts: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } } // Initialize the SSH Key Manager when the page loads diff --git a/static/style.css b/static/style.css index 8f7c943..ff81ed2 100644 --- a/static/style.css +++ b/static/style.css @@ -155,6 +155,10 @@ header h1 { color: var(--primary-color); } +.stat-value.deprecated { + color: var(--danger-color); +} + .stat-label { color: var(--text-secondary); font-size: 0.875rem; @@ -663,3 +667,132 @@ input[type="checkbox"]:indeterminate { .form-group textarea:valid { border-color: var(--success-color); } + +/* DNS Scan Modal Styles */ +.modal-large { + max-width: 800px; +} + +.scan-stats { + background: var(--background); + padding: 1rem; + border-radius: var(--border-radius); + margin-bottom: 1.5rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.scan-stat { + text-align: center; +} + +.scan-stat-value { + display: block; + font-size: 1.5rem; + font-weight: 600; + color: var(--primary-color); +} + +.scan-stat-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.unresolved-count { + color: var(--danger-color) !important; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.section-header h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.host-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--border-radius); +} + +.host-item { + display: flex; + align-items: center; + padding: 0.75rem; + border-bottom: 1px solid var(--border); + transition: background-color 0.2s ease; +} + +.host-item:last-child { + border-bottom: none; +} + +.host-item:hover { + background-color: var(--background); +} + +.host-item label { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + cursor: pointer; + margin: 0; +} + +.host-name { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-weight: 500; + color: var(--text-primary); +} + +.host-error { + font-size: 0.75rem; + color: var(--danger-color); + margin-left: auto; + max-width: 200px; + word-break: break-word; +} + +.empty-state { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +.scan-progress { + background: var(--background); + padding: 1rem; + border-radius: var(--border-radius); + margin-bottom: 1rem; + text-align: center; +} + +.scan-progress-text { + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.progress-bar { + width: 100%; + height: 8px; + background: var(--border); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--primary-color); + transition: width 0.3s ease; + border-radius: 4px; +}