73 Commits

Author SHA1 Message Date
Alexandr Bogomiakov
0d35beac1f web wasm ui 2025-07-24 08:34:53 +03:00
Alexandr Bogomiakov
8af35dca4f web wasm ui 2025-07-24 08:24:51 +03:00
Alexandr Bogomiakov
626b152227 Split cli and gui bins 2025-07-24 05:10:41 +03:00
Alexandr Bogomiakov
828bb74780 Split cli and gui bins 2025-07-24 05:01:04 +03:00
Alexandr Bogomiakov
dd8f895d8a Split cli and gui bins 2025-07-24 04:50:08 +03:00
Alexandr Bogomiakov
f0e3ca35ba Split cli and gui bins 2025-07-24 04:38:47 +03:00
Alexandr Bogomiakov
98520a9a50 Fixed tray icon on linux 2025-07-24 04:20:36 +03:00
Alexandr Bogomiakov
66c9753fc3 Fixed tray icon on linux 2025-07-24 04:16:10 +03:00
Alexandr Bogomiakov
f4eb46afce Fixed tray icon on linux 2025-07-24 04:04:15 +03:00
Alexandr Bogomiakov
3cc326e8dc Fixed tray icon on linux 2025-07-24 03:54:13 +03:00
Alexandr Bogomiakov
6bc817172d Fix Release action 2025-07-24 03:29:36 +03:00
Alexandr Bogomiakov
18db65c612 Fix Release action 2025-07-24 03:22:54 +03:00
Alexandr Bogomiakov
cb40370c83 Fix Release action 2025-07-24 03:18:59 +03:00
Alexandr Bogomiakov
2afe56934f Fix Release action 2025-07-24 03:16:14 +03:00
Alexandr Bogomiakov
aab258f45b Fix Release action 2025-07-24 03:12:09 +03:00
Alexandr Bogomiakov
6825f1fff6 Fix Release action 2025-07-24 03:11:53 +03:00
Alexandr Bogomiakov
fcc1f76d70 Fixed windows start console 2025-07-24 02:36:21 +03:00
Alexandr Bogomiakov
fbadd66d12 Fixed windows start console 2025-07-24 02:11:27 +03:00
Alexandr Bogomiakov
ebb0967bc9 Fix Release action 2025-07-24 01:32:52 +03:00
Alexandr Bogomiakov
774af5a269 Fix Release action 2025-07-24 01:26:06 +03:00
Alexandr Bogomiakov
b74667bcfa Fix Release action 2025-07-24 01:18:17 +03:00
Alexandr Bogomiakov
fe79905de5 Fix Release action 2025-07-24 01:12:34 +03:00
Alexandr Bogomiakov
e5ac8fbc02 Disabled musl build 2025-07-24 00:58:35 +03:00
Alexandr Bogomiakov
b8d78ac481 Disabled musl build 2025-07-24 00:57:42 +03:00
Alexandr Bogomiakov
b322299054 Fix musl build 2025-07-24 00:44:48 +03:00
Alexandr Bogomiakov
8387b8c84b Fix musl build 2025-07-24 00:42:28 +03:00
Alexandr Bogomiakov
9b6c64e2f1 Fix GUI feature declaration 2025-07-24 00:35:13 +03:00
Alexandr Bogomiakov
3161e6f08d Drop ui check CI 2025-07-24 00:15:42 +03:00
Alexandr Bogomiakov
f5d6afc29c Drop ui check CI 2025-07-24 00:13:21 +03:00
Alexandr Bogomiakov
3a1c6ff072 Fix musl build 2025-07-24 00:12:37 +03:00
Alexandr Bogomiakov
43db14196a Fixed build workflow 2025-07-24 00:01:49 +03:00
Alexandr Bogomiakov
977d67cbf0 Fixed build workflow 2025-07-23 23:53:46 +03:00
Alexandr Bogomiakov
201d008d81 Build fixes
Some checks failed
UI Branch Build Check / Build Check (x86_64-unknown-linux-musl, ubuntu-latest) (push) Failing after 2m46s
UI Branch Build Check / Build Check (aarch64-apple-darwin, macos-latest) (push) Has been cancelled
UI Branch Build Check / Build Check (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
2025-07-23 15:45:51 +03:00
Alexandr Bogomiakov
04ae4078d1 Build fixes 2025-07-23 15:41:00 +03:00
Alexandr Bogomiakov
b8d6b18cde Build fixes 2025-07-23 15:38:35 +03:00
Alexandr Bogomiakov
02c7de174e Build fixes 2025-07-23 15:34:10 +03:00
Alexandr Bogomiakov
551d639e08 Build fixes 2025-07-23 15:13:36 +03:00
Alexandr Bogomiakov
66542edc4b Build fixes 2025-07-23 15:10:06 +03:00
Alexandr Bogomiakov
22db2d0ab1 Build fixes 2025-07-23 15:06:31 +03:00
Alexandr Bogomiakov
821b3024ba Build fixes 2025-07-23 14:54:33 +03:00
Alexandr Bogomiakov
c23b3a8531 Build fixes 2025-07-23 14:47:58 +03:00
Ultradesu
cba8c58af7 UI code reworked 2025-07-22 23:14:55 +03:00
Ultradesu
7cc446d227 UI code reworked 2025-07-22 20:10:44 +03:00
Ultradesu
c342134f03 UI code reworked 2025-07-22 18:06:48 +03:00
Ultradesu
c69803ca3d Operations log 2025-07-22 17:43:12 +03:00
Ultradesu
f6e3ad8558 Fix watcher 2025-07-22 17:41:48 +03:00
Ultradesu
6012728644 Fix watcher 2025-07-22 17:38:37 +03:00
Ultradesu
fa6c6b0203 Fix watcher 2025-07-22 17:33:15 +03:00
Ultradesu
cd6373d78a Fix watcher 2025-07-22 17:29:08 +03:00
Ultradesu
6ad3cd8f23 Fix watcher 2025-07-22 17:27:19 +03:00
Ultradesu
6dc0f279b2 Fix watcher 2025-07-22 16:36:44 +03:00
Ultradesu
d604bb8119 GUI Feature 2025-07-22 16:20:39 +03:00
Ultradesu
2e5cf1ca29 Fixed button colors 2025-07-22 15:21:30 +03:00
Ultradesu
567e744247 Added egui admin 2025-07-22 14:48:47 +03:00
Ultradesu
cd9c10f2dd Added tooltip 2025-07-22 14:22:57 +03:00
Ultradesu
c6099f4569 Egui WOrks fIne 2025-07-22 14:12:09 +03:00
Ultradesu
ad3dfeee34 Works with egui 2025-07-22 13:44:54 +03:00
Ultradesu
8b920596e5 Works with egui 2025-07-22 13:44:39 +03:00
Ultradesu
a3a96eebce works with macos native ui 2025-07-22 13:02:22 +03:00
Ultradesu
9d3d52f38a works with macos native ui 2025-07-22 12:50:56 +03:00
Ultradesu
07ff4454d2 works with macos native ui 2025-07-22 12:50:01 +03:00
Ultradesu
af6c4d7e61 Added auto deprecation feature 2025-07-20 17:37:46 +03:00
Ultradesu
9c5518b39e Added auto deprecation feature 2025-07-20 17:26:44 +03:00
Ultradesu
1eccc0e0f7 Fixed client mode flow args 2025-07-19 15:51:17 +03:00
Ultradesu
45ac3fca51 Fixed web ui. Added deprecation feature 2025-07-19 12:56:25 +03:00
Ultradesu
e33910a2db Added web ui 2025-07-19 12:20:52 +03:00
Ultradesu
c5d8ebd89f Added web ui 2025-07-19 12:20:37 +03:00
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
48 changed files with 18787 additions and 552 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# Git
.git/
.gitignore
# Rust build artifacts
target/
Cargo.lock
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Documentation
*.md
LICENSE
# CI/CD
.github/
.gitlab-ci.yml
# Testing
tests/
benches/
# Development files
*.log
*.tmp

View File

@@ -7,25 +7,37 @@ on:
env:
CARGO_TERM_COLOR: always
BINARY_NAME: khm
CLI_BINARY_NAME: khm
DESKTOP_BINARY_NAME: khm-desktop
jobs:
build:
name: Build static binary
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
# - os: ubuntu-latest
# build_target: x86_64-unknown-linux-musl
# platform_name: linux-amd64-musl
# build_type: musl
- os: ubuntu-latest
build_target: x86_64-unknown-linux-musl
build_target: x86_64-unknown-linux-gnu
platform_name: linux-amd64
build_type: dynamic
- os: ubuntu-latest
build_target: aarch64-unknown-linux-gnu
platform_name: linux-arm64
build_type: dynamic # CLI only - GUI deps too complex for cross-compilation
- os: windows-latest
build_target: x86_64-pc-windows-msvc
platform_name: windows-amd64
build_type: default
- os: macos-latest
build_target: aarch64-apple-darwin
platform_name: macos-arm64
build_type: default
permissions:
contents: write
steps:
@@ -60,79 +72,149 @@ jobs:
- name: Install rust targets
run: rustup target add ${{ matrix.build_target }}
- name: Build Linux MUSL
if: matrix.os == 'ubuntu-latest'
uses: gmiam/rust-musl-action@master
with:
args: cargo build --target ${{ matrix.build_target }} --release
- name: Install Linux x86_64 dependencies
if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'dynamic' && matrix.build_target == 'x86_64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y libssl-dev pkg-config libgtk-3-dev libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev libgdk-pixbuf2.0-dev libxdo-dev libayatana-appindicator3-dev
- name: Install Linux ARM64 cross-compilation dependencies
if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'dynamic' && matrix.build_target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
# Install cross-compilation tools and build dependencies for vendored OpenSSL
sudo apt-get install -y gcc-aarch64-linux-gnu pkg-config libssl-dev build-essential make perl
- name: Build Linux x86_64
if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'dynamic' && matrix.build_target == 'x86_64-unknown-linux-gnu'
run: |
# Build CLI without GUI features
cargo build --target ${{ matrix.build_target }} --release --bin khm --no-default-features --features cli
# Build Desktop with GUI features
cargo build --target ${{ matrix.build_target }} --release --bin khm-desktop
- name: Build Linux ARM64 (CLI only)
if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'dynamic' && matrix.build_target == 'aarch64-unknown-linux-gnu'
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
run: cargo build --target ${{ matrix.build_target }} --release --bin khm --no-default-features --features cli
# - name: Build Linux MUSL (no GUI)
# if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'musl'
# uses: gmiam/rust-musl-action@master
# with:
# args: |
# sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list
# sed -i 's/security.debian.org/archive.debian.org/g' /etc/apt/sources.list
# sed -i '/buster-updates/d' /etc/apt/sources.list
# apt-get update && apt-get install -y pkg-config
# cargo build --target ${{ matrix.build_target }} --release --no-default-features --features server
- name: Build MacOS
if: matrix.os == 'macos-latest'
run: cargo build --target ${{ matrix.build_target }} --release
run: |
# Build CLI without GUI features
cargo build --target ${{ matrix.build_target }} --release --bin khm --no-default-features --features cli
# Build Desktop with GUI features
cargo build --target ${{ matrix.build_target }} --release --bin khm-desktop
- name: Build Windows
if: matrix.os == 'windows-latest'
run: cargo build --target ${{ matrix.build_target }} --release
run: |
# Build CLI without GUI features
cargo build --target ${{ matrix.build_target }} --release --bin khm --no-default-features --features cli
# Build Desktop with GUI features
cargo build --target ${{ matrix.build_target }} --release --bin khm-desktop
- name: Upload artifact
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }}
path: target/${{ matrix.build_target }}/release/${{ env.BINARY_NAME }}*
name: ${{ env.CLI_BINARY_NAME }}_${{ matrix.platform_name }}
path: |
target/${{ matrix.build_target }}/release/${{ env.CLI_BINARY_NAME }}${{ matrix.os == 'windows-latest' && '.exe' || '' }}
- name: Upload Desktop artifact
# Only upload desktop binary for x86_64 platforms (not ARM64)
if: matrix.build_target != 'aarch64-unknown-linux-gnu'
uses: actions/upload-artifact@v4
with:
name: ${{ env.DESKTOP_BINARY_NAME }}_${{ matrix.platform_name }}
path: |
target/${{ matrix.build_target }}/release/${{ env.DESKTOP_BINARY_NAME }}${{ matrix.os == 'windows-latest' && '.exe' || '' }}
continue-on-error: true # Don't fail if desktop binary doesn't build on some platforms
release:
name: Create Release Page
name: Create Release and Upload Assets
if: always() # Always run even if some builds fail
needs: build
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
path: artifacts/
- name: Prepare release assets
run: |
mkdir -p release-assets/
# Copy files with proper naming from each artifact directory
for artifact_dir in artifacts/*/; do
if [[ -d "$artifact_dir" ]]; then
artifact_name=$(basename "$artifact_dir")
echo "Processing artifact: $artifact_name"
# Extract binary type and platform from artifact name
if [[ "$artifact_name" =~ ^khm-desktop_(.*)$ ]]; then
binary_type="desktop"
platform="${BASH_REMATCH[1]}"
binary_name="${{ env.DESKTOP_BINARY_NAME }}"
elif [[ "$artifact_name" =~ ^khm_(.*)$ ]]; then
binary_type="cli"
platform="${BASH_REMATCH[1]}"
binary_name="${{ env.CLI_BINARY_NAME }}"
else
echo "Unknown artifact format: $artifact_name"
continue
fi
echo "Binary type: $binary_type, Platform: $platform, Binary name: $binary_name"
# For Windows, look for .exe file specifically
if [[ "$platform" == "windows-amd64" ]]; then
exe_file=$(find "$artifact_dir" -name "${binary_name}.exe" -type f | head -1)
if [[ -n "$exe_file" ]]; then
cp "$exe_file" "release-assets/${binary_name}_${platform}.exe"
echo "Copied: $exe_file -> release-assets/${binary_name}_${platform}.exe"
fi
else
# For Linux/macOS, look for binary without extension
binary_file=$(find "$artifact_dir" -name "${binary_name}" -type f | head -1)
if [[ -n "$binary_file" ]]; then
cp "$binary_file" "release-assets/${binary_name}_${platform}"
echo "Copied: $binary_file -> release-assets/${binary_name}_${platform}"
fi
fi
fi
done
echo "Final release assets:"
ls -la release-assets/
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: Release ${{ github.ref_name }}
files: release-assets/*
draft: false
prerelease: false
upload:
name: Upload Release Assets
needs: release
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
platform_name: linux-amd64
- os: windows-latest
platform_name: windows-amd64
- os: macos-latest
platform_name: macos-arm64
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
name: Download ${{ matrix.platform_name }} artifact
with:
name: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }}
path: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }}
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }}/${{ env.BINARY_NAME }}${{ matrix.platform_name == 'windows-amd64' && '.exe' || '' }}
asset_name: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }}${{ matrix.platform_name == 'windows-amd64' && '.exe' || '' }}
asset_content_type: application/octet-stream
generate_release_notes: true
fail_on_unmatched_files: false
build_docker:
name: Build and Publish Docker Image
@@ -141,15 +223,26 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
name: Download Linux artifact
- name: Download Linux AMD64 CLI artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.BINARY_NAME }}_linux-amd64
path: .
name: ${{ env.CLI_BINARY_NAME }}_linux-amd64
path: amd64/
- name: ls
- name: Download Linux ARM64 CLI artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.CLI_BINARY_NAME }}_linux-arm64
path: arm64/
- name: Prepare binaries for multi-arch build
run: |
ls -lah
mkdir -p bin/linux_amd64 bin/linux_arm64
cp amd64/${{ env.CLI_BINARY_NAME }} bin/linux_amd64/${{ env.CLI_BINARY_NAME }}
cp arm64/${{ env.CLI_BINARY_NAME }} bin/linux_arm64/${{ env.CLI_BINARY_NAME }}
chmod +x bin/linux_amd64/${{ env.CLI_BINARY_NAME }}
chmod +x bin/linux_arm64/${{ env.CLI_BINARY_NAME }}
ls -la bin/*/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -163,10 +256,6 @@ jobs:
username: ultradesu
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set exec flag
run: |
chmod +x ${{ env.BINARY_NAME }}
- name: Set outputs
id: get_tag
run: |
@@ -178,5 +267,5 @@ jobs:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ultradesu/${{ env.BINARY_NAME }}:latest,ultradesu/${{ env.BINARY_NAME }}:${{ steps.get_tag.outputs.tag }}
tags: ultradesu/${{ env.CLI_BINARY_NAME }}:latest,ultradesu/${{ env.CLI_BINARY_NAME }}:${{ steps.get_tag.outputs.tag }}

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
/target
*.swp
*.swo
.claude/
khm-wasm/target

3945
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,80 @@
[package]
name = "khm"
version = "0.4.1"
version = "0.7.1"
edition = "2021"
authors = ["AB <ab@hexor.cy>"]
description = "KHM - Known Hosts Manager for SSH key management and synchronization"
homepage = "https://github.com/house-of-vanity/khm"
repository = "https://github.com/house-of-vanity/khm"
license = "WTFPL"
keywords = ["ssh", "known-hosts", "security", "system-admin", "automation"]
categories = ["command-line-utilities", "network-programming"]
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "khm"
path = "src/bin/cli.rs"
[[bin]]
name = "khm-desktop"
path = "src/bin/desktop.rs"
required-features = ["gui"]
[dependencies]
actix-web = "4"
actix-web = { version = "4", optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
env_logger = "0.11.3"
log = "0.4"
regex = "1.10.5"
base64 = "0.21"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
clap = { version = "4", features = ["derive"] }
chrono = "0.4.38"
reqwest = { version = "0.12", features = ["json"] }
hostname = "0.3"
regex = { version = "1.10.5", optional = true }
base64 = { version = "0.21", optional = true }
tokio = { version = "1", features = ["full", "sync"], optional = true }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true }
tokio-util = { version = "0.7", features = ["codec"], optional = true }
clap = { version = "4", features = ["derive"], optional = true }
chrono = { version = "0.4.38", features = ["serde"], optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true }
trust-dns-resolver = { version = "0.23", optional = true }
futures = { version = "0.3", optional = true }
hostname = { version = "0.3", optional = true }
rust-embed = { version = "8.0", optional = true }
tray-icon = { version = "0.21", optional = true }
notify = { version = "6.1", optional = true }
notify-debouncer-mini = { version = "0.4", optional = true }
dirs = "5.0"
eframe = { version = "0.29", optional = true }
egui = { version = "0.29", optional = true }
wasm-bindgen-futures = { version = "0.4", optional = true }
web-sys = { version = "0.3", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
tracing-wasm = { version = "0.2", optional = true }
getrandom = { version = "0.2", features = ["js"], optional = true }
winit = { version = "0.30", optional = true }
env_logger = "0.11"
urlencoding = "2.1"
# Linux-specific dependencies for GTK tray support
[target.'cfg(target_os = "linux")'.dependencies]
gtk = { version = "0.18", optional = true }
glib = { version = "0.18", optional = true }
[features]
default = ["server", "web", "gui"]
cli = ["server", "web", "web-gui"]
desktop = ["gui"]
gui = ["tray-icon", "eframe", "egui", "winit", "notify", "notify-debouncer-mini", "gtk", "glib"]
web-gui = ["egui", "eframe", "wasm-bindgen-futures", "web-sys", "wasm-bindgen", "console_error_panic_hook", "tracing-wasm", "getrandom"]
web-gui-wasm = ["web-gui"]
server = ["actix-web", "tokio", "tokio-postgres", "tokio-util", "clap", "chrono", "regex", "base64", "futures", "hostname", "rust-embed", "trust-dns-resolver", "reqwest"]
web = ["server"]
# Target-specific dependencies for cross-compilation
[target.aarch64-unknown-linux-gnu.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
# WASM-specific dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }

View File

@@ -1,5 +1,19 @@
# syntax=docker/dockerfile:1
FROM alpine:latest
COPY khm /usr/local/bin/khm
FROM debian:12-slim
# Install only essential runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy the CLI binary (without GUI dependencies)
ARG TARGETARCH
COPY bin/linux_${TARGETARCH}/khm /usr/local/bin/khm
RUN chmod +x /usr/local/bin/khm
# Create non-root user
RUN useradd -m -u 1000 khm
USER khm
ENTRYPOINT ["/usr/local/bin/khm"]

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.

162
README.MD
View File

@@ -1,64 +1,166 @@
# KHM - Known Hosts Manager
KHM allows you to synchronize the `known_hosts` file across multiple hosts. This application manages SSH keys and flows, either as a server or client. In server mode, it stores keys and flows in a PostgreSQL database. In client mode, it sends keys to the server and can update the `known_hosts` file with keys from the server.
KHM is a comprehensive SSH key management tool that allows you to synchronize `known_hosts` files across multiple hosts and environments. The application supports multiple operation modes: server mode for centralized key storage, client mode for synchronization, and GUI mode for easy management.
## Features
- Synchronize `known_hosts` file across multiple hosts.
- Manage SSH keys and flows in a PostgreSQL database.
- Operate in both server and client modes.
- Automatically update `known_hosts` file with keys from the server.
- **Multi-mode operation**: Server, client, and GUI modes
- **Centralized key management**: Store SSH keys and flows in PostgreSQL database
- **Cross-platform GUI**: Modern tray application with settings window
- **Automatic synchronization**: Keep `known_hosts` files updated across environments
- **Flow-based organization**: Manage different environments (production, staging, development)
- **Authentication support**: Basic authentication for secure API access
- **Real-time monitoring**: Auto-sync capabilities with configurable intervals
## Usage
## Operation Modes
### Server Mode
To run the application in server mode, use the following command:
Runs a web server that stores and manages SSH keys in a PostgreSQL database.
```bash
khm --server --ip 127.0.0.1 --port 8080 --db-host 127.0.0.1 --db-name khm --db-user admin --db-password <SECRET> --flows work,home
khm --server --ip 0.0.0.0 --port 1337 --db-host psql.psql.svc --db-name khm --db-user admin --db-password <SECRET> --flows work,home
```
### Client Mode
To run the application in client mode, use the following command:
Connects to a KHM server to send local keys and optionally sync the `known_hosts` file.
```bash
khm --host http://khm.example.com:8080/<FLOW_NAME>/ --known-hosts ~/.ssh/known_hosts --in-place
khm --host https://khm.example.com --flow work --known-hosts ~/.ssh/known_hosts --in-place
```
### Arguments
Options:
- `--server` Run in server mode
- `--in-place` Server mode: Sync the known_hosts file with keys from the server
- `--flows <FLOWS>...` Server mode: Comma-separated list of flows to manage [default: default]
- `-i, --ip <IP>` Server mode: IP address to bind the server to [default: 127.0.0.1]
- `-p, --port <PORT>` Server mode: Port to bind the server to [default: 8080]
- `--db-host <DB_HOST>` Server mode: Hostname or IP address of the PostgreSQL database [default: 127.0.0.1]
- `--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
- `--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]
### GUI Mode
Launches a system tray application with a modern interface for easy management.
```bash
# Run tray application
khm --gui
# Run settings window only
khm --settings-ui
```
## Command Line Arguments
### General Options
- `--server` - Run in server mode
- `--gui` - Run with GUI tray interface
- `--settings-ui` - Run settings UI window (used with --gui)
### Server Mode Options
- `-i, --ip <IP>` - IP address to bind the server to [default: 127.0.0.1]
- `-p, --port <PORT>` - Port to bind the server to [default: 8080]
- `--flows <FLOWS>` - Comma-separated list of flows to manage [default: default]
- `--db-host <DB_HOST>` - PostgreSQL database hostname [default: 127.0.0.1]
- `--db-name <DB_NAME>` - PostgreSQL database name [default: khm]
- `--db-user <DB_USER>` - PostgreSQL database username (required)
- `--db-password <DB_PASSWORD>` - PostgreSQL database password (required)
### Client Mode Options
- `--host <HOST>` - Server URL (e.g., https://khm.example.com) (required)
- `--flow <FLOW>` - Flow name to use on the server (required)
- `--known-hosts <PATH>` - Path to known_hosts file [default: ~/.ssh/known_hosts]
- `--in-place` - Update known_hosts file with server keys after sync
- `--basic-auth <CREDENTIALS>` - Basic authentication (format: user:pass)
## GUI Features
The GUI mode provides:
- **System Tray Integration**: Runs quietly in the system tray
- **Settings Management**: Easy configuration through modern UI
- **Connection Testing**: Built-in server connectivity testing
- **Manual Synchronization**: On-demand sync operations
- **Auto-sync Configuration**: Configurable automatic synchronization intervals
- **Operation Logging**: Real-time activity monitoring
- **Cross-platform Paths**: Automatic path handling for different operating systems
## Installation
1. Ensure you have Rust installed. If not, you can install it from [rustup.rs](https://rustup.rs/).
### From Binary Releases
Download the latest binary from the [Releases](https://github.com/house-of-vanity/khm/releases) page.
### From Source
1. Install Rust from [rustup.rs](https://rustup.rs/)
2. Clone the repository:
```bash
git clone https://github.com/house-of-vanity/khm.git
cd khm
```
3. Run the project:
3. Build and run:
```bash
cargo run --release -- --help
# Build both binaries (CLI without GUI, Desktop with GUI)
cargo build --release --bin khm --no-default-features --features cli
cargo build --release --bin khm-desktop
# Or build all at once with default features
cargo build --release
```
### System Dependencies
For GUI features on Linux:
```bash
# Build dependencies
sudo apt-get install libgtk-3-dev libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev libgdk-pixbuf2.0-dev
```
## Configuration
### GUI Configuration
Settings are automatically saved to:
- **Windows**: `%USERPROFILE%\.khm\khm_config.json`
- **macOS**: `~/.khm/khm_config.json`
- **Linux**: `~/.khm/khm_config.json`
### Example Configuration
```json
{
"host": "https://khm.example.com",
"flow": "production",
"known_hosts": "/home/user/.ssh/known_hosts",
"basic_auth": "",
"in_place": true,
"auto_sync_interval_minutes": 60
}
```
## Examples
### Complete Server Setup
```bash
# Start server with multiple flows
khm --server \
--ip 0.0.0.0 \
--port 8080 \
--db-host localhost \
--db-name khm \
--db-user khm_user \
--db-password secure_password \
--flows production,staging,development
```
### Client Synchronization
```bash
# Send keys and update local known_hosts
khm --host https://khm.company.com \
--flow production \
--known-hosts ~/.ssh/known_hosts \
--in-place \
--basic-auth "username:password"
```
### GUI Usage
```bash
# Launch tray application
khm --gui
# Open settings window directly
khm --settings-ui
```
## Contributing
Contributions are welcome! Please open an issue or submit a pull request for any changes.
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
## License
This project is licensed under the WTFPL License.
This project is licensed under the WTFPL License - see the [LICENSE](LICENSE) file for details.

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
version: '3.8'
services:
khm:
image: ultradesu/khm:latest
restart: unless-stopped
environment:
# Server mode configuration
- KHM_SERVER=true
- KHM_IP=0.0.0.0
- KHM_PORT=8080
- KHM_DB_HOST=postgres
- KHM_DB_NAME=khm
- KHM_DB_USER=khm
- KHM_DB_PASSWORD=changeme
- KHM_FLOWS=prod,staging,dev
ports:
- "8080:8080"
depends_on:
- postgres
command: ["--server", "--ip", "0.0.0.0", "--port", "8080", "--db-host", "postgres", "--db-name", "khm", "--db-user", "khm", "--db-password", "changeme", "--flows", "prod,staging,dev"]
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=khm
- POSTGRES_PASSWORD=changeme
- POSTGRES_DB=khm
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

2715
khm-wasm/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
khm-wasm/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "khm-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
eframe = { version = "0.29", default-features = false, features = ["glow"] }
egui = "0.29"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console", "Headers", "Request", "RequestInit", "RequestMode", "Response", "Window"] }
console_error_panic_hook = "0.1"
tracing-wasm = "0.2"
getrandom = { version = "0.2", features = ["js"] }
serde-wasm-bindgen = "0.6"
[features]
default = []

1698
khm-wasm/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

180
src/bin/cli.rs Normal file
View File

@@ -0,0 +1,180 @@
use khm::{client, server, Args};
use clap::Parser;
use env_logger;
use log::{error, info};
/// CLI version of KHM - Known Hosts Manager for SSH key management and synchronization
/// Supports server and client modes without GUI dependencies
#[derive(Parser, Debug, Clone)]
#[command(
author = env!("CARGO_PKG_AUTHORS"),
version = env!("CARGO_PKG_VERSION"),
about = "SSH Host Key Manager (CLI with Server)",
long_about = None,
after_help = "Examples:\n\
\n\
Running in server mode:\n\
khm --server --ip 0.0.0.0 --port 1337 --db-host psql.psql.svc --db-name khm --db-user admin --db-password <SECRET> --flows work,home\n\
\n\
Running in client mode to send diff and sync ~/.ssh/known_hosts with remote flow `work` in place:\n\
khm --host https://khm.example.com --flow work --known-hosts ~/.ssh/known_hosts --in-place\n\
\n\
"
)]
pub struct CliArgs {
/// Run in server mode (default: false)
#[arg(long, help = "Run in server mode")]
pub server: bool,
/// Update the known_hosts file with keys from the server after sending keys (default: false)
#[arg(
long,
help = "Server mode: Sync the known_hosts file with keys from the server"
)]
pub in_place: bool,
/// Comma-separated list of flows to manage (default: default)
#[arg(long, default_value = "default", value_parser, num_args = 1.., value_delimiter = ',', help = "Server mode: Comma-separated list of flows to manage")]
pub flows: Vec<String>,
/// IP address to bind the server or client to (default: 127.0.0.1)
#[arg(
short,
long,
default_value = "127.0.0.1",
help = "Server mode: IP address to bind the server to"
)]
pub ip: String,
/// Port to bind the server or client to (default: 8080)
#[arg(
short,
long,
default_value = "8080",
help = "Server mode: Port to bind the server to"
)]
pub port: u16,
/// Hostname or IP address of the PostgreSQL database (default: 127.0.0.1)
#[arg(
long,
default_value = "127.0.0.1",
help = "Server mode: Hostname or IP address of the PostgreSQL database"
)]
pub db_host: String,
/// Name of the PostgreSQL database (default: khm)
#[arg(
long,
default_value = "khm",
help = "Server mode: Name of the PostgreSQL database"
)]
pub db_name: String,
/// Username for the PostgreSQL database (required in server mode)
#[arg(
long,
required_if_eq("server", "true"),
help = "Server mode: Username for the PostgreSQL database"
)]
pub db_user: Option<String>,
/// Password for the PostgreSQL database (required in server mode)
#[arg(
long,
required_if_eq("server", "true"),
help = "Server mode: Password for the PostgreSQL database"
)]
pub db_password: Option<String>,
/// Host address of the server to connect to in client mode (required in client mode)
#[arg(
long,
required_if_eq("server", "false"),
help = "Client mode: Full host address of the server to connect to. Like https://khm.example.com"
)]
pub host: Option<String>,
/// Flow name to use on the server
#[arg(
long,
required_if_eq("server", "false"),
help = "Client mode: Flow name to use on the server"
)]
pub flow: Option<String>,
/// Path to the known_hosts file (default: ~/.ssh/known_hosts)
#[arg(
long,
default_value = "~/.ssh/known_hosts",
help = "Client mode: Path to the known_hosts file"
)]
pub known_hosts: String,
/// Basic auth string for client mode. Format: user:pass
#[arg(long, default_value = "", help = "Client mode: Basic Auth credentials")]
pub basic_auth: String,
}
impl From<CliArgs> for Args {
fn from(cli_args: CliArgs) -> Self {
Args {
server: cli_args.server,
daemon: false,
settings_ui: false,
in_place: cli_args.in_place,
flows: cli_args.flows,
ip: cli_args.ip,
port: cli_args.port,
db_host: cli_args.db_host,
db_name: cli_args.db_name,
db_user: cli_args.db_user,
db_password: cli_args.db_password,
host: cli_args.host,
flow: cli_args.flow,
known_hosts: cli_args.known_hosts,
basic_auth: cli_args.basic_auth,
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Configure logging to show only khm logs, filtering out noisy library logs
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Warn) // Default level for all modules
.filter_module("khm", log::LevelFilter::Debug) // Our app logs
.filter_module("actix_web", log::LevelFilter::Info) // Server logs
.filter_module("reqwest", log::LevelFilter::Warn) // HTTP client
.init();
info!("Starting SSH Key Manager (CLI)");
let cli_args = CliArgs::parse();
let args: Args = cli_args.into();
// Validate arguments - either server mode or client mode with required args
if !args.server && (args.host.is_none() || args.flow.is_none()) {
error!("CLI version requires either --server mode or client mode with --host and --flow arguments");
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid arguments for CLI mode",
));
}
if args.server {
info!("Running in server mode");
if let Err(e) = server::run_server(args).await {
error!("Failed to run server: {}", e);
}
} else {
info!("Running in client mode");
if let Err(e) = client::run_client(args).await {
error!("Failed to run client: {}", e);
}
}
info!("Application has exited");
Ok(())
}

