6 Commits

Author SHA1 Message Date
Ultradesu
1534d88300 Added web ui 2025-07-18 18:35:04 +03:00
Ultradesu
3fa43d276d Added web ui 2025-07-18 18:06:26 +03:00
Ultradesu
d5ce88dfff Added web ui 2025-07-18 17:52:58 +03:00
Alexandr Bogomyakov
484ddd9803 Add files via upload 2025-07-17 16:19:45 +03:00
Alexandr Bogomyakov
2f1fcd681e Update README.MD 2025-05-12 02:46:25 +03:00
A B
26acbf75ac Fix cross-flow keys 2025-05-11 23:44:18 +00:00
12 changed files with 1918 additions and 30 deletions

66
Cargo.lock generated
View File

@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "khm"
version = "0.4.0"
version = "0.8.0"
dependencies = [
"actix-web",
"base64 0.21.7",
@@ -1075,10 +1075,12 @@ dependencies = [
"log",
"regex",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"tokio",
"tokio-postgres",
"tokio-util",
]
[[package]]
@@ -1574,6 +1576,40 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust-embed"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@@ -1648,6 +1684,15 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.23"
@@ -2133,6 +2178,16 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -2257,6 +2312,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "khm"
version = "0.4.1"
version = "0.8.0"
edition = "2021"
authors = ["AB <ab@hexor.cy>"]
@@ -14,7 +14,9 @@ regex = "1.10.5"
base64 = "0.21"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
tokio-util = { version = "0.7", features = ["codec"] }
clap = { version = "4", features = ["derive"] }
chrono = "0.4.38"
reqwest = { version = "0.12", features = ["json"] }
hostname = "0.3"
rust-embed = "8.0"

13
LICENSE-WTFPL Normal file
View File

@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@@ -38,6 +38,7 @@ Options:
- `--db-name <DB_NAME>` Server mode: Name of the PostgreSQL database [default: khm]
- `--db-user <DB_USER>` Server mode: Username for the PostgreSQL database
- `--db-password <DB_PASSWORD>` Server mode: Password for the PostgreSQL database
- `--basic-auth <BASIC_AUTH>` Client mode: Basic Auth credentials [default: ""]
- `--host <HOST>` Client mode: Full host address of the server to connect to. Like `https://khm.example.com/<FLOW_NAME>`
- `--known-hosts <KNOWN_HOSTS>` Client mode: Path to the known_hosts file [default: ~/.ssh/known_hosts]

View File

@@ -11,6 +11,8 @@ use std::path::Path;
struct SshKey {
server: String,
public_key: String,
#[serde(default)]
deprecated: bool,
}
fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> {
@@ -26,7 +28,11 @@ fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> {
if parts.len() >= 2 {
let server = parts[0].to_string();
let public_key = parts[1..].join(" ");
keys.push(SshKey { server, public_key });
keys.push(SshKey {
server,
public_key,
deprecated: false, // Keys from known_hosts are not deprecated
});
}
}
Err(e) => {
@@ -42,10 +48,14 @@ fn write_known_hosts(file_path: &str, keys: &[SshKey]) -> io::Result<()> {
let path = Path::new(file_path);
let mut file = File::create(&path)?;
for key in keys {
// Filter out deprecated keys - they should not be written to known_hosts
let active_keys: Vec<&SshKey> = keys.iter().filter(|key| !key.deprecated).collect();
let active_count = active_keys.len();
for key in active_keys {
writeln!(file, "{} {}", key.server, key.public_key)?;
}
info!("Wrote {} keys to known_hosts file", keys.len());
info!("Wrote {} active keys to known_hosts file (filtered out deprecated keys)", active_count);
Ok(())
}
@@ -162,23 +172,43 @@ async fn get_keys_from_server(
pub async fn run_client(args: crate::Args) -> std::io::Result<()> {
info!("Client mode: Reading known_hosts file");
let keys = read_known_hosts(&args.known_hosts).expect("Failed to read known hosts file");
let keys = match read_known_hosts(&args.known_hosts) {
Ok(keys) => keys,
Err(e) => {
if e.kind() == io::ErrorKind::NotFound {
info!("known_hosts file not found: {}. Starting with empty key list.", args.known_hosts);
Vec::new()
} else {
error!("Failed to read known_hosts file: {}", e);
return Err(e);
}
}
};
let host = args.host.expect("host is required in client mode");
info!("Client mode: Sending keys to server at {}", host);
send_keys_to_server(&host, keys, &args.basic_auth)
.await
.expect("Failed to send keys to server");
if let Err(e) = send_keys_to_server(&host, keys, &args.basic_auth).await {
error!("Failed to send keys to server: {}", e);
return Err(io::Error::new(io::ErrorKind::Other, format!("Network error: {}", e)));
}
if args.in_place {
info!("Client mode: In-place update is enabled. Fetching keys from server.");
let server_keys = get_keys_from_server(&host, &args.basic_auth)
.await
.expect("Failed to get keys from server");
let server_keys = match get_keys_from_server(&host, &args.basic_auth).await {
Ok(keys) => keys,
Err(e) => {
error!("Failed to get keys from server: {}", e);
return Err(io::Error::new(io::ErrorKind::Other, format!("Network error: {}", e)));
}
};
info!("Client mode: Writing updated known_hosts file");
write_known_hosts(&args.known_hosts, &server_keys)
.expect("Failed to write known hosts file");
if let Err(e) = write_known_hosts(&args.known_hosts, &server_keys) {
error!("Failed to write known_hosts file: {}", e);
return Err(e);
}
}
info!("Client mode: Finished operations");

173
src/db.rs
View File

@@ -45,6 +45,7 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres:
host VARCHAR(255) NOT NULL,
key TEXT NOT NULL,
updated TIMESTAMP WITH TIME ZONE NOT NULL,
deprecated BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT unique_host_key UNIQUE (host, key)
)",
&[],
@@ -79,6 +80,33 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres:
info!("Database schema created successfully");
} else {
info!("Database schema already exists");
// Check if deprecated column exists, add it if missing (migration)
let column_exists = client
.query(
"SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'keys'
AND column_name = 'deprecated'
)",
&[],
)
.await?
.get(0)
.map(|row| row.get::<_, bool>(0))
.unwrap_or(false);
if !column_exists {
info!("Adding deprecated column to existing keys table...");
client
.execute(
"ALTER TABLE public.keys ADD COLUMN deprecated BOOLEAN NOT NULL DEFAULT FALSE",
&[],
)
.await?;
info!("Migration completed: deprecated column added");
}
}
Ok(())
@@ -106,9 +134,9 @@ pub async fn batch_insert_keys(
key_values.push(&key.public_key);
}
// First, check which keys already exist in the database
// First, check which keys already exist in the database (including deprecated status)
let mut existing_keys = HashMap::new();
let mut key_query = String::from("SELECT host, key, key_id FROM public.keys WHERE ");
let mut key_query = String::from("SELECT host, key, key_id, deprecated FROM public.keys WHERE ");
for i in 0..keys.len() {
if i > 0 {
@@ -130,18 +158,27 @@ pub async fn batch_insert_keys(
let host: String = row.get(0);
let key: String = row.get(1);
let key_id: i32 = row.get(2);
existing_keys.insert((host, key), key_id);
let deprecated: bool = row.get(3);
existing_keys.insert((host, key), (key_id, deprecated));
}
// Determine which keys need to be inserted and which already exist
let mut keys_to_insert = Vec::new();
let mut unchanged_keys = Vec::new();
let mut ignored_deprecated = 0;
for key in keys {
let key_tuple = (key.server.clone(), key.public_key.clone());
if existing_keys.contains_key(&key_tuple) {
unchanged_keys.push((key.clone(), *existing_keys.get(&key_tuple).unwrap()));
if let Some((key_id, is_deprecated)) = existing_keys.get(&key_tuple) {
if *is_deprecated {
// Ignore deprecated keys - don't add them to any flow
ignored_deprecated += 1;
} else {
// Key exists and is not deprecated - add to unchanged
unchanged_keys.push((key.clone(), *key_id));
}
} else {
// Key doesn't exist - add to insert list
keys_to_insert.push(key.clone());
}
}
@@ -200,8 +237,8 @@ pub async fn batch_insert_keys(
};
info!(
"Keys stats: received={}, new={}, unchanged={}",
stats.total, stats.inserted, stats.unchanged
"Keys stats: received={}, new={}, unchanged={}, ignored_deprecated={}",
stats.total, stats.inserted, stats.unchanged, ignored_deprecated
);
Ok(stats)
@@ -293,3 +330,125 @@ pub async fn batch_insert_flow_keys(
Ok(affected_usize)
}
// Function to deprecate keys instead of deleting them
pub async fn deprecate_key_by_server(
client: &Client,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// Update keys to deprecated status for the given server
let affected = client
.execute(
"UPDATE public.keys
SET deprecated = TRUE, updated = NOW()
WHERE host = $1
AND key_id IN (
SELECT key_id FROM public.flows WHERE name = $2
)",
&[&server_name, &flow_name],
)
.await?;
info!(
"Deprecated {} key(s) for server '{}' in flow '{}'",
affected, server_name, flow_name
);
Ok(affected)
}
// Function to restore deprecated key back to active
pub async fn restore_key_by_server(
client: &Client,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// Update keys to active status for the given server in the flow
let affected = client
.execute(
"UPDATE public.keys
SET deprecated = FALSE, updated = NOW()
WHERE host = $1
AND deprecated = TRUE
AND key_id IN (
SELECT key_id FROM public.flows WHERE name = $2
)",
&[&server_name, &flow_name],
)
.await?;
info!(
"Restored {} key(s) for server '{}' in flow '{}'",
affected, server_name, flow_name
);
Ok(affected)
}
// Function to permanently delete keys from database
pub async fn permanently_delete_key_by_server(
client: &Client,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// First, find the key_ids for the given server in the flow
let key_rows = client
.query(
"SELECT k.key_id FROM public.keys k
INNER JOIN public.flows f ON k.key_id = f.key_id
WHERE k.host = $1 AND f.name = $2",
&[&server_name, &flow_name]
)
.await?;
if key_rows.is_empty() {
return Ok(0);
}
let key_ids: Vec<i32> = key_rows.iter().map(|row| row.get::<_, i32>(0)).collect();
// Delete flow associations first
let mut flow_delete_count = 0;
for key_id in &key_ids {
let deleted = client
.execute(
"DELETE FROM public.flows WHERE name = $1 AND key_id = $2",
&[&flow_name, key_id],
)
.await?;
flow_delete_count += deleted;
}
// Check if any of these keys are used in other flows
let mut keys_to_delete = Vec::new();
for key_id in &key_ids {
let count: i64 = client
.query_one(
"SELECT COUNT(*) FROM public.flows WHERE key_id = $1",
&[key_id],
)
.await?
.get(0);
if count == 0 {
keys_to_delete.push(*key_id);
}
}
// Permanently delete keys that are no longer referenced by any flow
let mut total_deleted = 0;
for key_id in keys_to_delete {
let deleted = client
.execute("DELETE FROM public.keys WHERE key_id = $1", &[&key_id])
.await?;
total_deleted += deleted;
}
info!(
"Permanently deleted {} flow associations and {} orphaned keys for server '{}' in flow '{}'",
flow_delete_count, total_deleted, server_name, flow_name
);
Ok(std::cmp::max(flow_delete_count, total_deleted))
}

View File

@@ -1,6 +1,7 @@
mod client;
mod db;
mod server;
mod web;
use clap::Parser;
use env_logger;
@@ -119,6 +120,19 @@ async fn main() -> std::io::Result<()> {
let args = Args::parse();
// Check if we have the minimum required arguments
if !args.server && args.host.is_none() {
// Neither server mode nor client mode properly configured
eprintln!("Error: You must specify either server mode (--server) or client mode (--host)");
eprintln!();
eprintln!("Examples:");
eprintln!(" Server mode: {} --server --db-user admin --db-password pass --flows work,home", env!("CARGO_PKG_NAME"));
eprintln!(" Client mode: {} --host https://khm.example.com/work", env!("CARGO_PKG_NAME"));
eprintln!();
eprintln!("Use --help for more information.");
std::process::exit(1);
}
if args.server {
info!("Running in server mode");
if let Err(e) = server::run_server(args).await {

View File

@@ -12,6 +12,8 @@ use crate::db;
pub struct SshKey {
pub server: String,
pub public_key: String,
#[serde(default)]
pub deprecated: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -35,11 +37,9 @@ pub fn is_valid_ssh_key(key: &str) -> bool {
|| ed25519_re.is_match(key)
}
// Note: Removed unused functions insert_key_if_not_exists and insert_flow_key
pub async fn get_keys_from_db(client: &Client) -> Result<Vec<Flow>, tokio_postgres::Error> {
let rows = client.query(
"SELECT k.host, k.key, f.name FROM public.keys k INNER JOIN public.flows f ON k.key_id = f.key_id",
"SELECT k.host, k.key, k.deprecated, f.name FROM public.keys k INNER JOIN public.flows f ON k.key_id = f.key_id",
&[]
).await?;
@@ -48,11 +48,13 @@ pub async fn get_keys_from_db(client: &Client) -> Result<Vec<Flow>, tokio_postgr
for row in rows {
let host: String = row.get(0);
let key: String = row.get(1);
let flow: String = row.get(2);
let deprecated: bool = row.get(2);
let flow: String = row.get(3);
let ssh_key = SshKey {
server: host,
public_key: key,
deprecated,
};
if let Some(flow_entry) = flows_map.get_mut(&flow) {
@@ -185,9 +187,9 @@ pub async fn add_keys(
}
};
// If there are no new keys, no need to update flow associations
if key_stats.inserted > 0 {
// Extract only key IDs from statistics
// Always try to associate all keys with the flow, regardless of whether they're new or existing
if !key_stats.key_id_map.is_empty() {
// Extract all key IDs from statistics, both new and existing
let key_ids: Vec<i32> = key_stats.key_id_map.iter().map(|(_, id)| *id).collect();
// Batch insert key-flow associations
@@ -209,7 +211,7 @@ pub async fn add_keys(
);
} else {
info!(
"No new keys to associate from client '{}' with flow '{}'",
"No keys to associate from client '{}' with flow '{}'",
client_hostname, flow_id_str
);
}
@@ -326,8 +328,17 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> {
.app_data(web::Data::new(flows.clone()))
.app_data(web::Data::new(db_client.clone()))
.app_data(allowed_flows.clone())
// API routes
.route("/api/flows", web::get().to(crate::web::get_flows_api))
.route("/{flow_id}/keys/{server}", web::delete().to(crate::web::delete_key_by_server))
.route("/{flow_id}/keys/{server}/restore", web::post().to(crate::web::restore_key_by_server))
.route("/{flow_id}/keys/{server}/delete", web::delete().to(crate::web::permanently_delete_key_by_server))
// Original API routes
.route("/{flow_id}/keys", web::get().to(get_keys))
.route("/{flow_id}/keys", web::post().to(add_keys))
// Web interface routes
.route("/", web::get().to(crate::web::serve_web_interface))
.route("/static/{filename:.*}", web::get().to(crate::web::serve_static_file))
})
.bind((args.ip.as_str(), args.port))?
.run()

222
src/web.rs Normal file
View File

@@ -0,0 +1,222 @@
use actix_web::{web, HttpResponse, Result};
use log::info;
use rust_embed::RustEmbed;
use serde_json::json;
use tokio_postgres::Client;
use crate::server::{get_keys_from_db, Flows};
#[derive(RustEmbed)]
#[folder = "static/"]
struct StaticAssets;
// API endpoint to get list of available flows
pub async fn get_flows_api(allowed_flows: web::Data<Vec<String>>) -> Result<HttpResponse> {
info!("API request for available flows");
Ok(HttpResponse::Ok().json(&**allowed_flows))
}
// API endpoint to deprecate a specific key by server name
pub async fn delete_key_by_server(
flows: web::Data<Flows>,
path: web::Path<(String, String)>,
db_client: web::Data<std::sync::Arc<Client>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
let (flow_id_str, server_name) = path.into_inner();
info!("API request to deprecate key for server '{}' in flow '{}'", server_name, flow_id_str);
if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({
"error": "Flow ID not allowed"
})));
}
// Deprecate in database
match crate::db::deprecate_key_by_server(&db_client, &server_name, &flow_id_str).await {
Ok(deprecated_count) => {
if deprecated_count > 0 {
info!("Deprecated {} key(s) for server '{}' in flow '{}'", deprecated_count, server_name, flow_id_str);
// Refresh the in-memory flows
let updated_flows = match get_keys_from_db(&db_client).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;
Ok(HttpResponse::Ok().json(json!({
"message": format!("Successfully deprecated {} key(s) for server '{}'", deprecated_count, server_name),
"deprecated_count": deprecated_count
})))
} else {
Ok(HttpResponse::NotFound().json(json!({
"error": format!("No keys found for server '{}'", server_name)
})))
}
}
Err(e) => {
Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to deprecate key: {}", e)
})))
}
}
}
// API endpoint to restore a deprecated key
pub async fn restore_key_by_server(
flows: web::Data<Flows>,
path: web::Path<(String, String)>,
db_client: web::Data<std::sync::Arc<Client>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
let (flow_id_str, server_name) = path.into_inner();
info!("API request to restore key for server '{}' in flow '{}'", server_name, flow_id_str);
if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({
"error": "Flow ID not allowed"
})));
}
// Restore in database
match crate::db::restore_key_by_server(&db_client, &server_name, &flow_id_str).await {
Ok(restored_count) => {
if restored_count > 0 {
info!("Restored {} key(s) for server '{}' in flow '{}'", restored_count, server_name, flow_id_str);
// Refresh the in-memory flows
let updated_flows = match get_keys_from_db(&db_client).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;
Ok(HttpResponse::Ok().json(json!({
"message": format!("Successfully restored {} key(s) for server '{}'", restored_count, server_name),
"restored_count": restored_count
})))
} else {
Ok(HttpResponse::NotFound().json(json!({
"error": format!("No deprecated keys found for server '{}'", server_name)
})))
}
}
Err(e) => {
Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to restore key: {}", e)
})))
}
}
}
// API endpoint to permanently delete a key
pub async fn permanently_delete_key_by_server(
flows: web::Data<Flows>,
path: web::Path<(String, String)>,
db_client: web::Data<std::sync::Arc<Client>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
let (flow_id_str, server_name) = path.into_inner();
info!("API request to permanently delete key for server '{}' in flow '{}'", server_name, flow_id_str);
if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({
"error": "Flow ID not allowed"
})));
}
// Permanently delete from database
match crate::db::permanently_delete_key_by_server(&db_client, &server_name, &flow_id_str).await {
Ok(deleted_count) => {
if deleted_count > 0 {
info!("Permanently deleted {} key(s) for server '{}' in flow '{}'", deleted_count, server_name, flow_id_str);
// Refresh the in-memory flows
let updated_flows = match get_keys_from_db(&db_client).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;
Ok(HttpResponse::Ok().json(json!({
"message": format!("Successfully deleted {} key(s) for server '{}'", deleted_count, server_name),
"deleted_count": deleted_count
})))
} else {
Ok(HttpResponse::NotFound().json(json!({
"error": format!("No keys found for server '{}'", server_name)
})))
}
}
Err(e) => {
Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to delete key: {}", e)
})))
}
}
}
// Serve static files from embedded assets
pub async fn serve_static_file(path: web::Path<String>) -> Result<HttpResponse> {
let file_path = path.into_inner();
match StaticAssets::get(&file_path) {
Some(content) => {
let content_type = match std::path::Path::new(&file_path)
.extension()
.and_then(|s| s.to_str())
{
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js") => "application/javascript; charset=utf-8",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
};
Ok(HttpResponse::Ok()
.content_type(content_type)
.body(content.data.as_ref().to_vec()))
}
None => {
Ok(HttpResponse::NotFound().body(format!("File not found: {}", file_path)))
}
}
}
// Serve the main web interface from embedded assets
pub async fn serve_web_interface() -> Result<HttpResponse> {
match StaticAssets::get("index.html") {
Some(content) => {
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.data.as_ref().to_vec()))
}
None => {
Ok(HttpResponse::NotFound().body("Web interface not found"))
}
}
}

