2 Commits

Author SHA1 Message Date
Ultradesu
3bedadf394 Fix ci 2025-03-14 02:44:17 +02:00
Ultradesu
a0f83db19a Fix ci 2025-03-14 02:30:34 +02:00
14 changed files with 91 additions and 1994 deletions

View File

@@ -57,6 +57,10 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: mbrobbel/rustfmt-check@master
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install rust targets
run: rustup target add ${{ matrix.build_target }}
@@ -90,16 +94,13 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create Release
- uses: ncipollo/release-action@v1
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
allowUpdates: true
#artifacts: "release.tar.gz,foo/*.txt"
upload:
name: Upload Release Assets

2
.gitignore vendored
View File

@@ -1,3 +1 @@
/target
*.swp
*.swo

106
Cargo.lock generated
View File

@@ -836,17 +836,6 @@ dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -1064,23 +1053,20 @@ dependencies = [
[[package]]
name = "khm"
version = "0.5.0"
version = "0.2.2"
dependencies = [
"actix-web",
"base64 0.21.7",
"chrono",
"clap",
"env_logger",
"hostname",
"log",
"regex",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"tokio",
"tokio-postgres",
"tokio-util",
]
[[package]]
@@ -1134,12 +1120,6 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1576,40 +1556,6 @@ 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"
@@ -1684,15 +1630,6 @@ 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"
@@ -2178,16 +2115,6 @@ 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"
@@ -2296,37 +2223,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.52.0"

View File

@@ -1,8 +1,8 @@
[package]
name = "khm"
version = "0.5.0"
version = "0.3.0"
edition = "2021"
authors = ["AB <ab@hexor.cy>"]
authors = ["AB <ab@hexor.cy>", "ChatGPT-4o"]
[dependencies]
actix-web = "4"
@@ -14,9 +14,6 @@ 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"

View File

@@ -1,13 +0,0 @@
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,7 +38,6 @@ 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]
@@ -62,4 +61,4 @@ Contributions are welcome! Please open an issue or submit a pull request for any
## License
This project is licensed under the WTFPL License.
This project is licensed under the WTFPL License.

View File