142
src/bin/desktop.rs Normal file
View File

@@ -0,0 +1,142 @@
use khm::{gui, Args};
use clap::Parser;
use env_logger;
use log::{error, info};
/// Desktop version of KHM - Known Hosts Manager with GUI interface
/// Primarily runs in GUI mode with tray application and settings windows
#[derive(Parser, Debug, Clone)]
#[command(
author = env!("CARGO_PKG_AUTHORS"),
version = env!("CARGO_PKG_VERSION"),
about = "SSH Host Key Manager (Desktop)",
long_about = None,
after_help = "Examples:\n\
\n\
Running in GUI tray mode (default):\n\
khm-desktop\n\
\n\
Running in GUI tray mode with background daemon:\n\
khm-desktop --daemon\n\
\n\
Running settings window:\n\
khm-desktop --settings-ui\n\
\n\
"
)]
pub struct DesktopArgs {
/// Hide console window and run in background (default: auto when no arguments)
#[arg(long, help = "Hide console window and run in background")]
pub daemon: bool,
/// Run settings UI window
#[arg(long, help = "Run settings UI window")]
pub settings_ui: bool,
}
impl From<DesktopArgs> for Args {
fn from(desktop_args: DesktopArgs) -> Self {
Args {
server: false,
daemon: desktop_args.daemon,
settings_ui: desktop_args.settings_ui,
in_place: false,
flows: vec!["default".to_string()],
ip: "127.0.0.1".to_string(),
port: 8080,
db_host: "127.0.0.1".to_string(),
db_name: "khm".to_string(),
db_user: None,
db_password: None,
host: None,
flow: None,
known_hosts: "~/.ssh/known_hosts".to_string(),
basic_auth: String::new(),
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Configure logging to show only khm logs, filtering out noisy library logs
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Warn) // Default level for all modules
.filter_module("khm", log::LevelFilter::Debug) // Our app logs
.filter_module("winit", log::LevelFilter::Error) // Window management
.filter_module("egui", log::LevelFilter::Error) // GUI framework
.filter_module("eframe", log::LevelFilter::Error) // GUI framework
.filter_module("tray_icon", log::LevelFilter::Error) // Tray icon
.filter_module("wgpu", log::LevelFilter::Error) // Graphics
.filter_module("naga", log::LevelFilter::Error) // Graphics
.filter_module("glow", log::LevelFilter::Error) // Graphics
.filter_module("tracing", log::LevelFilter::Error) // Tracing spans
.init();
info!("Starting SSH Key Manager (Desktop)");
let desktop_args = DesktopArgs::parse();
let args: Args = desktop_args.into();
// Hide console on Windows if daemon flag is set
if args.daemon {
#[cfg(target_os = "windows")]
{
extern "system" {
fn FreeConsole() -> i32;
}
unsafe {
FreeConsole();
}
}
}
// Settings UI mode - just show settings window and exit
if args.settings_ui {
// Always hide console for settings window
#[cfg(target_os = "windows")]
{
extern "system" {
fn FreeConsole() -> i32;
}
unsafe {
FreeConsole();
}
}
#[cfg(feature = "gui")]
{
info!("Running settings UI window");
gui::run_settings_window();
return Ok(());
}
#[cfg(not(feature = "gui"))]
{
error!("GUI features not compiled. Install system dependencies and rebuild with --features gui");
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"GUI features not compiled",
));
}
}
// Default to GUI mode for desktop version
info!("Running in GUI mode");
#[cfg(feature = "gui")]
{
if let Err(e) = gui::run_gui().await {
error!("Failed to run GUI: {}", e);
}
}
#[cfg(not(feature = "gui"))]
{
error!("GUI features not compiled. Install system dependencies and rebuild with --features gui");
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"GUI features not compiled",
));
}
info!("Application has exited");
Ok(())
}

View File

@@ -8,12 +8,14 @@ use std::io::{self, BufRead, Write};
use std::path::Path;
#[derive(Serialize, Deserialize, Clone, Debug)]
struct SshKey {
pub struct SshKey {
server: String,
public_key: String,
#[serde(default)]
deprecated: bool,
}
fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> {
pub fn read_known_hosts(file_path: &str) -> io::Result<Vec<SshKey>> {
let path = Path::new(file_path);
let file = File::open(&path)?;
let reader = io::BufReader::new(file);
@@ -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,17 @@ 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 +175,55 @@ 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");
let flow = args.flow.expect("flow is required in client mode");
let url = format!("{}/{}", host, flow);
info!("Client mode: Sending keys to server at {}", url);
if let Err(e) = send_keys_to_server(&url, 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(&url, &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");

530
src/db.rs
View File

@@ -1,8 +1,10 @@
use crate::server::SshKey;
use log::info;
use log::{error, info};
use std::collections::HashMap;
use std::collections::HashSet;
use tokio_postgres::Client;
use tokio_postgres::tls::NoTlsStream;
use tokio_postgres::Socket;
use tokio_postgres::{Client, Connection, NoTls};
// Structure for storing key processing statistics
pub struct KeyInsertStats {
@@ -12,11 +14,59 @@ pub struct KeyInsertStats {
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> {
// Simple database client that exits on connection errors
pub struct DbClient {
client: Client,
}
impl DbClient {
pub async fn connect(
connection_string: &str,
) -> Result<(Self, Connection<Socket, NoTlsStream>), tokio_postgres::Error> {
info!("Connecting to database...");
let (client, connection) = tokio_postgres::connect(connection_string, NoTls).await?;
info!("Successfully connected to database");
Ok((DbClient { client }, connection))
}
// Helper function to handle database errors - exits the application on connection errors
fn handle_db_error<T>(
result: Result<T, tokio_postgres::Error>,
operation: &str,
) -> Result<T, tokio_postgres::Error> {
match result {
Ok(value) => Ok(value),
Err(e) => {
if Self::is_connection_error(&e) {
error!("Database connection lost during {}: {}", operation, e);
error!("Exiting application due to database connection failure");
std::process::exit(1);
} else {
// For non-connection errors, just return the error
Err(e)
}
}
}
}
fn is_connection_error(error: &tokio_postgres::Error) -> bool {
// Check if the error is related to connection issues
let error_str = error.to_string();
error_str.contains("connection closed")
|| error_str.contains("connection reset")
|| error_str.contains("broken pipe")
|| error_str.contains("Connection refused")
|| error_str.contains("connection terminated")
|| error.as_db_error().is_none() // Non-database errors are often connection issues
}
pub async fn initialize_schema(&self) -> Result<(), tokio_postgres::Error> {
info!("Checking and initializing database schema if needed");
// Check if tables exist by querying information_schema
let tables_exist = client
let result = self
.client
.query(
"SELECT EXISTS (
SELECT FROM information_schema.tables
@@ -29,7 +79,9 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres:
)",
&[],
)
.await?
.await;
let tables_exist = Self::handle_db_error(result, "checking table existence")?
.get(0)
.map(|row| row.get::<_, bool>(0))
.unwrap_or(false);
@@ -38,21 +90,25 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres:
info!("Database schema doesn't exist. Creating tables...");
// Create the keys table
client
let result = self
.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,
deprecated BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT unique_host_key UNIQUE (host, key)
)",
&[],
)
.await?;
.await;
Self::handle_db_error(result, "creating keys table")?;
// Create the flows table
client
let result = self
.client
.execute(
"CREATE TABLE IF NOT EXISTS public.flows (
flow_id SERIAL PRIMARY KEY,
@@ -66,26 +122,60 @@ pub async fn initialize_db_schema(client: &Client) -> Result<(), tokio_postgres:
)",
&[],
)
.await?;
.await;
Self::handle_db_error(result, "creating flows table")?;
// Create an index for faster lookups
client
let result = self
.client
.execute(
"CREATE INDEX IF NOT EXISTS idx_flows_name ON public.flows(name)",
&[],
)
.await?;
.await;
Self::handle_db_error(result, "creating index")?;
info!("Database schema created successfully");
} else {
info!("Database schema already exists");
// Check if deprecated column exists, add it if missing (migration)
let result = self
.client
.query(
"SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'keys'
AND column_name = 'deprecated'
)",
&[],
)
.await;
let column_exists = Self::handle_db_error(result, "checking deprecated column")?
.get(0)
.map(|row| row.get::<_, bool>(0))
.unwrap_or(false);
if !column_exists {
info!("Adding deprecated column to existing keys table...");
let result = self.client
.execute(
"ALTER TABLE public.keys ADD COLUMN deprecated BOOLEAN NOT NULL DEFAULT FALSE",
&[],
)
.await;
Self::handle_db_error(result, "adding deprecated column")?;
info!("Migration completed: deprecated column added");
}
}
Ok(())
}
pub async fn batch_insert_keys(
client: &Client,
&self,
keys: &[SshKey],
) -> Result<KeyInsertStats, tokio_postgres::Error> {
if keys.is_empty() {
@@ -106,9 +196,10 @@ 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 {
@@ -124,24 +215,34 @@ pub async fn batch_insert_keys(
params.push(&key_values[i]);
}
let rows = client.query(&key_query, &params[..]).await?;
let result = self.client.query(&key_query, &params[..]).await;
let rows = Self::handle_db_error(result, "checking existing keys")?;
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);
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());
}
}
@@ -150,7 +251,8 @@ pub async fn batch_insert_keys(
// 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_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;
@@ -167,7 +269,8 @@ pub async fn batch_insert_keys(
insert_sql.push_str(" RETURNING key_id, host, key");
let inserted_rows = client.query(&insert_sql, &insert_params[..]).await?;
let result = self.client.query(&insert_sql, &insert_params[..]).await;
let inserted_rows = Self::handle_db_error(result, "inserting keys")?;
for row in inserted_rows {
let host: String = row.get(1);
@@ -200,15 +303,15 @@ 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)
}
pub async fn batch_insert_flow_keys(
client: &Client,
&self,
flow_name: &str,
key_ids: &[i32],
) -> Result<usize, tokio_postgres::Error> {
@@ -236,7 +339,8 @@ pub async fn batch_insert_flow_keys(
params.push(key_id);
}
let rows = client.query(&existing_query, &params[..]).await?;
let result = self.client.query(&existing_query, &params[..]).await;
let rows = Self::handle_db_error(result, "checking existing flow associations")?;
let mut existing_associations = HashSet::new();
for row in rows {
@@ -280,7 +384,8 @@ pub async fn batch_insert_flow_keys(
}
// Execute query
let affected = client.execute(&sql, &insert_params[..]).await?;
let result = self.client.execute(&sql, &insert_params[..]).await;
let affected = Self::handle_db_error(result, "inserting flow associations")?;
let affected_usize = affected as usize;
@@ -293,3 +398,378 @@ pub async fn batch_insert_flow_keys(
Ok(affected_usize)
}
pub async fn get_keys_from_db(
&self,
) -> Result<Vec<crate::server::Flow>, tokio_postgres::Error> {
let result = self.client.query(
"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;
let rows = Self::handle_db_error(result, "getting keys from database")?;
let mut flows_map: HashMap<String, crate::server::Flow> = HashMap::new();
for row in rows {
let host: String = row.get(0);
let key: String = row.get(1);
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) {
flow_entry.servers.push(ssh_key);
} else {
flows_map.insert(
flow.clone(),
crate::server::Flow {
name: flow,
servers: vec![ssh_key],
},
);
}
}
info!("Retrieved {} flows from database", flows_map.len());
Ok(flows_map.into_values().collect())
}
pub async fn deprecate_key_by_server(
&self,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// Update keys to deprecated status for the given server
let result = self
.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;
let affected = Self::handle_db_error(result, "deprecating key")?;
info!(
"Deprecated {} key(s) for server '{}' in flow '{}'",
affected, server_name, flow_name
);
Ok(affected)
}
pub async fn bulk_deprecate_keys_by_servers(
&self,
server_names: &[String],
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
if server_names.is_empty() {
return Ok(0);
}
// Update keys to deprecated status for multiple servers in one query
let result = self
.client
.execute(
"UPDATE public.keys
SET deprecated = TRUE, updated = NOW()
WHERE host = ANY($1)
AND key_id IN (
SELECT key_id FROM public.flows WHERE name = $2
)",
&[&server_names, &flow_name],
)
.await;
let affected = Self::handle_db_error(result, "bulk deprecating keys")?;
info!(
"Bulk deprecated {} key(s) for {} servers in flow '{}'",
affected,
server_names.len(),
flow_name
);
Ok(affected)
}
pub async fn bulk_restore_keys_by_servers(
&self,
server_names: &[String],
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
if server_names.is_empty() {
return Ok(0);
}
// Update keys to active status for multiple servers in one query
let result = self
.client
.execute(
"UPDATE public.keys
SET deprecated = FALSE, updated = NOW()
WHERE host = ANY($1)
AND deprecated = TRUE
AND key_id IN (
SELECT key_id FROM public.flows WHERE name = $2
)",
&[&server_names, &flow_name],
)
.await;
let affected = Self::handle_db_error(result, "bulk restoring keys")?;
info!(
"Bulk restored {} key(s) for {} servers in flow '{}'",
affected,
server_names.len(),
flow_name
);
Ok(affected)
}
pub async fn restore_key_by_server(
&self,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// Update keys to active status for the given server in the flow
let result = self
.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;
let affected = Self::handle_db_error(result, "restoring key")?;
info!(
"Restored {} key(s) for server '{}' in flow '{}'",
affected, server_name, flow_name
);
Ok(affected)
}
pub async fn permanently_delete_key_by_server(
&self,
server_name: &str,
flow_name: &str,
) -> Result<u64, tokio_postgres::Error> {
// First, find the key_ids for the given server in the flow
let result = self
.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;
let key_rows = Self::handle_db_error(result, "finding keys to delete")?;
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 result = self
.client
.execute(
"DELETE FROM public.flows WHERE name = $1 AND key_id = $2",
&[&flow_name, key_id],
)
.await;
let deleted = Self::handle_db_error(result, "deleting flow association")?;
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 result = self
.client
.query_one(
"SELECT COUNT(*) FROM public.flows WHERE key_id = $1",
&[key_id],
)
.await;
let count: i64 = Self::handle_db_error(result, "checking key references")?.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 result = self
.client
.execute("DELETE FROM public.keys WHERE key_id = $1", &[&key_id])
.await;
let deleted = Self::handle_db_error(result, "deleting key")?;
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))
}
}
// Compatibility wrapper for transition
pub struct ReconnectingDbClient {
inner: Option<DbClient>,
}
impl ReconnectingDbClient {
pub fn new(_connection_string: String) -> Self {
Self { inner: None }
}
pub async fn connect(&mut self, connection_string: &str) -> Result<(), tokio_postgres::Error> {
let (client, connection) = DbClient::connect(connection_string).await?;
// Spawn connection handler that will exit on error
tokio::spawn(async move {
if let Err(e) = connection.await {
error!("Database connection error: {}", e);
error!("Exiting application due to database connection failure");
std::process::exit(1);
}
});
self.inner = Some(client);
Ok(())
}
pub async fn initialize_schema(&self) -> Result<(), tokio_postgres::Error> {
match &self.inner {
Some(client) => client.initialize_schema().await,
None => panic!("Database client not initialized"),
}
}
pub async fn batch_insert_keys_reconnecting(
&self,
keys: Vec<SshKey>,
) -> Result<KeyInsertStats, tokio_postgres::Error> {
match &self.inner {
Some(client) => client.batch_insert_keys(&keys).await,
None => panic!("Database client not initialized"),
}
}
pub async fn batch_insert_flow_keys_reconnecting(
&self,
flow_name: String,
key_ids: Vec<i32>,
) -> Result<usize, tokio_postgres::Error> {
match &self.inner {
Some(client) => client.batch_insert_flow_keys(&flow_name, &key_ids).await,
None => panic!("Database client not initialized"),
}
}
pub async fn get_keys_from_db_reconnecting(
&self,
) -> Result<Vec<crate::server::Flow>, tokio_postgres::Error> {
match &self.inner {
Some(client) => client.get_keys_from_db().await,
None => panic!("Database client not initialized"),
}
}
pub async fn deprecate_key_by_server_reconnecting(
&self,
server_name: String,
flow_name: String,
) -> Result<u64, tokio_postgres::Error> {
match &self.inner {
Some(client) => {
client
.deprecate_key_by_server(&server_name, &flow_name)
.await
}
None => panic!("Database client not initialized"),
}
}
pub async fn bulk_deprecate_keys_by_servers_reconnecting(
&self,
server_names: Vec<String>,
flow_name: String,
) -> Result<u64, tokio_postgres::Error> {
match &self.inner {
Some(client) => {
client
.bulk_deprecate_keys_by_servers(&server_names, &flow_name)
.await
}
None => panic!("Database client not initialized"),
}
}
pub async fn bulk_restore_keys_by_servers_reconnecting(
&self,
server_names: Vec<String>,
flow_name: String,
) -> Result<u64, tokio_postgres::Error> {
match &self.inner {
Some(client) => {
client
.bulk_restore_keys_by_servers(&server_names, &flow_name)
.await
}
None => panic!("Database client not initialized"),
}
}
pub async fn restore_key_by_server_reconnecting(
&self,
server_name: String,
flow_name: String,
) -> Result<u64, tokio_postgres::Error> {
match &self.inner {
Some(client) => client.restore_key_by_server(&server_name, &flow_name).await,
None => panic!("Database client not initialized"),
}
}
pub async fn permanently_delete_key_by_server_reconnecting(
&self,
server_name: String,
flow_name: String,
) -> Result<u64, tokio_postgres::Error> {
match &self.inner {
Some(client) => {
client
.permanently_delete_key_by_server(&server_name, &flow_name)
.await
}
None => panic!("Database client not initialized"),
}
}
}

9
src/gui/admin/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
#[cfg(feature = "gui")]
mod state;
#[cfg(feature = "gui")]
mod ui;
#[cfg(feature = "gui")]
pub use state::*;
#[cfg(feature = "gui")]
pub use ui::*;

185
src/gui/admin/state.rs Normal file
View File

@@ -0,0 +1,185 @@
use crate::gui::api::{fetch_keys, SshKey};
use crate::gui::common::KhmSettings;
use eframe::egui;
use log::{error, info};
use std::collections::HashMap;
use std::sync::mpsc;
#[derive(Debug, Clone)]
pub enum AdminOperation {
LoadingKeys,
DeprecatingKey,
RestoringKey,
DeletingKey,
BulkDeprecating,
BulkRestoring,
None,
}
#[derive(Debug, Clone)]
pub struct AdminState {
pub keys: Vec<SshKey>,
pub filtered_keys: Vec<SshKey>,
pub search_term: String,
pub show_deprecated_only: bool,
pub selected_servers: HashMap<String, bool>,
pub expanded_servers: HashMap<String, bool>,
pub current_operation: AdminOperation,
pub last_load_time: Option<std::time::Instant>,
}
impl Default for AdminState {
fn default() -> Self {
Self {
keys: Vec::new(),
filtered_keys: Vec::new(),
search_term: String::new(),
show_deprecated_only: false,
selected_servers: HashMap::new(),
expanded_servers: HashMap::new(),
current_operation: AdminOperation::None,
last_load_time: None,
}
}
}
impl AdminState {
/// Filter keys based on current search term and deprecated filter
pub fn filter_keys(&mut self) {
let mut filtered = self.keys.clone();
// Apply deprecated filter
if self.show_deprecated_only {
filtered.retain(|key| key.deprecated);
}
// Apply search filter
if !self.search_term.is_empty() {
let search_term = self.search_term.to_lowercase();
filtered.retain(|key| {
key.server.to_lowercase().contains(&search_term)
|| key.public_key.to_lowercase().contains(&search_term)
});
}
self.filtered_keys = filtered;
}
/// Load keys from server
pub fn load_keys(
&mut self,
settings: &KhmSettings,
ctx: &egui::Context,
) -> Option<mpsc::Receiver<Result<Vec<SshKey>, String>>> {
if settings.host.is_empty() || settings.flow.is_empty() {
return None;
}
self.current_operation = AdminOperation::LoadingKeys;
let (tx, rx) = mpsc::channel();
let host = settings.host.clone();
let flow = settings.flow.clone();
let basic_auth = settings.basic_auth.clone();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async { fetch_keys(host, flow, basic_auth).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
Some(rx)
}
/// Handle keys load result
pub fn handle_keys_loaded(&mut self, result: Result<Vec<SshKey>, String>) {
match result {
Ok(keys) => {
self.keys = keys;
self.last_load_time = Some(std::time::Instant::now());
self.filter_keys();
self.current_operation = AdminOperation::None;
info!("Keys loaded successfully: {} keys", self.keys.len());
}
Err(error) => {
self.current_operation = AdminOperation::None;
error!("Failed to load keys: {}", error);
}
}
}
/// Get selected servers list
pub fn get_selected_servers(&self) -> Vec<String> {
self.selected_servers
.iter()
.filter_map(|(server, &selected)| if selected { Some(server.clone()) } else { None })
.collect()
}
/// Clear selected servers
pub fn clear_selection(&mut self) {
self.selected_servers.clear();
}
/// Get statistics
pub fn get_statistics(&self) -> AdminStatistics {
let total_keys = self.keys.len();
let active_keys = self.keys.iter().filter(|k| !k.deprecated).count();
let deprecated_keys = total_keys - active_keys;
let unique_servers = self
.keys
.iter()
.map(|k| &k.server)
.collect::<std::collections::HashSet<_>>()
.len();
AdminStatistics {
total_keys,
active_keys,
deprecated_keys,
unique_servers,
}
}
}
#[derive(Debug, Clone)]
pub struct AdminStatistics {
pub total_keys: usize,
pub active_keys: usize,
pub deprecated_keys: usize,
pub unique_servers: usize,
}
/// Get SSH key type from public key string
pub fn get_key_type(public_key: &str) -> String {
if public_key.starts_with("ssh-rsa") {
"RSA".to_string()
} else if public_key.starts_with("ssh-ed25519") {
"ED25519".to_string()
} else if public_key.starts_with("ecdsa-sha2-nistp") {
"ECDSA".to_string()
} else if public_key.starts_with("ssh-dss") {
"DSA".to_string()
} else {
"Unknown".to_string()
}
}
/// Get preview of SSH key (first 12 characters of key part)
pub fn get_key_preview(public_key: &str) -> String {
let parts: Vec<&str> = public_key.split_whitespace().collect();
if parts.len() >= 2 {
let key_part = parts[1];
if key_part.len() > 12 {
format!("{}...", &key_part[..12])
} else {
key_part.to_string()
}
} else {
format!("{}...", &public_key[..std::cmp::min(12, public_key.len())])
}
}

630
src/gui/admin/ui.rs Normal file
View File

@@ -0,0 +1,630 @@
use super::state::{get_key_preview, get_key_type, AdminState};
use crate::gui::api::SshKey;
use eframe::egui;
use std::collections::BTreeMap;
/// Render statistics cards
pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) {
let stats = admin_state.get_statistics();
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("📊 Statistics").size(16.0).strong());
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.columns(4, |cols| {
// Total keys
cols[0].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("📊").size(20.0));
ui.label(
egui::RichText::new(stats.total_keys.to_string())
.size(24.0)
.strong(),
);
ui.label(
egui::RichText::new("Total Keys")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Active keys
cols[1].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
ui.label(
egui::RichText::new(stats.active_keys.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_GREEN),
);
ui.label(
egui::RichText::new("Active")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Deprecated keys
cols[2].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
ui.label(
egui::RichText::new(stats.deprecated_keys.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_RED),
);
ui.label(
egui::RichText::new("Deprecated")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Servers
cols[3].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("💻").size(20.0));
ui.label(
egui::RichText::new(stats.unique_servers.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_BLUE),
);
ui.label(
egui::RichText::new("Servers")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
});
});
});
});
}
/// Render search and filter controls
pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) -> bool {
let mut changed = false;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("🔍 Search").size(16.0).strong());
ui.add_space(8.0);
// Search field with full width
ui.horizontal(|ui| {
ui.label(egui::RichText::new("🔍").size(14.0));
let search_response = ui.add_sized(
[ui.available_width() * 0.6, 20.0],
egui::TextEdit::singleline(&mut admin_state.search_term)
.hint_text("Search servers or keys..."),
);
if admin_state.search_term.is_empty() {
ui.label(
egui::RichText::new("Type to search")
.size(11.0)
.color(egui::Color32::GRAY),
);
} else {
ui.label(
egui::RichText::new(format!("{} results", admin_state.filtered_keys.len()))
.size(11.0),
);
if ui
.add(
egui::Button::new(
egui::RichText::new("").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(170, 170, 170))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89)))
.rounding(egui::Rounding::same(3.0))
.min_size(egui::vec2(18.0, 18.0)),
)
.on_hover_text("Clear search")
.clicked()
{
admin_state.search_term.clear();
changed = true;
}
}
// Handle search text changes
if search_response.changed() {
changed = true;
}
});
ui.add_space(5.0);
// Filter controls
ui.horizontal(|ui| {
ui.label("Filter:");
let show_deprecated = admin_state.show_deprecated_only;
if ui.selectable_label(!show_deprecated, "✅ Active").clicked() {
admin_state.show_deprecated_only = false;
changed = true;
}
if ui
.selectable_label(show_deprecated, "❗ Deprecated")
.clicked()
{
admin_state.show_deprecated_only = true;
changed = true;
}
});
});
});
if changed {
admin_state.filter_keys();
}
changed
}
/// Render bulk actions controls
pub fn render_bulk_actions(ui: &mut egui::Ui, admin_state: &mut AdminState) -> BulkAction {
let selected_count = admin_state
.selected_servers
.values()
.filter(|&&v| v)
.count();
if selected_count == 0 {
return BulkAction::None;
}
let mut action = BulkAction::None;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("📋").size(14.0));
ui.label(
egui::RichText::new(format!("Selected {} servers", selected_count))
.size(14.0)
.strong()
.color(egui::Color32::LIGHT_BLUE),
);
});
ui.add_space(5.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("❗ Deprecate Selected")
.color(egui::Color32::BLACK),
)
.fill(egui::Color32::from_rgb(255, 200, 0))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72)))
.rounding(egui::Rounding::same(6.0))
.min_size(egui::vec2(130.0, 28.0)),
)
.clicked()
{
action = BulkAction::DeprecateSelected;
}
if ui
.add(
egui::Button::new(
egui::RichText::new("✅ Restore Selected").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(101, 199, 40))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25)))
.rounding(egui::Rounding::same(6.0))
.min_size(egui::vec2(120.0, 28.0)),
)
.clicked()
{
action = BulkAction::RestoreSelected;
}
if ui
.add(
egui::Button::new(
egui::RichText::new("X Clear Selection").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(170, 170, 170))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89)))
.rounding(egui::Rounding::same(6.0))
.min_size(egui::vec2(110.0, 28.0)),
)
.clicked()
{
admin_state.clear_selection();
action = BulkAction::ClearSelection;
}
});
});
});
action
}
/// Render keys table grouped by servers
pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> KeyAction {
if admin_state.filtered_keys.is_empty() {
render_empty_state(ui, admin_state);
return KeyAction::None;
}
let mut action = KeyAction::None;
// Group keys by server
let mut servers: BTreeMap<String, Vec<SshKey>> = BTreeMap::new();
for key in &admin_state.filtered_keys {
servers
.entry(key.server.clone())
.or_insert_with(Vec::new)
.push(key.clone());
}
// Render each server group
for (server_name, server_keys) in servers {
let is_expanded = admin_state
.expanded_servers
.get(&server_name)
.copied()
.unwrap_or(false);
let active_count = server_keys.iter().filter(|k| !k.deprecated).count();
let deprecated_count = server_keys.len() - active_count;
// Server header
ui.group(|ui| {
ui.horizontal(|ui| {
// Server selection checkbox
let mut selected = admin_state
.selected_servers
.get(&server_name)
.copied()
.unwrap_or(false);
if ui
.add(egui::Checkbox::new(&mut selected, "").indeterminate(false))
.changed()
{
admin_state
.selected_servers
.insert(server_name.clone(), selected);
}
// Expand/collapse button
let expand_icon = if is_expanded { "-" } else { "+" };
if ui
.add(
egui::Button::new(expand_icon)
.fill(egui::Color32::TRANSPARENT)
.stroke(egui::Stroke::NONE)
.min_size(egui::vec2(20.0, 20.0)),
)
.clicked()
{
admin_state
.expanded_servers
.insert(server_name.clone(), !is_expanded);
}
// Server icon and name
ui.label(egui::RichText::new("💻").size(16.0));
ui.label(
egui::RichText::new(&server_name)
.size(15.0)
.strong()
.color(egui::Color32::WHITE),
);
// Keys count badge
render_badge(
ui,
&format!("{} keys", server_keys.len()),
egui::Color32::from_rgb(52, 152, 219),
egui::Color32::WHITE,
);
ui.add_space(5.0);
// Deprecated count badge
if deprecated_count > 0 {
render_badge(
ui,
&format!("{} depr", deprecated_count),
egui::Color32::from_rgb(231, 76, 60),
egui::Color32::WHITE,
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Server action buttons
if deprecated_count > 0 {
if ui
.add(
egui::Button::new(
egui::RichText::new("✅ Restore").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(101, 199, 40))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgb(94, 105, 25),
))
.rounding(egui::Rounding::same(4.0))
.min_size(egui::vec2(70.0, 24.0)),
)
.clicked()
{
action = KeyAction::RestoreServer(server_name.clone());
}
}
if active_count > 0 {
if ui
.add(
egui::Button::new(
egui::RichText::new("❗ Deprecate").color(egui::Color32::BLACK),
)
.fill(egui::Color32::from_rgb(255, 200, 0))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgb(102, 94, 72),
))
.rounding(egui::Rounding::same(4.0))
.min_size(egui::vec2(85.0, 24.0)),
)
.clicked()
{
action = KeyAction::DeprecateServer(server_name.clone());
}
}
});
});
});
// Expanded key details
if is_expanded {
ui.indent("server_keys", |ui| {
for key in &server_keys {
if let Some(key_action) = render_key_item(ui, key, &server_name) {
action = key_action;
}
}
});
}
ui.add_space(5.0);
}
action
}
/// Render empty state when no keys are available
fn render_empty_state(ui: &mut egui::Ui, admin_state: &AdminState) {
ui.vertical_centered(|ui| {
ui.add_space(60.0);
if admin_state.keys.is_empty() {
ui.label(
egui::RichText::new("🔑")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No SSH keys available")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("Keys will appear here once loaded from the server")
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
} else if !admin_state.search_term.is_empty() {
ui.label(
egui::RichText::new("🔍")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No results found")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new(format!(
"Try adjusting your search: '{}'",
admin_state.search_term
))
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
} else {
ui.label(
egui::RichText::new("")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No keys match current filters")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("Try adjusting your search or filter settings")
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
}
});
}
/// Render individual key item
fn render_key_item(ui: &mut egui::Ui, key: &SshKey, server_name: &str) -> Option<KeyAction> {
let mut action = None;
ui.group(|ui| {
ui.horizontal(|ui| {
// Key type badge
let key_type = get_key_type(&key.public_key);
let (badge_color, text_color) = match key_type.as_str() {
"RSA" => (egui::Color32::from_rgb(52, 144, 220), egui::Color32::WHITE),
"ED25519" => (egui::Color32::from_rgb(46, 204, 113), egui::Color32::WHITE),
"ECDSA" => (egui::Color32::from_rgb(241, 196, 15), egui::Color32::BLACK),
"DSA" => (egui::Color32::from_rgb(230, 126, 34), egui::Color32::WHITE),
_ => (egui::Color32::GRAY, egui::Color32::WHITE),
};
render_small_badge(ui, &key_type, badge_color, text_color);
ui.add_space(5.0);
// Status badge
if key.deprecated {
ui.label(
egui::RichText::new("❗ DEPR")
.size(10.0)
.color(egui::Color32::from_rgb(231, 76, 60))
.strong(),
);
} else {
ui.label(
egui::RichText::new("")
.size(10.0)
.color(egui::Color32::from_rgb(46, 204, 113))
.strong(),
);
}
ui.add_space(5.0);
// Key preview
ui.label(
egui::RichText::new(get_key_preview(&key.public_key))
.font(egui::FontId::monospace(10.0))
.color(egui::Color32::LIGHT_GRAY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Key action buttons
if key.deprecated {
if ui
.add(
egui::Button::new(
egui::RichText::new("[R]").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(101, 199, 40))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25)))
.rounding(egui::Rounding::same(3.0))
.min_size(egui::vec2(22.0, 18.0)),
)
.on_hover_text("Restore key")
.clicked()
{
action = Some(KeyAction::RestoreKey(server_name.to_string()));
}
if ui
.add(
egui::Button::new(
egui::RichText::new("Del").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(246, 36, 71))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(129, 18, 17)))
.rounding(egui::Rounding::same(3.0))
.min_size(egui::vec2(26.0, 18.0)),
)
.on_hover_text("Delete key")
.clicked()
{
action = Some(KeyAction::DeleteKey(server_name.to_string()));
}
} else {
if ui
.add(
egui::Button::new(
egui::RichText::new("").color(egui::Color32::BLACK),
)
.fill(egui::Color32::from_rgb(255, 200, 0))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72)))
.rounding(egui::Rounding::same(3.0))
.min_size(egui::vec2(22.0, 18.0)),
)
.on_hover_text("Deprecate key")
.clicked()
{
action = Some(KeyAction::DeprecateKey(server_name.to_string()));
}
}
if ui
.add(
egui::Button::new(egui::RichText::new("Copy").color(egui::Color32::WHITE))
.fill(egui::Color32::from_rgb(0, 111, 230))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(35, 84, 97)))
.rounding(egui::Rounding::same(3.0))
.min_size(egui::vec2(30.0, 18.0)),
)
.on_hover_text("Copy to clipboard")
.clicked()
{
ui.output_mut(|o| o.copied_text = key.public_key.clone());
}
});
});
});
action
}
/// Render a badge with text
fn render_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(50.0, 18.0), egui::Sense::hover());
ui.painter()
.rect_filled(rect, egui::Rounding::same(8.0), bg_color);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(10.0),
text_color,
);
}
/// Render a small badge with text
fn render_small_badge(
ui: &mut egui::Ui,
text: &str,
bg_color: egui::Color32,
text_color: egui::Color32,
) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(40.0, 16.0), egui::Sense::hover());
ui.painter()
.rect_filled(rect, egui::Rounding::same(3.0), bg_color);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(9.0),
text_color,
);
}
/// Actions that can be performed on keys
#[derive(Debug, Clone)]
pub enum KeyAction {
None,
DeprecateKey(String),
RestoreKey(String),
DeleteKey(String),
DeprecateServer(String),
RestoreServer(String),
}
/// Bulk actions that can be performed
#[derive(Debug, Clone)]
pub enum BulkAction {
None,
DeprecateSelected,
RestoreSelected,
ClearSelection,
}