135
static/index.html Normal file
View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSH Key Manager</title>
<link rel="stylesheet" href="/static/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<h1>SSH Key Manager</h1>
<div class="flow-selector">
<label for="flowSelect">Flow:</label>
<select id="flowSelect">
<option value="">Select a flow...</option>
</select>
<button id="refreshBtn" class="btn btn-secondary">Refresh</button>
</div>
</header>
<main>
<div class="stats-panel">
<div class="stat-item">
<span class="stat-value" id="totalKeys">0</span>
<span class="stat-label">Total Keys</span>
</div>
<div class="stat-item">
<span class="stat-value" id="uniqueServers">0</span>
<span class="stat-label">Unique Servers</span>
</div>
</div>
<div class="actions-panel">
<button id="addKeyBtn" class="btn btn-primary">Add SSH Key</button>
<button id="bulkDeleteBtn" class="btn btn-danger" disabled>Deprecate Selected</button>
<button id="bulkPermanentDeleteBtn" class="btn btn-danger" disabled style="display: none;">Delete Selected</button>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search servers or keys...">
</div>
</div>
<div class="keys-table-container">
<table class="keys-table">
<thead>
<tr>
<th>
<input type="checkbox" id="selectAll">
</th>
<th>Server</th>
<th>Key Type</th>
<th>Key Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="keysTableBody">
<!-- Keys will be populated here -->
</tbody>
</table>
<div id="noKeysMessage" class="no-keys-message" style="display: none;">
No SSH keys found for this flow.
</div>
</div>
<div class="pagination">
<button id="prevPage" class="btn btn-secondary" disabled>Previous</button>
<span id="pageInfo">Page 1 of 1</span>
<button id="nextPage" class="btn btn-secondary" disabled>Next</button>
</div>
</main>
</div>
<!-- Add Key Modal -->
<div id="addKeyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Add SSH Key</h2>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="addKeyForm">
<div class="form-group">
<label for="serverInput">Server/Hostname:</label>
<input type="text" id="serverInput" required placeholder="example.com">
</div>
<div class="form-group">
<label for="keyInput">SSH Public Key:</label>
<textarea id="keyInput" required placeholder="ssh-rsa AAAAB3..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelAdd">Cancel</button>
<button type="submit" class="btn btn-primary">Add Key</button>
</div>
</form>
</div>
</div>
</div>
<!-- View Key Modal -->
<div id="viewKeyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>SSH Key Details</h2>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label>Server:</label>
<div id="viewServer" class="read-only-field"></div>
</div>
<div class="form-group">
<label>SSH Public Key:</label>
<textarea id="viewKey" class="read-only-field" readonly></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="closeView">Close</button>
<button type="button" class="btn btn-primary" id="copyKey">Copy Key</button>
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">Loading...</div>
</div>
<!-- Toast Notifications -->
<div id="toastContainer" class="toast-container"></div>
<script src="/static/script.js"></script>
</body>
</html>