@@ -50,14 +50,6 @@ fn write_known_hosts(file_path: &str, keys: &[SshKey]) -> io::Result<()> {
Ok(())
}
// Get local hostname for request headers
fn get_hostname() -> String {
match hostname::get() {
Ok(name) => name.to_string_lossy().to_string(),
Err(_) => "unknown-host".to_string(),
}
}
async fn send_keys_to_server(
host: &str,
keys: Vec<SshKey>,
@@ -69,17 +61,6 @@ async fn send_keys_to_server(
let mut headers = HeaderMap::new();
// Add hostname header
let hostname = get_hostname();
headers.insert(
"X-Client-Hostname",
HeaderValue::from_str(&hostname).unwrap_or_else(|_| {
error!("Failed to create hostname header value");
HeaderValue::from_static("unknown-host")
}),
);
info!("Adding hostname header: {}", hostname);
if !auth_string.is_empty() {
let parts: Vec<&str> = auth_string.splitn(2, ':').collect();
if parts.len() == 2 {
@@ -124,17 +105,6 @@ async fn get_keys_from_server(
let mut headers = HeaderMap::new();
// Add hostname header
let hostname = get_hostname();
headers.insert(
"X-Client-Hostname",
HeaderValue::from_str(&hostname).unwrap_or_else(|_| {
error!("Failed to create hostname header value");
HeaderValue::from_static("unknown-host")
}),
);
info!("Adding hostname header: {}", hostname);
if !auth_string.is_empty() {
let parts: Vec<&str> = auth_string.splitn(2, ':').collect();
if parts.len() == 2 {
@@ -162,43 +132,23 @@ 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 = 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 keys = read_known_hosts(&args.known_hosts).expect("Failed to read known hosts file");
let host = args.host.expect("host is required in client mode");
info!("Client mode: Sending keys to server at {}", host);
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)));
}
send_keys_to_server(&host, keys, &args.basic_auth)
.await
.expect("Failed to send keys to server");
if args.in_place {
info!("Client mode: In-place update is enabled. Fetching 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)));
}
};
let server_keys = get_keys_from_server(&host, &args.basic_auth)
.await
.expect("Failed to get keys from server");
info!("Client mode: Writing updated 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);
}
write_known_hosts(&args.known_hosts, &server_keys)
.expect("Failed to write known hosts file");
}
info!("Client mode: Finished operations");

295
src/db.rs
View File

@@ -1,295 +0,0 @@
use crate::server::SshKey;
use log::info;
use std::collections::HashMap;
use std::collections::HashSet;
use tokio_postgres::Client;
// Structure for storing key processing statistics
pub struct KeyInsertStats {
pub total: usize, // Total number of received keys
pub inserted: usize, // Number of new keys
pub unchanged: usize, // Number of unchanged keys
pub key_id_map: Vec<(SshKey, i32)>, // Mapping of keys to their IDs in the database
}
pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres::Error> {
info!("Checking and initializing database schema if needed");
// Check if tables exist by querying information_schema
let tables_exist = client
.query(
"SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'keys'
) AND EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'flows'
)",
&[],
)
.await?
.get(0)
.map(|row| row.get::<_, bool>(0))
.unwrap_or(false);
if !tables_exist {
info!("Database schema doesn't exist. Creating tables...");
// Create the keys table
client
.execute(
"CREATE TABLE IF NOT EXISTS public.keys (
key_id SERIAL PRIMARY KEY,
host VARCHAR(255) NOT NULL,
key TEXT NOT NULL,
updated TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT unique_host_key UNIQUE (host, key)
)",
&[],
)
.await?;
// Create the flows table
client
.execute(
"CREATE TABLE IF NOT EXISTS public.flows (
flow_id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
key_id INTEGER NOT NULL,
CONSTRAINT fk_key
FOREIGN KEY(key_id)
REFERENCES public.keys(key_id)
ON DELETE CASCADE,
CONSTRAINT unique_flow_key UNIQUE (name, key_id)
)",
&[],
)
.await?;
// Create an index for faster lookups
client
.execute(
"CREATE INDEX IF NOT EXISTS idx_flows_name ON public.flows(name)",
&[],
)
.await?;
info!("Database schema created successfully");
} else {
info!("Database schema already exists");
}
Ok(())
}
pub async fn batch_insert_keys(
client: &Client,
keys: &[SshKey],
) -> Result<KeyInsertStats, tokio_postgres::Error> {
if keys.is_empty() {
return Ok(KeyInsertStats {
total: 0,
inserted: 0,
unchanged: 0,
key_id_map: Vec::new(),
});
}
// Prepare arrays for batch insertion
let mut host_values: Vec<&str> = Vec::with_capacity(keys.len());
let mut key_values: Vec<&str> = Vec::with_capacity(keys.len());
for key in keys {
host_values.push(&key.server);
key_values.push(&key.public_key);
}
// First, check which keys already exist in the database
let mut existing_keys = HashMap::new();
let mut key_query = String::from("SELECT host, key, key_id FROM public.keys WHERE ");
for i in 0..keys.len() {
if i > 0 {
key_query.push_str(" OR ");
}
key_query.push_str(&format!("(host = ${} AND key = ${})", i * 2 + 1, i * 2 + 2));
}
let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
Vec::with_capacity(keys.len() * 2);
for i in 0..keys.len() {
params.push(&host_values[i]);
params.push(&key_values[i]);
}
let rows = client.query(&key_query, &params[..]).await?;
for row in rows {
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);
}
// Determine which keys need to be inserted and which already exist
let mut keys_to_insert = Vec::new();
let mut unchanged_keys = Vec::new();
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()));
} else {
keys_to_insert.push(key.clone());
}
}
let mut inserted_keys = Vec::new();
// If there are keys to insert, perform the insertion
if !keys_to_insert.is_empty() {
let mut insert_sql = String::from("INSERT INTO public.keys (host, key, updated) VALUES ");
let mut insert_params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
let mut param_count = 1;
for (i, key) in keys_to_insert.iter().enumerate() {
if i > 0 {
insert_sql.push_str(", ");
}
insert_sql.push_str(&format!("(${}, ${}, NOW())", param_count, param_count + 1));
insert_params.push(&key.server);
insert_params.push(&key.public_key);
param_count += 2;
}
insert_sql.push_str(" RETURNING key_id, host, key");
let inserted_rows = client.query(&insert_sql, &insert_params[..]).await?;
for row in inserted_rows {
let host: String = row.get(1);
let key_text: String = row.get(2);
let key_id: i32 = row.get(0);
if let Some(orig_key) = keys_to_insert
.iter()
.find(|k| k.server == host && k.public_key == key_text)
{
inserted_keys.push((orig_key.clone(), key_id));
}
}
}
// Save the number of elements before combining
let inserted_count = inserted_keys.len();
let unchanged_count = unchanged_keys.len();
// Combine results and generate statistics
let mut key_id_map = Vec::with_capacity(unchanged_count + inserted_count);
key_id_map.extend(unchanged_keys);
key_id_map.extend(inserted_keys);
let stats = KeyInsertStats {
total: keys.len(),
inserted: inserted_count,
unchanged: unchanged_count,
key_id_map,
};
info!(
"Keys stats: received={}, new={}, unchanged={}",
stats.total, stats.inserted, stats.unchanged
);
Ok(stats)
}
pub async fn batch_insert_flow_keys(
client: &Client,
flow_name: &str,
key_ids: &[i32],
) -> Result<usize, tokio_postgres::Error> {
if key_ids.is_empty() {
info!("No keys to associate with flow '{}'", flow_name);
return Ok(0);
}
// First, check which associations already exist
let mut existing_query =
String::from("SELECT key_id FROM public.flows WHERE name = $1 AND key_id IN (");
for i in 0..key_ids.len() {
if i > 0 {
existing_query.push_str(", ");
}
existing_query.push_str(&format!("${}", i + 2));
}
existing_query.push_str(")");
let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
Vec::with_capacity(key_ids.len() + 1);
params.push(&flow_name);
for key_id in key_ids {
params.push(key_id);
}
let rows = client.query(&existing_query, &params[..]).await?;
let mut existing_associations = HashSet::new();
for row in rows {
let key_id: i32 = row.get(0);
existing_associations.insert(key_id);
}
// Filter only keys that are not yet associated with the flow
let new_key_ids: Vec<&i32> = key_ids
.iter()
.filter(|&id| !existing_associations.contains(id))
.collect();
if new_key_ids.is_empty() {
info!(
"All {} keys are already associated with flow '{}'",
key_ids.len(),
flow_name
);
return Ok(0);
}
// Build SQL query with multiple values only for new associations
let mut sql = String::from("INSERT INTO public.flows (name, key_id) VALUES ");
for i in 0..new_key_ids.len() {
if i > 0 {
sql.push_str(", ");
}
sql.push_str(&format!("($1, ${})", i + 2));
}
sql.push_str(" ON CONFLICT (name, key_id) DO NOTHING");
// Prepare parameters for the query
let mut insert_params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
Vec::with_capacity(new_key_ids.len() + 1);
insert_params.push(&flow_name);
for key_id in &new_key_ids {
insert_params.push(*key_id);
}
// Execute query
let affected = client.execute(&sql, &insert_params[..]).await?;
let affected_usize = affected as usize;
info!(
"Added {} new key-flow associations for flow '{}' (skipped {} existing)",
affected_usize,
flow_name,
existing_associations.len()
);
Ok(affected_usize)
}