369
src/gui/api/client.rs Normal file
View File

@@ -0,0 +1,369 @@
use crate::gui::common::{perform_sync, KhmSettings};
use log::info;
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshKey {
pub server: String,
pub public_key: String,
#[serde(default)]
pub deprecated: bool,
}
/// Test connection to KHM server
#[cfg(feature = "gui")]
pub async fn test_connection(
host: String,
flow: String,
basic_auth: String,
) -> Result<String, String> {
if host.is_empty() || flow.is_empty() {
return Err("Host and flow must be specified".to_string());
}
let url = format!("{}/{}/keys", host.trim_end_matches('/'), flow);
info!("Testing connection to: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let keys: Vec<SshKey> =
serde_json::from_str(&body).map_err(|e| format!("Failed to parse response: {}", e))?;
let message = format!("Found {} SSH keys from flow '{}'", keys.len(), flow);
info!("Connection test successful: {}", message);
Ok(message)
}
/// Fetch all SSH keys including deprecated ones
#[cfg(feature = "gui")]
pub async fn fetch_keys(
host: String,
flow: String,
basic_auth: String,
) -> Result<Vec<SshKey>, String> {
if host.is_empty() || flow.is_empty() {
return Err("Host and flow must be specified".to_string());
}
let url = format!(
"{}/{}/keys?include_deprecated=true",
host.trim_end_matches('/'),
flow
);
info!("Fetching keys from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let keys: Vec<SshKey> =
serde_json::from_str(&body).map_err(|e| format!("Failed to parse response: {}", e))?;
info!("Fetched {} SSH keys", keys.len());
Ok(keys)
}
/// Deprecate a key for a specific server
#[cfg(feature = "gui")]
pub async fn deprecate_key(
host: String,
flow: String,
basic_auth: String,
server: String,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}",
host.trim_end_matches('/'),
flow,
urlencoding::encode(&server)
);
info!("Deprecating key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.delete(&url);
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
parse_api_response(
&body,
&format!("Successfully deprecated key for server '{}'", server),
)
}
/// Restore a key for a specific server
#[cfg(feature = "gui")]
pub async fn restore_key(
host: String,
flow: String,
basic_auth: String,
server: String,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}/restore",
host.trim_end_matches('/'),
flow,
urlencoding::encode(&server)
);
info!("Restoring key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.post(&url);
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
parse_api_response(
&body,
&format!("Successfully restored key for server '{}'", server),
)
}
/// Delete a key permanently for a specific server
#[cfg(feature = "gui")]
pub async fn delete_key(
host: String,
flow: String,
basic_auth: String,
server: String,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}/delete",
host.trim_end_matches('/'),
flow,
urlencoding::encode(&server)
);
info!(
"Permanently deleting key for server '{}' at: {}",
server, url
);
let client = create_http_client()?;
let mut request = client.delete(&url);
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
parse_api_response(
&body,
&format!("Successfully deleted key for server '{}'", server),
)
}
/// Bulk deprecate multiple servers
#[cfg(feature = "gui")]
pub async fn bulk_deprecate_servers(
host: String,
flow: String,
basic_auth: String,
servers: Vec<String>,
) -> Result<String, String> {
let url = format!("{}/{}/bulk-deprecate", host.trim_end_matches('/'), flow);
info!("Bulk deprecating {} servers at: {}", servers.len(), url);
let client = create_http_client()?;
let mut request = client.post(&url).json(&serde_json::json!({
"servers": servers
}));
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
parse_api_response(&body, "Successfully deprecated servers")
}
/// Bulk restore multiple servers
#[cfg(feature = "gui")]
pub async fn bulk_restore_servers(
host: String,
flow: String,
basic_auth: String,
servers: Vec<String>,
) -> Result<String, String> {
let url = format!("{}/{}/bulk-restore", host.trim_end_matches('/'), flow);
info!("Bulk restoring {} servers at: {}", servers.len(), url);
let client = create_http_client()?;
let mut request = client.post(&url).json(&serde_json::json!({
"servers": servers
}));
request = add_auth_if_needed(request, &basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
parse_api_response(&body, "Successfully restored servers")
}
/// Perform manual sync operation
#[cfg(feature = "gui")]
pub async fn perform_manual_sync(settings: KhmSettings) -> Result<String, String> {
match perform_sync(&settings).await {
Ok(keys_count) => Ok(format!(
"Sync completed successfully with {} keys",
keys_count
)),
Err(e) => Err(e.to_string()),
}
}
// Helper functions
#[cfg(feature = "gui")]
fn create_http_client() -> Result<Client, String> {
Client::builder()
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
#[cfg(feature = "gui")]
fn add_auth_if_needed(
request: reqwest::RequestBuilder,
basic_auth: &str,
) -> Result<reqwest::RequestBuilder, String> {
if basic_auth.is_empty() {
return Ok(request);
}
let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect();
if auth_parts.len() == 2 {
Ok(request.basic_auth(auth_parts[0], Some(auth_parts[1])))
} else {
Err("Basic auth format should be 'username:password'".to_string())
}
}
#[cfg(feature = "gui")]
fn check_response_status(response: &reqwest::Response) -> Result<(), String> {
let status = response.status().as_u16();
if status == 401 {
return Err(
"Authentication required. Please provide valid basic auth credentials.".to_string(),
);
}
if status >= 300 && status < 400 {
return Err("Server redirects to login page. Authentication may be required.".to_string());
}
if !response.status().is_success() {
return Err(format!(
"Server returned error: {} {}",
status,
response.status().canonical_reason().unwrap_or("Unknown")
));
}
Ok(())
}
#[cfg(feature = "gui")]
fn check_html_response(body: &str) -> Result<(), String> {
if body.trim_start().starts_with("<!DOCTYPE") || body.trim_start().starts_with("<html") {
return Err("Server returned HTML page instead of JSON. This usually means authentication is required or the endpoint is incorrect.".to_string());
}
Ok(())
}
#[cfg(feature = "gui")]
fn parse_api_response(body: &str, default_message: &str) -> Result<String, String> {
if let Ok(json_response) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) {
Ok(message.to_string())
} else {
Ok(default_message.to_string())
}
} else {
Ok(default_message.to_string())
}
}

5
src/gui/api/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
#[cfg(feature = "gui")]
mod client;
#[cfg(feature = "gui")]
pub use client::*;

5
src/gui/common/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
#[cfg(feature = "gui")]
mod settings;
#[cfg(feature = "gui")]
pub use settings::*;

156
src/gui/common/settings.rs Normal file
View File

@@ -0,0 +1,156 @@
#[cfg(feature = "gui")]
use dirs::home_dir;
#[cfg(feature = "gui")]
use log::{debug, error, info};
#[cfg(feature = "gui")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "gui")]
use std::fs;
#[cfg(feature = "gui")]
use std::path::PathBuf;
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KhmSettings {
pub host: String,
pub flow: String,
pub known_hosts: String,
pub basic_auth: String,
pub in_place: bool,
pub auto_sync_interval_minutes: u32,
}
#[cfg(feature = "gui")]
impl Default for KhmSettings {
fn default() -> Self {
Self {
host: String::new(),
flow: String::new(),
known_hosts: get_default_known_hosts_path(),
basic_auth: String::new(),
in_place: true,
auto_sync_interval_minutes: 60,
}
}
}
/// Get default known_hosts file path based on OS
#[cfg(feature = "gui")]
fn get_default_known_hosts_path() -> String {
if let Some(home) = home_dir() {
let ssh_dir = home.join(".ssh");
let known_hosts_file = ssh_dir.join("known_hosts");
known_hosts_file.to_string_lossy().to_string()
} else {
"~/.ssh/known_hosts".to_string()
}
}
/// Get configuration file path
#[cfg(feature = "gui")]
pub fn get_config_path() -> PathBuf {
let mut path = home_dir().expect("Could not find home directory");
path.push(".khm");
fs::create_dir_all(&path).ok();
path.push("khm_config.json");
path
}
/// Load settings from configuration file
#[cfg(feature = "gui")]
pub fn load_settings() -> KhmSettings {
let path = get_config_path();
match fs::read_to_string(&path) {
Ok(contents) => {
let mut settings: KhmSettings = serde_json::from_str(&contents).unwrap_or_else(|e| {
error!("Failed to parse KHM config: {}", e);
KhmSettings::default()
});
// Fill in default known_hosts path if empty
if settings.known_hosts.is_empty() {
settings.known_hosts = get_default_known_hosts_path();
}
settings
}
Err(_) => {
debug!("KHM config file not found, using defaults");
KhmSettings::default()
}
}
}
/// Save settings to configuration file
#[cfg(feature = "gui")]
pub fn save_settings(settings: &KhmSettings) -> Result<(), std::io::Error> {
let path = get_config_path();
let json = serde_json::to_string_pretty(settings)?;
fs::write(&path, json)?;
info!("KHM settings saved");
Ok(())
}
/// Expand path with ~ substitution
#[cfg(feature = "gui")]
pub fn expand_path(path: &str) -> String {
if path.starts_with("~/") {
if let Some(home) = home_dir() {
return home.join(&path[2..]).to_string_lossy().to_string();
}
}
path.to_string()
}
/// Perform sync operation using KHM client logic
#[cfg(feature = "gui")]
pub async fn perform_sync(settings: &KhmSettings) -> Result<usize, std::io::Error> {
use crate::Args;
info!(
"Starting sync with settings: host={}, flow={}, known_hosts={}, in_place={}",
settings.host, settings.flow, settings.known_hosts, settings.in_place
);
// Convert KhmSettings to Args for client module
let args = Args {
server: false,
daemon: false,
settings_ui: false,
in_place: settings.in_place,
flows: vec!["default".to_string()], // Not used in client mode
ip: "127.0.0.1".to_string(), // Not used in client mode
port: 8080, // Not used in client mode
db_host: "127.0.0.1".to_string(), // Not used in client mode
db_name: "khm".to_string(), // Not used in client mode
db_user: None, // Not used in client mode
db_password: None, // Not used in client mode
host: Some(settings.host.clone()),
flow: Some(settings.flow.clone()),
known_hosts: expand_path(&settings.known_hosts),
basic_auth: settings.basic_auth.clone(),
};
info!("Expanded known_hosts path: {}", args.known_hosts);
// Get keys count before and after sync
let keys_before = crate::client::read_known_hosts(&args.known_hosts)
.unwrap_or_else(|_| Vec::new())
.len();
crate::client::run_client(args.clone()).await?;
let keys_after = if args.in_place {
crate::client::read_known_hosts(&args.known_hosts)
.unwrap_or_else(|_| Vec::new())
.len()
} else {
keys_before
};
info!(
"Sync completed: {} keys before, {} keys after",
keys_before, keys_after
);
Ok(keys_after)
}

45
src/gui/mod.rs Normal file
View File

@@ -0,0 +1,45 @@
#[cfg(feature = "gui")]
use log::info;
// Modules
#[cfg(feature = "gui")]
mod admin;
mod api;
mod common;
#[cfg(feature = "gui")]
mod settings;
#[cfg(feature = "gui")]
mod tray;
// Re-exports for backward compatibility and external usage
#[cfg(feature = "gui")]
pub use settings::run_settings_window;
#[cfg(feature = "gui")]
pub use tray::run_tray_app;
// User events for GUI communication
#[cfg(feature = "gui")]
#[derive(Debug)]
pub enum UserEvent {
TrayIconEvent,
MenuEvent(tray_icon::menu::MenuEvent),
ConfigFileChanged,
UpdateMenu,
}
/// Run GUI application in tray mode
#[cfg(feature = "gui")]
pub async fn run_gui() -> std::io::Result<()> {
info!("Starting KHM tray application");
run_tray_app().await
}
/// Stub function when GUI is disabled
#[cfg(not(feature = "gui"))]
pub async fn run_gui() -> std::io::Result<()> {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"GUI features not compiled. Install system dependencies and rebuild with --features gui",
));
}

View File