697
static/script.js Normal file
View File

@@ -0,0 +1,697 @@
class SSHKeyManager {
constructor() {
this.currentFlow = null;
this.keys = [];
this.filteredKeys = [];
this.currentPage = 1;
this.keysPerPage = 20;
this.selectedKeys = new Set();
this.initializeEventListeners();
this.loadFlows();
}
initializeEventListeners() {
// Flow selection
document.getElementById('flowSelect').addEventListener('change', (e) => {
this.currentFlow = e.target.value;
if (this.currentFlow) {
this.loadKeys();
} else {
this.clearTable();
}
});
// Refresh button
document.getElementById('refreshBtn').addEventListener('click', () => {
this.loadFlows();
if (this.currentFlow) {
this.loadKeys();
}
});
// Add key button
document.getElementById('addKeyBtn').addEventListener('click', () => {
this.showAddKeyModal();
});
// Bulk delete button
document.getElementById('bulkDeleteBtn').addEventListener('click', () => {
this.deleteSelectedKeys();
});
// Bulk permanent delete button
document.getElementById('bulkPermanentDeleteBtn').addEventListener('click', () => {
this.permanentlyDeleteSelectedKeys();
});
// Search input
document.getElementById('searchInput').addEventListener('input', (e) => {
this.filterKeys(e.target.value);
});
// Select all checkbox
document.getElementById('selectAll').addEventListener('change', (e) => {
this.toggleSelectAll(e.target.checked);
});
// Pagination
document.getElementById('prevPage').addEventListener('click', () => {
this.changePage(this.currentPage - 1);
});
document.getElementById('nextPage').addEventListener('click', () => {
this.changePage(this.currentPage + 1);
});
// Modal events
this.initializeModalEvents();
}
initializeModalEvents() {
// Add key modal
const addModal = document.getElementById('addKeyModal');
const addForm = document.getElementById('addKeyForm');
addForm.addEventListener('submit', (e) => {
e.preventDefault();
this.addKey();
});
document.getElementById('cancelAdd').addEventListener('click', () => {
this.hideModal('addKeyModal');
});
// View key modal
document.getElementById('closeView').addEventListener('click', () => {
this.hideModal('viewKeyModal');
});
document.getElementById('copyKey').addEventListener('click', () => {
this.copyKeyToClipboard();
});
// Close modals when clicking on close button or outside
document.querySelectorAll('.modal .close').forEach(closeBtn => {
closeBtn.addEventListener('click', (e) => {
this.hideModal(e.target.closest('.modal').id);
});
});
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideModal(modal.id);
}
});
});
}
async loadFlows() {
try {
this.showLoading();
const response = await fetch('/api/flows');
if (!response.ok) throw new Error('Failed to load flows');
const flows = await response.json();
this.populateFlowSelector(flows);
} catch (error) {
this.showToast('Failed to load flows: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
populateFlowSelector(flows) {
const select = document.getElementById('flowSelect');
select.innerHTML = '<option value="">Select a flow...</option>';
flows.forEach(flow => {
const option = document.createElement('option');
option.value = flow;
option.textContent = flow;
select.appendChild(option);
});
// Auto-select the first flow if available
if (flows.length > 0) {
select.value = flows[0];
this.currentFlow = flows[0];
this.loadKeys();
}
}
async loadKeys() {
if (!this.currentFlow) return;
try {
this.showLoading();
const response = await fetch(`/${this.currentFlow}/keys`);
if (!response.ok) throw new Error('Failed to load keys');
this.keys = await response.json();
this.filteredKeys = [...this.keys];
this.updateStats();
this.renderTable();
this.selectedKeys.clear();
this.updateBulkDeleteButton();
} catch (error) {
this.showToast('Failed to load keys: ' + error.message, 'error');
this.clearTable();
} finally {
this.hideLoading();
}
}
filterKeys(searchTerm) {
if (!searchTerm.trim()) {
this.filteredKeys = [...this.keys];
} else {
const term = searchTerm.toLowerCase();
this.filteredKeys = this.keys.filter(key =>
key.server.toLowerCase().includes(term) ||
key.public_key.toLowerCase().includes(term)
);
}
this.currentPage = 1;
this.renderTable();
}
updateStats() {
document.getElementById('totalKeys').textContent = this.keys.length;
const uniqueServers = new Set(this.keys.map(key => key.server));
document.getElementById('uniqueServers').textContent = uniqueServers.size;
}
renderTable() {
const tbody = document.getElementById('keysTableBody');
const noKeysMessage = document.getElementById('noKeysMessage');
if (this.filteredKeys.length === 0) {
tbody.innerHTML = '';
noKeysMessage.style.display = 'block';
this.updatePagination();
return;
}
noKeysMessage.style.display = 'none';
const startIndex = (this.currentPage - 1) * this.keysPerPage;
const endIndex = startIndex + this.keysPerPage;
const pageKeys = this.filteredKeys.slice(startIndex, endIndex);
tbody.innerHTML = pageKeys.map((key, index) => {
const keyType = this.getKeyType(key.public_key);
const keyPreview = this.getKeyPreview(key.public_key);
const keyId = `${key.server}-${key.public_key}`;
return `
<tr${key.deprecated ? ' class="deprecated"' : ''}>
<td>
<input type="checkbox" data-key-id="${keyId}" ${this.selectedKeys.has(keyId) ? 'checked' : ''}>
</td>
<td>
${this.escapeHtml(key.server)}
${key.deprecated ? '<span class="deprecated-badge">DEPRECATED</span>' : ''}
</td>
<td><span class="key-type ${keyType.toLowerCase()}">${keyType}</span></td>
<td><span class="key-preview">${keyPreview}</span></td>
<td class="table-actions">
<button class="btn btn-sm btn-secondary" onclick="sshKeyManager.viewKey('${keyId}')">View</button>
${key.deprecated ?
`<button class="btn btn-sm btn-success" onclick="sshKeyManager.restoreKey('${keyId}')">Restore</button>
<button class="btn btn-sm btn-danger" onclick="sshKeyManager.permanentlyDeleteKey('${keyId}')">Delete</button>` :
`<button class="btn btn-sm btn-danger" onclick="sshKeyManager.deleteKey('${keyId}')">Deprecate</button>`
}
</td>
</tr>
`;
}).join('');
// Add event listeners for checkboxes
tbody.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const keyId = e.target.dataset.keyId;
if (e.target.checked) {
this.selectedKeys.add(keyId);
} else {
this.selectedKeys.delete(keyId);
}
this.updateBulkDeleteButton();
this.updateSelectAllCheckbox();
});
});
this.updatePagination();
}
updatePagination() {
const totalPages = Math.ceil(this.filteredKeys.length / this.keysPerPage);
document.getElementById('pageInfo').textContent = `Page ${this.currentPage} of ${totalPages}`;
document.getElementById('prevPage').disabled = this.currentPage <= 1;
document.getElementById('nextPage').disabled = this.currentPage >= totalPages;
}
changePage(newPage) {
const totalPages = Math.ceil(this.filteredKeys.length / this.keysPerPage);
if (newPage >= 1 && newPage <= totalPages) {
this.currentPage = newPage;
this.renderTable();
}
}
toggleSelectAll(checked) {
this.selectedKeys.clear();
if (checked) {
this.filteredKeys.forEach(key => {
const keyId = `${key.server}-${key.public_key}`;
this.selectedKeys.add(keyId);
});
}
this.renderTable();
this.updateBulkDeleteButton();
}
updateSelectAllCheckbox() {
const selectAllCheckbox = document.getElementById('selectAll');
const visibleKeys = this.filteredKeys.length;
const selectedVisibleKeys = this.filteredKeys.filter(key =>
this.selectedKeys.has(`${key.server}-${key.public_key}`)
).length;
if (selectedVisibleKeys === 0) {
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.checked = false;
} else if (selectedVisibleKeys === visibleKeys) {
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.checked = true;
} else {
selectAllCheckbox.indeterminate = true;
}
}
updateBulkDeleteButton() {
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
const bulkPermanentDeleteBtn = document.getElementById('bulkPermanentDeleteBtn');
if (this.selectedKeys.size === 0) {
// No keys selected - hide both buttons
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.textContent = 'Deprecate Selected';
bulkPermanentDeleteBtn.style.display = 'none';
bulkPermanentDeleteBtn.disabled = true;
return;
}
// Count selected active and deprecated keys
let activeCount = 0;
let deprecatedCount = 0;
Array.from(this.selectedKeys).forEach(keyId => {
const key = this.findKeyById(keyId);
if (key) {
if (key.deprecated) {
deprecatedCount++;
} else {
activeCount++;
}
}
});
// Show/hide deprecate button
if (activeCount > 0) {
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.textContent = `Deprecate Selected (${activeCount})`;
} else {
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.textContent = 'Deprecate Selected';
}
// Show/hide permanent delete button
if (deprecatedCount > 0) {
bulkPermanentDeleteBtn.style.display = 'inline-flex';
bulkPermanentDeleteBtn.disabled = false;
bulkPermanentDeleteBtn.textContent = `Delete Selected (${deprecatedCount})`;
} else {
bulkPermanentDeleteBtn.style.display = 'none';
bulkPermanentDeleteBtn.disabled = true;
}
}
showAddKeyModal() {
if (!this.currentFlow) {
this.showToast('Please select a flow first', 'warning');
return;
}
document.getElementById('serverInput').value = '';
document.getElementById('keyInput').value = '';
this.showModal('addKeyModal');
}
async addKey() {
const server = document.getElementById('serverInput').value.trim();
const publicKey = document.getElementById('keyInput').value.trim();
if (!server || !publicKey) {
this.showToast('Please fill in all fields', 'warning');
return;
}
if (!this.validateSSHKey(publicKey)) {
this.showToast('Invalid SSH key format', 'error');
return;
}
try {
this.showLoading();
const response = await fetch(`/${this.currentFlow}/keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify([{
server: server,
public_key: publicKey
}])
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to add key');
}
this.hideModal('addKeyModal');
this.showToast('SSH key added successfully', 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to add key: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
viewKey(keyId) {
const key = this.findKeyById(keyId);
if (!key) return;
document.getElementById('viewServer').textContent = key.server;
document.getElementById('viewKey').value = key.public_key;
this.showModal('viewKeyModal');
}
async deleteKey(keyId) {
const key = this.findKeyById(keyId);
if (!key) return;
if (key.deprecated) {
this.showToast('This key is already deprecated', 'warning');
return;
}
if (!confirm('Are you sure you want to deprecate this SSH key?')) {
return;
}
try {
this.showLoading();
const response = await fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to deprecate key');
}
this.showToast('SSH key deprecated successfully', 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to deprecate key: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
async restoreKey(keyId) {
const key = this.findKeyById(keyId);
if (!key) return;
if (!key.deprecated) {
this.showToast('This key is not deprecated', 'warning');
return;
}
if (!confirm('Are you sure you want to restore this SSH key from deprecated status?')) {
return;
}
try {
this.showLoading();
const response = await fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}/restore`, {
method: 'POST'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to restore key');
}
this.showToast('SSH key restored successfully', 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to restore key: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
async permanentlyDeleteKey(keyId) {
const key = this.findKeyById(keyId);
if (!key) return;
if (!confirm('⚠️ Are you sure you want to PERMANENTLY DELETE this SSH key?\n\nThis action cannot be undone!')) {
return;
}
// Double confirmation for permanent deletion
if (!confirm('This will permanently remove the key from the database.\n\nConfirm permanent deletion?')) {
return;
}
try {
this.showLoading();
const response = await fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}/delete`, {
method: 'DELETE'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to permanently delete key');
}
this.showToast('SSH key permanently deleted', 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to permanently delete key: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
async deleteSelectedKeys() {
if (this.selectedKeys.size === 0) return;
// Filter out already deprecated keys
const activeKeys = Array.from(this.selectedKeys).filter(keyId => {
const key = this.findKeyById(keyId);
return key && !key.deprecated;
});
if (activeKeys.length === 0) {
this.showToast('All selected keys are already deprecated', 'warning');
return;
}
if (!confirm(`Are you sure you want to deprecate ${activeKeys.length} selected SSH keys?`)) {
return;
}
try {
this.showLoading();
const deprecatePromises = activeKeys.map(keyId => {
const key = this.findKeyById(keyId);
if (!key) return Promise.resolve();
return fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}`, {
method: 'DELETE'
});
});
await Promise.all(deprecatePromises);
this.showToast(`${activeKeys.length} SSH keys deprecated successfully`, 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to deprecate selected keys: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
async permanentlyDeleteSelectedKeys() {
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 PERMANENTLY DELETE ${deprecatedKeys.length} deprecated SSH keys?\n\nThis action cannot be undone!`)) {
return;
}
// Double confirmation for permanent deletion
if (!confirm('This will permanently remove the keys from the database.\n\nConfirm permanent deletion?')) {
return;
}
try {
this.showLoading();
const deletePromises = deprecatedKeys.map(keyId => {
const key = this.findKeyById(keyId);
if (!key) return Promise.resolve();
return fetch(`/${this.currentFlow}/keys/${encodeURIComponent(key.server)}/delete`, {
method: 'DELETE'
});
});
await Promise.all(deletePromises);
this.showToast(`${deprecatedKeys.length} SSH keys permanently deleted`, 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to permanently delete selected keys: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
findKeyById(keyId) {
return this.keys.find(key => `${key.server}-${key.public_key}` === keyId);
}
validateSSHKey(key) {
const sshKeyRegex = /^(ssh-rsa|ssh-dss|ecdsa-sha2-nistp(256|384|521)|ssh-ed25519)\s+[A-Za-z0-9+/]+=*(\s+.*)?$/;
return sshKeyRegex.test(key.trim());
}
getKeyType(publicKey) {
if (publicKey.startsWith('ssh-rsa')) return 'RSA';
if (publicKey.startsWith('ssh-ed25519')) return 'ED25519';
if (publicKey.startsWith('ecdsa-sha2-nistp')) return 'ECDSA';
if (publicKey.startsWith('ssh-dss')) return 'DSA';
return 'Unknown';
}
getKeyPreview(publicKey) {
const parts = publicKey.split(' ');
if (parts.length >= 2) {
const keyPart = parts[1];
if (keyPart.length > 20) {
return keyPart.substring(0, 20) + '...';
}
return keyPart;
}
return publicKey.substring(0, 20) + '...';
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
copyKeyToClipboard() {
const keyTextarea = document.getElementById('viewKey');
keyTextarea.select();
keyTextarea.setSelectionRange(0, 99999);
try {
document.execCommand('copy');
this.showToast('SSH key copied to clipboard', 'success');
} catch (error) {
this.showToast('Failed to copy to clipboard', 'error');
}
}
clearTable() {
document.getElementById('keysTableBody').innerHTML = '';
document.getElementById('noKeysMessage').style.display = 'block';
document.getElementById('totalKeys').textContent = '0';
document.getElementById('uniqueServers').textContent = '0';
this.selectedKeys.clear();
this.updateBulkDeleteButton();
}
showModal(modalId) {
document.getElementById(modalId).style.display = 'block';
document.body.style.overflow = 'hidden';
}
hideModal(modalId) {
document.getElementById(modalId).style.display = 'none';
document.body.style.overflow = 'auto';
}
showLoading() {
document.getElementById('loadingOverlay').style.display = 'block';
}
hideLoading() {
document.getElementById('loadingOverlay').style.display = 'none';
}
showToast(message, type = 'info') {
const toastContainer = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 100);
// Remove toast after 4 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 4000);
}
}
// Initialize the SSH Key Manager when the page loads
document.addEventListener('DOMContentLoaded', () => {
window.sshKeyManager = new SSHKeyManager();
});

540
static/style.css Normal file
View File

@@ -0,0 +1,540 @@
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--secondary-color: #64748b;
--danger-color: #dc2626;
--danger-hover: #b91c1c;
--success-color: #16a34a;
--warning-color: #d97706;
--background: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--text-primary: #1e293b;
--text-secondary: #64748b;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--border-radius: 0.5rem;
--font-family: 'Inter', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--background);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border);
}
header h1 {
font-size: 2.5rem;
font-weight: 600;
color: var(--text-primary);
}
.flow-selector {
display: flex;
align-items: center;
gap: 1rem;
}
.flow-selector label {
font-weight: 500;
color: var(--text-secondary);
}
.flow-selector select {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: var(--border-radius);
background: var(--surface);
color: var(--text-primary);
font-size: 1rem;
min-width: 200px;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--border-radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #475569;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: var(--danger-hover);
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: #059669;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.stats-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-item {
background: var(--surface);
padding: 1.5rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 600;
color: var(--primary-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.actions-panel {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.search-box input {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: var(--border-radius);
background: var(--surface);
color: var(--text-primary);
font-size: 1rem;
width: 300px;
}
.keys-table-container {
background: var(--surface);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 1.5rem;
}
.keys-table {
width: 100%;
border-collapse: collapse;
}
.keys-table th,
.keys-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.keys-table th {
background-color: #f1f5f9;
font-weight: 600;
color: var(--text-primary);
}
.keys-table tbody tr:hover {
background-color: #f8fafc;
}
.keys-table tbody tr.deprecated {
opacity: 0.6;
background-color: #fef2f2;
}
.keys-table tbody tr.deprecated:hover {
background-color: #fee2e2;
}
.keys-table tbody tr.deprecated .key-preview,
.keys-table tbody tr.deprecated td:nth-child(2) {
text-decoration: line-through;
color: var(--text-secondary);
}
.key-preview {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.key-type {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: #e0e7ff;
color: #3730a3;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.key-type.rsa { background-color: #fef3c7; color: #92400e; }
.key-type.ed25519 { background-color: #dcfce7; color: #166534; }
.key-type.ecdsa { background-color: #e0e7ff; color: #3730a3; }
.key-type.dsa { background-color: #fce7f3; color: #9d174d; }
.deprecated-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: #fecaca;
color: #991b1b;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
margin-left: 0.5rem;
}
.no-keys-message {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
font-size: 1.125rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-content {
background-color: var(--surface);
margin: 5% auto;
padding: 0;
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1.5rem;
font-weight: 600;
}
.close {
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
color: var(--text-secondary);
padding: 0.5rem;
border-radius: var(--border-radius);
transition: all 0.2s ease;
}
.close:hover {
background-color: var(--background);
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem;
max-height: 60vh;
overflow-y: auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--border-radius);
font-size: 1rem;
font-family: var(--font-family);
background: var(--surface);
color: var(--text-primary);
transition: border-color 0.2s ease;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 120px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
}
.read-only-field {
background-color: var(--background) !important;
cursor: not-allowed;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.loading-overlay {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border: 4px solid var(--border);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
position: absolute;
top: 60%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-secondary);
font-weight: 500;
}
@keyframes spin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 1rem 1.5rem;
border-radius: var(--border-radius);
color: white;
font-weight: 500;
box-shadow: var(--shadow-lg);
transform: translateX(100%);
transition: transform 0.3s ease;
max-width: 400px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.toast.show {
transform: translateX(0);
}
.toast.success {
background-color: var(--success-color);
}
.toast.error {
background-color: var(--danger-color);
}
.toast.warning {
background-color: var(--warning-color);
}
.toast.info {
background-color: var(--primary-color);
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.actions-panel {
flex-direction: column;
align-items: stretch;
}
.search-box input {
width: 100%;
}
.keys-table-container {
overflow-x: auto;
}
.keys-table {
min-width: 600px;
}
.modal-content {
margin: 10% auto;
width: 95%;
}
.form-actions {
flex-direction: column;
}
.stats-panel {
grid-template-columns: 1fr;
}
}
/* Checkbox styles */
input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--primary-color);
}
/* Action buttons in table */
.table-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Error states */
.form-group input:invalid,
.form-group textarea:invalid {
border-color: var(--danger-color);
}
.form-group input:invalid:focus,
.form-group textarea:invalid:focus {
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
/* Success states */
.form-group input:valid,
.form-group textarea:valid {
border-color: var(--success-color);
}