View File

@@ -1,7 +1,5 @@
mod client;
mod db;
mod server;
mod web;
use clap::Parser;
use env_logger;
@@ -120,19 +118,6 @@ 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

@@ -1,4 +1,4 @@
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use log::{error, info};
use regex::Regex;
use serde::{Deserialize, Serialize};
@@ -6,8 +6,6 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio_postgres::{Client, NoTls};
use crate::db;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SshKey {
pub server: String,
@@ -35,6 +33,51 @@ pub fn is_valid_ssh_key(key: &str) -> bool {
|| ed25519_re.is_match(key)
}
pub async fn insert_key_if_not_exists(
client: &Client,
key: &SshKey,
) -> Result<i32, tokio_postgres::Error> {
let row = client
.query_opt(
"SELECT key_id FROM public.keys WHERE host = $1 AND key = $2",
&[&key.server, &key.public_key],
)
.await?;
if let Some(row) = row {
client
.execute(
"UPDATE public.keys SET updated = NOW() WHERE key_id = $1",
&[&row.get::<_, i32>(0)],
)
.await?;
info!("Updated existing key for server: {}", key.server);
Ok(row.get(0))
} else {
let row = client.query_one(
"INSERT INTO public.keys (host, key, updated) VALUES ($1, $2, NOW()) RETURNING key_id",
&[&key.server, &key.public_key]
).await?;
info!("Inserted new key for server: {}", key.server);
Ok(row.get(0))
}
}
pub async fn insert_flow_key(
client: &Client,
flow_name: &str,
key_id: i32,
) -> Result<(), tokio_postgres::Error> {
client
.execute(
"INSERT INTO public.flows (name, key_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
&[&flow_name, &key_id],
)
.await?;
info!("Inserted key_id {} into flow: {}", key_id, flow_name);
Ok(())
}
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",
@@ -70,53 +113,24 @@ pub async fn get_keys_from_db(client: &Client) -> Result<Vec<Flow>, tokio_postgr
Ok(flows_map.into_values().collect())
}
// Extract client hostname from request headers
fn get_client_hostname(req: &HttpRequest) -> String {
if let Some(hostname) = req.headers().get("X-Client-Hostname") {
if let Ok(hostname_str) = hostname.to_str() {
return hostname_str.to_string();
}
}
"unknown-client".to_string()
}
pub async fn get_keys(
flows: web::Data<Flows>,
flow_id: web::Path<String>,
allowed_flows: web::Data<Vec<String>>,
req: HttpRequest,
) -> impl Responder {
let client_hostname = get_client_hostname(&req);
let flow_id_str = flow_id.into_inner();
info!(
"Received keys request from client '{}' for flow '{}'",
client_hostname, flow_id_str
);
if !allowed_flows.contains(&flow_id_str) {
error!(
"Flow ID not allowed for client '{}': {}",
client_hostname, flow_id_str
);
error!("Flow ID not allowed: {}", flow_id_str);
return HttpResponse::Forbidden().body("Flow ID not allowed");
}
let flows = flows.lock().unwrap();
if let Some(flow) = flows.iter().find(|flow| flow.name == flow_id_str) {
let servers: Vec<&SshKey> = flow.servers.iter().collect();
info!(
"Returning {} keys for flow '{}' to client '{}'",
servers.len(),
flow_id_str,
client_hostname
);
info!("Returning {} keys for flow: {}", servers.len(), flow_id_str);
HttpResponse::Ok().json(servers)
} else {
error!(
"Flow ID not found for client '{}': {}",
client_hostname, flow_id_str
);
error!("Flow ID not found: {}", flow_id_str);
HttpResponse::NotFound().body("Flow ID not found")
}
}
@@ -127,99 +141,42 @@ pub async fn add_keys(
new_keys: web::Json<Vec<SshKey>>,
db_client: web::Data<Arc<Client>>,
allowed_flows: web::Data<Vec<String>>,
req: HttpRequest,
) -> impl Responder {
let client_hostname = get_client_hostname(&req);
let flow_id_str = flow_id.into_inner();
info!(
"Received {} keys from client '{}' for flow '{}'",
new_keys.len(),
client_hostname,
flow_id_str
);
if !allowed_flows.contains(&flow_id_str) {
error!(
"Flow ID not allowed for client '{}': {}",
client_hostname, flow_id_str
);
error!("Flow ID not allowed: {}", flow_id_str);
return HttpResponse::Forbidden().body("Flow ID not allowed");
}
// Check SSH key format
let mut valid_keys = Vec::new();
for new_key in new_keys.iter() {
if !is_valid_ssh_key(&new_key.public_key) {
error!(
"Invalid SSH key format from client '{}' for server: {}",
client_hostname, new_key.server
);
error!("Invalid SSH key format for server: {}", new_key.server);
return HttpResponse::BadRequest().body(format!(
"Invalid SSH key format for server: {}",
new_key.server
));
}
valid_keys.push(new_key.clone());
match insert_key_if_not_exists(&db_client, new_key).await {
Ok(key_id) => {
if let Err(e) = insert_flow_key(&db_client, &flow_id_str, key_id).await {
error!("Failed to insert flow key into database: {}", e);
return HttpResponse::InternalServerError()
.body("Failed to insert flow key into database");
}
}
Err(e) => {
error!("Failed to insert key into database: {}", e);
return HttpResponse::InternalServerError()
.body("Failed to insert key into database");
}
}
}
info!(
"Processing batch of {} keys from client '{}' for flow: {}",
valid_keys.len(),
client_hostname,
flow_id_str
);
// Batch insert keys with statistics
let key_stats = match crate::db::batch_insert_keys(&db_client, &valid_keys).await {
Ok(stats) => stats,
Err(e) => {
error!(
"Failed to batch insert keys from client '{}' into database: {}",
client_hostname, e
);
return HttpResponse::InternalServerError()
.body("Failed to batch insert keys into database");
}
};
// 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
if let Err(e) = crate::db::batch_insert_flow_keys(&db_client, &flow_id_str, &key_ids).await
{
error!(
"Failed to batch insert flow keys from client '{}' into database: {}",
client_hostname, e
);
return HttpResponse::InternalServerError()
.body("Failed to batch insert flow keys into database");
}
info!(
"Added flow associations for {} keys from client '{}' in flow '{}'",
key_ids.len(),
client_hostname,
flow_id_str
);
} else {
info!(
"No keys to associate from client '{}' with flow '{}'",
client_hostname, flow_id_str
);
}
// Get updated data
let updated_flows = match get_keys_from_db(&db_client).await {
Ok(flows) => flows,
Err(e) => {
error!(
"Failed to get updated flows from database after client '{}' request: {}",
client_hostname, e
);
error!("Failed to get updated flows from database: {}", e);
return HttpResponse::InternalServerError()
.body("Failed to refresh flows from database");
}
@@ -231,28 +188,10 @@ pub async fn add_keys(
let updated_flow = flows_guard.iter().find(|flow| flow.name == flow_id_str);
if let Some(flow) = updated_flow {
let servers: Vec<&SshKey> = flow.servers.iter().collect();
info!(
"Keys summary for client '{}', flow '{}': total received={}, new={}, unchanged={}, total in flow={}",
client_hostname,
flow_id_str,
key_stats.total,
key_stats.inserted,
key_stats.unchanged,
servers.len()
);
// Add statistics to HTTP response headers
let mut response = HttpResponse::Ok();
response.append_header(("X-Keys-Total", key_stats.total.to_string()));
response.append_header(("X-Keys-New", key_stats.inserted.to_string()));
response.append_header(("X-Keys-Unchanged", key_stats.unchanged.to_string()));
response.json(servers)
info!("Updated flow: {} with {} keys", flow_id_str, servers.len());
HttpResponse::Ok().json(servers)
} else {
error!(
"Flow ID not found after update from client '{}': {}",
client_hostname, flow_id_str
);
error!("Flow ID not found after update: {}", flow_id_str);
HttpResponse::NotFound().body("Flow ID not found")
}
}
@@ -268,17 +207,7 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> {
args.db_host, db_user, db_password, args.db_name
);
info!("Connecting to database at {}", args.db_host);
let (db_client, connection) = match tokio_postgres::connect(&db_conn_str, NoTls).await {
Ok((client, conn)) => (client, conn),
Err(e) => {
error!("Failed to connect to the database: {}", e);
return Err(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
format!("Database connection error: {}", e),
));
}
};
let (db_client, connection) = tokio_postgres::connect(&db_conn_str, NoTls).await.unwrap();
let db_client = Arc::new(db_client);
// Spawn a new thread to run the database connection
@@ -288,15 +217,6 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> {
}
});
// Initialize database schema if needed
if let Err(e) = db::initialize_db_schema(&db_client).await {
error!("Failed to initialize database schema: {}", e);
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Database schema initialization error: {}", e),
));
}
let mut initial_flows = match get_keys_from_db(&db_client).await {
Ok(flows) => flows,
Err(e) => {
@@ -324,15 +244,8 @@ 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))
// 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()