@@ -0,0 +1,228 @@
use crate::gui::api::{perform_manual_sync, test_connection};
use crate::gui::common::{save_settings, KhmSettings};
use eframe::egui;
use log::{error, info};
use std::sync::mpsc;
#[derive(Debug, Clone)]
pub enum ConnectionStatus {
Unknown,
Connected { keys_count: usize, flow: String },
Error(String),
}
#[derive(Debug, Clone)]
pub enum SyncStatus {
Unknown,
Success { keys_count: usize },
Error(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SettingsTab {
Connection,
Admin,
}
pub struct ConnectionTab {
pub connection_status: ConnectionStatus,
pub is_testing_connection: bool,
pub test_result_receiver: Option<mpsc::Receiver<Result<String, String>>>,
pub is_syncing: bool,
pub sync_result_receiver: Option<mpsc::Receiver<Result<String, String>>>,
pub sync_status: SyncStatus,
pub should_auto_test: bool,
}
impl Default for ConnectionTab {
fn default() -> Self {
Self {
connection_status: ConnectionStatus::Unknown,
is_testing_connection: false,
test_result_receiver: None,
is_syncing: false,
sync_result_receiver: None,
sync_status: SyncStatus::Unknown,
should_auto_test: false,
}
}
}
impl ConnectionTab {
/// Start connection test
pub fn start_test(&mut self, settings: &KhmSettings, ctx: &egui::Context) {
if self.is_testing_connection {
return;
}
self.is_testing_connection = true;
self.connection_status = ConnectionStatus::Unknown;
let (tx, rx) = mpsc::channel();
self.test_result_receiver = Some(rx);
let host = settings.host.clone();
let flow = settings.flow.clone();
let basic_auth = settings.basic_auth.clone();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async { test_connection(host, flow, basic_auth).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
/// Start manual sync
pub fn start_sync(&mut self, settings: &KhmSettings, ctx: &egui::Context) {
if self.is_syncing {
return;
}
self.is_syncing = true;
self.sync_status = SyncStatus::Unknown;
let (tx, rx) = mpsc::channel();
self.sync_result_receiver = Some(rx);
let settings = settings.clone();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async { perform_manual_sync(settings).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
/// Check for test/sync results and handle auto-test
pub fn check_results(
&mut self,
ctx: &egui::Context,
settings: &KhmSettings,
operation_log: &mut Vec<String>,
) {
// Handle auto-test on first frame if needed
if self.should_auto_test && !self.is_testing_connection {
self.should_auto_test = false;
self.start_test(settings, ctx);
}
// Check for test connection result
if let Some(receiver) = &self.test_result_receiver {
if let Ok(result) = receiver.try_recv() {
self.is_testing_connection = false;
match result {
Ok(message) => {
// Parse keys count from message
let keys_count = if let Some(start) = message.find("Found ") {
if let Some(end) = message[start + 6..].find(" SSH keys") {
message[start + 6..start + 6 + end]
.parse::<usize>()
.unwrap_or(0)
} else {
0
}
} else {
0
};
self.connection_status = ConnectionStatus::Connected {
keys_count,
flow: settings.flow.clone(),
};
info!("Connection test successful: {}", message);
// Add to UI log
super::ui::add_log_entry(
operation_log,
format!("✅ Connection test successful: {}", message),
);
}
Err(error) => {
self.connection_status = ConnectionStatus::Error(error.clone());
error!("Connection test failed");
// Add to UI log
super::ui::add_log_entry(
operation_log,
format!("❌ Connection test failed: {}", error),
);
}
}
self.test_result_receiver = None;
ctx.request_repaint();
}
}
// Check for sync result
if let Some(receiver) = &self.sync_result_receiver {
if let Ok(result) = receiver.try_recv() {
self.is_syncing = false;
match result {
Ok(message) => {
// Parse keys count from message
let keys_count = parse_keys_count(&message);
self.sync_status = SyncStatus::Success { keys_count };
info!("Sync successful: {}", message);
// Add to UI log
super::ui::add_log_entry(
operation_log,
format!("✅ Sync completed: {}", message),
);
}
Err(error) => {
self.sync_status = SyncStatus::Error(error.clone());
error!("Sync failed");
// Add to UI log
super::ui::add_log_entry(
operation_log,
format!("❌ Sync failed: {}", error),
);
}
}
self.sync_result_receiver = None;
ctx.request_repaint();
}
}
}
}
/// Parse keys count from sync result message
fn parse_keys_count(message: &str) -> usize {
if let Some(start) = message.find("updated with ") {
let search_start = start + "updated with ".len();
if let Some(end) = message[search_start..].find(" keys") {
let number_str = &message[search_start..search_start + end];
return number_str.parse::<usize>().unwrap_or(0);
}
} else if let Some(start) = message.find("Retrieved ") {
let search_start = start + "Retrieved ".len();
if let Some(end) = message[search_start..].find(" keys") {
let number_str = &message[search_start..search_start + end];
return number_str.parse::<usize>().unwrap_or(0);
}
} else if let Some(keys_pos) = message.find(" keys") {
let before_keys = &message[..keys_pos];
if let Some(space_pos) = before_keys.rfind(' ') {
let number_str = &before_keys[space_pos + 1..];
return number_str.parse::<usize>().unwrap_or(0);
}
}
0
}
/// Save settings with validation
pub fn save_settings_validated(settings: &KhmSettings) -> Result<(), String> {
if settings.host.is_empty() || settings.flow.is_empty() {
return Err("Host URL and Flow Name are required".to_string());
}
save_settings(settings).map_err(|e| format!("Failed to save settings: {}", e))
}

5
src/gui/settings/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
mod connection;
mod ui;
mod window;
pub use window::*;

638
src/gui/settings/ui.rs Normal file
View File

@@ -0,0 +1,638 @@
use super::connection::{save_settings_validated, ConnectionStatus, ConnectionTab, SyncStatus};
use crate::gui::common::{get_config_path, KhmSettings};
use eframe::egui;
/// Render connection settings tab with modern horizontal UI design
pub fn render_connection_tab(
ui: &mut egui::Ui,
ctx: &egui::Context,
settings: &mut KhmSettings,
auto_sync_interval_str: &mut String,
connection_tab: &mut ConnectionTab,
operation_log: &mut Vec<String>,
) {
// Check for connection test and sync results
connection_tab.check_results(ctx, settings, operation_log);
// Use scrollable area for the entire content
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
ui.spacing_mut().button_padding = egui::vec2(12.0, 6.0);
ui.spacing_mut().indent = 16.0;
// Connection Status Card at top (full width)
render_connection_status_card(ui, connection_tab);
// Main configuration area - horizontal layout
ui.horizontal_top(|ui| {
let available_width = ui.available_width();
let left_panel_width = available_width * 0.6;
let right_panel_width = available_width * 0.38;
// Left panel - Connection and Local config
ui.allocate_ui_with_layout(
[left_panel_width, ui.available_height()].into(),
egui::Layout::top_down(egui::Align::Min),
|ui| {
// Connection Configuration Card
render_connection_config_card(ui, settings);
// Local Configuration Card
render_local_config_card(ui, settings);
},
);
ui.add_space(8.0);
// Right panel - Auto-sync and System info
ui.allocate_ui_with_layout(
[right_panel_width, ui.available_height()].into(),
egui::Layout::top_down(egui::Align::Min),
|ui| {
// Auto-sync Configuration Card
render_auto_sync_card(ui, settings, auto_sync_interval_str);
// System Information Card
render_system_info_card(ui);
},
);
});
ui.add_space(12.0);
// Action buttons at bottom
render_action_section(ui, ctx, settings, connection_tab, operation_log);
});
}
/// Connection status card with modern visual design
fn render_connection_status_card(ui: &mut egui::Ui, connection_tab: &ConnectionTab) {
let frame = egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.rounding(6.0)
.inner_margin(egui::Margin::same(12.0));
frame.show(ui, |ui| {
// Header with status indicator
ui.horizontal(|ui| {
let (status_icon, status_text, status_color) = match &connection_tab.connection_status {
ConnectionStatus::Connected { keys_count, flow } => {
let text = if flow.is_empty() {
format!("Connected • {} keys", keys_count)
} else {
format!("Connected to '{}' • {} keys", flow, keys_count)
};
("", text, egui::Color32::GREEN)
}
ConnectionStatus::Error(error_msg) => (
"",
format!("Connection Error: {}", error_msg),
egui::Color32::RED,
),
ConnectionStatus::Unknown => {
("", "Not Connected".to_string(), ui.visuals().text_color())
}
};
ui.label(egui::RichText::new(status_icon).size(14.0));
ui.label(egui::RichText::new("Connection Status").size(14.0).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if connection_tab.is_testing_connection {
ui.spinner();
ui.label(
egui::RichText::new("Testing...")
.italics()
.color(ui.visuals().weak_text_color()),
);
} else {
ui.label(
egui::RichText::new(&status_text)
.size(13.0)
.color(status_color),
);
}
});
});
// Sync status - always visible
ui.add_space(6.0);
ui.separator();
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("🔄");
ui.label("Last Sync:");
match &connection_tab.sync_status {
SyncStatus::Success { keys_count } => {
ui.label(
egui::RichText::new(format!("{} keys synced", keys_count))
.size(13.0)
.color(egui::Color32::GREEN),
);
}
SyncStatus::Error(error_msg) => {
ui.label(
egui::RichText::new("❌ Failed")
.size(13.0)
.color(egui::Color32::RED),
)
.on_hover_text(error_msg);
}
SyncStatus::Unknown => {
ui.label(
egui::RichText::new("No sync performed yet")
.size(13.0)
.color(ui.visuals().weak_text_color()),
);
}
}
});
});
ui.add_space(8.0);
}
/// Connection configuration card with input fields
fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) {
let frame = egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.rounding(6.0)
.inner_margin(egui::Margin::same(12.0));
frame.show(ui, |ui| {
// Header
ui.horizontal(|ui| {
ui.label("🌐");
ui.label(
egui::RichText::new("Server Configuration")
.size(14.0)
.strong(),
);
});
ui.add_space(8.0);
// Input fields with better spacing
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 8.0;
// Host URL
ui.vertical(|ui| {
ui.label(egui::RichText::new("Host URL").size(13.0).strong());
ui.add_space(3.0);
ui.add_sized(
[ui.available_width(), 28.0], // Smaller height for better centering
egui::TextEdit::singleline(&mut settings.host)
.hint_text("https://your-khm-server.com")
.font(egui::FontId::new(14.0, egui::FontFamily::Monospace))
.margin(egui::Margin::symmetric(8.0, 6.0)), // Better vertical centering
);
});
// Flow Name
ui.vertical(|ui| {
ui.label(egui::RichText::new("Flow Name").size(13.0).strong());
ui.add_space(3.0);
ui.add_sized(
[ui.available_width(), 28.0],
egui::TextEdit::singleline(&mut settings.flow)
.hint_text("production, staging, development")
.font(egui::FontId::new(14.0, egui::FontFamily::Proportional))
.margin(egui::Margin::symmetric(8.0, 6.0)),
);
});
// Basic Auth (optional)
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Basic Authentication")
.size(13.0)
.strong(),
);
ui.label(
egui::RichText::new("(optional)")
.size(12.0)
.weak()
.italics(),
);
});
ui.add_space(3.0);
ui.add_sized(
[ui.available_width(), 28.0],
egui::TextEdit::singleline(&mut settings.basic_auth)
.hint_text("username:password")
.password(true)
.font(egui::FontId::new(14.0, egui::FontFamily::Monospace))
.margin(egui::Margin::symmetric(8.0, 6.0)),
);
});
});
});
ui.add_space(8.0);
}
/// Local configuration card
fn render_local_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) {
let frame = egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.rounding(6.0)
.inner_margin(egui::Margin::same(12.0));
frame.show(ui, |ui| {
// Header
ui.horizontal(|ui| {
ui.label("📁");
ui.label(
egui::RichText::new("Local Configuration")
.size(14.0)
.strong(),
);
});
ui.add_space(8.0);
// Known hosts file
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Known Hosts File Path")
.size(13.0)
.strong(),
);
ui.add_space(3.0);
ui.add_sized(
[ui.available_width(), 28.0],
egui::TextEdit::singleline(&mut settings.known_hosts)
.hint_text("~/.ssh/known_hosts")
.font(egui::FontId::new(14.0, egui::FontFamily::Monospace))
.margin(egui::Margin::symmetric(8.0, 6.0)),
);
ui.add_space(8.0);
// In-place update option with better styling
ui.horizontal(|ui| {
ui.checkbox(&mut settings.in_place, "");
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Update file in-place after sync")
.size(13.0)
.strong(),
);
ui.label(
egui::RichText::new(
"Automatically modify the known_hosts file when synchronizing",
)
.size(12.0)
.weak()
.italics(),
);
});
});
});
});
ui.add_space(8.0);
}
/// Auto-sync configuration card
fn render_auto_sync_card(
ui: &mut egui::Ui,
settings: &mut KhmSettings,
auto_sync_interval_str: &mut String,
) {
let frame = egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.rounding(6.0)
.inner_margin(egui::Margin::same(12.0));
frame.show(ui, |ui| {
let is_auto_sync_enabled =
!settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place;
// Header with status
ui.horizontal(|ui| {
ui.label("🔄");
ui.label(egui::RichText::new("Auto Sync").size(14.0).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (status_text, status_color) = if is_auto_sync_enabled {
("✅ Active", egui::Color32::GREEN)
} else {
("❌ Inactive", egui::Color32::from_gray(128))
};
ui.label(
egui::RichText::new(status_text)
.size(12.0)
.color(status_color),
);
});
});
ui.add_space(8.0);
// Interval setting
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Interval").size(13.0).strong());
ui.add_space(6.0);
ui.add_sized(
[80.0, 26.0], // Smaller height
egui::TextEdit::singleline(auto_sync_interval_str)
.font(egui::FontId::new(14.0, egui::FontFamily::Monospace))
.margin(egui::Margin::symmetric(6.0, 5.0)),
);
ui.label("min");
// Update the actual setting
if let Ok(value) = auto_sync_interval_str.parse::<u32>() {
if value > 0 {
settings.auto_sync_interval_minutes = value;
}
}
});
// Requirements - always visible
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new("Requirements:").size(12.0).strong());
ui.add_space(3.0);
let host_ok = !settings.host.is_empty();
let flow_ok = !settings.flow.is_empty();
let in_place_ok = settings.in_place;
ui.horizontal(|ui| {
let (icon, color) = if host_ok {
("", egui::Color32::GREEN)
} else {
("", egui::Color32::RED)
};
ui.label(egui::RichText::new(icon).color(color));
ui.label(egui::RichText::new("Host URL").size(11.0));
});
ui.horizontal(|ui| {
let (icon, color) = if flow_ok {
("", egui::Color32::GREEN)
} else {
("", egui::Color32::RED)
};
ui.label(egui::RichText::new(icon).color(color));
ui.label(egui::RichText::new("Flow name").size(11.0));
});
ui.horizontal(|ui| {
let (icon, color) = if in_place_ok {
("", egui::Color32::GREEN)
} else {
("", egui::Color32::RED)
};
ui.label(egui::RichText::new(icon).color(color));
ui.label(egui::RichText::new("In-place update").size(11.0));
});
});
});
ui.add_space(8.0);
}
/// System information card
fn render_system_info_card(ui: &mut egui::Ui) {
let frame = egui::Frame::group(ui.style())
.fill(ui.visuals().extreme_bg_color)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.rounding(6.0)
.inner_margin(egui::Margin::same(12.0));
frame.show(ui, |ui| {
// Header
ui.horizontal(|ui| {
ui.label("🔧");
ui.label(egui::RichText::new("System Info").size(14.0).strong());
});
ui.add_space(8.0);
// Config file location
ui.vertical(|ui| {
ui.label(egui::RichText::new("Config File").size(13.0).strong());
ui.add_space(3.0);
let config_path = get_config_path();
let path_str = config_path.display().to_string();
ui.vertical(|ui| {
ui.add_sized(
[ui.available_width(), 26.0], // Smaller height
egui::TextEdit::singleline(&mut path_str.clone())
.interactive(false)
.font(egui::FontId::new(12.0, egui::FontFamily::Monospace))
.margin(egui::Margin::symmetric(8.0, 5.0)),
);
ui.add_space(4.0);
if ui.small_button("📋 Copy Path").clicked() {
ui.output_mut(|o| o.copied_text = path_str);
}
});
});
});
ui.add_space(8.0);
}
/// Action section with buttons only (Activity Log moved to bottom panel)
fn render_action_section(
ui: &mut egui::Ui,
ctx: &egui::Context,
settings: &KhmSettings,
connection_tab: &mut ConnectionTab,
operation_log: &mut Vec<String>,
) {
ui.add_space(2.0);
// Validation for save button
let save_enabled = !settings.host.is_empty() && !settings.flow.is_empty();
// Action buttons with modern styling
render_modern_action_buttons(
ui,
ctx,
settings,
connection_tab,
save_enabled,
operation_log,
);
}
/// Modern action buttons with improved styling and layout
fn render_modern_action_buttons(
ui: &mut egui::Ui,
ctx: &egui::Context,
settings: &KhmSettings,
connection_tab: &mut ConnectionTab,
save_enabled: bool,
operation_log: &mut Vec<String>,
) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// Primary actions (left side)
let mut save_button = ui.add_enabled(
save_enabled,
egui::Button::new(
egui::RichText::new("💾 Save & Close")
.size(13.0)
.color(egui::Color32::WHITE)
)
.fill(if save_enabled {
egui::Color32::from_rgb(0, 120, 212)
} else {
ui.visuals().widgets.inactive.bg_fill
})
.min_size(egui::vec2(120.0, 32.0))
.rounding(6.0)
);
// Add tooltip when button is disabled
if !save_enabled {
save_button = save_button.on_hover_text("Complete server configuration to enable saving:\n• Host URL is required\n• Flow name is required");
}
if save_button.clicked() {
match save_settings_validated(settings) {
Ok(()) => {
add_log_entry(operation_log, "✅ Settings saved successfully".to_string());
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
Err(e) => {
add_log_entry(operation_log, format!("❌ Failed to save settings: {}", e));
}
}
}
if ui.add(
egui::Button::new(
egui::RichText::new("✖ Cancel")
.size(13.0)
.color(ui.visuals().text_color())
)
.stroke(egui::Stroke::new(1.0, ui.visuals().text_color()))
.fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(80.0, 32.0))
.rounding(6.0)
).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
// Spacer
ui.add_space(ui.available_width() - 220.0);
// Secondary actions (right side)
let can_test = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_testing_connection;
let can_sync = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_syncing;
if ui.add_enabled(
can_test,
egui::Button::new(
egui::RichText::new(
if connection_tab.is_testing_connection {
"🔄 Testing..."
} else {
"🔍 Test"
}
)
.size(13.0)
.color(egui::Color32::WHITE)
)
.fill(if can_test {
egui::Color32::from_rgb(16, 124, 16)
} else {
ui.visuals().widgets.inactive.bg_fill
})
.min_size(egui::vec2(80.0, 32.0))
.rounding(6.0)
).on_hover_text("Test server connection").clicked() {
add_log_entry(operation_log, "🔍 Testing connection...".to_string());
connection_tab.start_test(settings, ctx);
}
if ui.add_enabled(
can_sync,
egui::Button::new(
egui::RichText::new(
if connection_tab.is_syncing {
"🔄 Syncing..."
} else {
"🔄 Sync"
}
)
.size(13.0)
.color(egui::Color32::WHITE)
)
.fill(if can_sync {
egui::Color32::from_rgb(255, 140, 0)
} else {
ui.visuals().widgets.inactive.bg_fill
})
.min_size(egui::vec2(80.0, 32.0))
.rounding(6.0)
).on_hover_text("Synchronize SSH keys now").clicked() {
add_log_entry(operation_log, "🔄 Starting sync...".to_string());
connection_tab.start_sync(settings, ctx);
}
});
}
/// Add entry to operation log with timestamp
pub fn add_log_entry(operation_log: &mut Vec<String>, message: String) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
let secs = now.as_secs();
let millis = now.subsec_millis();
// Format as HH:MM:SS.mmm
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
let timestamp = format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis);
let log_entry = format!("{} {}", timestamp, message);
operation_log.push(log_entry);
// Keep only last 20 entries to prevent memory growth
if operation_log.len() > 20 {
operation_log.remove(0);
}
}

638
src/gui/settings/window.rs Normal file
View File

