diff --git a/fileglancer/app.py b/fileglancer/app.py index 5b2beeec..7ad4a9b5 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -35,6 +35,7 @@ from fileglancer.user_context import UserContext, EffectiveUserContext, CurrentUserContext from fileglancer.filestore import Filestore from fileglancer.log import AccessLogMiddleware +from fileglancer import sshkeys from x2s3.utils import get_read_access_acl, get_nosuchbucket_response, get_error_response from x2s3.client_file import FileProxyClient @@ -841,6 +842,121 @@ async def get_profile(username: str = Depends(get_current_user)): "groups": user_groups, } + # SSH Key Management endpoints + @app.get("/api/ssh-keys", response_model=sshkeys.SSHKeyListResponse, + description="List all SSH keys in the user's ~/.ssh directory") + async def list_ssh_keys(username: str = Depends(get_current_user)): + """List SSH keys for the authenticated user""" + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + keys = sshkeys.list_ssh_keys(ssh_dir) + return sshkeys.SSHKeyListResponse(keys=keys) + except Exception as e: + logger.error(f"Error listing SSH keys for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/api/ssh-keys", response_model=sshkeys.GenerateKeyResponse, + description="Generate a new ed25519 SSH key") + async def generate_ssh_key( + body: sshkeys.GenerateKeyRequest, + username: str = Depends(get_current_user) + ): + """Generate a new SSH key for the authenticated user""" + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + key_info = sshkeys.generate_ssh_key( + ssh_dir, + body.key_name, + body.comment + ) + + message = f"SSH key '{body.key_name}' generated successfully" + + # Optionally add to authorized_keys + if body.add_to_authorized_keys: + sshkeys.add_to_authorized_keys(ssh_dir, key_info.public_key) + # Update the is_authorized flag + key_info = sshkeys.SSHKeyInfo( + filename=key_info.filename, + key_type=key_info.key_type, + fingerprint=key_info.fingerprint, + comment=key_info.comment, + public_key=key_info.public_key, + has_private_key=key_info.has_private_key, + is_authorized=True + ) + message += " and added to authorized_keys" + + return sshkeys.GenerateKeyResponse(key=key_info, message=message) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + logger.error(f"Error generating SSH key for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/api/ssh-keys/{key_name}/authorize", + description="Add a public key to authorized_keys") + async def authorize_ssh_key( + key_name: str = Path(..., description="The name of the key file (without extension)"), + username: str = Depends(get_current_user) + ): + """Add a public key to authorized_keys for cluster SSH access""" + with _get_user_context(username): + try: + # Validate key name + sshkeys.validate_key_name(key_name) + + ssh_dir = sshkeys.get_ssh_directory() + # Use safe_join_path to prevent path traversal + pubkey_path = sshkeys.safe_join_path(ssh_dir, f"{key_name}.pub") + + if not os.path.exists(pubkey_path): + raise HTTPException(status_code=404, detail=f"Public key '{key_name}.pub' not found") + + # Read the public key + with open(pubkey_path, 'r') as f: + public_key = f.read().strip() + + # Add to authorized_keys + sshkeys.add_to_authorized_keys(ssh_dir, public_key) + + return {"message": f"Key '{key_name}' added to authorized_keys"} + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error authorizing SSH key for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.delete("/api/ssh-keys/{key_name}", + description="Delete an SSH key pair") + async def delete_ssh_key( + key_name: str = Path(..., description="The name of the key file (without extension)"), + username: str = Depends(get_current_user) + ): + """Delete an SSH key pair (both private and public key files)""" + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + sshkeys.delete_ssh_key(ssh_dir, key_name) + return {"message": f"Key '{key_name}' deleted successfully"} + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + logger.error(f"Error deleting SSH key for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error deleting SSH key for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + # File content endpoint @app.head("/api/content/{path_name:path}") async def head_file_content(path_name: str, diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py new file mode 100644 index 00000000..4818c072 --- /dev/null +++ b/fileglancer/sshkeys.py @@ -0,0 +1,576 @@ +"""SSH Key management utilities for Fileglancer. + +This module provides functions for listing, generating, and managing SSH keys +in a user's ~/.ssh directory. +""" + +import os +import re +import shutil +import subprocess +import tempfile +from typing import List, Optional + +from loguru import logger +from pydantic import BaseModel, Field + +# Constants +AUTHORIZED_KEYS_FILENAME = "authorized_keys" +SSH_KEY_PREFIX = "ssh-" + + +def validate_path_in_directory(base_dir: str, path: str) -> str: + """Validate that a path is within the expected base directory. + + This prevents path traversal attacks by ensuring the resolved path + stays within the intended directory. + + Args: + base_dir: The base directory that the path must be within + path: The path to validate + + Returns: + The normalized absolute path if valid + + Raises: + ValueError: If the path escapes the base directory + """ + # Normalize both paths to resolve symlinks and collapse .. + real_base = os.path.realpath(base_dir) + real_path = os.path.realpath(path) + + # Ensure the path is within the base directory + if not real_path.startswith(real_base + os.sep) and real_path != real_base: + raise ValueError(f"Path '{path}' is outside the allowed directory") + + return real_path + + +def safe_join_path(base_dir: str, *parts: str) -> str: + """Safely join path components and validate the result is within base_dir. + + Args: + base_dir: The base directory + *parts: Path components to join + + Returns: + The validated absolute path + + Raises: + ValueError: If the resulting path escapes the base directory + """ + # First normalize the path to collapse any .. components + joined = os.path.normpath(os.path.join(base_dir, *parts)) + # Then validate it's within the base directory + return validate_path_in_directory(base_dir, joined) + + +class SSHKeyInfo(BaseModel): + """Information about an SSH key""" + filename: str = Field(description="The key filename without extension (e.g., 'id_ed25519')") + key_type: str = Field(description="The SSH key type (e.g., 'ssh-ed25519', 'ssh-rsa')") + fingerprint: str = Field(description="SHA256 fingerprint of the key") + comment: str = Field(description="Comment associated with the key") + public_key: str = Field(description="Full public key content") + private_key: Optional[str] = Field(default=None, description="Private key content (if available)") + has_private_key: bool = Field(description="Whether the corresponding private key exists") + is_authorized: bool = Field(description="Whether this key is in authorized_keys") + + +class SSHKeyListResponse(BaseModel): + """Response containing a list of SSH keys""" + keys: List[SSHKeyInfo] = Field(description="List of SSH keys") + + +class GenerateKeyRequest(BaseModel): + """Request to generate a new SSH key""" + key_name: str = Field(description="Name for the new key file (without extension)") + comment: Optional[str] = Field(default=None, description="Optional comment for the key") + add_to_authorized_keys: bool = Field(default=True, description="Whether to add the key to authorized_keys") + + +class GenerateKeyResponse(BaseModel): + """Response after generating an SSH key""" + key: SSHKeyInfo = Field(description="The generated key info") + message: str = Field(description="Status message") + + +# Regex pattern for valid key names (alphanumeric, underscore, hyphen) +KEY_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$') + + +def validate_key_name(key_name: str) -> None: + """Validate that a key name is safe and doesn't allow path traversal. + + Args: + key_name: The key name to validate + + Raises: + ValueError: If the key name is invalid + """ + if not key_name: + raise ValueError("Key name cannot be empty") + + if not KEY_NAME_PATTERN.match(key_name): + raise ValueError("Key name can only contain letters, numbers, underscores, and hyphens") + + if key_name.startswith('.') or key_name.startswith('-'): + raise ValueError("Key name cannot start with '.' or '-'") + + if len(key_name) > 100: + raise ValueError("Key name is too long (max 100 characters)") + + +def get_ssh_directory() -> str: + """Get the path to the current user's .ssh directory. + + Returns: + The absolute path to ~/.ssh + """ + return os.path.expanduser("~/.ssh") + + +def ensure_ssh_directory_exists(ssh_dir: str) -> None: + """Ensure the .ssh directory exists with correct permissions. + + Args: + ssh_dir: Path to the .ssh directory + """ + if not os.path.exists(ssh_dir): + os.makedirs(ssh_dir, mode=0o700) + logger.info(f"Created SSH directory: {ssh_dir}") + else: + # Ensure permissions are correct + current_mode = os.stat(ssh_dir).st_mode & 0o777 + if current_mode != 0o700: + os.chmod(ssh_dir, 0o700) + logger.info(f"Fixed SSH directory permissions: {ssh_dir}") + + +def get_key_fingerprint(pubkey_path: str) -> str: + """Get the SHA256 fingerprint of a public key. + + Args: + pubkey_path: Path to the public key file + + Returns: + The SHA256 fingerprint string + + Raises: + ValueError: If the fingerprint cannot be determined + """ + try: + result = subprocess.run( + ['ssh-keygen', '-lf', pubkey_path], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + raise ValueError(f"Failed to get fingerprint: {result.stderr}") + + # Output format: "256 SHA256:xxxxx comment (ED25519)" + parts = result.stdout.strip().split() + if len(parts) >= 2: + return parts[1] # SHA256:xxxxx + raise ValueError("Unexpected ssh-keygen output format") + except subprocess.TimeoutExpired: + raise ValueError("Timeout getting key fingerprint") + except FileNotFoundError: + raise ValueError("ssh-keygen not found") + + +def parse_public_key(pubkey_path: str, ssh_dir: str) -> SSHKeyInfo: + """Parse a public key file and return its information. + + Args: + pubkey_path: Path to the public key file + ssh_dir: Path to the .ssh directory (for checking authorized_keys) + + Returns: + SSHKeyInfo object with the key details + """ + with open(pubkey_path, 'r') as f: + public_key = f.read().strip() + + # Parse the public key content: "type base64key comment" + parts = public_key.split(None, 2) + if len(parts) < 2: + raise ValueError(f"Invalid public key format in {pubkey_path}") + + key_type = parts[0] # e.g., "ssh-ed25519" + comment = parts[2] if len(parts) > 2 else "" + + # Get fingerprint + fingerprint = get_key_fingerprint(pubkey_path) + + # Determine filename (without .pub extension) + filename = os.path.basename(pubkey_path) + if filename.endswith('.pub'): + filename = filename[:-4] + + # Check if private key exists and read it + private_key_path = pubkey_path[:-4] if pubkey_path.endswith('.pub') else pubkey_path + has_private_key = os.path.exists(private_key_path) and private_key_path != pubkey_path + private_key = None + if has_private_key: + try: + with open(private_key_path, 'r') as f: + private_key = f.read() + except Exception as e: + logger.warning(f"Could not read private key {private_key_path}: {e}") + + # Check if key is in authorized_keys + is_authorized = is_key_in_authorized_keys(ssh_dir, fingerprint) + + return SSHKeyInfo( + filename=filename, + key_type=key_type, + fingerprint=fingerprint, + comment=comment, + public_key=public_key, + private_key=private_key, + has_private_key=has_private_key, + is_authorized=is_authorized + ) + + +def is_key_in_authorized_keys(ssh_dir: str, fingerprint: str) -> bool: + """Check if a key with the given fingerprint is in authorized_keys. + + Args: + ssh_dir: Path to the .ssh directory + fingerprint: The SHA256 fingerprint to look for + + Returns: + True if the key is in authorized_keys, False otherwise + """ + authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) + + if not os.path.exists(authorized_keys_path): + return False + + try: + # Get fingerprints of all keys in authorized_keys + result = subprocess.run( + ['ssh-keygen', '-lf', authorized_keys_path], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + logger.warning(f"Could not check authorized_keys: {result.stderr}") + return False + + # Check each line for the fingerprint + for line in result.stdout.strip().split('\n'): + if fingerprint in line: + return True + + return False + except Exception as e: + logger.warning(f"Error checking authorized_keys: {e}") + return False + + +def list_ssh_keys(ssh_dir: str) -> List[SSHKeyInfo]: + """List all SSH keys in the given directory. + + Args: + ssh_dir: Path to the .ssh directory + + Returns: + List of SSHKeyInfo objects + """ + keys = [] + + if not os.path.exists(ssh_dir): + return keys + + # Find all .pub files + for filename in os.listdir(ssh_dir): + if filename.endswith('.pub'): + try: + pubkey_path = safe_join_path(ssh_dir, filename) + key_info = parse_public_key(pubkey_path, ssh_dir) + keys.append(key_info) + except ValueError as e: + logger.warning(f"Skipping suspicious filename {filename}: {e}") + continue + except Exception as e: + logger.warning(f"Could not parse key {filename}: {e}") + continue + + # Sort by filename + keys.sort(key=lambda k: k.filename) + + logger.info(f"Listed {len(keys)} SSH keys in {ssh_dir}") + + return keys + + +def generate_ssh_key(ssh_dir: str, key_name: str, comment: Optional[str] = None) -> SSHKeyInfo: + """Generate a new ed25519 SSH key. + + Args: + ssh_dir: Path to the .ssh directory + key_name: Name for the key file (without extension) + comment: Optional comment for the key + + Returns: + SSHKeyInfo for the generated key + + Raises: + ValueError: If the key name is invalid or key already exists + RuntimeError: If key generation fails + """ + # Validate key name + validate_key_name(key_name) + + # Ensure .ssh directory exists + ensure_ssh_directory_exists(ssh_dir) + + # Build and validate key paths (prevents path traversal) + key_path = safe_join_path(ssh_dir, key_name) + pubkey_path = safe_join_path(ssh_dir, f"{key_name}.pub") + + # Check if key already exists + if os.path.exists(key_path) or os.path.exists(pubkey_path): + raise ValueError(f"Key '{key_name}' already exists") + + # Build ssh-keygen command + cmd = [ + 'ssh-keygen', + '-t', 'ed25519', + '-N', '', # No passphrase + '-f', key_path, + ] + + if comment: + cmd.extend(['-C', comment]) + + logger.info(f"Generating SSH key: {key_name}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise RuntimeError(f"ssh-keygen failed: {result.stderr}") + + # Set correct permissions + os.chmod(key_path, 0o600) + os.chmod(pubkey_path, 0o644) + + logger.info(f"Successfully generated SSH key: {key_name}") + + # Parse and return the generated key info + return parse_public_key(pubkey_path, ssh_dir) + + except subprocess.TimeoutExpired: + raise RuntimeError("Key generation timed out") + except FileNotFoundError: + raise RuntimeError("ssh-keygen not found on system") + + +def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: + """Add a public key to the authorized_keys file. + + Args: + ssh_dir: Path to the .ssh directory + public_key: The public key content to add + + Returns: + True if the key was added successfully + + Raises: + ValueError: If the public key is invalid + RuntimeError: If adding the key fails + """ + # Validate public key format (basic check) + if not public_key or not public_key.startswith(SSH_KEY_PREFIX): + raise ValueError("Invalid public key format") + + # Ensure .ssh directory exists + ensure_ssh_directory_exists(ssh_dir) + + authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) + + # Check if key is already present (by content) + if os.path.exists(authorized_keys_path): + with open(authorized_keys_path, 'r') as f: + existing_content = f.read() + # Check if the key (base64 part) is already present + key_parts = public_key.split() + if len(key_parts) >= 2 and key_parts[1] in existing_content: + logger.info("Key already in authorized_keys") + return True + + # Append the key + try: + # Ensure the file ends with a newline before appending + needs_newline = False + if os.path.exists(authorized_keys_path): + file_size = os.path.getsize(authorized_keys_path) + if file_size > 0: + with open(authorized_keys_path, 'rb') as f: + f.seek(-1, 2) # Seek to last byte + needs_newline = f.read(1) != b'\n' + + with open(authorized_keys_path, 'a') as f: + if needs_newline: + f.write('\n') + f.write(public_key) + f.write('\n') + + # Ensure correct permissions + os.chmod(authorized_keys_path, 0o600) + + logger.info(f"Added key to {authorized_keys_path}") + return True + + except Exception as e: + raise RuntimeError(f"Failed to add key to authorized_keys: {e}") + + +def remove_from_authorized_keys(ssh_dir: str, public_key: str) -> bool: + """Remove a public key from the authorized_keys file. + + Uses atomic write with backup to prevent data loss. + + Args: + ssh_dir: Path to the .ssh directory + public_key: The public key content to remove + + Returns: + True if the key was removed, False if it wasn't found + """ + authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) + backup_path = f"{authorized_keys_path}.bak" + + if not os.path.exists(authorized_keys_path): + return False + + # Extract the key data (type + base64) for matching, ignoring comments + key_parts = public_key.split() + if len(key_parts) < 2: + return False + key_identifier = f"{key_parts[0]} {key_parts[1]}" + + try: + with open(authorized_keys_path, 'r') as f: + lines = f.readlines() + + # Filter out lines that contain this key + new_lines = [] + removed = False + for line in lines: + line_stripped = line.strip() + if line_stripped and key_identifier in line_stripped: + removed = True + logger.info("Removing key from authorized_keys") + else: + new_lines.append(line) + + if removed: + # Create backup before modifying + shutil.copy2(authorized_keys_path, backup_path) + logger.info(f"Created backup at {backup_path}") + + # Write to temp file first, then atomically rename + fd, temp_path = tempfile.mkstemp(dir=ssh_dir, prefix='.authorized_keys_') + try: + with os.fdopen(fd, 'w') as f: + f.writelines(new_lines) + os.chmod(temp_path, 0o600) + os.rename(temp_path, authorized_keys_path) + logger.info("Updated authorized_keys successfully") + except Exception: + # Clean up temp file on failure + if os.path.exists(temp_path): + os.remove(temp_path) + raise + + return removed + + except Exception as e: + logger.warning(f"Error removing key from authorized_keys: {e}") + return False + + +def delete_ssh_key(ssh_dir: str, key_name: str) -> bool: + """Delete an SSH key (both private and public key files). + + Creates backups before deletion and removes the key from authorized_keys. + Backups are stored as {key_name}.deleted and {key_name}.pub.deleted. + + Args: + ssh_dir: Path to the .ssh directory + key_name: Name of the key to delete (without extension) + + Returns: + True if the key was deleted successfully + + Raises: + ValueError: If the key name is invalid or key doesn't exist + RuntimeError: If deletion fails + """ + # Validate key name to prevent path traversal + validate_key_name(key_name) + + # Build and validate paths (prevents path traversal) + private_key_path = safe_join_path(ssh_dir, key_name) + public_key_path = safe_join_path(ssh_dir, f"{key_name}.pub") + + # Check if at least one of the key files exists + private_exists = os.path.exists(private_key_path) + public_exists = os.path.exists(public_key_path) + + if not private_exists and not public_exists: + raise ValueError(f"Key '{key_name}' does not exist") + + # Read the public key content before any modifications + public_key = None + if public_exists: + with open(public_key_path, 'r') as f: + public_key = f.read().strip() + + try: + # Step 1: Create backups before any destructive operations + if private_exists: + backup_private = f"{private_key_path}.deleted" + shutil.copy2(private_key_path, backup_private) + os.chmod(backup_private, 0o600) + logger.info(f"Created backup: {backup_private}") + + if public_exists: + backup_public = f"{public_key_path}.deleted" + shutil.copy2(public_key_path, backup_public) + logger.info(f"Created backup: {backup_public}") + + # Step 2: Delete the key files + if private_exists: + os.remove(private_key_path) + logger.info(f"Deleted private key: {private_key_path}") + + if public_exists: + os.remove(public_key_path) + logger.info(f"Deleted public key: {public_key_path}") + + # Step 3: Remove from authorized_keys (cleanup, non-critical) + # Done last so key files are already gone even if this fails + if public_key: + if remove_from_authorized_keys(ssh_dir, public_key): + logger.info(f"Removed key '{key_name}' from authorized_keys") + + return True + + except PermissionError as e: + raise RuntimeError(f"Permission denied when deleting key: {e}") + except Exception as e: + raise RuntimeError(f"Failed to delete key: {e}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e62dafa7..3fd68f30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; import Links from '@/components/Links'; import Notifications from '@/components/Notifications'; +import SSHKeys from '@/components/SSHKeys'; import ErrorFallback from '@/components/ErrorFallback'; function RequireAuth({ children }: { readonly children: ReactNode }) { @@ -125,6 +126,14 @@ const AppComponent = () => { } path="notifications" /> + + + + } + path="ssh-keys" + /> +
+ + SSH Keys + + +
+ + +
+ +
+ + What are SSH keys? + + + SSH keys allow you to securely connect to cluster nodes without + entering a password. When you generate a key and add it to{' '} + + authorized_keys + + , you can SSH to any node that shares your home directory. + + + To work with Seqera Platform, click{' '} + Copy SSH Private Key and + paste the private key into the Seqera Platform credentials + settings. + +
+
+
+ + {isLoading ? ( +
+ +
+ ) : null} + + {error ? ( + + + Failed to load SSH keys: {error.message} + + + + ) : null} + + {!isLoading && !error && keys && keys.length === 0 ? ( + + + + No SSH keys found + + + You don't have any SSH keys in your ~/.ssh directory yet. Generate + your first key to get started. + + + + ) : null} + + {!isLoading && !error && keys && keys.length > 0 ? ( +
+ {keys.map(key => ( + + ))} +
+ ) : null} + + + + ); +} diff --git a/frontend/src/components/ui/Navbar/ProfileMenu.tsx b/frontend/src/components/ui/Navbar/ProfileMenu.tsx index 2bff1555..8c1fee1f 100644 --- a/frontend/src/components/ui/Navbar/ProfileMenu.tsx +++ b/frontend/src/components/ui/Navbar/ProfileMenu.tsx @@ -2,7 +2,8 @@ import { IconButton, Menu, Typography } from '@material-tailwind/react'; import { HiOutlineLogout, HiOutlineUserCircle, - HiOutlineBell + HiOutlineBell, + HiOutlineKey } from 'react-icons/hi'; import { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2'; import { Link } from 'react-router-dom'; @@ -60,6 +61,14 @@ export default function ProfileMenu() { Notifications + + + Manage SSH Keys + >; + readonly keyInfo: SSHKeyInfo; +}; + +export default function DeleteSSHKeyDialog({ + showDialog, + setShowDialog, + keyInfo +}: DeleteSSHKeyDialogProps) { + const deleteMutation = useDeleteSSHKeyMutation(); + + const handleDelete = async () => { + try { + await deleteMutation.mutateAsync({ key_name: keyInfo.filename }); + toast.success(`Key "${keyInfo.filename}" deleted successfully`); + } catch (error) { + toast.error( + `Failed to delete key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setShowDialog(false); + } + }; + + return ( + setShowDialog(false)} + open={showDialog} + > +
+ + Delete SSH Key? + + + Are you sure you want to delete the SSH key{' '} + {keyInfo.filename}? + + + This will remove both the private and public key files from your + ~/.ssh directory, and remove the key from authorized_keys if present. + Backup copies will be saved with a .deleted extension. + +
+ +
+ ); +} diff --git a/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx b/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx new file mode 100644 index 00000000..cbb8f77e --- /dev/null +++ b/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import type { ChangeEvent, Dispatch, SetStateAction } from 'react'; +import { Button, Typography } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import { Spinner } from '@/components/ui/widgets/Loaders'; +import { useGenerateSSHKeyMutation } from '@/queries/sshKeyQueries'; + +type GenerateKeyDialogProps = { + readonly showDialog: boolean; + readonly setShowDialog: Dispatch>; +}; + +export default function GenerateKeyDialog({ + showDialog, + setShowDialog +}: GenerateKeyDialogProps) { + const [keyName, setKeyName] = useState('id_ed25519_fileglancer'); + const [comment, setComment] = useState(''); + const [addToAuthorized, setAddToAuthorized] = useState(true); + + const generateMutation = useGenerateSSHKeyMutation(); + + const handleClose = () => { + setShowDialog(false); + // Reset form + setKeyName('id_ed25519_fileglancer'); + setComment(''); + setAddToAuthorized(true); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!keyName.trim()) { + toast.error('Key name is required'); + return; + } + + try { + const result = await generateMutation.mutateAsync({ + key_name: keyName.trim(), + comment: comment.trim() || undefined, + add_to_authorized_keys: addToAuthorized + }); + + toast.success(result.message); + handleClose(); + } catch (error) { + toast.error( + `Failed to generate key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + return ( + +
+ + Generate New SSH Key + + + + This will create a new ed25519 SSH key pair in your ~/.ssh directory. + The key can be used to authenticate with other systems. + + +
+
+ + Key Name + + ) => { + setKeyName(event.target.value); + }} + placeholder="id_ed25519_mykey" + type="text" + value={keyName} + /> + + Only letters, numbers, underscores, and hyphens are allowed. + +
+ +
+ + Comment (optional) + + ) => { + setComment(event.target.value); + }} + placeholder="your.email@example.com" + type="text" + value={comment} + /> + + A comment to help identify this key (usually an email address). + +
+ +
+ { + setAddToAuthorized(!addToAuthorized); + }} + type="checkbox" + /> + + Add to authorized_keys (enables SSH to cluster) + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx new file mode 100644 index 00000000..cc64cd83 --- /dev/null +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { Button, Card, Chip, Typography } from '@material-tailwind/react'; +import { + HiOutlineClipboardCopy, + HiOutlineKey, + HiOutlineTrash +} from 'react-icons/hi'; +import toast from 'react-hot-toast'; + +import CopyTooltip from '@/components/ui/widgets/CopyTooltip'; +import DeleteSSHKeyDialog from '@/components/ui/SSHKeys/DeleteSSHKeyDialog'; +import { Spinner } from '@/components/ui/widgets/Loaders'; +import { useAuthorizeSSHKeyMutation } from '@/queries/sshKeyQueries'; +import type { SSHKeyInfo } from '@/queries/sshKeyQueries'; + +type SSHKeyCardProps = { + readonly keyInfo: SSHKeyInfo; +}; + +export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const authorizeMutation = useAuthorizeSSHKeyMutation(); + + const handleAuthorize = async () => { + try { + await authorizeMutation.mutateAsync({ key_name: keyInfo.filename }); + toast.success(`Key "${keyInfo.filename}" added to authorized_keys`); + } catch (error) { + toast.error( + `Failed to authorize key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + // Truncate fingerprint for display + const shortFingerprint = + keyInfo.fingerprint.replace('SHA256:', '').slice(0, 16) + '...'; + + return ( + +
+
+ +
+ + {keyInfo.filename} + + + {keyInfo.key_type} + + + {shortFingerprint} + + {keyInfo.comment ? ( + + {keyInfo.comment} + + ) : null} +
+
+ +
+ {keyInfo.is_authorized ? ( + + Authorized + + ) : ( + + )} + + + + Copy Public Key + + + {keyInfo.private_key ? ( + + + Copy Private Key + + ) : ( + + Private key not available + + )} + + +
+
+ + +
+ ); +} diff --git a/frontend/src/components/ui/widgets/CopyTooltip.tsx b/frontend/src/components/ui/widgets/CopyTooltip.tsx index 7cb2b711..79afd482 100644 --- a/frontend/src/components/ui/widgets/CopyTooltip.tsx +++ b/frontend/src/components/ui/widgets/CopyTooltip.tsx @@ -7,12 +7,14 @@ export default function CopyTooltip({ children, primaryLabel, textToCopy, - tooltipTriggerClasses + tooltipTriggerClasses, + variant = 'ghost' }: { readonly children: React.ReactNode; readonly primaryLabel: string; readonly textToCopy: string; readonly tooltipTriggerClasses?: string; + readonly variant?: 'outline' | 'ghost'; }) { const { showCopiedTooltip, handleCopy } = useCopyTooltip(); @@ -25,7 +27,7 @@ export default function CopyTooltip({ }} openCondition={showCopiedTooltip ? true : undefined} triggerClasses={tooltipTriggerClasses} - variant="ghost" + variant={variant} > {children} diff --git a/frontend/src/queries/sshKeyQueries.ts b/frontend/src/queries/sshKeyQueries.ts new file mode 100644 index 00000000..7908c9fa --- /dev/null +++ b/frontend/src/queries/sshKeyQueries.ts @@ -0,0 +1,215 @@ +import { + useQuery, + useMutation, + useQueryClient, + UseQueryResult, + UseMutationResult +} from '@tanstack/react-query'; + +import { sendFetchRequest } from '@/utils'; +import { toHttpError } from '@/utils/errorHandling'; + +/** + * Information about an SSH key + */ +export type SSHKeyInfo = { + filename: string; + key_type: string; + fingerprint: string; + comment: string; + public_key: string; + private_key: string | null; + has_private_key: boolean; + is_authorized: boolean; +}; + +/** + * Response from the list SSH keys endpoint + */ +type SSHKeyListResponse = { + keys: SSHKeyInfo[]; +}; + +/** + * Payload for generating a new SSH key + */ +type GenerateKeyPayload = { + key_name: string; + comment?: string; + add_to_authorized_keys: boolean; +}; + +/** + * Response from the generate SSH key endpoint + */ +type GenerateKeyResponse = { + key: SSHKeyInfo; + message: string; +}; + +/** + * Payload for authorizing a key + */ +type AuthorizeKeyPayload = { + key_name: string; +}; + +/** + * Response from the authorize key endpoint + */ +type AuthorizeKeyResponse = { + message: string; +}; + +/** + * Payload for deleting a key + */ +type DeleteKeyPayload = { + key_name: string; +}; + +/** + * Response from the delete key endpoint + */ +type DeleteKeyResponse = { + message: string; +}; + +// Query key factory for SSH keys +export const sshKeyQueryKeys = { + all: ['sshKeys'] as const, + list: () => ['sshKeys', 'list'] as const +}; + +/** + * Fetches all SSH keys from the backend + */ +const fetchSSHKeys = async (signal?: AbortSignal): Promise => { + const response = await sendFetchRequest('/api/ssh-keys', 'GET', undefined, { + signal + }); + + if (!response.ok) { + throw await toHttpError(response); + } + + const data = (await response.json()) as SSHKeyListResponse; + return data.keys ?? []; +}; + +/** + * Query hook for fetching all SSH keys + * + * @returns Query result with all SSH keys + */ +export function useSSHKeysQuery(): UseQueryResult { + return useQuery({ + queryKey: sshKeyQueryKeys.list(), + queryFn: ({ signal }) => fetchSSHKeys(signal) + }); +} + +/** + * Mutation hook for generating a new SSH key + * + * @example + * const mutation = useGenerateSSHKeyMutation(); + * mutation.mutate({ key_name: 'my_key', add_to_authorized_keys: true }); + */ +export function useGenerateSSHKeyMutation(): UseMutationResult< + GenerateKeyResponse, + Error, + GenerateKeyPayload +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: GenerateKeyPayload) => { + const response = await sendFetchRequest('/api/ssh-keys', 'POST', payload); + + if (!response.ok) { + throw await toHttpError(response); + } + + return (await response.json()) as GenerateKeyResponse; + }, + onSuccess: () => { + // Invalidate and refetch the list + queryClient.invalidateQueries({ + queryKey: sshKeyQueryKeys.all + }); + } + }); +} + +/** + * Mutation hook for authorizing an SSH key (adding to authorized_keys) + * + * @example + * const mutation = useAuthorizeSSHKeyMutation(); + * mutation.mutate({ key_name: 'id_ed25519' }); + */ +export function useAuthorizeSSHKeyMutation(): UseMutationResult< + AuthorizeKeyResponse, + Error, + AuthorizeKeyPayload +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: AuthorizeKeyPayload) => { + const response = await sendFetchRequest( + `/api/ssh-keys/${encodeURIComponent(payload.key_name)}/authorize`, + 'POST' + ); + + if (!response.ok) { + throw await toHttpError(response); + } + + return (await response.json()) as AuthorizeKeyResponse; + }, + onSuccess: () => { + // Invalidate and refetch the list to update is_authorized flags + queryClient.invalidateQueries({ + queryKey: sshKeyQueryKeys.all + }); + } + }); +} + +/** + * Mutation hook for deleting an SSH key + * + * @example + * const mutation = useDeleteSSHKeyMutation(); + * mutation.mutate({ key_name: 'id_ed25519' }); + */ +export function useDeleteSSHKeyMutation(): UseMutationResult< + DeleteKeyResponse, + Error, + DeleteKeyPayload +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: DeleteKeyPayload) => { + const response = await sendFetchRequest( + `/api/ssh-keys/${encodeURIComponent(payload.key_name)}`, + 'DELETE' + ); + + if (!response.ok) { + throw await toHttpError(response); + } + + return (await response.json()) as DeleteKeyResponse; + }, + onSuccess: () => { + // Invalidate and refetch the list + queryClient.invalidateQueries({ + queryKey: sshKeyQueryKeys.all + }); + } + }); +}