View File

@@ -1,184 +0,0 @@
use actix_web::{web, HttpResponse, Result};
use log::info;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashSet;
use tokio_postgres::Client;
use crate::server::{get_keys_from_db, Flows};
#[derive(RustEmbed)]
#[folder = "static/"]
struct StaticAssets;
#[derive(Deserialize)]
struct DeleteKeyPath {
flow_id: String,
server: String,
}
// 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 delete 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 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"
})));
}
// Delete from database
match delete_key_from_db(&db_client, &server_name, &flow_id_str).await {
Ok(deleted_count) => {
if deleted_count > 0 {
info!("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)
})))
}
}
}
// Helper function to delete a key from database
async fn delete_key_from_db(
client: &Client,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// First, find the key_ids for the given server
let key_rows = client
.query("SELECT key_id FROM public.keys WHERE host = $1", &[&server_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);
}
}
// 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!(
"Deleted {} flow associations and {} orphaned keys for server '{}'",
flow_delete_count, total_deleted, server_name
);
Ok(std::cmp::max(flow_delete_count, total_deleted))
}
// 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"))
}
}
}

View File

@@ -1,134 +0,0 @@
<!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>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>

View File

@@ -1,511 +0,0 @@
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();
});
// 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);
});
}
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>
<td>
<input type="checkbox" data-key-id="${keyId}" ${this.selectedKeys.has(keyId) ? 'checked' : ''}>
</td>
<td>${this.escapeHtml(key.server)}</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>
<button class="btn btn-sm btn-danger" onclick="sshKeyManager.deleteKey('${keyId}')">Delete</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');
bulkDeleteBtn.disabled = this.selectedKeys.size === 0;
bulkDeleteBtn.textContent = this.selectedKeys.size > 0
? `Delete Selected (${this.selectedKeys.size})`
: 'Delete Selected';
}
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) {
if (!confirm('Are you sure you want to delete this SSH key?')) {
return;
}
const key = this.findKeyById(keyId);
if (!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 delete key');
}
this.showToast('SSH key deleted successfully', 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to delete key: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
async deleteSelectedKeys() {
if (this.selectedKeys.size === 0) return;
if (!confirm(`Are you sure you want to delete ${this.selectedKeys.size} selected SSH keys?`)) {
return;
}
try {
this.showLoading();
const deletePromises = Array.from(this.selectedKeys).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(deletePromises);
this.showToast(`${this.selectedKeys.size} SSH keys deleted successfully`, 'success');
await this.loadKeys();
} catch (error) {
this.showToast('Failed to 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();
});

View File

@@ -1,505 +0,0 @@
: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-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;
}
.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; }
.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);
}