@@ -0,0 +1,638 @@
use crate::gui::admin::{
render_bulk_actions, render_keys_table, render_search_controls, render_statistics,
AdminOperation, AdminState, BulkAction, KeyAction,
};
use crate::gui::api::{
bulk_deprecate_servers, bulk_restore_servers, delete_key, deprecate_key, restore_key, SshKey,
};
use crate::gui::common::{load_settings, KhmSettings};
use eframe::egui;
use log::info;
use std::sync::mpsc;
use super::connection::{ConnectionTab, SettingsTab};
use super::ui::{add_log_entry, render_connection_tab};
pub struct SettingsWindow {
settings: KhmSettings,
auto_sync_interval_str: String,
current_tab: SettingsTab,
connection_tab: ConnectionTab,
admin_state: AdminState,
admin_receiver: Option<mpsc::Receiver<Result<Vec<SshKey>, String>>>,
operation_receiver: Option<mpsc::Receiver<Result<String, String>>>,
operation_log: Vec<String>,
}
impl SettingsWindow {
pub fn new() -> Self {
let settings = load_settings();
let auto_sync_interval_str = settings.auto_sync_interval_minutes.to_string();
let mut instance = Self {
settings,
auto_sync_interval_str,
current_tab: SettingsTab::Connection,
connection_tab: ConnectionTab::default(),
admin_state: AdminState::default(),
admin_receiver: None,
operation_receiver: None,
operation_log: Vec::new(),
};
// Auto-test connection if configuration is found and valid
if !instance.settings.host.is_empty() && !instance.settings.flow.is_empty() {
add_log_entry(
&mut instance.operation_log,
"🔍 Auto-testing connection with saved configuration...".to_string(),
);
// We can't call start_test here because we don't have egui::Context yet
// So we set a flag to trigger test on first frame
instance.connection_tab.should_auto_test = true;
}
instance
}
}
impl eframe::App for SettingsWindow {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Check for admin operation results
self.check_admin_results(ctx);
// Apply enhanced modern dark theme
apply_modern_theme(ctx);
// Bottom panel for Activity Log (fixed at bottom)
egui::TopBottomPanel::bottom("activity_log_panel")
.resizable(false)
.min_height(140.0)
.max_height(140.0)
.frame(
egui::Frame::none()
.fill(egui::Color32::from_gray(12))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60))),
)
.show(ctx, |ui| {
render_bottom_activity_log(ui, &mut self.operation_log);
});
egui::CentralPanel::default()
.frame(
egui::Frame::none()
.fill(egui::Color32::from_gray(18))
.inner_margin(egui::Margin::same(20.0)),
)
.show(ctx, |ui| {
// Modern header with gradient-like styling
let header_frame = egui::Frame::none()
.fill(ui.visuals().panel_fill)
.rounding(egui::Rounding::same(8.0))
.inner_margin(egui::Margin::same(12.0))
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
));
header_frame.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add_space(4.0);
ui.label("🔑");
ui.heading(egui::RichText::new("KHM Settings").size(20.0).strong());
ui.label(
egui::RichText::new(
"(Known Hosts Manager for SSH key management and synchronization)",
)
.size(11.0)
.weak()
.italics(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Version from Cargo.toml
let version = env!("CARGO_PKG_VERSION");
if ui
.small_button(format!("v{}", version))
.on_hover_text(format!(
"{}\n{}\nRepository: {}\nLicense: {}",
env!("CARGO_PKG_DESCRIPTION"),
env!("CARGO_PKG_AUTHORS"),
env!("CARGO_PKG_REPOSITORY"),
"WTFPL"
))
.clicked()
{
// Open repository URL
if let Err(_) = std::process::Command::new("open")
.arg(env!("CARGO_PKG_REPOSITORY"))
.spawn()
{
// Fallback for non-macOS systems
let _ = std::process::Command::new("xdg-open")
.arg(env!("CARGO_PKG_REPOSITORY"))
.spawn();
}
}
});
});
});
ui.add_space(12.0);
// Modern tab selector with card styling
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
// Connection/Settings Tab
let connection_selected = matches!(self.current_tab, SettingsTab::Connection);
let connection_button =
egui::Button::new(egui::RichText::new("🌐 Connection").size(13.0))
.fill(if connection_selected {
egui::Color32::from_rgb(0, 120, 212)
} else {
ui.visuals().widgets.inactive.bg_fill
})
.stroke(if connection_selected {
egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 212))
} else {
egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
)
})
.rounding(6.0)
.min_size(egui::vec2(110.0, 32.0));
if ui.add(connection_button).clicked() {
self.current_tab = SettingsTab::Connection;
}
// Admin Tab
let admin_selected = matches!(self.current_tab, SettingsTab::Admin);
let admin_button =
egui::Button::new(egui::RichText::new("🔧 Admin Panel").size(13.0))
.fill(if admin_selected {
egui::Color32::from_rgb(120, 80, 0)
} else {
ui.visuals().widgets.inactive.bg_fill
})
.stroke(if admin_selected {
egui::Stroke::new(1.0, egui::Color32::from_rgb(120, 80, 0))
} else {
egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
)
})
.rounding(6.0)
.min_size(egui::vec2(110.0, 32.0));
if ui.add(admin_button).clicked() {
self.current_tab = SettingsTab::Admin;
}
});
ui.add_space(16.0);
// Content area with proper spacing
match self.current_tab {
SettingsTab::Connection => {
render_connection_tab(
ui,
ctx,
&mut self.settings,
&mut self.auto_sync_interval_str,
&mut self.connection_tab,
&mut self.operation_log,
);
}
SettingsTab::Admin => {
self.render_admin_tab(ui, ctx);
}
}
});
}
}
impl SettingsWindow {
fn check_admin_results(&mut self, ctx: &egui::Context) {
// Check for admin keys loading result
if let Some(receiver) = &self.admin_receiver {
if let Ok(result) = receiver.try_recv() {
self.admin_state.handle_keys_loaded(result);
self.admin_receiver = None;
ctx.request_repaint();
}
}
// Check for operation results
if let Some(receiver) = &self.operation_receiver {
if let Ok(result) = receiver.try_recv() {
match result {
Ok(message) => {
info!("Operation completed: {}", message);
add_log_entry(&mut self.operation_log, format!("{}", message));
// Reload keys after operation
self.load_admin_keys(ctx);
}
Err(error) => {
add_log_entry(
&mut self.operation_log,
format!("❌ Operation failed: {}", error),
);
}
}
self.admin_state.current_operation = AdminOperation::None;
self.operation_receiver = None;
ctx.request_repaint();
}
}
}
fn render_admin_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
// Admin tab header
ui.horizontal(|ui| {
ui.label(egui::RichText::new("🔧 Admin Panel").size(18.0).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("🔁 Refresh").clicked() {
self.load_admin_keys(ctx);
}
if let Some(last_load) = self.admin_state.last_load_time {
let elapsed = last_load.elapsed().as_secs();
ui.label(format!("Updated {}s ago", elapsed));
}
});
});
ui.separator();
ui.add_space(10.0);
// Check if connection is configured
if self.settings.host.is_empty() || self.settings.flow.is_empty() {
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("❗ Please configure connection settings first")
.size(16.0)
.color(egui::Color32::YELLOW),
);
ui.add_space(10.0);
if ui.button("Go to Connection Settings").clicked() {
self.current_tab = SettingsTab::Connection;
}
});
return;
}
// Load keys automatically on first view
if self.admin_state.keys.is_empty()
&& !matches!(
self.admin_state.current_operation,
AdminOperation::LoadingKeys
)
{
self.load_admin_keys(ctx);
}
// Show loading state
if matches!(
self.admin_state.current_operation,
AdminOperation::LoadingKeys
) {
ui.vertical_centered(|ui| {
ui.spinner();
ui.label("Loading keys...");
});
return;
}
// Statistics section
render_statistics(ui, &self.admin_state);
ui.add_space(10.0);
// Search and filters
render_search_controls(ui, &mut self.admin_state);
ui.add_space(10.0);
// Bulk actions
let bulk_action = render_bulk_actions(ui, &mut self.admin_state);
self.handle_bulk_action(bulk_action, ctx);
if self.admin_state.selected_servers.values().any(|&v| v) {
ui.add_space(8.0);
}
// Keys table
egui::ScrollArea::vertical()
.max_height(450.0)
.auto_shrink([false; 2])
.show(ui, |ui| {
let key_action = render_keys_table(ui, &mut self.admin_state);
self.handle_key_action(key_action, ctx);
});
}
fn load_admin_keys(&mut self, ctx: &egui::Context) {
if let Some(receiver) = self.admin_state.load_keys(&self.settings, ctx) {
self.admin_receiver = Some(receiver);
}
}
fn handle_bulk_action(&mut self, action: BulkAction, ctx: &egui::Context) {
match action {
BulkAction::DeprecateSelected => {
let selected = self.admin_state.get_selected_servers();
if !selected.is_empty() {
self.start_bulk_deprecate(selected, ctx);
}
}
BulkAction::RestoreSelected => {
let selected = self.admin_state.get_selected_servers();
if !selected.is_empty() {
self.start_bulk_restore(selected, ctx);
}
}
BulkAction::ClearSelection => {
// Selection already cleared in UI
}
BulkAction::None => {}
}
}
fn handle_key_action(&mut self, action: KeyAction, ctx: &egui::Context) {
match action {
KeyAction::DeprecateKey(server) | KeyAction::DeprecateServer(server) => {
self.start_deprecate_key(&server, ctx);
}
KeyAction::RestoreKey(server) | KeyAction::RestoreServer(server) => {
self.start_restore_key(&server, ctx);
}
KeyAction::DeleteKey(server) => {
self.start_delete_key(&server, ctx);
}
KeyAction::None => {}
}
}
fn start_bulk_deprecate(&mut self, servers: Vec<String>, ctx: &egui::Context) {
self.admin_state.current_operation = AdminOperation::BulkDeprecating;
add_log_entry(
&mut self.operation_log,
format!("Deprecating {} servers...", servers.len()),
);
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
let host = self.settings.host.clone();
let flow = self.settings.flow.clone();
let basic_auth = self.settings.basic_auth.clone();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(async { bulk_deprecate_servers(host, flow, basic_auth, servers).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
fn start_bulk_restore(&mut self, servers: Vec<String>, ctx: &egui::Context) {
self.admin_state.current_operation = AdminOperation::BulkRestoring;
add_log_entry(
&mut self.operation_log,
format!("Restoring {} servers...", servers.len()),
);
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
let host = self.settings.host.clone();
let flow = self.settings.flow.clone();
let basic_auth = self.settings.basic_auth.clone();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result =
rt.block_on(async { bulk_restore_servers(host, flow, basic_auth, servers).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
fn start_deprecate_key(&mut self, server: &str, ctx: &egui::Context) {
self.admin_state.current_operation = AdminOperation::DeprecatingKey;
add_log_entry(
&mut self.operation_log,
format!("Deprecating key for server: {}", server),
);
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
let host = self.settings.host.clone();
let flow = self.settings.flow.clone();
let basic_auth = self.settings.basic_auth.clone();
let server_name = server.to_string();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result =
rt.block_on(async { deprecate_key(host, flow, basic_auth, server_name).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
fn start_restore_key(&mut self, server: &str, ctx: &egui::Context) {
self.admin_state.current_operation = AdminOperation::RestoringKey;
add_log_entry(
&mut self.operation_log,
format!("Restoring key for server: {}", server),
);
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
let host = self.settings.host.clone();
let flow = self.settings.flow.clone();
let basic_auth = self.settings.basic_auth.clone();
let server_name = server.to_string();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result =
rt.block_on(async { restore_key(host, flow, basic_auth, server_name).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
fn start_delete_key(&mut self, server: &str, ctx: &egui::Context) {
self.admin_state.current_operation = AdminOperation::DeletingKey;
add_log_entry(
&mut self.operation_log,
format!("Deleting key for server: {}", server),
);
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
let host = self.settings.host.clone();
let flow = self.settings.flow.clone();
let basic_auth = self.settings.basic_auth.clone();
let server_name = server.to_string();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result =
rt.block_on(async { delete_key(host, flow, basic_auth, server_name).await });
let _ = tx.send(result);
ctx_clone.request_repaint();
});
}
}
/// Apply modern dark theme for the settings window with enhanced styling
fn apply_modern_theme(ctx: &egui::Context) {
let mut visuals = egui::Visuals::dark();
// Modern color palette
visuals.window_fill = egui::Color32::from_gray(18); // Darker background
visuals.panel_fill = egui::Color32::from_gray(24); // Panel background
visuals.faint_bg_color = egui::Color32::from_gray(32); // Card background
visuals.extreme_bg_color = egui::Color32::from_gray(12); // Darkest areas
// Enhanced widget styling
visuals.button_frame = true;
visuals.collapsing_header_frame = true;
visuals.indent_has_left_vline = true;
visuals.striped = true;
// Modern rounded corners
let rounding = egui::Rounding::same(8.0);
visuals.menu_rounding = rounding;
visuals.window_rounding = egui::Rounding::same(16.0);
visuals.widgets.noninteractive.rounding = rounding;
visuals.widgets.inactive.rounding = rounding;
visuals.widgets.hovered.rounding = rounding;
visuals.widgets.active.rounding = rounding;
// Better widget colors
visuals.widgets.noninteractive.bg_fill = egui::Color32::from_gray(40);
visuals.widgets.inactive.bg_fill = egui::Color32::from_gray(45);
visuals.widgets.hovered.bg_fill = egui::Color32::from_gray(55);
visuals.widgets.active.bg_fill = egui::Color32::from_gray(60);
// Subtle borders
let border_color = egui::Color32::from_gray(60);
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border_color);
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border_color);
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5, egui::Color32::from_gray(80));
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.5, egui::Color32::from_gray(100));
ctx.set_visuals(visuals);
}
/// Render bottom activity log panel
fn render_bottom_activity_log(ui: &mut egui::Ui, operation_log: &mut Vec<String>) {
ui.add_space(18.0); // Larger top padding
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label("📋");
ui.label(egui::RichText::new("Activity Log").size(13.0).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
ui.add_space(8.0);
if ui.small_button("🗑 Clear").clicked() {
operation_log.clear();
}
});
});
ui.add_space(8.0);
// Add horizontal margin for the text area
ui.horizontal(|ui| {
ui.add_space(8.0); // Left margin
// Show last 5 log entries in multiline text
let log_text = if operation_log.is_empty() {
"No recent activity".to_string()
} else {
let start_idx = if operation_log.len() > 5 {
operation_log.len() - 5
} else {
0
};
operation_log[start_idx..].join("\n")
};
ui.add_sized(
[ui.available_width() - 8.0, 80.0], // Account for right margin
egui::TextEdit::multiline(&mut log_text.clone())
.font(egui::FontId::new(11.0, egui::FontFamily::Monospace))
.interactive(false),
);
ui.add_space(8.0); // Right margin
});
}
/// Create window icon for settings window
pub fn create_window_icon() -> egui::IconData {
// Create a simple programmatic icon (blue square with white border)
let icon_size = 32;
let icon_data: Vec<u8> = (0..icon_size * icon_size)
.flat_map(|i| {
let y = i / icon_size;
let x = i % icon_size;
if x < 2 || x >= 30 || y < 2 || y >= 30 {
[255, 255, 255, 255] // White border
} else {
[64, 128, 255, 255] // Blue center
}
})
.collect();
egui::IconData {
rgba: icon_data,
width: icon_size as u32,
height: icon_size as u32,
}
}
/// Run the settings window application with modern horizontal styling
pub fn run_settings_window() {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("KHM Settings")
.with_inner_size([900.0, 905.0]) // Decreased height by another 15px
.with_min_inner_size([900.0, 905.0]) // Fixed size
.with_max_inner_size([900.0, 905.0]) // Same as min - fixed size
.with_resizable(false) // Disable resizing since window is fixed size
.with_icon(create_window_icon())
.with_decorations(true)
.with_transparent(false),
centered: true,
..Default::default()
};
let _ = eframe::run_native(
"KHM Settings",
options,
Box::new(|_cc| Ok(Box::new(SettingsWindow::new()))),
);
}

476
src/gui/tray/app.rs Normal file
View File

@@ -0,0 +1,476 @@
use log::{error, info};
#[cfg(feature = "gui")]
use notify::RecursiveMode;
#[cfg(feature = "gui")]
use notify_debouncer_mini::{new_debouncer, DebounceEventResult};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tray_icon::{menu::MenuEvent, TrayIcon};
use winit::{
application::ApplicationHandler,
event_loop::{EventLoop, EventLoopProxy},
};
#[cfg(target_os = "macos")]
use winit::platform::macos::EventLoopBuilderExtMacOS;
#[cfg(target_os = "linux")]
use gtk::glib;
// Channel for Linux tray communication
#[cfg(target_os = "linux")]
enum LinuxTrayCommand {
CreateTray {
settings: KhmSettings,
sync_status: SyncStatus,
},
UpdateMenu {
settings: KhmSettings,
},
SetTooltip {
tooltip: String,
},
#[allow(dead_code)]
Quit,
}
#[cfg(target_os = "linux")]
enum LinuxTrayResponse {
TrayCreated {
menu_ids: TrayMenuIds,
},
#[allow(dead_code)]
MenuUpdated {
menu_ids: TrayMenuIds,
},
Error(String),
}
use super::{
create_tooltip, create_tray_icon, start_auto_sync_task, update_sync_status, update_tray_menu,
SyncStatus, TrayMenuIds,
};
use crate::gui::common::{get_config_path, load_settings, perform_sync, KhmSettings};
pub struct TrayApplication {
#[cfg(not(target_os = "linux"))]
tray_icon: Option<TrayIcon>,
#[cfg(target_os = "linux")]
linux_tray_tx: Option<std::sync::mpsc::Sender<LinuxTrayCommand>>,
#[cfg(target_os = "linux")]
linux_tray_handle: Option<std::thread::JoinHandle<()>>,
menu_ids: Option<TrayMenuIds>,
settings: Arc<Mutex<KhmSettings>>,
sync_status: Arc<Mutex<SyncStatus>>,
#[cfg(feature = "gui")]
_debouncer: Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>,
proxy: EventLoopProxy<crate::gui::UserEvent>,
auto_sync_handle: Option<std::thread::JoinHandle<()>>,
}
impl TrayApplication {
pub fn new(proxy: EventLoopProxy<crate::gui::UserEvent>) -> Self {
Self {
#[cfg(not(target_os = "linux"))]
tray_icon: None,
#[cfg(target_os = "linux")]
linux_tray_tx: None,
#[cfg(target_os = "linux")]
linux_tray_handle: None,
menu_ids: None,
settings: Arc::new(Mutex::new(load_settings())),
sync_status: Arc::new(Mutex::new(SyncStatus::default())),
#[cfg(feature = "gui")]
_debouncer: None,
proxy,
auto_sync_handle: None,
}
}
#[cfg(feature = "gui")]
fn setup_file_watcher(&mut self) {
let config_path = get_config_path();
let (tx, rx) = std::sync::mpsc::channel::<DebounceEventResult>();
let proxy = self.proxy.clone();
std::thread::spawn(move || {
while let Ok(result) = rx.recv() {
if let Ok(events) = result {
if events
.iter()
.any(|e| e.path.to_string_lossy().contains("khm_config.json"))
{
let _ = proxy.send_event(crate::gui::UserEvent::ConfigFileChanged);
}
}
}
});
if let Ok(mut debouncer) = new_debouncer(Duration::from_millis(500), tx) {
if let Some(config_dir) = config_path.parent() {
if debouncer
.watcher()
.watch(config_dir, RecursiveMode::NonRecursive)
.is_ok()
{
info!("File watcher started");
self._debouncer = Some(debouncer);
} else {
error!("Failed to start file watcher");
}
}
}
}
fn handle_config_change(&mut self) {
info!("Config file changed");
let new_settings = load_settings();
let old_interval = self.settings.lock().unwrap().auto_sync_interval_minutes;
let new_interval = new_settings.auto_sync_interval_minutes;
*self.settings.lock().unwrap() = new_settings;
// Update menu
#[cfg(not(target_os = "linux"))]
if let Some(tray_icon) = &self.tray_icon {
let settings = self.settings.lock().unwrap();
let new_menu_ids = update_tray_menu(tray_icon, &settings);
self.menu_ids = Some(new_menu_ids);
}
#[cfg(target_os = "linux")]
if let Some(ref tx) = self.linux_tray_tx {
let settings = self.settings.lock().unwrap().clone();
let _ = tx.send(LinuxTrayCommand::UpdateMenu { settings });
}
// Update tooltip
self.update_tooltip();
// Restart auto sync if interval changed
if old_interval != new_interval {
info!(
"Auto sync interval changed from {} to {} minutes, restarting auto sync",
old_interval, new_interval
);
self.start_auto_sync();
}
}
fn start_auto_sync(&mut self) {
if let Some(handle) = self.auto_sync_handle.take() {
// Note: In a real implementation, you'd want to properly signal the thread to stop
drop(handle);
}
self.auto_sync_handle = start_auto_sync_task(
Arc::clone(&self.settings),
Arc::clone(&self.sync_status),
self.proxy.clone(),
);
}
fn update_tooltip(&self) {
let settings = self.settings.lock().unwrap();
let sync_status = self.sync_status.lock().unwrap();
let tooltip = create_tooltip(&settings, &sync_status);
#[cfg(not(target_os = "linux"))]
if let Some(tray_icon) = &self.tray_icon {
let _ = tray_icon.set_tooltip(Some(&tooltip));
}
#[cfg(target_os = "linux")]
if let Some(ref tx) = self.linux_tray_tx {
let _ = tx.send(LinuxTrayCommand::SetTooltip { tooltip });
}
}
fn handle_menu_event(
&mut self,
event: MenuEvent,
event_loop: &winit::event_loop::ActiveEventLoop,
) {
if let Some(menu_ids) = &self.menu_ids {
if event.id == menu_ids.settings_id {
info!("Settings menu clicked");
self.launch_settings_window();
} else if event.id == menu_ids.quit_id {
info!("Quitting KHM application");
event_loop.exit();
} else if event.id == menu_ids.sync_id {
info!("Starting manual sync operation");
self.start_manual_sync();
}
}
}
fn launch_settings_window(&self) {
if let Ok(exe_path) = std::env::current_exe() {
std::thread::spawn(move || {
if let Err(e) = std::process::Command::new(&exe_path)
.arg("--settings-ui")
.spawn()
{
error!("Failed to launch settings window: {}", e);
}
});
}
}
fn start_manual_sync(&self) {
let settings = self.settings.lock().unwrap().clone();
let sync_status_clone: Arc<Mutex<SyncStatus>> = Arc::clone(&self.sync_status);
let proxy_clone = self.proxy.clone();
// Check if settings are valid
if settings.host.is_empty() || settings.flow.is_empty() {
error!("Cannot sync: host or flow not configured");
return;
}
info!(
"Syncing with host: {}, flow: {}",
settings.host, settings.flow
);
// Run sync in separate thread with its own tokio runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
match perform_sync(&settings).await {
Ok(keys_count) => {
info!("Sync completed successfully with {} keys", keys_count);
let mut status = sync_status_clone.lock().unwrap();
status.last_sync_time = Some(std::time::Instant::now());
status.last_sync_keys = Some(keys_count);
let _ = proxy_clone.send_event(crate::gui::UserEvent::UpdateMenu);
}
Err(e) => {
error!("Sync failed: {}", e);
}
}
});
});
}
fn handle_update_menu(&mut self) {
let settings = self.settings.lock().unwrap();
if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place {
let mut sync_status = self.sync_status.lock().unwrap();
update_sync_status(&settings, &mut sync_status);
}
drop(settings);
self.update_tooltip();
}
}
impl ApplicationHandler<crate::gui::UserEvent> for TrayApplication {
fn window_event(
&mut self,
_event_loop: &winit::event_loop::ActiveEventLoop,
_window_id: winit::window::WindowId,
_event: winit::event::WindowEvent,
) {
}
fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {
#[cfg(not(target_os = "linux"))]
if self.tray_icon.is_none() {
info!("Creating tray icon");
let settings = self.settings.lock().unwrap();
let sync_status = self.sync_status.lock().unwrap();
match std::panic::catch_unwind(|| create_tray_icon(&settings, &sync_status)) {
Ok((tray_icon, menu_ids)) => {
drop(settings);
drop(sync_status);
self.tray_icon = Some(tray_icon);
self.menu_ids = Some(menu_ids);
self.setup_file_watcher();
self.start_auto_sync();
info!("KHM tray application ready");
}
Err(_) => {
drop(settings);
drop(sync_status);
error!("Failed to create tray icon. This usually means the required system libraries are not installed.");
error!("KHM will exit as system tray integration is required for desktop mode.");
std::process::exit(1);
}
}
}
#[cfg(target_os = "linux")]
if self.linux_tray_tx.is_none() {
info!("Creating tray icon on Linux");
let (tx, rx) = std::sync::mpsc::channel();
let (response_tx, response_rx) = std::sync::mpsc::channel();
self.linux_tray_tx = Some(tx.clone());
let proxy = self.proxy.clone();
// Spawn GTK thread for tray
let handle = std::thread::spawn(move || {
if let Err(e) = gtk::init() {
error!("Failed to initialize GTK: {}", e);
let _ = response_tx.send(LinuxTrayResponse::Error(format!("GTK init failed: {}", e)));
return;
}
let mut tray_icon: Option<TrayIcon> = None;
// Set up GTK event handlers
let _tx_clone = tx.clone();
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
while let Ok(cmd) = rx.try_recv() {
match cmd {
LinuxTrayCommand::CreateTray { settings, sync_status } => {
match std::panic::catch_unwind(|| create_tray_icon(&settings, &sync_status)) {
Ok((icon, menu_ids)) => {
tray_icon = Some(icon);
let _ = response_tx.send(LinuxTrayResponse::TrayCreated { menu_ids });
}
Err(_) => {
let _ = response_tx.send(LinuxTrayResponse::Error("Failed to create tray".to_string()));
}
}
}
LinuxTrayCommand::UpdateMenu { settings } => {
if let Some(ref icon) = tray_icon {
let menu_ids = update_tray_menu(icon, &settings);
let _ = response_tx.send(LinuxTrayResponse::MenuUpdated { menu_ids });
}
}
LinuxTrayCommand::SetTooltip { tooltip } => {
if let Some(ref icon) = tray_icon {
let _ = icon.set_tooltip(Some(&tooltip));
}
}
LinuxTrayCommand::Quit => {
gtk::main_quit();
return glib::ControlFlow::Break;
}
}
}
// Check for menu events
if let Ok(event) = MenuEvent::receiver().try_recv() {
let _ = proxy.send_event(crate::gui::UserEvent::MenuEvent(event));
}
glib::ControlFlow::Continue
});
gtk::main();
});
self.linux_tray_handle = Some(handle);
// Send command to create tray
let settings = self.settings.lock().unwrap().clone();
let sync_status = self.sync_status.lock().unwrap().clone();
if let Some(ref tx) = self.linux_tray_tx {
let _ = tx.send(LinuxTrayCommand::CreateTray { settings, sync_status });
// Wait for response
match response_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(LinuxTrayResponse::TrayCreated { menu_ids }) => {
self.menu_ids = Some(menu_ids);
self.setup_file_watcher();
self.start_auto_sync();
info!("KHM tray application ready");
}
Ok(LinuxTrayResponse::Error(e)) => {
error!("Failed to create tray icon: {}", e);
error!("This usually means the required system libraries are not installed.");
error!("On Ubuntu/Debian, try installing: sudo apt install libayatana-appindicator3-1");
error!("Alternative: sudo apt install libappindicator3-1");
std::process::exit(1);
}
_ => {
error!("Timeout waiting for tray creation");
std::process::exit(1);
}
}
}
}
}
fn user_event(
&mut self,
event_loop: &winit::event_loop::ActiveEventLoop,
event: crate::gui::UserEvent,
) {
match event {
crate::gui::UserEvent::TrayIconEvent => {}
crate::gui::UserEvent::UpdateMenu => {
self.handle_update_menu();
}
crate::gui::UserEvent::MenuEvent(event) => {
self.handle_menu_event(event, event_loop);
}
crate::gui::UserEvent::ConfigFileChanged => {
self.handle_config_change();
}
}
}
}
/// Run tray application
pub async fn run_tray_app() -> std::io::Result<()> {
#[cfg(target_os = "macos")]
let event_loop = {
use winit::platform::macos::ActivationPolicy;
EventLoop::<crate::gui::UserEvent>::with_user_event()
.with_activation_policy(ActivationPolicy::Accessory)
.build()
.map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create event loop: {}", e),
)
})?
};
#[cfg(not(target_os = "macos"))]
let event_loop = EventLoop::<crate::gui::UserEvent>::with_user_event()
.build()
.map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create event loop: {}", e),
)
})?;
let proxy = event_loop.create_proxy();
// Setup event handlers
let proxy_clone = proxy.clone();
tray_icon::TrayIconEvent::set_event_handler(Some(move |_event| {
let _ = proxy_clone.send_event(crate::gui::UserEvent::TrayIconEvent);
}));
let proxy_clone = proxy.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
let _ = proxy_clone.send_event(crate::gui::UserEvent::MenuEvent(event));
}));
let mut app = TrayApplication::new(proxy);
event_loop.run_app(&mut app).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Event loop error: {:?}", e),
)
})?;
Ok(())
}

321
src/gui/tray/icon.rs Normal file
View File

@@ -0,0 +1,321 @@
use crate::gui::common::{perform_sync, KhmSettings};
use log::{error, info};
use std::sync::{Arc, Mutex};
use tray_icon::{
menu::{Menu, MenuId, MenuItem},
TrayIcon, TrayIconBuilder,
};
#[derive(Debug, Clone)]
pub struct SyncStatus {
pub last_sync_time: Option<std::time::Instant>,
pub last_sync_keys: Option<usize>,
pub next_sync_in_seconds: Option<u64>,
}
impl Default for SyncStatus {
fn default() -> Self {
Self {
last_sync_time: None,
last_sync_keys: None,
next_sync_in_seconds: None,
}
}
}
pub struct TrayMenuIds {
pub settings_id: MenuId,
pub quit_id: MenuId,
pub sync_id: MenuId,
}
/// Create tray icon with menu
pub fn create_tray_icon(
settings: &KhmSettings,
sync_status: &SyncStatus,
) -> (TrayIcon, TrayMenuIds) {
// Create simple blue icon
let icon_data: Vec<u8> = (0..32 * 32)
.flat_map(|i| {
let y = i / 32;
let x = i % 32;
if x < 2 || x >= 30 || y < 2 || y >= 30 {
[255, 255, 255, 255] // White border
} else {
[64, 128, 255, 255] // Blue center
}
})
.collect();
let icon = tray_icon::Icon::from_rgba(icon_data, 32, 32).unwrap();
let menu = Menu::new();
// Show current configuration status (static)
let host_text = if settings.host.is_empty() {
"Host: Not configured"
} else {
&format!("Host: {}", settings.host)
};
menu.append(&MenuItem::new(host_text, false, None)).unwrap();
let flow_text = if settings.flow.is_empty() {
"Flow: Not configured"
} else {
&format!("Flow: {}", settings.flow)
};
menu.append(&MenuItem::new(flow_text, false, None)).unwrap();
let is_auto_sync_enabled =
!settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place;
let sync_text = format!(
"Auto sync: {} ({}min)",
if is_auto_sync_enabled { "On" } else { "Off" },
settings.auto_sync_interval_minutes
);
menu.append(&MenuItem::new(&sync_text, false, None))
.unwrap();
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.unwrap();
// Sync Now menu item
let sync_item = MenuItem::new(
"Sync Now",
!settings.host.is_empty() && !settings.flow.is_empty(),
None,
);
let sync_id = sync_item.id().clone();
menu.append(&sync_item).unwrap();
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.unwrap();
// Settings menu item
let settings_item = MenuItem::new("Settings", true, None);
let settings_id = settings_item.id().clone();
menu.append(&settings_item).unwrap();
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.unwrap();
// Quit menu item
let quit_item = MenuItem::new("Quit", true, None);
let quit_id = quit_item.id().clone();
menu.append(&quit_item).unwrap();
// Create initial tooltip
let tooltip = create_tooltip(settings, sync_status);
let tray_icon = TrayIconBuilder::new()
.with_tooltip(&tooltip)
.with_icon(icon)
.with_menu(Box::new(menu))
.build()
.unwrap();
let menu_ids = TrayMenuIds {
settings_id,
quit_id,
sync_id,
};
(tray_icon, menu_ids)
}
/// Update tray menu with new settings
pub fn update_tray_menu(tray_icon: &TrayIcon, settings: &KhmSettings) -> TrayMenuIds {
let menu = Menu::new();
// Show current configuration status (static)
let host_text = if settings.host.is_empty() {
"Host: Not configured"
} else {
&format!("Host: {}", settings.host)
};
menu.append(&MenuItem::new(host_text, false, None)).unwrap();
let flow_text = if settings.flow.is_empty() {
"Flow: Not configured"
} else {
&format!("Flow: {}", settings.flow)
};
menu.append(&MenuItem::new(flow_text, false, None)).unwrap();
let is_auto_sync_enabled =
!settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place;
let sync_text = format!(
"Auto sync: {} ({}min)",
if is_auto_sync_enabled { "On" } else { "Off" },
settings.auto_sync_interval_minutes
);
menu.append(&MenuItem::new(&sync_text, false, None))
.unwrap();
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.unwrap();
// Sync Now menu item
let sync_item = MenuItem::new(
"Sync Now",
!settings.host.is_empty() && !settings.flow.is_empty(),
None,
);
let sync_id = sync_item.id().clone();
menu.append(&sync_item).unwrap();
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.unwrap();
// Settings menu item
let settings_item = MenuItem::new("Settings", true, None);
let settings_id = settings_item.id().clone();
menu.append(&settings_item).unwrap();
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.unwrap();
// Quit menu item
let quit_item = MenuItem::new("Quit", true, None);
let quit_id = quit_item.id().clone();
menu.append(&quit_item).unwrap();
tray_icon.set_menu(Some(Box::new(menu)));
TrayMenuIds {
settings_id,
quit_id,
sync_id,
}
}
/// Create tooltip text for tray icon
pub fn create_tooltip(settings: &KhmSettings, sync_status: &SyncStatus) -> String {
let mut tooltip = format!(
"KHM - SSH Key Manager\nHost: {}\nFlow: {}",
settings.host, settings.flow
);
if let Some(keys_count) = sync_status.last_sync_keys {
tooltip.push_str(&format!("\nLast sync: {} keys", keys_count));
} else {
tooltip.push_str("\nLast sync: Never");
}
if let Some(seconds) = sync_status.next_sync_in_seconds {
if seconds > 60 {
tooltip.push_str(&format!("\nNext sync: {}m {}s", seconds / 60, seconds % 60));
} else {
tooltip.push_str(&format!("\nNext sync: {}s", seconds));
}
}
tooltip
}
/// Start auto sync background task
pub fn start_auto_sync_task(
settings: Arc<Mutex<KhmSettings>>,
sync_status: Arc<Mutex<SyncStatus>>,
event_sender: winit::event_loop::EventLoopProxy<crate::gui::UserEvent>,
) -> Option<std::thread::JoinHandle<()>> {
let initial_settings = settings.lock().unwrap().clone();
// Only start auto sync if settings are valid and in_place is enabled
if initial_settings.host.is_empty()
|| initial_settings.flow.is_empty()
|| !initial_settings.in_place
{
info!("Auto sync disabled or settings invalid");
return None;
}
info!(
"Starting auto sync with interval {} minutes",
initial_settings.auto_sync_interval_minutes
);
let handle = std::thread::spawn(move || {
// Initial sync on startup
info!("Performing initial sync on startup");
let current_settings = settings.lock().unwrap().clone();
if !current_settings.host.is_empty() && !current_settings.flow.is_empty() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
match perform_sync(&current_settings).await {
Ok(keys_count) => {
info!(
"Initial sync completed successfully with {} keys",
keys_count
);
let mut status = sync_status.lock().unwrap();
status.last_sync_time = Some(std::time::Instant::now());
status.last_sync_keys = Some(keys_count);
let _ = event_sender.send_event(crate::gui::UserEvent::UpdateMenu);
}
Err(e) => {
error!("Initial sync failed: {}", e);
}
}
});
}
// Start menu update timer
let timer_sender = event_sender.clone();
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(1));
let _ = timer_sender.send_event(crate::gui::UserEvent::UpdateMenu);
});
// Periodic sync
loop {
let interval_minutes = current_settings.auto_sync_interval_minutes;
std::thread::sleep(std::time::Duration::from_secs(interval_minutes as u64 * 60));
let current_settings = settings.lock().unwrap().clone();
if current_settings.host.is_empty()
|| current_settings.flow.is_empty()
|| !current_settings.in_place
{
info!("Auto sync stopped due to invalid settings or disabled in_place");
break;
}
info!("Performing scheduled auto sync");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
match perform_sync(&current_settings).await {
Ok(keys_count) => {
info!("Auto sync completed successfully with {} keys", keys_count);
let mut status = sync_status.lock().unwrap();
status.last_sync_time = Some(std::time::Instant::now());
status.last_sync_keys = Some(keys_count);
let _ = event_sender.send_event(crate::gui::UserEvent::UpdateMenu);
}
Err(e) => {
error!("Auto sync failed: {}", e);
}
}
});
}
});
Some(handle)
}
/// Update sync status for tooltip
pub fn update_sync_status(settings: &KhmSettings, sync_status: &mut SyncStatus) {
if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place {
if let Some(last_sync) = sync_status.last_sync_time {
let elapsed = last_sync.elapsed().as_secs();
let interval_seconds = settings.auto_sync_interval_minutes as u64 * 60;
if elapsed < interval_seconds {
sync_status.next_sync_in_seconds = Some(interval_seconds - elapsed);
} else {
sync_status.next_sync_in_seconds = Some(0);
}
} else {
sync_status.next_sync_in_seconds = None;
}
}
}

8
src/gui/tray/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
mod app;
mod icon;
pub use app::*;
pub use icon::{
create_tooltip, create_tray_icon, start_auto_sync_task, update_sync_status, update_tray_menu,
SyncStatus, TrayMenuIds,
};

View File

@@ -1,45 +1,39 @@
mod client;
mod db;
mod server;
pub mod client;
pub mod db;
pub mod gui;
pub mod server;
#[cfg(feature = "web")]
pub mod web;
#[cfg(feature = "web-gui")]
pub mod web_gui;
use clap::Parser;
use env_logger;
use log::{error, info};
/// This application manages SSH keys and flows, either as a server or client.
/// In server mode, it stores keys and flows in a PostgreSQL database.
/// In client mode, it sends keys to the server and can update the known_hosts file with keys from the server.
#[derive(Parser, Debug)]
#[command(
author = env!("CARGO_PKG_AUTHORS"),
version = env!("CARGO_PKG_VERSION"),
about = "SSH Host Key Manager",
long_about = None,
after_help = "Examples:\n\
\n\
Running in server mode:\n\
khm --server --ip 0.0.0.0 --port 1337 --db-host psql.psql.svc --db-name khm --db-user admin --db-password <SECRET> --flows work,home\n\
\n\
Running in client mode to send diff and sync ~/.ssh/known_hosts with remote flow `work` in place:\n\
khm --host https://khm.example.com/work --known-hosts ~/.ssh/known_hosts --in-place\n\
\n\
"
)]
struct Args {
// Common Args structure used by all binaries
#[derive(Parser, Debug, Clone)]
pub struct Args {
/// Run in server mode (default: false)
#[arg(long, help = "Run in server mode")]
server: bool,
pub server: bool,
/// Hide console window and run in background (default: auto when no arguments)
#[arg(long, help = "Hide console window and run in background")]
pub daemon: bool,
/// Run settings UI window
#[arg(long, help = "Run settings UI window")]
pub settings_ui: bool,
/// Update the known_hosts file with keys from the server after sending keys (default: false)
#[arg(
long,
help = "Server mode: Sync the known_hosts file with keys from the server"
)]
in_place: bool,
pub in_place: bool,
/// Comma-separated list of flows to manage (default: default)
#[arg(long, default_value = "default", value_parser, num_args = 1.., value_delimiter = ',', help = "Server mode: Comma-separated list of flows to manage")]
flows: Vec<String>,
pub flows: Vec<String>,
/// IP address to bind the server or client to (default: 127.0.0.1)
#[arg(
@@ -48,7 +42,7 @@ struct Args {
default_value = "127.0.0.1",
help = "Server mode: IP address to bind the server to"
)]
ip: String,
pub ip: String,
/// Port to bind the server or client to (default: 8080)
#[arg(
@@ -57,7 +51,7 @@ struct Args {
default_value = "8080",
help = "Server mode: Port to bind the server to"
)]
port: u16,
pub port: u16,
/// Hostname or IP address of the PostgreSQL database (default: 127.0.0.1)
#[arg(
@@ -65,7 +59,7 @@ struct Args {
default_value = "127.0.0.1",
help = "Server mode: Hostname or IP address of the PostgreSQL database"
)]
db_host: String,
pub db_host: String,
/// Name of the PostgreSQL database (default: khm)
#[arg(
@@ -73,7 +67,7 @@ struct Args {
default_value = "khm",
help = "Server mode: Name of the PostgreSQL database"
)]
db_name: String,
pub db_name: String,
/// Username for the PostgreSQL database (required in server mode)
#[arg(
@@ -81,7 +75,7 @@ struct Args {
required_if_eq("server", "true"),
help = "Server mode: Username for the PostgreSQL database"
)]
db_user: Option<String>,
pub db_user: Option<String>,
/// Password for the PostgreSQL database (required in server mode)
#[arg(
@@ -89,15 +83,23 @@ struct Args {
required_if_eq("server", "true"),
help = "Server mode: Password for the PostgreSQL database"
)]
db_password: Option<String>,
pub db_password: Option<String>,
/// Host address of the server to connect to in client mode (required in client mode)
#[arg(
long,
required_if_eq("server", "false"),
help = "Client mode: Full host address of the server to connect to. Like https://khm.example.com/<FLOW_NAME>"
help = "Client mode: Full host address of the server to connect to. Like https://khm.example.com"
)]
host: Option<String>,
pub host: Option<String>,
/// Flow name to use on the server
#[arg(
long,
required_if_eq("server", "false"),
help = "Client mode: Flow name to use on the server"
)]
pub flow: Option<String>,
/// Path to the known_hosts file (default: ~/.ssh/known_hosts)
#[arg(
@@ -105,32 +107,13 @@ struct Args {
default_value = "~/.ssh/known_hosts",
help = "Client mode: Path to the known_hosts file"
)]
known_hosts: String,
pub known_hosts: String,
/// Basic auth string for client mode. Format: user:pass
#[arg(long, default_value = "", help = "Client mode: Basic Auth credentials")]
basic_auth: String,
pub basic_auth: String,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
info!("Starting SSH Key Manager");
let args = Args::parse();
if args.server {
info!("Running in server mode");
if let Err(e) = server::run_server(args).await {
error!("Failed to run server: {}", e);
}
} else {
info!("Running in client mode");
if let Err(e) = client::run_client(args).await {
error!("Failed to run client: {}", e);
}
}
info!("Application has exited");
Ok(())
}
// Re-export WASM functions for wasm-pack
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub use web_gui::wasm::*;

View File

@@ -2,16 +2,16 @@ use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};
use log::{error, info};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio_postgres::{Client, NoTls};
use crate::db;
use crate::db::ReconnectingDbClient;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SshKey {
pub server: String,
pub public_key: String,
#[serde(default)]
pub deprecated: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -35,43 +35,6 @@ 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",
&[]
).await?;
let mut flows_map: HashMap<String, Flow> = HashMap::new();
for row in rows {
let host: String = row.get(0);
let key: String = row.get(1);
let flow: String = row.get(2);
let ssh_key = SshKey {
server: host,
public_key: key,
};
if let Some(flow_entry) = flows_map.get_mut(&flow) {
flow_entry.servers.push(ssh_key);
} else {
flows_map.insert(
flow.clone(),
Flow {
name: flow,
servers: vec![ssh_key],
},
);
}
}
info!("Retrieved {} flows from database", flows_map.len());
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") {
@@ -87,6 +50,7 @@ pub async fn get_keys(
flow_id: web::Path<String>,
allowed_flows: web::Data<Vec<String>>,
req: HttpRequest,
query: web::Query<std::collections::HashMap<String, String>>,
) -> impl Responder {
let client_hostname = get_client_hostname(&req);
let flow_id_str = flow_id.into_inner();
@@ -106,10 +70,25 @@ pub async fn get_keys(
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();
// Check if we should include deprecated keys (default: false for CLI clients)
let include_deprecated = query
.get("include_deprecated")
.map(|v| v == "true")
.unwrap_or(false);
let servers: Vec<&SshKey> = if include_deprecated {
// Return all keys (for web interface)
flow.servers.iter().collect()
} else {
// Return only active keys (for CLI clients)
flow.servers.iter().filter(|key| !key.deprecated).collect()
};
info!(
"Returning {} keys for flow '{}' to client '{}'",
"Returning {} keys ({} total, deprecated filtered: {}) for flow '{}' to client '{}'",
servers.len(),
flow.servers.len(),
!include_deprecated,
flow_id_str,
client_hostname
);
@@ -127,7 +106,7 @@ pub async fn add_keys(
flows: web::Data<Flows>,
flow_id: web::Path<String>,
new_keys: web::Json<Vec<SshKey>>,
db_client: web::Data<Arc<Client>>,
db_client: web::Data<Arc<ReconnectingDbClient>>,
allowed_flows: web::Data<Vec<String>>,
req: HttpRequest,
) -> impl Responder {
@@ -173,7 +152,10 @@ pub async fn add_keys(
);
// Batch insert keys with statistics
let key_stats = match crate::db::batch_insert_keys(&db_client, &valid_keys).await {
let key_stats = match db_client
.batch_insert_keys_reconnecting(valid_keys.clone())
.await
{
Ok(stats) => stats,
Err(e) => {
error!(
@@ -185,13 +167,15 @@ 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
if let Err(e) = crate::db::batch_insert_flow_keys(&db_client, &flow_id_str, &key_ids).await
if let Err(e) = db_client
.batch_insert_flow_keys_reconnecting(flow_id_str.clone(), key_ids.clone())
.await
{
error!(
"Failed to batch insert flow keys from client '{}' into database: {}",
@@ -209,13 +193,13 @@ 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
);
}
// Get updated data
let updated_flows = match get_keys_from_db(&db_client).await {
let updated_flows = match db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
error!(
@@ -270,28 +254,22 @@ 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) => {
info!("Creating database client for {}", args.db_host);
let mut db_client_temp = ReconnectingDbClient::new(db_conn_str.clone());
// Initial connection
if let Err(e) = db_client_temp.connect(&db_conn_str).await {
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 = Arc::new(db_client);
// Spawn a new thread to run the database connection
tokio::spawn(async move {
if let Err(e) = connection.await {
error!("Connection error: {}", e);
}
});
let db_client = Arc::new(db_client_temp);
// Initialize database schema if needed
if let Err(e) = db::initialize_db_schema(&db_client).await {
if let Err(e) = db_client.initialize_schema().await {
error!("Failed to initialize database schema: {}", e);
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
@@ -299,7 +277,7 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> {
));
}
let mut initial_flows = match get_keys_from_db(&db_client).await {
let mut initial_flows = match db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
error!("Failed to get initial flows from database: {}", e);
@@ -322,14 +300,69 @@ pub async fn run_server(args: crate::Args) -> std::io::Result<()> {
info!("Starting HTTP server on {}:{}", args.ip, args.port);
HttpServer::new(move || {
App::new()
let mut app = App::new()
.app_data(web::Data::new(flows.clone()))
.app_data(web::Data::new(db_client.clone()))
.app_data(allowed_flows.clone())
// Original API routes
.route("/{flow_id}/keys", web::get().to(get_keys))
.route("/{flow_id}/keys", web::post().to(add_keys))
.route("/{flow_id}/keys", web::post().to(add_keys));
#[cfg(feature = "web")]
{
app = app.configure(configure_web_routes);
}
app
})
.bind((args.ip.as_str(), args.port))?
.run()
.await
}
#[cfg(feature = "web")]
fn configure_web_routes(cfg: &mut web::ServiceConfig) {
cfg
// API routes
.route("/api/version", web::get().to(crate::web::get_version_api))
.route("/api/flows", web::get().to(crate::web::get_flows_api))
.route(
"/{flow_id}/scan-dns",
web::post().to(crate::web::scan_dns_resolution),
)
.route(
"/{flow_id}/bulk-deprecate",
web::post().to(crate::web::bulk_deprecate_servers),
)
.route(
"/{flow_id}/bulk-restore",
web::post().to(crate::web::bulk_restore_servers),
)
.route(
"/{flow_id}/keys/{server}",
web::delete().to(crate::web::delete_key_by_server),
)
.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),
)
// Web interface routes
.route("/", web::get().to(crate::web::serve_web_interface))
.route(
"/static/{filename:.*}",
web::get().to(crate::web::serve_static_file),
);
// Web GUI routes
cfg.route("/gui", web::get().to(crate::web_gui::serve_egui_interface))
.route("/gui/", web::get().to(crate::web_gui::serve_egui_interface))
.route("/gui/config", web::get().to(crate::web_gui::get_gui_config))
.route("/gui/state", web::get().to(crate::web_gui::get_gui_state))
.route("/gui/settings", web::post().to(crate::web_gui::update_gui_settings))
.route("/wasm/{filename:.*}", web::get().to(crate::web_gui::serve_wasm_file));
}

265
src/wasm_lib.rs Normal file
View File

@@ -0,0 +1,265 @@
// Минимальная WASM библиотека только для egui интерфейса
use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Основные структуры данных (копии из main lib)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshKey {
pub server: String,
pub public_key: String,
#[serde(default)]
pub deprecated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsResult {
pub server: String,
pub resolved: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AdminSettings {
pub server_url: String,
pub basic_auth: String,
pub selected_flow: String,
pub auto_refresh: bool,
pub refresh_interval: u32,
}
impl Default for AdminSettings {
fn default() -> Self {
let server_url = {
#[cfg(target_arch = "wasm32")]
{
web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_else(|| "http://localhost:8080".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
{
"http://localhost:8080".to_string()
}
};
Self {
server_url,
basic_auth: String::new(),
selected_flow: String::new(),
auto_refresh: false,
refresh_interval: 30,
}
}
}
#[derive(Debug, Clone)]
pub struct AdminState {
pub keys: Vec<SshKey>,
pub filtered_keys: Vec<SshKey>,
pub search_term: String,
pub show_deprecated_only: bool,
pub selected_servers: HashMap<String, bool>,
pub expanded_servers: HashMap<String, bool>,
pub current_operation: String,
}
impl Default for AdminState {
fn default() -> Self {
Self {
keys: Vec::new(),
filtered_keys: Vec::new(),
search_term: String::new(),
show_deprecated_only: false,
selected_servers: HashMap::new(),
expanded_servers: HashMap::new(),
current_operation: String::new(),
}
}
}
impl AdminState {
pub fn filter_keys(&mut self) {
self.filtered_keys = self.keys.iter()
.filter(|key| {
if self.show_deprecated_only && !key.deprecated {
return false;
}
if !self.show_deprecated_only && key.deprecated {
return false;
}
if !self.search_term.is_empty() {
let search_lower = self.search_term.to_lowercase();
return key.server.to_lowercase().contains(&search_lower) ||
key.public_key.to_lowercase().contains(&search_lower);
}
true
})
.cloned()
.collect();
}
}
// Простое egui приложение
pub struct WebAdminApp {
settings: AdminSettings,
admin_state: AdminState,
status_message: String,
}
impl Default for WebAdminApp {
fn default() -> Self {
Self {
settings: AdminSettings::default(),
admin_state: AdminState::default(),
status_message: "Ready".to_string(),
}
}
}
impl eframe::App for WebAdminApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("🔑 KHM Web Admin Panel");
ui.separator();
// Connection Settings
egui::CollapsingHeader::new("⚙️ Connection Settings")
.default_open(true)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Server URL:");
ui.text_edit_singleline(&mut self.settings.server_url);
});
ui.horizontal(|ui| {
ui.label("Basic Auth:");
ui.add(egui::TextEdit::singleline(&mut self.settings.basic_auth).password(true));
});
ui.horizontal(|ui| {
ui.label("Flow:");
ui.text_edit_singleline(&mut self.settings.selected_flow);
});
ui.horizontal(|ui| {
if ui.button("Test Connection").clicked() {
self.status_message = "Testing connection... (WASM demo mode)".to_string();
}
if ui.button("Load Keys").clicked() {
// Add demo data
self.admin_state.keys = vec![
SshKey {
server: "demo-server-1".to_string(),
public_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC demo key 1".to_string(),
deprecated: false,
},
SshKey {
server: "demo-server-2".to_string(),
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 demo key 2".to_string(),
deprecated: true,
},
];
self.admin_state.filter_keys();
self.status_message = format!("Loaded {} demo keys", self.admin_state.keys.len());
}
});
});
ui.add_space(10.0);
// Keys display
if !self.admin_state.filtered_keys.is_empty() {
egui::CollapsingHeader::new("🔑 SSH Keys")
.default_open(true)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Search:");
let search_response = ui.text_edit_singleline(&mut self.admin_state.search_term);
if search_response.changed() {
self.admin_state.filter_keys();
}
});
ui.horizontal(|ui| {
if ui.selectable_label(!self.admin_state.show_deprecated_only, "✅ Active").clicked() {
self.admin_state.show_deprecated_only = false;
self.admin_state.filter_keys();
}
if ui.selectable_label(self.admin_state.show_deprecated_only, "❗ Deprecated").clicked() {
self.admin_state.show_deprecated_only = true;
self.admin_state.filter_keys();
}
});
ui.separator();
for key in &self.admin_state.filtered_keys {
ui.group(|ui| {
ui.horizontal(|ui| {
if key.deprecated {
ui.colored_label(egui::Color32::RED, "❗ DEPRECATED");
} else {
ui.colored_label(egui::Color32::GREEN, "✅ ACTIVE");
}
ui.label(&key.server);
ui.monospace(&key.public_key[..50.min(key.public_key.len())]);
if ui.small_button("Copy").clicked() {
ui.output_mut(|o| o.copied_text = key.public_key.clone());
}
});
});
}
});
}
ui.add_space(10.0);
// Status
ui.horizontal(|ui| {
ui.label("Status:");
ui.colored_label(egui::Color32::LIGHT_BLUE, &self.status_message);
});
// Info
ui.separator();
ui.label(" This is a demo WASM version. For full functionality, the server API integration is needed.");
});
}
}
/// WASM entry point
#[wasm_bindgen]
pub fn start_web_admin(canvas_id: &str) -> Result<(), JsValue> {
console_error_panic_hook::set_once();
tracing_wasm::set_as_global_default();
let web_options = eframe::WebOptions::default();
let canvas_id = canvas_id.to_string();
wasm_bindgen_futures::spawn_local(async move {
let app = WebAdminApp::default();
let result = eframe::WebRunner::new()
.start(
&canvas_id,
web_options,
Box::new(|_cc| Ok(Box::new(app))),
)
.await;
match result {
Ok(_) => web_sys::console::log_1(&"eframe started successfully".into()),
Err(e) => web_sys::console::error_1(&format!("Failed to start eframe: {:?}", e).into()),
}
});
Ok(())
}
#[wasm_bindgen(start)]
pub fn wasm_main() {
console_error_panic_hook::set_once();
}

493
src/web.rs Normal file
View File

@@ -0,0 +1,493 @@
use actix_web::{web, HttpResponse, Result};
use futures::future;
use log::info;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};
use trust_dns_resolver::config::*;
use trust_dns_resolver::TokioAsyncResolver;
use crate::db::ReconnectingDbClient;
use crate::server::Flows;
#[derive(RustEmbed)]
#[folder = "static/"]
struct StaticAssets;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DnsResolutionResult {
pub server: String,
pub resolved: bool,
pub error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct BulkDeprecateRequest {
pub servers: Vec<String>,
}
async fn check_dns_resolution(hostname: String, semaphore: Arc<Semaphore>) -> DnsResolutionResult {
let _permit = match semaphore.acquire().await {
Ok(permit) => permit,
Err(_) => {
return DnsResolutionResult {
server: hostname,
resolved: false,
error: Some("Failed to acquire semaphore".to_string()),
};
}
};
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
let lookup_result = timeout(Duration::from_secs(5), resolver.lookup_ip(&hostname)).await;
match lookup_result {
Ok(Ok(_)) => DnsResolutionResult {
server: hostname,
resolved: true,
error: None,
},
Ok(Err(e)) => DnsResolutionResult {
server: hostname,
resolved: false,
error: Some(e.to_string()),
},
Err(_) => DnsResolutionResult {
server: hostname,
resolved: false,
error: Some("DNS lookup timeout (5s)".to_string()),
},
}
}
// API endpoint to get application version
pub async fn get_version_api() -> Result<HttpResponse> {
Ok(HttpResponse::Ok().json(json!({
"version": env!("CARGO_PKG_VERSION")
})))
}
// 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 scan DNS resolution for all hosts in a flow
pub async fn scan_dns_resolution(
flows: web::Data<Flows>,
path: web::Path<String>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
let flow_id_str = path.into_inner();
info!(
"API request to scan DNS resolution for flow '{}'",
flow_id_str
);
if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({
"error": "Flow ID not allowed"
})));
}
let flows_guard = flows.lock().unwrap();
let flow = match flows_guard.iter().find(|flow| flow.name == flow_id_str) {
Some(flow) => flow,
None => {
return Ok(HttpResponse::NotFound().json(json!({
"error": "Flow ID not found"
})));
}
};
// Get unique hostnames
let mut hostnames: std::collections::HashSet<String> = std::collections::HashSet::new();
for key in &flow.servers {
hostnames.insert(key.server.clone());
}
drop(flows_guard);
info!(
"Scanning DNS resolution for {} unique hosts",
hostnames.len()
);
// Limit concurrent DNS requests to prevent "too many open files" error
let semaphore = Arc::new(Semaphore::new(20));
// Scan all hostnames concurrently with rate limiting
let mut scan_futures = Vec::new();
for hostname in hostnames {
scan_futures.push(check_dns_resolution(hostname, semaphore.clone()));
}
let results = future::join_all(scan_futures).await;
let unresolved_count = results.iter().filter(|r| !r.resolved).count();
info!(
"DNS scan complete: {} unresolved out of {} hosts",
unresolved_count,
results.len()
);
Ok(HttpResponse::Ok().json(json!({
"results": results,
"total": results.len(),
"unresolved": unresolved_count
})))
}
// API endpoint to bulk deprecate multiple servers
pub async fn bulk_deprecate_servers(
flows: web::Data<Flows>,
path: web::Path<String>,
request: web::Json<BulkDeprecateRequest>,
db_client: web::Data<Arc<ReconnectingDbClient>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
let flow_id_str = path.into_inner();
info!(
"API request to bulk deprecate {} servers in flow '{}'",
request.servers.len(),
flow_id_str
);
if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({
"error": "Flow ID not allowed"
})));
}
// Use single bulk operation instead of loop
let total_deprecated = match db_client
.bulk_deprecate_keys_by_servers_reconnecting(request.servers.clone(), flow_id_str.clone())
.await
{
Ok(count) => {
info!(
"Bulk deprecated {} key(s) for {} servers",
count,
request.servers.len()
);
count
}
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to bulk deprecate keys: {}", e)
})));
}
};
// Refresh the in-memory flows
let updated_flows = match db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to refresh flows: {}", e)
})));
}
};
let mut flows_guard = flows.lock().unwrap();
*flows_guard = updated_flows;
let response = json!({
"message": format!("Successfully deprecated {} key(s) for {} server(s)", total_deprecated, request.servers.len()),
"deprecated_count": total_deprecated,
"servers_processed": request.servers.len()
});
Ok(HttpResponse::Ok().json(response))
}
// API endpoint to bulk restore multiple servers
pub async fn bulk_restore_servers(
flows: web::Data<Flows>,
path: web::Path<String>,
request: web::Json<BulkDeprecateRequest>,
db_client: web::Data<Arc<ReconnectingDbClient>>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
let flow_id_str = path.into_inner();
info!(
"API request to bulk restore {} servers in flow '{}'",
request.servers.len(),
flow_id_str
);
if !allowed_flows.contains(&flow_id_str) {
return Ok(HttpResponse::Forbidden().json(json!({
"error": "Flow ID not allowed"
})));
}
// Use single bulk operation
let total_restored = match db_client
.bulk_restore_keys_by_servers_reconnecting(request.servers.clone(), flow_id_str.clone())
.await
{
Ok(count) => {
info!(
"Bulk restored {} key(s) for {} servers",
count,
request.servers.len()
);
count
}
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to bulk restore keys: {}", e)
})));
}
};
// Refresh the in-memory flows
let updated_flows = match db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to refresh flows: {}", e)
})));
}
};
let mut flows_guard = flows.lock().unwrap();
*flows_guard = updated_flows;
let response = json!({
"message": format!("Successfully restored {} key(s) for {} server(s)", total_restored, request.servers.len()),
"restored_count": total_restored,
"servers_processed": request.servers.len()
});
Ok(HttpResponse::Ok().json(response))
}
// API endpoint to deprecate a specific key by server name
pub async fn delete_key_by_server(
flows: web::Data<Flows>,
path: web::Path<(String, String)>,
db_client: web::Data<Arc<ReconnectingDbClient>>,
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 db_client
.deprecate_key_by_server_reconnecting(server_name.clone(), flow_id_str.clone())
.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 db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to refresh flows: {}", e)
})));
}
};
let mut flows_guard = flows.lock().unwrap();
*flows_guard = updated_flows;
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<Arc<ReconnectingDbClient>>,
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 db_client
.restore_key_by_server_reconnecting(server_name.clone(), flow_id_str.clone())
.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 db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to refresh flows: {}", e)
})));
}
};
let mut flows_guard = flows.lock().unwrap();
*flows_guard = updated_flows;
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<Arc<ReconnectingDbClient>>,
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 db_client
.permanently_delete_key_by_server_reconnecting(server_name.clone(), flow_id_str.clone())
.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 db_client.get_keys_from_db_reconnecting().await {
Ok(flows) => flows,
Err(e) => {
return Ok(HttpResponse::InternalServerError().json(json!({
"error": format!("Failed to refresh flows: {}", e)
})));
}
};
let mut flows_guard = flows.lock().unwrap();
*flows_guard = updated_flows;
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")),
}
}

288
src/web_gui.rs Normal file
View File

@@ -0,0 +1,288 @@
use actix_web::{HttpResponse, Result, web};
use serde_json::json;
use log::info;
#[cfg(feature = "web-gui")]
pub mod app;
#[cfg(feature = "web-gui")]
pub mod state;
#[cfg(feature = "web-gui")]
pub mod ui;
#[cfg(all(feature = "web-gui", not(target_arch = "wasm32")))]
pub mod api;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub mod wasm_api;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub mod wasm;
/// Serve the egui web GUI interface
pub async fn serve_egui_interface() -> Result<HttpResponse> {
#[cfg(feature = "web-gui")]
{
let html = r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>KHM Admin Panel</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #2b2b2b;
font-family: system-ui, sans-serif;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}
#loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 18px;
z-index: 1000;
text-align: center;
}
.spinner {
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top: 3px solid #667eea;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="loading">
<div class="spinner"></div>
Loading KHM Admin Panel...
</div>
<canvas id="the_canvas_id"></canvas>
<script type="module">
import init, { start_web_admin } from './wasm/khm_wasm.js';
async function run() {
try {
// Initialize WASM module
await init();
// Hide loading indicator
document.getElementById('loading').style.display = 'none';
// Start the egui web app
start_web_admin('the_canvas_id');
console.log('KHM Web Admin Panel started successfully');
} catch (error) {
console.error('Failed to start KHM Web Admin Panel:', error);
// Show error message
document.getElementById('loading').innerHTML = `
<div style="color: #ff6b6b; text-align: center;">
<h3>⚠️ WASM Module Not Available</h3>
<p>The egui web interface requires WASM compilation.</p>
<p style="font-size: 14px; color: #ccc; margin: 20px 0;">Build steps:</p>
<div style="background: #333; padding: 15px; border-radius: 5px; font-family: monospace; text-align: left; max-width: 600px; margin: 0 auto;">
<div style="color: #888; margin-bottom: 10px;"># Install wasm-pack</div>
<div style="color: #fff;">cargo install wasm-pack</div>
<div style="color: #888; margin: 10px 0;"># Build WASM module</div>
<div style="color: #fff;">wasm-pack build --target web --out-dir wasm --features web-gui</div>
<div style="color: #888; margin: 10px 0;"># Restart server</div>
<div style="color: #fff;">cargo run --features "server,web,web-gui"</div>
</div>
<p style="font-size: 12px; color: #888; margin-top: 20px;">Error: ${error.message}</p>
</div>
`;
}
}
run();
</script>
</body>
</html>
"#;
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}
#[cfg(not(feature = "web-gui"))]
{
let html = r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>KHM Admin Panel - Not Available</title>
<style>
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
text-align: center;
}
</style>
</head>
<body>
<div>
<h1>⚠️ Web GUI Not Available</h1>
<p>This server was compiled without web-gui support.</p>
<p>Please rebuild with <code>--features web-gui</code> to enable the admin interface.</p>
</div>
</body>
</html>
"#;
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}
}
/// API endpoint to get GUI configuration
pub async fn get_gui_config(
flows: web::Data<crate::server::Flows>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
info!("Web GUI config requested");
let flows_guard = flows.lock().unwrap();
let available_flows: Vec<String> = flows_guard.iter().map(|f| f.name.clone()).collect();
Ok(HttpResponse::Ok().json(json!({
"version": env!("CARGO_PKG_VERSION"),
"gui_ready": cfg!(feature = "web-gui"),
"features": ["key_management", "bulk_operations", "real_time_updates"],
"available_flows": available_flows,
"allowed_flows": &**allowed_flows,
"api_endpoints": {
"flows": "/api/flows",
"keys": "/{flow}/keys",
"deprecate": "/{flow}/keys/{server}",
"restore": "/{flow}/keys/{server}/restore",
"delete": "/{flow}/keys/{server}/delete",
"bulk_deprecate": "/{flow}/bulk-deprecate",
"bulk_restore": "/{flow}/bulk-restore",
"dns_scan": "/{flow}/scan-dns"
}
})))
}
/// API endpoint for web GUI state management
pub async fn get_gui_state(
flows: web::Data<crate::server::Flows>,
allowed_flows: web::Data<Vec<String>>,
) -> Result<HttpResponse> {
info!("Web GUI state requested");
let flows_guard = flows.lock().unwrap();
let flow_data: Vec<_> = flows_guard.iter().map(|f| json!({
"name": f.name,
"servers_count": f.servers.len(),
"active_keys": f.servers.iter().filter(|k| !k.deprecated).count(),
"deprecated_keys": f.servers.iter().filter(|k| k.deprecated).count()
})).collect();
Ok(HttpResponse::Ok().json(json!({
"flows": flow_data,
"allowed_flows": &**allowed_flows,
"timestamp": chrono::Utc::now().to_rfc3339()
})))
}
/// API endpoint to update GUI settings
pub async fn update_gui_settings(
settings: web::Json<serde_json::Value>,
) -> Result<HttpResponse> {
info!("Web GUI settings updated: {:?}", settings);
Ok(HttpResponse::Ok().json(json!({
"status": "success",
"message": "Settings updated successfully",
"timestamp": chrono::Utc::now().to_rfc3339()
})))
}
/// Serve WASM files for egui web application
pub async fn serve_wasm_file(path: web::Path<String>) -> Result<HttpResponse> {
let filename = path.into_inner();
info!("WASM file requested: {}", filename);
// Try to read the actual WASM files from the wasm directory
let wasm_dir = std::path::Path::new("wasm");
let file_path = wasm_dir.join(&filename);
match std::fs::read(&file_path) {
Ok(content) => {
let content_type = if filename.ends_with(".js") {
"application/javascript; charset=utf-8"
} else if filename.ends_with(".wasm") {
"application/wasm"
} else {
"application/octet-stream"
};
info!("Serving WASM file: {} ({} bytes)", filename, content.len());
Ok(HttpResponse::Ok()
.content_type(content_type)
.body(content))
}
Err(_) => {
// Fallback to placeholder if files don't exist
let content = match filename.as_str() {
"khm_wasm.js" => {
r#"
// KHM WASM Module Not Found
// Build the WASM module first:
// cd khm-wasm && wasm-pack build --target web --out-dir ../wasm
export default function init() {
return Promise.reject(new Error('WASM module not found. Run: cd khm-wasm && wasm-pack build --target web --out-dir ../wasm'));
}
export function start_web_admin(canvas_id) {
throw new Error('WASM module not found. Run: cd khm-wasm && wasm-pack build --target web --out-dir ../wasm');
}
"#
}
_ => {
return Ok(HttpResponse::NotFound().json(json!({
"error": "WASM file not found",
"filename": filename,
"message": "Run: cd khm-wasm && wasm-pack build --target web --out-dir ../wasm"
})));
}
};
Ok(HttpResponse::Ok()
.content_type("application/javascript; charset=utf-8")
.body(content))
}
}
}

399
src/web_gui/api.rs Normal file
View File

@@ -0,0 +1,399 @@
use super::state::{SshKey, DnsResult, AdminSettings};
use log::info;
use reqwest::Client;
use std::time::Duration;
/// Create HTTP client for API requests
fn create_http_client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
/// Add basic auth to request if provided
fn add_auth_if_needed(
request: reqwest::RequestBuilder,
basic_auth: &str,
) -> Result<reqwest::RequestBuilder, String> {
if basic_auth.is_empty() {
return Ok(request);
}
let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect();
if auth_parts.len() == 2 {
Ok(request.basic_auth(auth_parts[0], Some(auth_parts[1])))
} else {
Err("Basic auth format should be 'username:password'".to_string())
}
}
/// Check response status for errors
fn check_response_status(response: &reqwest::Response) -> Result<(), String> {
let status = response.status().as_u16();
if status == 401 {
return Err("Authentication required. Please provide valid basic auth credentials.".to_string());
}
if status >= 300 && status < 400 {
return Err("Server redirects to login page. Authentication may be required.".to_string());
}
if !response.status().is_success() {
return Err(format!(
"Server returned error: {} {}",
status,
response.status().canonical_reason().unwrap_or("Unknown")
));
}
Ok(())
}
/// Check if response is HTML instead of JSON
fn check_html_response(body: &str) -> Result<(), String> {
if body.trim_start().starts_with("<!DOCTYPE") || body.trim_start().starts_with("<html") {
return Err("Server returned HTML page instead of JSON. This usually means authentication is required or the endpoint is incorrect.".to_string());
}
Ok(())
}
/// Get application version from API
pub async fn get_version(settings: &AdminSettings) -> Result<String, String> {
if settings.server_url.is_empty() {
return Err("Server URL must be specified".to_string());
}
let url = format!("{}/api/version", settings.server_url.trim_end_matches('/'));
info!("Getting version from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let version_response: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse version: {}", e))?;
let version = version_response
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
info!("KHM server version: {}", version);
Ok(version)
}
/// Test connection to KHM server using existing API endpoint
pub async fn test_connection(settings: &AdminSettings) -> Result<String, String> {
if settings.server_url.is_empty() || settings.selected_flow.is_empty() {
return Err("Server URL and flow must be specified".to_string());
}
let url = format!(
"{}/{}/keys",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Testing connection to: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let keys: Vec<SshKey> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
let message = format!("Connection successful! Found {} SSH keys from flow '{}'", keys.len(), settings.selected_flow);
info!("{}", message);
Ok(message)
}
/// Load available flows from server
pub async fn load_flows(settings: &AdminSettings) -> Result<Vec<String>, String> {
if settings.server_url.is_empty() {
return Err("Server URL must be specified".to_string());
}
let url = format!("{}/api/flows", settings.server_url.trim_end_matches('/'));
info!("Loading flows from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let flows: Vec<String> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse flows: {}", e))?;
info!("Loaded {} flows", flows.len());
Ok(flows)
}
/// Fetch all SSH keys including deprecated ones using existing API endpoint
pub async fn fetch_keys(settings: &AdminSettings) -> Result<Vec<SshKey>, String> {
if settings.server_url.is_empty() || settings.selected_flow.is_empty() {
return Err("Server URL and flow must be specified".to_string());
}
let url = format!(
"{}/{}/keys",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Fetching keys from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let keys: Vec<SshKey> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse keys: {}", e))?;
info!("Fetched {} SSH keys", keys.len());
Ok(keys)
}
/// Deprecate a key for a specific server
pub async fn deprecate_key(
settings: &AdminSettings,
server: &str,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}",
settings.server_url.trim_end_matches('/'),
settings.selected_flow,
urlencoding::encode(server)
);
info!("Deprecating key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.delete(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok(format!("Successfully deprecated key for server '{}'", server))
}
/// Restore a key for a specific server
pub async fn restore_key(
settings: &AdminSettings,
server: &str,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}/restore",
settings.server_url.trim_end_matches('/'),
settings.selected_flow,
urlencoding::encode(server)
);
info!("Restoring key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.post(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok(format!("Successfully restored key for server '{}'", server))
}
/// Delete a key permanently for a specific server
pub async fn delete_key(
settings: &AdminSettings,
server: &str,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}/delete",
settings.server_url.trim_end_matches('/'),
settings.selected_flow,
urlencoding::encode(server)
);
info!("Permanently deleting key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.delete(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok(format!("Successfully deleted key for server '{}'", server))
}
/// Bulk deprecate multiple servers
pub async fn bulk_deprecate_servers(
settings: &AdminSettings,
servers: Vec<String>,
) -> Result<String, String> {
let url = format!(
"{}/{}/bulk-deprecate",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Bulk deprecating {} servers at: {}", servers.len(), url);
let client = create_http_client()?;
let mut request = client.post(&url).json(&serde_json::json!({
"servers": servers
}));
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok("Successfully deprecated selected servers".to_string())
}
/// Bulk restore multiple servers
pub async fn bulk_restore_servers(
settings: &AdminSettings,
servers: Vec<String>,
) -> Result<String, String> {
let url = format!(
"{}/{}/bulk-restore",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Bulk restoring {} servers at: {}", servers.len(), url);
let client = create_http_client()?;
let mut request = client.post(&url).json(&serde_json::json!({
"servers": servers
}));
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok("Successfully restored selected servers".to_string())
}
/// Scan DNS resolution for servers using existing API endpoint
pub async fn scan_dns_resolution(
settings: &AdminSettings,
) -> Result<Vec<DnsResult>, String> {
let url = format!(
"{}/{}/scan-dns",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Scanning DNS resolution at: {}", url);
let client = create_http_client()?;
let mut request = client.post(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
// Parse the response format from existing API: {"results": [...], "total": N, "unresolved": N}
let api_response: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse DNS response: {}", e))?;
let results = api_response
.get("results")
.and_then(|r| serde_json::from_value(r.clone()).ok())
.unwrap_or_else(Vec::new);
info!("DNS scan completed for {} servers", results.len());
Ok(results)
}

590
src/web_gui/app.rs Normal file
View File

@@ -0,0 +1,590 @@
use super::state::{AdminSettings, AdminState, ConnectionStatus, AdminOperation};
use super::ui::{self, ConnectionAction, KeyAction, BulkAction};
#[cfg(not(target_arch = "wasm32"))]
use super::api;
#[cfg(target_arch = "wasm32")]
use super::wasm_api as api;
use eframe::egui;
use std::sync::mpsc;
pub struct WebAdminApp {
settings: AdminSettings,
admin_state: AdminState,
flows: Vec<String>,
connection_status: ConnectionStatus,
operation_receiver: Option<mpsc::Receiver<AdminOperation>>,
last_operation: String,
server_version: Option<String>,
}
impl Default for WebAdminApp {
fn default() -> Self {
// Get server URL from current location if possible
let server_url = {
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_else(|| "http://localhost:8080".to_string())
}
#[cfg(not(all(target_arch = "wasm32", feature = "web-gui")))]
{
"http://localhost:8080".to_string()
}
};
Self {
settings: AdminSettings {
server_url,
..Default::default()
},
admin_state: AdminState::default(),
flows: Vec::new(),
connection_status: ConnectionStatus::Disconnected,
operation_receiver: None,
last_operation: "Application started".to_string(),
server_version: None,
}
}
}
impl eframe::App for WebAdminApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Handle async operations
if let Some(receiver) = &self.operation_receiver {
if let Ok(operation) = receiver.try_recv() {
self.handle_operation_result(operation);
ctx.request_repaint();
}
}
// Use the same UI structure as desktop version
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("🔑 KHM Web Admin Panel");
ui.separator();
// Connection Settings (always visible for web version)
egui::CollapsingHeader::new("⚙️ Connection Settings")
.default_open(matches!(self.connection_status, ConnectionStatus::Disconnected))
.show(ui, |ui| {
let connection_action = ui::render_connection_settings(
ui,
&mut self.settings,
&self.connection_status,
&self.flows,
&self.server_version,
);
match connection_action {
ConnectionAction::LoadFlows => self.load_flows(ctx),
ConnectionAction::TestConnection => self.test_connection(ctx),
ConnectionAction::LoadKeys => self.load_keys(ctx),
ConnectionAction::LoadVersion => self.load_version(ctx),
ConnectionAction::None => {}
}
});
ui.add_space(10.0);
// Statistics (from desktop version)
if !self.admin_state.keys.is_empty() {
ui::render_statistics(ui, &self.admin_state);
ui.add_space(10.0);
}
// Key Management (from desktop version)
if !self.admin_state.keys.is_empty() {
egui::CollapsingHeader::new("🔑 Key Management")
.default_open(true)
.show(ui, |ui| {
// Search and filter controls (from desktop version)
ui::render_search_controls(ui, &mut self.admin_state);
ui.add_space(5.0);
// Bulk actions (from desktop version)
let bulk_action = ui::render_bulk_actions(ui, &mut self.admin_state);
match bulk_action {
BulkAction::DeprecateSelected => self.bulk_deprecate(ctx),
BulkAction::RestoreSelected => self.bulk_restore(ctx),
BulkAction::ClearSelection => {
self.admin_state.clear_selection();
}
BulkAction::None => {}
}
ui.add_space(5.0);
// Keys table (from desktop version)
let key_action = ui::render_keys_table(ui, &mut self.admin_state);
match key_action {
KeyAction::DeprecateKey(server) => self.deprecate_key(server, ctx),
KeyAction::RestoreKey(server) => self.restore_key(server, ctx),
KeyAction::DeleteKey(server) => self.delete_key(server, ctx),
KeyAction::DeprecateServer(server) => self.deprecate_server(server, ctx),
KeyAction::RestoreServer(server) => self.restore_server(server, ctx),
KeyAction::None => {}
}
});
ui.add_space(10.0);
}
// Additional web-specific actions
if matches!(self.connection_status, ConnectionStatus::Connected) && !self.settings.selected_flow.is_empty() {
ui.horizontal(|ui| {
if ui.button("🔍 Scan DNS").clicked() {
self.scan_dns(ctx);
}
if ui.button("🔄 Refresh Keys").clicked() {
self.load_keys(ctx);
}
ui.checkbox(&mut self.settings.auto_refresh, "Auto-refresh");
});
ui.add_space(10.0);
}
// Status bar (from desktop version)
ui.horizontal(|ui| {
ui.label("Status:");
match &self.connection_status {
ConnectionStatus::Connected => {
ui.colored_label(egui::Color32::GREEN, "● Connected");
}
ConnectionStatus::Connecting => {
ui.colored_label(egui::Color32::YELLOW, "● Connecting...");
}
ConnectionStatus::Disconnected => {
ui.colored_label(egui::Color32::GRAY, "● Disconnected");
}
ConnectionStatus::Error(msg) => {
ui.colored_label(egui::Color32::RED, format!("● Error: {}", msg));
}
}
ui.separator();
ui.label(&self.last_operation);
});
});
// Auto-refresh like desktop version
if self.settings.auto_refresh && matches!(self.connection_status, ConnectionStatus::Connected) {
ctx.request_repaint_after(std::time::Duration::from_secs(self.settings.refresh_interval as u64));
}
}
}
impl WebAdminApp {
fn handle_operation_result(&mut self, operation: AdminOperation) {
match operation {
AdminOperation::LoadFlows(result) => {
match result {
Ok(flows) => {
self.flows = flows;
if !self.flows.is_empty() && self.settings.selected_flow.is_empty() {
self.settings.selected_flow = self.flows[0].clone();
}
self.last_operation = format!("Loaded {} flows", self.flows.len());
}
Err(err) => {
self.connection_status = ConnectionStatus::Error(err.clone());
self.last_operation = format!("Failed to load flows: {}", err);
}
}
}
AdminOperation::LoadKeys(result) => {
match result {
Ok(keys) => {
self.admin_state.keys = keys;
self.admin_state.filter_keys();
self.connection_status = ConnectionStatus::Connected;
self.last_operation = format!("Loaded {} keys", self.admin_state.keys.len());
}
Err(err) => {
self.connection_status = ConnectionStatus::Error(err.clone());
self.last_operation = format!("Failed to load keys: {}", err);
}
}
}
AdminOperation::TestConnection(result) => {
match result {
Ok(msg) => {
self.connection_status = ConnectionStatus::Connected;
self.last_operation = msg;
}
Err(err) => {
self.connection_status = ConnectionStatus::Error(err.clone());
self.last_operation = format!("Connection failed: {}", err);
}
}
}
AdminOperation::DeprecateKey(server, result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Failed to deprecate key for {}: {}", server, err);
}
}
}
AdminOperation::RestoreKey(server, result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Failed to restore key for {}: {}", server, err);
}
}
}
AdminOperation::DeleteKey(server, result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Failed to delete key for {}: {}", server, err);
}
}
}
AdminOperation::BulkDeprecate(result) | AdminOperation::BulkRestore(result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.admin_state.clear_selection();
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Bulk operation failed: {}", err);
}
}
}
AdminOperation::ScanDns(result) => {
match result {
Ok(results) => {
let resolved = results.iter().filter(|r| r.resolved).count();
let total = results.len();
self.last_operation = format!("DNS scan completed: {}/{} servers resolved", resolved, total);
}
Err(err) => {
self.last_operation = format!("DNS scan failed: {}", err);
}
}
}
AdminOperation::LoadVersion(result) => {
match result {
Ok(version) => {
self.server_version = Some(version.clone());
self.last_operation = format!("Server version: {}", version);
}
Err(err) => {
self.last_operation = format!("Failed to get server version: {}", err);
}
}
}
}
}
// Async operation methods - adapted from desktop version
fn load_flows(&mut self, _ctx: &egui::Context) {
self.last_operation = "Loading flows...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::load_flows(&settings));
let _ = tx.send(AdminOperation::LoadFlows(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::load_flows(&settings).await;
let _ = tx.send(AdminOperation::LoadFlows(result));
});
}
}
fn test_connection(&mut self, _ctx: &egui::Context) {
self.connection_status = ConnectionStatus::Connecting;
self.last_operation = "Testing connection...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::test_connection(&settings));
let _ = tx.send(AdminOperation::TestConnection(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::test_connection(&settings).await;
let _ = tx.send(AdminOperation::TestConnection(result));
});
}
}
fn load_keys(&mut self, _ctx: &egui::Context) {
self.admin_state.current_operation = "Loading keys...".to_string();
self.last_operation = "Loading keys...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::fetch_keys(&settings));
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::fetch_keys(&settings).await;
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
}
fn load_keys_silent(&mut self) {
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::fetch_keys(&settings));
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::fetch_keys(&settings).await;
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
}
fn deprecate_key(&mut self, server: String, _ctx: &egui::Context) {
self.last_operation = format!("Deprecating key for {}...", server);
let settings = self.settings.clone();
let server_clone = server.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::deprecate_key(&settings, &server));
let _ = tx.send(AdminOperation::DeprecateKey(server_clone, result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::deprecate_key(&settings, &server).await;
let _ = tx.send(AdminOperation::DeprecateKey(server_clone, result));
});
}
}
fn restore_key(&mut self, server: String, _ctx: &egui::Context) {
self.last_operation = format!("Restoring key for {}...", server);
let settings = self.settings.clone();
let server_clone = server.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::restore_key(&settings, &server));
let _ = tx.send(AdminOperation::RestoreKey(server_clone, result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::restore_key(&settings, &server).await;
let _ = tx.send(AdminOperation::RestoreKey(server_clone, result));
});
}
}
fn delete_key(&mut self, server: String, _ctx: &egui::Context) {
self.last_operation = format!("Deleting key for {}...", server);
let settings = self.settings.clone();
let server_clone = server.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::delete_key(&settings, &server));
let _ = tx.send(AdminOperation::DeleteKey(server_clone, result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::delete_key(&settings, &server).await;
let _ = tx.send(AdminOperation::DeleteKey(server_clone, result));
});
}
}
fn deprecate_server(&mut self, server: String, ctx: &egui::Context) {
self.deprecate_key(server, ctx);
}
fn restore_server(&mut self, server: String, ctx: &egui::Context) {
self.restore_key(server, ctx);
}
fn bulk_deprecate(&mut self, _ctx: &egui::Context) {
let servers = self.admin_state.get_selected_servers();
if servers.is_empty() {
return;
}
self.last_operation = format!("Bulk deprecating {} servers...", servers.len());
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::bulk_deprecate_servers(&settings, servers));
let _ = tx.send(AdminOperation::BulkDeprecate(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::bulk_deprecate_servers(&settings, servers).await;
let _ = tx.send(AdminOperation::BulkDeprecate(result));
});
}
}
fn bulk_restore(&mut self, _ctx: &egui::Context) {
let servers = self.admin_state.get_selected_servers();
if servers.is_empty() {
return;
}
self.last_operation = format!("Bulk restoring {} servers...", servers.len());
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::bulk_restore_servers(&settings, servers));
let _ = tx.send(AdminOperation::BulkRestore(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::bulk_restore_servers(&settings, servers).await;
let _ = tx.send(AdminOperation::BulkRestore(result));
});
}
}
fn scan_dns(&mut self, _ctx: &egui::Context) {
self.last_operation = "Scanning DNS resolution...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::scan_dns_resolution(&settings));
let _ = tx.send(AdminOperation::ScanDns(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::scan_dns_resolution(&settings).await;
let _ = tx.send(AdminOperation::ScanDns(result));
});
}
}
fn load_version(&mut self, _ctx: &egui::Context) {
self.last_operation = "Loading server version...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::get_version(&settings));
let _ = tx.send(AdminOperation::LoadVersion(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::get_version(&settings).await;
let _ = tx.send(AdminOperation::LoadVersion(result));
});
}
}
}

182
src/web_gui/state.rs Normal file
View File

@@ -0,0 +1,182 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminSettings {
pub server_url: String,
pub basic_auth: String,
pub selected_flow: String,
pub auto_refresh: bool,
pub refresh_interval: u32,
}
impl Default for AdminSettings {
fn default() -> Self {
Self {
server_url: String::new(),
basic_auth: String::new(),
selected_flow: String::new(),
auto_refresh: false,
refresh_interval: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminState {
pub keys: Vec<SshKey>,
pub filtered_keys: Vec<SshKey>,
pub search_term: String,
pub show_deprecated_only: bool,
pub selected_servers: HashMap<String, bool>,
pub expanded_servers: HashMap<String, bool>,
pub current_operation: String,
}
impl Default for AdminState {
fn default() -> Self {
Self {
keys: Vec::new(),
filtered_keys: Vec::new(),
search_term: String::new(),
show_deprecated_only: false,
selected_servers: HashMap::new(),
expanded_servers: HashMap::new(),
current_operation: "Ready".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshKey {
pub server: String,
pub public_key: String,
#[serde(default)]
pub deprecated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConnectionStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
impl PartialEq for ConnectionStatus {
fn eq(&self, other: &Self) -> bool {
std::mem::discriminant(self) == std::mem::discriminant(other)
}
}
#[derive(Debug, Clone)]
pub enum AdminOperation {
LoadKeys(Result<Vec<SshKey>, String>),
LoadFlows(Result<Vec<String>, String>),
DeprecateKey(String, Result<String, String>),
RestoreKey(String, Result<String, String>),
DeleteKey(String, Result<String, String>),
BulkDeprecate(Result<String, String>),
BulkRestore(Result<String, String>),
TestConnection(Result<String, String>),
ScanDns(Result<Vec<DnsResult>, String>),
LoadVersion(Result<String, String>),
}
// Re-export DnsResolutionResult from web.rs for consistency
pub use crate::web::DnsResolutionResult as DnsResult;
impl AdminState {
/// Filter keys based on search term and deprecated filter
pub fn filter_keys(&mut self) {
let mut filtered = self.keys.clone();
// Apply deprecated filter
if self.show_deprecated_only {
filtered.retain(|key| key.deprecated);
}
// Apply search filter
if !self.search_term.is_empty() {
let search_term = self.search_term.to_lowercase();
filtered.retain(|key| {
key.server.to_lowercase().contains(&search_term)
|| key.public_key.to_lowercase().contains(&search_term)
});
}
self.filtered_keys = filtered;
}
/// Get selected servers list
pub fn get_selected_servers(&self) -> Vec<String> {
self.selected_servers
.iter()
.filter_map(|(server, &selected)| {
if selected { Some(server.clone()) } else { None }
})
.collect()
}
/// Clear selection
pub fn clear_selection(&mut self) {
self.selected_servers.clear();
}
/// Get statistics
pub fn get_statistics(&self) -> AdminStatistics {
let total_keys = self.keys.len();
let active_keys = self.keys.iter().filter(|k| !k.deprecated).count();
let deprecated_keys = total_keys - active_keys;
let unique_servers = self.keys
.iter()
.map(|k| &k.server)
.collect::<std::collections::HashSet<_>>()
.len();
AdminStatistics {
total_keys,
active_keys,
deprecated_keys,
unique_servers,
}
}
}
#[derive(Debug, Clone)]
pub struct AdminStatistics {
pub total_keys: usize,
pub active_keys: usize,
pub deprecated_keys: usize,
pub unique_servers: usize,
}
/// Get SSH key type from public key string
pub fn get_key_type(public_key: &str) -> String {
if public_key.starts_with("ssh-rsa") {
"RSA".to_string()
} else if public_key.starts_with("ssh-ed25519") {
"ED25519".to_string()
} else if public_key.starts_with("ecdsa-sha2-nistp") {
"ECDSA".to_string()
} else if public_key.starts_with("ssh-dss") {
"DSA".to_string()
} else {
"Unknown".to_string()
}
}
/// Get preview of SSH key (first 16 characters of key part)
pub fn get_key_preview(public_key: &str) -> String {
let parts: Vec<&str> = public_key.split_whitespace().collect();
if parts.len() >= 2 {
let key_part = parts[1];
if key_part.len() > 16 {
format!("{}...", &key_part[..16])
} else {
key_part.to_string()
}
} else {
format!("{}...", &public_key[..std::cmp::min(16, public_key.len())])
}
}

532
src/web_gui/ui.rs Normal file
View File

@@ -0,0 +1,532 @@
use super::state::{AdminState, AdminSettings, ConnectionStatus, get_key_type, get_key_preview};
use eframe::egui;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub enum KeyAction {
None,
DeprecateKey(String),
RestoreKey(String),
DeleteKey(String),
DeprecateServer(String),
RestoreServer(String),
}
#[derive(Debug, Clone)]
pub enum BulkAction {
None,
DeprecateSelected,
RestoreSelected,
ClearSelection,
}
/// Render connection settings panel
pub fn render_connection_settings(
ui: &mut egui::Ui,
settings: &mut AdminSettings,
connection_status: &ConnectionStatus,
flows: &[String],
server_version: &Option<String>,
) -> ConnectionAction {
let mut action = ConnectionAction::None;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("⚙️ Connection Settings").size(16.0).strong());
ui.add_space(8.0);
// Server URL
ui.horizontal(|ui| {
ui.label("Server URL:");
ui.text_edit_singleline(&mut settings.server_url);
});
// Basic Auth
ui.horizontal(|ui| {
ui.label("Basic Auth:");
ui.add(egui::TextEdit::singleline(&mut settings.basic_auth).password(true));
});
// Flow selection
ui.horizontal(|ui| {
ui.label("Flow:");
egui::ComboBox::from_id_salt("flow_select")
.selected_text(&settings.selected_flow)
.show_ui(ui, |ui| {
for flow in flows {
ui.selectable_value(&mut settings.selected_flow, flow.clone(), flow);
}
});
});
// Connection status
ui.horizontal(|ui| {
ui.label("Status:");
match connection_status {
ConnectionStatus::Connected => {
ui.colored_label(egui::Color32::GREEN, "● Connected");
}
ConnectionStatus::Connecting => {
ui.colored_label(egui::Color32::YELLOW, "● Connecting...");
}
ConnectionStatus::Disconnected => {
ui.colored_label(egui::Color32::GRAY, "● Disconnected");
}
ConnectionStatus::Error(msg) => {
ui.colored_label(egui::Color32::RED, format!("● Error: {}", msg));
}
}
});
// Server version display
if let Some(version) = server_version {
ui.horizontal(|ui| {
ui.label("Server Version:");
ui.colored_label(egui::Color32::LIGHT_BLUE, version);
});
}
ui.add_space(8.0);
// Action buttons
ui.horizontal(|ui| {
if ui.button("Load Flows").clicked() {
action = ConnectionAction::LoadFlows;
}
if ui.button("Test Connection").clicked() {
action = ConnectionAction::TestConnection;
}
if ui.button("Get Version").clicked() {
action = ConnectionAction::LoadVersion;
}
if !settings.selected_flow.is_empty() && ui.button("Load Keys").clicked() {
action = ConnectionAction::LoadKeys;
}
});
});
});
action
}
/// Render statistics cards
pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) {
let stats = admin_state.get_statistics();
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("📊 Statistics").size(16.0).strong());
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.columns(4, |cols| {
// Total keys
cols[0].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("📊").size(20.0));
ui.label(
egui::RichText::new(stats.total_keys.to_string())
.size(24.0)
.strong(),
);
ui.label(
egui::RichText::new("Total Keys")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Active keys
cols[1].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
ui.label(
egui::RichText::new(stats.active_keys.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_GREEN),
);
ui.label(
egui::RichText::new("Active")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Deprecated keys
cols[2].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
ui.label(
egui::RichText::new(stats.deprecated_keys.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_RED),
);
ui.label(
egui::RichText::new("Deprecated")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Servers
cols[3].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("💻").size(20.0));
ui.label(
egui::RichText::new(stats.unique_servers.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_BLUE),
);
ui.label(
egui::RichText::new("Servers")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
});
});
});
});
}
/// Render search and filter controls
pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) -> bool {
let mut changed = false;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("🔍 Search & Filter").size(16.0).strong());
ui.add_space(8.0);
// Search field
ui.horizontal(|ui| {
ui.label("Search:");
let search_response = ui.add_sized(
[ui.available_width() * 0.6, 20.0],
egui::TextEdit::singleline(&mut admin_state.search_term)
.hint_text("Search servers or keys..."),
);
if search_response.changed() {
changed = true;
}
if !admin_state.search_term.is_empty() {
if ui.small_button("Clear").clicked() {
admin_state.search_term.clear();
changed = true;
}
}
});
ui.add_space(5.0);
// Filter controls
ui.horizontal(|ui| {
ui.label("Filter:");
let show_deprecated = admin_state.show_deprecated_only;
if ui.selectable_label(!show_deprecated, "✅ Active").clicked() {
admin_state.show_deprecated_only = false;
changed = true;
}
if ui.selectable_label(show_deprecated, "❗ Deprecated").clicked() {
admin_state.show_deprecated_only = true;
changed = true;
}
});
});
});
if changed {
admin_state.filter_keys();
}
changed
}
/// Render bulk actions controls
pub fn render_bulk_actions(ui: &mut egui::Ui, admin_state: &mut AdminState) -> BulkAction {
let selected_count = admin_state
.selected_servers
.values()
.filter(|&&v| v)
.count();
if selected_count == 0 {
return BulkAction::None;
}
let mut action = BulkAction::None;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("📋").size(14.0));
ui.label(
egui::RichText::new(format!("Selected {} servers", selected_count))
.size(14.0)
.strong()
.color(egui::Color32::LIGHT_BLUE),
);
});
ui.add_space(5.0);
ui.horizontal(|ui| {
if ui.button("❗ Deprecate Selected").clicked() {
action = BulkAction::DeprecateSelected;
}
if ui.button("✅ Restore Selected").clicked() {
action = BulkAction::RestoreSelected;
}
if ui.button("Clear Selection").clicked() {
action = BulkAction::ClearSelection;
}
});
});
});
action
}
/// Render keys table grouped by servers
pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> KeyAction {
if admin_state.filtered_keys.is_empty() {
render_empty_state(ui, admin_state);
return KeyAction::None;
}
let mut action = KeyAction::None;
// Group keys by server
let mut servers: BTreeMap<String, Vec<&crate::web_gui::state::SshKey>> = BTreeMap::new();
for key in &admin_state.filtered_keys {
servers
.entry(key.server.clone())
.or_insert_with(Vec::new)
.push(key);
}
// Render each server group
egui::ScrollArea::vertical().show(ui, |ui| {
for (server_name, server_keys) in servers {
let is_expanded = admin_state
.expanded_servers
.get(&server_name)
.copied()
.unwrap_or(false);
let active_count = server_keys.iter().filter(|k| !k.deprecated).count();
let deprecated_count = server_keys.len() - active_count;
// Server header
ui.group(|ui| {
ui.horizontal(|ui| {
// Server selection checkbox
let mut selected = admin_state
.selected_servers
.get(&server_name)
.copied()
.unwrap_or(false);
if ui.checkbox(&mut selected, "").changed() {
admin_state
.selected_servers
.insert(server_name.clone(), selected);
}
// Expand/collapse button
let expand_icon = if is_expanded { "" } else { "" };
if ui.small_button(expand_icon).clicked() {
admin_state
.expanded_servers
.insert(server_name.clone(), !is_expanded);
}
// Server icon and name
ui.label(egui::RichText::new("💻").size(16.0));
ui.label(
egui::RichText::new(&server_name)
.size(15.0)
.strong(),
);
// Keys count badge
ui.label(format!("({} keys)", server_keys.len()));
// Deprecated count badge
if deprecated_count > 0 {
ui.colored_label(
egui::Color32::RED,
format!("{} deprecated", deprecated_count)
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Server action buttons
if deprecated_count > 0 {
if ui.small_button("✅ Restore").clicked() {
action = KeyAction::RestoreServer(server_name.clone());
}
}
if active_count > 0 {
if ui.small_button("❗ Deprecate").clicked() {
action = KeyAction::DeprecateServer(server_name.clone());
}
}
});
});
});
// Expanded key details
if is_expanded {
ui.indent(&server_name, |ui| {
for key in &server_keys {
if let Some(key_action) = render_key_item(ui, key, &server_name) {
action = key_action;
}
}
});
}
ui.add_space(5.0);
}
});
action
}
/// Render empty state when no keys are available
fn render_empty_state(ui: &mut egui::Ui, admin_state: &AdminState) {
ui.vertical_centered(|ui| {
ui.add_space(60.0);
if admin_state.keys.is_empty() {
ui.label(
egui::RichText::new("🔑")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No SSH keys available")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("Keys will appear here once loaded from the server")
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
} else if !admin_state.search_term.is_empty() {
ui.label(
egui::RichText::new("🔍")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No results found")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new(format!(
"Try adjusting your search: '{}'",
admin_state.search_term
))
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
} else {
ui.label(
egui::RichText::new("")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No keys match current filters")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("Try adjusting your search or filter settings")
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
}
});
}
/// Render individual key item
fn render_key_item(
ui: &mut egui::Ui,
key: &crate::web_gui::state::SshKey,
server_name: &str,
) -> Option<KeyAction> {
let mut action = None;
ui.group(|ui| {
ui.horizontal(|ui| {
// Key type badge
let key_type = get_key_type(&key.public_key);
let badge_color = match key_type.as_str() {
"RSA" => egui::Color32::from_rgb(52, 144, 220),
"ED25519" => egui::Color32::from_rgb(46, 204, 113),
"ECDSA" => egui::Color32::from_rgb(241, 196, 15),
"DSA" => egui::Color32::from_rgb(230, 126, 34),
_ => egui::Color32::GRAY,
};
ui.colored_label(badge_color, &key_type);
ui.add_space(5.0);
// Status badge
if key.deprecated {
ui.colored_label(egui::Color32::RED, "❗ DEPRECATED");
} else {
ui.colored_label(egui::Color32::GREEN, "✅ ACTIVE");
}
ui.add_space(5.0);
// Key preview
ui.monospace(get_key_preview(&key.public_key));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Key action buttons
if key.deprecated {
if ui.small_button("Restore").clicked() {
action = Some(KeyAction::RestoreKey(server_name.to_string()));
}
if ui.small_button("Delete").clicked() {
action = Some(KeyAction::DeleteKey(server_name.to_string()));
}
} else {
if ui.small_button("Deprecate").clicked() {
action = Some(KeyAction::DeprecateKey(server_name.to_string()));
}
}
if ui.small_button("Copy").clicked() {
ui.output_mut(|o| o.copied_text = key.public_key.clone());
}
});
});
});
action
}
#[derive(Debug, Clone)]
pub enum ConnectionAction {
None,
LoadFlows,
TestConnection,
LoadKeys,
LoadVersion,
}

43
src/web_gui/wasm.rs Normal file
View File

@@ -0,0 +1,43 @@
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use wasm_bindgen::prelude::*;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use super::app::WebAdminApp;
/// WASM entry point for the web admin application
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
#[wasm_bindgen]
pub fn start_web_admin(canvas_id: &str) -> Result<(), JsValue> {
// Setup console logging for WASM
console_error_panic_hook::set_once();
tracing_wasm::set_as_global_default();
let web_options = eframe::WebOptions::default();
let canvas_id = canvas_id.to_string();
wasm_bindgen_futures::spawn_local(async move {
let app = WebAdminApp::default();
let result = eframe::WebRunner::new()
.start(
&canvas_id,
web_options,
Box::new(|_cc| Ok(Box::new(app))),
)
.await;
match result {
Ok(_) => web_sys::console::log_1(&"eframe started successfully".into()),
Err(e) => web_sys::console::error_1(&format!("Failed to start eframe: {:?}", e).into()),
}
});
Ok(())
}
/// Initialize the WASM module
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
#[wasm_bindgen(start)]
pub fn wasm_main() {
console_error_panic_hook::set_once();
}

131
src/web_gui/wasm_api.rs Normal file
View File

@@ -0,0 +1,131 @@
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use super::state::{SshKey, DnsResult, AdminSettings};
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use wasm_bindgen::prelude::*;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use wasm_bindgen_futures::JsFuture;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use web_sys::{Request, RequestInit, RequestMode, Response};
/// Simplified API for WASM - uses browser fetch API
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn test_connection(settings: &AdminSettings) -> Result<String, String> {
let url = format!("{}/{}/keys", settings.server_url.trim_end_matches('/'), settings.selected_flow);
let response = fetch_json(&url).await?;
let keys: Result<Vec<SshKey>, _> = serde_json::from_str(&response);
match keys {
Ok(keys) => Ok(format!("Connection successful! Found {} SSH keys from flow '{}'", keys.len(), settings.selected_flow)),
Err(e) => Err(format!("Failed to parse response: {}", e)),
}
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn load_flows(settings: &AdminSettings) -> Result<Vec<String>, String> {
let url = format!("{}/api/flows", settings.server_url.trim_end_matches('/'));
let response = fetch_json(&url).await?;
let flows: Result<Vec<String>, _> = serde_json::from_str(&response);
flows.map_err(|e| format!("Failed to parse flows: {}", e))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn fetch_keys(settings: &AdminSettings) -> Result<Vec<SshKey>, String> {
let url = format!("{}/{}/keys", settings.server_url.trim_end_matches('/'), settings.selected_flow);
let response = fetch_json(&url).await?;
let keys: Result<Vec<SshKey>, _> = serde_json::from_str(&response);
keys.map_err(|e| format!("Failed to parse keys: {}", e))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn get_version(settings: &AdminSettings) -> Result<String, String> {
let url = format!("{}/api/version", settings.server_url.trim_end_matches('/'));
let response = fetch_json(&url).await?;
let version_response: Result<serde_json::Value, _> = serde_json::from_str(&response);
match version_response {
Ok(data) => {
let version = data.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Ok(version)
}
Err(e) => Err(format!("Failed to parse version: {}", e)),
}
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn deprecate_key(_settings: &AdminSettings, server: &str) -> Result<String, String> {
Ok(format!("WASM: Would deprecate key for {}", server))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn restore_key(_settings: &AdminSettings, server: &str) -> Result<String, String> {
Ok(format!("WASM: Would restore key for {}", server))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn delete_key(_settings: &AdminSettings, server: &str) -> Result<String, String> {
Ok(format!("WASM: Would delete key for {}", server))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn bulk_deprecate_servers(_settings: &AdminSettings, servers: Vec<String>) -> Result<String, String> {
Ok(format!("WASM: Would bulk deprecate {} servers", servers.len()))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn bulk_restore_servers(_settings: &AdminSettings, servers: Vec<String>) -> Result<String, String> {
Ok(format!("WASM: Would bulk restore {} servers", servers.len()))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn scan_dns_resolution(_settings: &AdminSettings) -> Result<Vec<DnsResult>, String> {
Ok(vec![
DnsResult {
server: "demo-server".to_string(),
resolved: true,
error: None,
}
])
}
/// Helper function to make HTTP requests using browser's fetch API
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
async fn fetch_json(url: &str) -> Result<String, String> {
let window = web_sys::window().ok_or("No window object")?;
let mut opts = RequestInit::new();
opts.method("GET");
opts.mode(RequestMode::Cors);
let request = Request::new_with_str_and_init(url, &opts)
.map_err(|e| format!("Failed to create request: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value.dyn_into()
.map_err(|e| format!("Failed to cast response: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {} {}", resp.status(), resp.status_text()));
}
let text_promise = resp.text()
.map_err(|e| format!("Failed to get text promise: {:?}", e))?;
let text_value = JsFuture::from(text_promise)
.await
.map_err(|e| format!("Failed to get text: {:?}", e))?;
text_value.as_string()
.ok_or("Response is not a string".to_string())
}

BIN
static/.DS_Store vendored Normal file

Binary file not shown.

180
static/index.html Normal file
View File

@@ -0,0 +1,180 @@
<!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>
<div class="header-title">
<h1>SSH Key Manager</h1>
<span class="version" id="appVersion">Loading...</span>
</div>
<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="activeKeys">0</span>
<span class="stat-label">Active Keys</span>
</div>
<div class="stat-item">
<span class="stat-value deprecated" id="deprecatedKeys">0</span>
<span class="stat-label">Deprecated 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="scanDnsBtn" class="btn btn-secondary">Scan DNS Resolution</button>
<button id="bulkDeleteBtn" class="btn btn-danger" disabled>Deprecate Selected</button>
<button id="bulkRestoreBtn" class="btn btn-success" disabled style="display: none;">Restore Selected</button>
<button id="bulkPermanentDeleteBtn" class="btn btn-danger" disabled style="display: none;">Delete Selected</button>
<div class="filter-controls">
<label class="filter-label">
<input type="checkbox" id="showDeprecatedOnly">
<span>Show only deprecated keys</span>
</label>
</div>
<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/Type</th>
<th>Key Preview</th>
<th></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>
<!-- DNS Scan Results Modal -->
<div id="dnsScanModal" class="modal">
<div class="modal-content modal-large">
<div class="modal-header">
<h2>DNS Resolution Scan Results</h2>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<div id="dnsScanStats" class="scan-stats"></div>
<div id="unresolvedHosts" class="unresolved-hosts">
<div class="section-header">
<h3>Unresolved Hosts</h3>
<button id="selectAllUnresolved" class="btn btn-sm btn-secondary">Select All</button>
</div>
<div id="unresolvedList" class="host-list"></div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="closeDnsScan">Close</button>
<button type="button" class="btn btn-danger" id="deprecateUnresolved" disabled>Deprecate Selected</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>

10
static/khm-icon.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#2c3e50"/>
<rect x="4" y="8" width="24" height="2" rx="1" fill="#3498db"/>
<rect x="4" y="12" width="20" height="2" rx="1" fill="#e74c3c"/>
<rect x="4" y="16" width="22" height="2" rx="1" fill="#2ecc71"/>
<rect x="4" y="20" width="18" height="2" rx="1" fill="#f39c12"/>
<circle cx="24" cy="6" r="3" fill="#e67e22"/>
<text x="24" y="9" text-anchor="middle" font-family="Arial, sans-serif" font-size="6" fill="white">K</text>
</svg>

After

Width:  |  Height:  |  Size: 610 B

1109
static/script.js Normal file

File diff suppressed because it is too large Load Diff

825
static/style.css Normal file
View File

@@ -0,0 +1,825 @@
: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);
margin: 0;
}
.header-title {
display: flex;
align-items: baseline;
gap: 1rem;
}
.version {
font-size: 0.875rem;
color: var(--text-secondary);
background: var(--background);
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius);
font-weight: 500;
border: 1px solid var(--border);
}
.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-value.deprecated {
color: var(--danger-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;
}
.filter-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.filter-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
user-select: none;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius);
transition: background-color 0.2s ease;
}
.filter-label:hover {
background-color: var(--background);
}
.filter-label.active {
background-color: var(--primary-color);
color: white;
}
.filter-label.active input[type="checkbox"] {
accent-color: white;
}
.filter-label input[type="checkbox"] {
margin: 0;
}
.filter-label span {
white-space: nowrap;
}
.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);
}
.host-group-header {
background-color: #f1f5f9;
font-weight: 600;
transition: background-color 0.2s ease;
border-left: 4px solid var(--primary-color);
}
.host-group-header:hover {
background-color: #e2e8f0;
}
.host-group-header.collapsed {
border-left-color: var(--secondary-color);
}
.host-group-header .expand-icon {
transition: transform 0.2s ease;
display: inline-block;
margin-right: 0.5rem;
user-select: none;
}
.host-group-header.collapsed .expand-icon {
transform: rotate(-90deg);
}
.host-group-header input[type="checkbox"] {
margin: 0;
}
.host-group-header td:first-child {
width: 50px;
text-align: center;
}
.host-group-header td:nth-child(2) {
cursor: pointer;
user-select: none;
}
.key-row {
border-left: 4px solid transparent;
}
.key-row.hidden {
display: none;
}
.host-summary {
font-size: 0.875rem;
color: var(--text-secondary);
}
.key-count {
background-color: var(--primary-color);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
margin-left: 0.5rem;
}
.deprecated-count {
background-color: var(--danger-color);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
margin-left: 0.25rem;
}
.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;
}
.header-title {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.header-title h1 {
font-size: 2rem;
}
.actions-panel {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.filter-controls {
justify-content: center;
}
.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);
}
/* Indeterminate checkbox styling */
input[type="checkbox"]:indeterminate {
background-color: var(--primary-color);
background-image: linear-gradient(90deg, transparent 40%, white 40%, white 60%, transparent 60%);
border-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);
}
/* DNS Scan Modal Styles */
.modal-large {
max-width: 800px;
}
.scan-stats {
background: var(--background);
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.scan-stat {
text-align: center;
}
.scan-stat-value {
display: block;
font-size: 1.5rem;
font-weight: 600;
color: var(--primary-color);
}
.scan-stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.unresolved-count {
color: var(--danger-color) !important;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.host-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--border-radius);
}
.host-item {
display: flex;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
transition: background-color 0.2s ease;
}
.host-item:last-child {
border-bottom: none;
}
.host-item:hover {
background-color: var(--background);
}
.host-item label {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
cursor: pointer;
margin: 0;
}
.host-name {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: 500;
color: var(--text-primary);
}
.host-error {
font-size: 0.75rem;
color: var(--danger-color);
margin-left: auto;
max-width: 200px;
word-break: break-word;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-style: italic;
}
.scan-progress {
background: var(--background);
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
text-align: center;
}
.scan-progress-text {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s ease;
border-radius: 4px;
}