From cedc2422e5ff7c510bb73e525b761cbb3154fe0c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 7 Jan 2026 16:45:51 -0500 Subject: [PATCH 01/14] added ssh key manager --- fileglancer/app.py | 115 ++++ fileglancer/sshkeys.py | 518 ++++++++++++++++++ frontend/src/App.tsx | 9 + frontend/src/components/SSHKeys.tsx | 109 ++++ .../src/components/ui/Navbar/ProfileMenu.tsx | 11 +- .../ui/SSHKeys/DeleteSSHKeyDialog.tsx | 62 +++ .../ui/SSHKeys/GenerateKeyDialog.tsx | 152 +++++ .../src/components/ui/SSHKeys/SSHKeyCard.tsx | 115 ++++ frontend/src/queries/sshKeyQueries.ts | 215 ++++++++ 9 files changed, 1305 insertions(+), 1 deletion(-) create mode 100644 fileglancer/sshkeys.py create mode 100644 frontend/src/components/SSHKeys.tsx create mode 100644 frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx create mode 100644 frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx create mode 100644 frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx create mode 100644 frontend/src/queries/sshKeyQueries.ts diff --git a/fileglancer/app.py b/fileglancer/app.py index 5b2beeec..1554d402 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,120 @@ 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() + pubkey_path = os.path.join(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..d27980ae --- /dev/null +++ b/fileglancer/sshkeys.py @@ -0,0 +1,518 @@ +"""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 + + +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') + + 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'): + pubkey_path = os.path.join(ssh_dir, filename) + try: + key_info = parse_public_key(pubkey_path, ssh_dir) + keys.append(key_info) + except Exception as e: + logger.warning(f"Could not parse key {filename}: {e}") + continue + + # Sort by filename + keys.sort(key=lambda k: k.filename) + + 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 key path + key_path = os.path.join(ssh_dir, key_name) + pubkey_path = f"{key_path}.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) + + # 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-'): + 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') + + # 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') + 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) + + private_key_path = os.path.join(ssh_dir, key_name) + public_key_path = f"{private_key_path}.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`); + setShowDialog(false); + } catch (error) { + toast.error( + `Failed to delete key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + 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..2c92c2be --- /dev/null +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { Button, Card, Chip, IconButton, 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 + + ) : ( + + )} + + {keyInfo.private_key ? ( + + + Copy SSH Private Key + + ) : ( + + Private key not available + + )} + + setShowDeleteDialog(true)} + size="sm" + variant="ghost" + > + + +
+
+ + +
+ ); +} 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 + }); + } + }); +} From 761c7f717c8ab6ac32fcc41291e4080a3a58cea3 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 7 Jan 2026 17:08:33 -0500 Subject: [PATCH 02/14] improved code quality for ssh key manager --- fileglancer/sshkeys.py | 16 ++++++++++---- .../ui/SSHKeys/DeleteSSHKeyDialog.tsx | 3 ++- .../src/components/ui/SSHKeys/SSHKeyCard.tsx | 22 ++++++++++++++----- .../src/components/ui/widgets/CopyTooltip.tsx | 6 +++-- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py index d27980ae..e0e5c56a 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -14,6 +14,10 @@ from loguru import logger from pydantic import BaseModel, Field +# Constants +AUTHORIZED_KEYS_FILENAME = "authorized_keys" +SSH_KEY_PREFIX = "ssh-" + class SSHKeyInfo(BaseModel): """Information about an SSH key""" @@ -195,7 +199,7 @@ def is_key_in_authorized_keys(ssh_dir: str, fingerprint: str) -> bool: Returns: True if the key is in authorized_keys, False otherwise """ - authorized_keys_path = os.path.join(ssh_dir, 'authorized_keys') + authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) if not os.path.exists(authorized_keys_path): return False @@ -252,6 +256,8 @@ def list_ssh_keys(ssh_dir: str) -> List[SSHKeyInfo]: # Sort by filename keys.sort(key=lambda k: k.filename) + logger.info(f"Listed {len(keys)} SSH keys in {ssh_dir}") + return keys @@ -312,6 +318,8 @@ def generate_ssh_key(ssh_dir: str, key_name: str, comment: Optional[str] = None) 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) @@ -336,13 +344,13 @@ def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: RuntimeError: If adding the key fails """ # Validate public key format (basic check) - if not public_key or not public_key.startswith('ssh-'): + 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') + 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): @@ -393,7 +401,7 @@ def remove_from_authorized_keys(ssh_dir: str, public_key: str) -> bool: Returns: True if the key was removed, False if it wasn't found """ - authorized_keys_path = os.path.join(ssh_dir, 'authorized_keys') + 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): diff --git a/frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx b/frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx index 10b83748..8718c75f 100644 --- a/frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx +++ b/frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx @@ -24,11 +24,12 @@ export default function DeleteSSHKeyDialog({ try { await deleteMutation.mutateAsync({ key_name: keyInfo.filename }); toast.success(`Key "${keyInfo.filename}" deleted successfully`); - setShowDialog(false); } catch (error) { toast.error( `Failed to delete key: ${error instanceof Error ? error.message : 'Unknown error'}` ); + } finally { + setShowDialog(false); } }; diff --git a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx index 2c92c2be..cc64cd83 100644 --- a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Button, Card, Chip, IconButton, Typography } from '@material-tailwind/react'; +import { Button, Card, Chip, Typography } from '@material-tailwind/react'; import { HiOutlineClipboardCopy, HiOutlineKey, @@ -79,6 +79,15 @@ export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { )} + + + Copy Public Key + + {keyInfo.private_key ? ( - Copy SSH Private Key + Copy Private Key ) : ( @@ -94,14 +103,15 @@ export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { )} - setShowDeleteDialog(true)} size="sm" - variant="ghost" + variant="outline" > - - + + Delete + 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} From 7be2f5e5536bbeab327108adcd48e1048269b9ae Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 7 Jan 2026 19:57:41 -0500 Subject: [PATCH 03/14] added input validation --- fileglancer/app.py | 3 +- fileglancer/sshkeys.py | 62 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 1554d402..7ad4a9b5 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -912,7 +912,8 @@ async def authorize_ssh_key( sshkeys.validate_key_name(key_name) ssh_dir = sshkeys.get_ssh_directory() - pubkey_path = os.path.join(ssh_dir, f"{key_name}.pub") + # 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") diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py index e0e5c56a..4818c072 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -19,6 +19,52 @@ 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')") @@ -245,10 +291,13 @@ def list_ssh_keys(ssh_dir: str) -> List[SSHKeyInfo]: # Find all .pub files for filename in os.listdir(ssh_dir): if filename.endswith('.pub'): - pubkey_path = os.path.join(ssh_dir, filename) 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 @@ -282,9 +331,9 @@ def generate_ssh_key(ssh_dir: str, key_name: str, comment: Optional[str] = None) # Ensure .ssh directory exists ensure_ssh_directory_exists(ssh_dir) - # Build key path - key_path = os.path.join(ssh_dir, key_name) - pubkey_path = f"{key_path}.pub" + # 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): @@ -474,8 +523,9 @@ def delete_ssh_key(ssh_dir: str, key_name: str) -> bool: # Validate key name to prevent path traversal validate_key_name(key_name) - private_key_path = os.path.join(ssh_dir, key_name) - public_key_path = f"{private_key_path}.pub" + # 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) From eaab3fe1581b888038d16f77273a61b537ad980c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 12:15:41 -0500 Subject: [PATCH 04/14] removed ability to delete SSH keys --- fileglancer/app.py | 22 --- fileglancer/sshkeys.py | 140 ------------------ .../ui/SSHKeys/DeleteSSHKeyDialog.tsx | 63 -------- .../src/components/ui/SSHKeys/SSHKeyCard.tsx | 25 +--- frontend/src/queries/sshKeyQueries.ts | 50 ------- 5 files changed, 1 insertion(+), 299 deletions(-) delete mode 100644 frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx diff --git a/fileglancer/app.py b/fileglancer/app.py index 7ad4a9b5..f03ee6c6 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -935,28 +935,6 @@ async def authorize_ssh_key( 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 index 4818c072..d3b3ef34 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -6,9 +6,7 @@ import os import re -import shutil import subprocess -import tempfile from typing import List, Optional from loguru import logger @@ -436,141 +434,3 @@ def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: 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/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx b/frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx deleted file mode 100644 index 8718c75f..00000000 --- a/frontend/src/components/ui/SSHKeys/DeleteSSHKeyDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react'; -import { Typography } from '@material-tailwind/react'; -import toast from 'react-hot-toast'; - -import FgDialog from '@/components/ui/Dialogs/FgDialog'; -import DeleteBtn from '@/components/ui/buttons/DeleteBtn'; -import { useDeleteSSHKeyMutation } from '@/queries/sshKeyQueries'; -import type { SSHKeyInfo } from '@/queries/sshKeyQueries'; - -type DeleteSSHKeyDialogProps = { - readonly showDialog: boolean; - readonly setShowDialog: Dispatch>; - 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/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx index cc64cd83..2f2079fc 100644 --- a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -1,14 +1,8 @@ -import { useState } from 'react'; import { Button, Card, Chip, Typography } from '@material-tailwind/react'; -import { - HiOutlineClipboardCopy, - HiOutlineKey, - HiOutlineTrash -} from 'react-icons/hi'; +import { HiOutlineClipboardCopy, HiOutlineKey } 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'; @@ -18,7 +12,6 @@ type SSHKeyCardProps = { }; export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { - const [showDeleteDialog, setShowDeleteDialog] = useState(false); const authorizeMutation = useAuthorizeSSHKeyMutation(); const handleAuthorize = async () => { @@ -102,24 +95,8 @@ export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { Private key not available )} - - - - ); } diff --git a/frontend/src/queries/sshKeyQueries.ts b/frontend/src/queries/sshKeyQueries.ts index 7908c9fa..e29869ec 100644 --- a/frontend/src/queries/sshKeyQueries.ts +++ b/frontend/src/queries/sshKeyQueries.ts @@ -61,20 +61,6 @@ 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, @@ -177,39 +163,3 @@ export function useAuthorizeSSHKeyMutation(): UseMutationResult< } }); } - -/** - * 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 - }); - } - }); -} From 33dbb0a940c94613a3dd3d9b652ee6f723114af4 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 12:28:46 -0500 Subject: [PATCH 05/14] only allow generation of ed25519 if it doesn't exist --- fileglancer/app.py | 82 +++------- fileglancer/sshkeys.py | 56 +------ frontend/src/components/SSHKeys.tsx | 34 ++--- .../ui/SSHKeys/GenerateKeyDialog.tsx | 143 ++++-------------- .../src/components/ui/SSHKeys/SSHKeyCard.tsx | 33 +--- frontend/src/queries/sshKeyQueries.ts | 71 +-------- 6 files changed, 86 insertions(+), 333 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index f03ee6c6..ce5b846c 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -857,39 +857,35 @@ async def list_ssh_keys(username: str = Depends(get_current_user)): raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/ssh-keys", response_model=sshkeys.GenerateKeyResponse, - description="Generate a new ed25519 SSH key") + description="Generate the default ed25519 SSH key (id_ed25519)") async def generate_ssh_key( - body: sshkeys.GenerateKeyRequest, username: str = Depends(get_current_user) ): - """Generate a new SSH key for the authenticated user""" + """Generate the default SSH key (id_ed25519) and add it to authorized_keys""" 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 + key_info = sshkeys.generate_ssh_key(ssh_dir) + + # Always 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, + private_key=key_info.private_key, + has_private_key=key_info.has_private_key, + is_authorized=True ) - 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) + return sshkeys.GenerateKeyResponse( + key=key_info, + message="SSH key generated and added to authorized_keys" + ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -899,42 +895,6 @@ async def generate_ssh_key( 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)) - # 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 index d3b3ef34..69d111c8 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -5,7 +5,6 @@ """ import os -import re import subprocess from typing import List, Optional @@ -80,45 +79,12 @@ class SSHKeyListResponse(BaseModel): 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. @@ -308,34 +274,31 @@ def list_ssh_keys(ssh_dir: str) -> List[SSHKeyInfo]: return keys -def generate_ssh_key(ssh_dir: str, key_name: str, comment: Optional[str] = None) -> SSHKeyInfo: - """Generate a new ed25519 SSH key. +def generate_ssh_key(ssh_dir: str) -> SSHKeyInfo: + """Generate the default ed25519 SSH key (id_ed25519). 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 + ValueError: If the key already exists RuntimeError: If key generation fails """ - # Validate key name - validate_key_name(key_name) + key_name = "id_ed25519" # 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") + # Build key paths + key_path = os.path.join(ssh_dir, key_name) + pubkey_path = os.path.join(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") + raise ValueError(f"SSH key '{key_name}' already exists") # Build ssh-keygen command cmd = [ @@ -345,9 +308,6 @@ def generate_ssh_key(ssh_dir: str, key_name: str, comment: Optional[str] = None) '-f', key_path, ] - if comment: - cmd.extend(['-C', comment]) - logger.info(f"Generating SSH key: {key_name}") try: diff --git a/frontend/src/components/SSHKeys.tsx b/frontend/src/components/SSHKeys.tsx index 3f72048f..9cdd77ce 100644 --- a/frontend/src/components/SSHKeys.tsx +++ b/frontend/src/components/SSHKeys.tsx @@ -15,16 +15,15 @@ export default function SSHKeys() { const [showGenerateDialog, setShowGenerateDialog] = useState(false); const { data: keys, isLoading, error, refetch } = useSSHKeysQuery(); + // Check if the default key (id_ed25519) already exists + const hasDefaultKey = keys?.some(key => key.filename === 'id_ed25519'); + return ( <> -
+
SSH Keys -
@@ -36,17 +35,18 @@ export default function SSHKeys() { SSH keys allow you to securely connect to cluster nodes without - entering a password. When you generate a key and add it to{' '} + entering a password. When you generate a key, it is automatically + added to your{' '} authorized_keys - - , you can SSH to any node that shares your home directory. + {' '} + file, enabling SSH access 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. + Copy Private Key and paste + it into the Seqera Platform credentials settings.
@@ -75,19 +75,19 @@ export default function SSHKeys() { ) : null} - {!isLoading && !error && keys && keys.length === 0 ? ( - + {!isLoading && !error && !hasDefaultKey ? ( + - No SSH keys found + No ed25519 key found - You don't have any SSH keys in your ~/.ssh directory yet. Generate - your first key to get started. + Generate an ed25519 SSH key to enable passwordless access to cluster nodes + and integration with Seqera Platform. ) : null} diff --git a/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx b/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx index cbb8f77e..f37368bc 100644 --- a/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx +++ b/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx @@ -1,5 +1,4 @@ -import { useState } from 'react'; -import type { ChangeEvent, Dispatch, SetStateAction } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import { Button, Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; @@ -16,35 +15,15 @@ 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; - } - + const handleGenerate = async () => { try { - const result = await generateMutation.mutateAsync({ - key_name: keyName.trim(), - comment: comment.trim() || undefined, - add_to_authorized_keys: addToAuthorized - }); - + const result = await generateMutation.mutateAsync(); toast.success(result.message); handleClose(); } catch (error) { @@ -56,97 +35,37 @@ export default function GenerateKeyDialog({ 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. - -
+ + Generate SSH Key + -
- - 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). - -
+ + This will create a new ed25519 SSH key pair ( + id_ed25519) in your ~/.ssh + directory and add it to your authorized_keys file. + -
- { - setAddToAuthorized(!addToAuthorized); - }} - type="checkbox" - /> - - Add to authorized_keys (enables SSH to cluster) - -
-
+ + Once created, you can use this key to SSH to cluster nodes and copy the + private key for use with Seqera Platform. + -
- - -
-
+
+ + +
); } diff --git a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx index 2f2079fc..32015e11 100644 --- a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -1,10 +1,7 @@ -import { Button, Card, Chip, Typography } from '@material-tailwind/react'; +import { Card, Chip, Typography } from '@material-tailwind/react'; import { HiOutlineClipboardCopy, HiOutlineKey } from 'react-icons/hi'; -import toast from 'react-hot-toast'; import CopyTooltip from '@/components/ui/widgets/CopyTooltip'; -import { Spinner } from '@/components/ui/widgets/Loaders'; -import { useAuthorizeSSHKeyMutation } from '@/queries/sshKeyQueries'; import type { SSHKeyInfo } from '@/queries/sshKeyQueries'; type SSHKeyCardProps = { @@ -12,19 +9,6 @@ type SSHKeyCardProps = { }; export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { - 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) + '...'; @@ -57,20 +41,7 @@ export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { Authorized - ) : ( - - )} + ) : null} { } /** - * Mutation hook for generating a new SSH key + * Mutation hook for generating the default SSH key (id_ed25519) + * + * Creates an ed25519 key pair and adds it to authorized_keys. * * @example * const mutation = useGenerateSSHKeyMutation(); - * mutation.mutate({ key_name: 'my_key', add_to_authorized_keys: true }); + * mutation.mutate(); */ export function useGenerateSSHKeyMutation(): UseMutationResult< GenerateKeyResponse, Error, - GenerateKeyPayload + void > { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (payload: GenerateKeyPayload) => { - const response = await sendFetchRequest('/api/ssh-keys', 'POST', payload); + mutationFn: async () => { + const response = await sendFetchRequest('/api/ssh-keys', 'POST'); if (!response.ok) { throw await toHttpError(response); @@ -127,39 +106,3 @@ export function useGenerateSSHKeyMutation(): UseMutationResult< } }); } - -/** - * 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 - }); - } - }); -} From 9bde32b887eb36798b287adcd59ec848cccbb80b Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 13:58:42 -0500 Subject: [PATCH 06/14] added feature flag for ssh keys --- docs/Development.md | 19 ++++++++ fileglancer/sshkeys.py | 45 ++++++++++--------- frontend/src/App.tsx | 19 ++++---- frontend/src/__tests__/setup.ts | 1 + frontend/src/components/SSHKeys.tsx | 29 +++--------- .../src/components/ui/Navbar/ProfileMenu.tsx | 19 ++++---- 6 files changed, 72 insertions(+), 60 deletions(-) diff --git a/docs/Development.md b/docs/Development.md index a327fcbe..ceb194cb 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -66,6 +66,25 @@ file_share_mounts: Instead of using the `file_share_mounts` setting, you can configure file share paths in the database. This is useful for production deployments where you want centralized management of file share paths. To use the paths in the database, set `file_share_mounts: []`. See [fileglancer-janelia](https://github.com/JaneliaSciComp/fileglancer-janelia) for an example of populating the file share paths in the database, using a private wiki source. +### Feature Flags + +Optional features can be enabled via Vite environment variables. Create or edit `frontend/.env`: + +```bash +# Enable background tasks/jobs feature +VITE_ENABLE_TASKS=true + +# Enable SSH key management feature +VITE_ENABLE_SSH_KEYS=true +``` + +After changing `.env`, rebuild the frontend and restart the dev server: + +```bash +pixi run node-build +pixi run dev-launch +``` + ### Running with SSL/HTTPS (Secure Mode) By default, `pixi run dev-launch` runs the server in insecure HTTP mode on port 7878. This is suitable for most local development scenarios. diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py index 69d111c8..531aacc1 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -337,7 +337,7 @@ def generate_ssh_key(ssh_dir: str) -> SSHKeyInfo: def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: - """Add a public key to the authorized_keys file. + """Add a public key to the authorized_keys file using cat command. Args: ssh_dir: Path to the .ssh directory @@ -359,32 +359,31 @@ def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) - # Check if key is already present (by content) + # Check if key is already present using grep 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: + key_parts = public_key.split() + if len(key_parts) >= 2: + result = subprocess.run( + ['grep', '-qF', key_parts[1], authorized_keys_path], + capture_output=True, + timeout=10 + ) + if result.returncode == 0: logger.info("Key already in authorized_keys") return True - # Append the key + # Append the key using cat 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') + result = subprocess.run( + ['sh', '-c', f'cat >> "{authorized_keys_path}"'], + input=public_key + '\n', + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode != 0: + raise RuntimeError(f"cat failed: {result.stderr}") # Ensure correct permissions os.chmod(authorized_keys_path, 0o600) @@ -392,5 +391,7 @@ def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: logger.info(f"Added key to {authorized_keys_path}") return True + except subprocess.TimeoutExpired: + raise RuntimeError("Timed out adding key to authorized_keys") except Exception as e: raise RuntimeError(f"Failed to add key to authorized_keys: {e}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fd68f30..e502bbc4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -83,6 +83,7 @@ function RootRedirect() { const AppComponent = () => { const tasksEnabled = import.meta.env.VITE_ENABLE_TASKS === 'true'; + const sshKeysEnabled = import.meta.env.VITE_ENABLE_SSH_KEYS === 'true'; return ( @@ -126,14 +127,16 @@ const AppComponent = () => { } path="notifications" /> - - - - } - path="ssh-keys" - /> + {sshKeysEnabled ? ( + + + + } + path="ssh-keys" + /> + ) : null}
-
- - What are SSH keys? - - - SSH keys allow you to securely connect to cluster nodes without - entering a password. When you generate a key, it is automatically - added to your{' '} - - authorized_keys - {' '} - file, enabling SSH access to any node that shares your home - directory. - - - To work with Seqera Platform, click{' '} - Copy Private Key and paste - it into the Seqera Platform credentials settings. - -
+ + SSH keys allow you to securely connect to cluster nodes without + entering a password. Specifically, you need an ed25519 SSH key to + use Seqera Platform to run pipelines on the cluster. +
@@ -82,8 +67,8 @@ export default function SSHKeys() { No ed25519 key found - Generate an ed25519 SSH key to enable passwordless access to cluster nodes - and integration with Seqera Platform. + Generate an ed25519 SSH key to enable passwordless access to cluster + nodes and integration with Seqera Platform. + )} { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const response = await sendFetchRequest( + '/api/ssh-keys/authorize', + 'POST' + ); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as AuthorizeKeyResponse; + }, + onSuccess: () => { + // Invalidate and refetch the list to update is_authorized status + queryClient.invalidateQueries({ + queryKey: sshKeyQueryKeys.all + }); + } + }); +} From 7cb1e1a54be7b4901be89575487178baedcb8870 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 17:16:42 -0500 Subject: [PATCH 11/14] only send keys if user copies them --- fileglancer/app.py | 32 +++++++- fileglancer/sshkeys.py | 55 +++++++++---- .../src/components/ui/SSHKeys/SSHKeyCard.tsx | 80 +++++++++++++++---- frontend/src/queries/sshKeyQueries.ts | 35 +++++++- 4 files changed, 163 insertions(+), 39 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index d7c0ea24..8b3a1398 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -888,8 +888,13 @@ async def generate_ssh_key( ssh_dir = sshkeys.get_ssh_directory() key_info = sshkeys.generate_ssh_key(ssh_dir) - # Always add to authorized_keys - sshkeys.add_to_authorized_keys(ssh_dir, key_info.public_key) + # Read public key content to add to authorized_keys + pubkey_path = os.path.join(ssh_dir, f"{key_info.filename}.pub") + 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) # Update the is_authorized flag key_info = sshkeys.SSHKeyInfo( @@ -897,8 +902,6 @@ async def generate_ssh_key( key_type=key_info.key_type, fingerprint=key_info.fingerprint, comment=key_info.comment, - public_key=key_info.public_key, - private_key=key_info.private_key, has_private_key=key_info.has_private_key, is_authorized=True ) @@ -947,6 +950,27 @@ async def authorize_ssh_key(username: str = Depends(get_current_user)): logger.error(f"Error authorizing SSH key for {username}: {e}") raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/ssh-keys/content", response_model=sshkeys.SSHKeyContent, + description="Get the content of the default SSH key (id_ed25519)") + async def get_ssh_key_content( + key_type: str = Query(..., description="Type of key to fetch: 'public' or 'private'"), + username: str = Depends(get_current_user) + ): + """Get the public or private key content for copying""" + if key_type not in ("public", "private"): + raise HTTPException(status_code=400, detail="key_type must be 'public' or 'private'") + + with _get_user_context(username): + try: + ssh_dir = sshkeys.get_ssh_directory() + return sshkeys.get_key_content(ssh_dir, "id_ed25519", key_type) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Error getting SSH key content 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 index 531aacc1..0c8ef8fa 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -63,17 +63,20 @@ def safe_join_path(base_dir: str, *parts: str) -> str: class SSHKeyInfo(BaseModel): - """Information about an SSH key""" + """Information about an SSH key (without sensitive content)""" 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 SSHKeyContent(BaseModel): + """SSH key content - only fetched on demand""" + key: str = Field(description="The requested key content") + + class SSHKeyListResponse(BaseModel): """Response containing a list of SSH keys""" keys: List[SSHKeyInfo] = Field(description="List of SSH keys") @@ -145,14 +148,14 @@ def get_key_fingerprint(pubkey_path: str) -> str: def parse_public_key(pubkey_path: str, ssh_dir: str) -> SSHKeyInfo: - """Parse a public key file and return its information. + """Parse a public key file and return its information (without key content). 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 + SSHKeyInfo object with the key details (no sensitive content) """ with open(pubkey_path, 'r') as f: public_key = f.read().strip() @@ -173,16 +176,9 @@ def parse_public_key(pubkey_path: str, ssh_dir: str) -> SSHKeyInfo: if filename.endswith('.pub'): filename = filename[:-4] - # Check if private key exists and read it + # Check if private key exists (but don't 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) @@ -192,13 +188,42 @@ def parse_public_key(pubkey_path: str, ssh_dir: str) -> SSHKeyInfo: 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 get_key_content(ssh_dir: str, filename: str, key_type: str = "public") -> SSHKeyContent: + """Get the content of an SSH key (public or private). + + Args: + ssh_dir: Path to the .ssh directory + filename: Key filename without extension (e.g., 'id_ed25519') + key_type: Type of key to fetch: 'public' or 'private' + + Returns: + SSHKeyContent with the requested key content + + Raises: + ValueError: If the key doesn't exist or is invalid + """ + if key_type == "public": + pubkey_path = safe_join_path(ssh_dir, f"{filename}.pub") + if not os.path.exists(pubkey_path): + raise ValueError(f"Public key '{filename}' not found") + with open(pubkey_path, 'r') as f: + return SSHKeyContent(key=f.read().strip()) + + elif key_type == "private": + private_key_path = safe_join_path(ssh_dir, filename) + if not os.path.exists(private_key_path): + raise ValueError(f"Private key '{filename}' not found") + with open(private_key_path, 'r') as f: + return SSHKeyContent(key=f.read()) + + raise ValueError(f"Invalid key_type: {key_type}") + + def is_key_in_authorized_keys(ssh_dir: str, fingerprint: str) -> bool: """Check if a key with the given fingerprint is in authorized_keys. diff --git a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx index b9e03420..9ca7ad7a 100644 --- a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -1,10 +1,10 @@ +import { useState } from 'react'; import { Button, Card, Chip, Typography } from '@material-tailwind/react'; import { HiOutlineClipboardCopy, HiOutlineKey, HiOutlinePlus } from 'react-icons/hi'; import toast from 'react-hot-toast'; -import CopyTooltip from '@/components/ui/widgets/CopyTooltip'; import { Spinner } from '@/components/ui/widgets/Loaders'; -import { useAuthorizeSSHKeyMutation } from '@/queries/sshKeyQueries'; +import { useAuthorizeSSHKeyMutation, fetchSSHKeyContent } from '@/queries/sshKeyQueries'; import type { SSHKeyInfo } from '@/queries/sshKeyQueries'; type SSHKeyCardProps = { @@ -13,6 +13,8 @@ type SSHKeyCardProps = { export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { const authorizeMutation = useAuthorizeSSHKeyMutation(); + const [isCopyingPublic, setIsCopyingPublic] = useState(false); + const [isCopyingPrivate, setIsCopyingPrivate] = useState(false); // Truncate fingerprint for display const shortFingerprint = @@ -29,6 +31,36 @@ export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { } }; + const handleCopyPublicKey = async () => { + setIsCopyingPublic(true); + try { + const content = await fetchSSHKeyContent('public'); + await navigator.clipboard.writeText(content.key); + toast.success('Public key copied to clipboard'); + } catch (error) { + toast.error( + `Failed to copy public key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsCopyingPublic(false); + } + }; + + const handleCopyPrivateKey = async () => { + setIsCopyingPrivate(true); + try { + const content = await fetchSSHKeyContent('private'); + await navigator.clipboard.writeText(content.key); + toast.success('Private key copied to clipboard'); + } catch (error) { + toast.error( + `Failed to copy private key: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsCopyingPrivate(false); + } + }; + return (
@@ -76,24 +108,38 @@ export default function SSHKeyCard({ keyInfo }: SSHKeyCardProps) { )} - - - Copy Public Key - + {isCopyingPublic ? ( + + ) : ( + <> + + Copy Public Key + + )} + - {keyInfo.private_key ? ( - - - Copy Private Key - + {isCopyingPrivate ? ( + + ) : ( + <> + + Copy Private Key + + )} + ) : ( Private key not available diff --git a/frontend/src/queries/sshKeyQueries.ts b/frontend/src/queries/sshKeyQueries.ts index 02a9b3bf..9853333c 100644 --- a/frontend/src/queries/sshKeyQueries.ts +++ b/frontend/src/queries/sshKeyQueries.ts @@ -8,19 +8,24 @@ import { } from '@/queries/queryUtils'; /** - * Information about an SSH key + * Information about an SSH key (without sensitive content) */ 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; }; +/** + * SSH key content - fetched on demand when user clicks copy + */ +export type SSHKeyContent = { + key: string; +}; + /** * Response from the list SSH keys endpoint */ @@ -153,3 +158,27 @@ export function useAuthorizeSSHKeyMutation(): UseMutationResult< } }); } + +/** + * Fetch SSH key content (public or private key) on demand. + * This is not a hook - call it imperatively when user clicks copy. + * + * @param keyType - Type of key to fetch: 'public' or 'private' + * @returns Promise with the key content + */ +export async function fetchSSHKeyContent( + keyType: 'public' | 'private' +): Promise { + const response = await sendFetchRequest( + `/api/ssh-keys/content?key_type=${keyType}`, + 'GET' + ); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as SSHKeyContent; +} From 3357fc7d375f7b050936e4a09819ba0e39af3658 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 17:38:43 -0500 Subject: [PATCH 12/14] fix newline bug, backup authorized_keys --- fileglancer/sshkeys.py | 78 +++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py index 0c8ef8fa..b03b9994 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -5,7 +5,9 @@ """ import os +import shutil import subprocess +import tempfile from typing import List, Optional from loguru import logger @@ -72,11 +74,6 @@ class SSHKeyInfo(BaseModel): is_authorized: bool = Field(description="Whether this key is in authorized_keys") -class SSHKeyContent(BaseModel): - """SSH key content - only fetched on demand""" - key: str = Field(description="The requested key content") - - class SSHKeyListResponse(BaseModel): """Response containing a list of SSH keys""" keys: List[SSHKeyInfo] = Field(description="List of SSH keys") @@ -88,6 +85,11 @@ class GenerateKeyResponse(BaseModel): message: str = Field(description="Status message") +class SSHKeyContent(BaseModel): + """SSH key content - only fetched on demand""" + key: str = Field(description="The requested key content") + + def get_ssh_directory() -> str: """Get the path to the current user's .ssh directory. @@ -362,7 +364,7 @@ def generate_ssh_key(ssh_dir: str) -> SSHKeyInfo: def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: - """Add a public key to the authorized_keys file using cat command. + """Add a public key to the authorized_keys file. Args: ssh_dir: Path to the .ssh directory @@ -384,31 +386,47 @@ def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: authorized_keys_path = os.path.join(ssh_dir, AUTHORIZED_KEYS_FILENAME) - # Check if key is already present using grep - if os.path.exists(authorized_keys_path): - key_parts = public_key.split() - if len(key_parts) >= 2: - result = subprocess.run( - ['grep', '-qF', key_parts[1], authorized_keys_path], - capture_output=True, - timeout=10 - ) - if result.returncode == 0: - logger.info("Key already in authorized_keys") - return True - - # Append the key using cat + # Get fingerprint of the key we're adding to check if already present + # Write key to temp file to get its fingerprint try: - result = subprocess.run( - ['sh', '-c', f'cat >> "{authorized_keys_path}"'], - input=public_key + '\n', - capture_output=True, - text=True, - timeout=10 - ) + with tempfile.NamedTemporaryFile(mode='w', suffix='.pub', delete=False) as tmp: + tmp.write(public_key) + tmp_path = tmp.name - if result.returncode != 0: - raise RuntimeError(f"cat failed: {result.stderr}") + try: + fingerprint = get_key_fingerprint(tmp_path) + finally: + os.unlink(tmp_path) + + # Use fingerprint-based check (same as UI uses) + if is_key_in_authorized_keys(ssh_dir, fingerprint): + logger.info("Key already in authorized_keys (fingerprint match)") + return True + + except Exception as e: + logger.warning(f"Could not check fingerprint, proceeding with add: {e}") + + # Backup and append the key + try: + # Backup existing file before modifying + if os.path.exists(authorized_keys_path): + backup_path = authorized_keys_path + '.bak' + shutil.copy2(authorized_keys_path, backup_path) + logger.info(f"Backed up authorized_keys to {backup_path}") + + # Ensure file ends with newline before appending + with open(authorized_keys_path, 'rb') as f: + f.seek(0, 2) # Seek to end + if f.tell() > 0: # File is not empty + f.seek(-1, 2) # Seek to last byte + if f.read(1) != b'\n': + # File doesn't end with newline, add one + with open(authorized_keys_path, 'a') as af: + af.write('\n') + + # Append the key + with open(authorized_keys_path, 'a') as f: + f.write(public_key + '\n') # Ensure correct permissions os.chmod(authorized_keys_path, 0o600) @@ -416,7 +434,5 @@ def add_to_authorized_keys(ssh_dir: str, public_key: str) -> bool: logger.info(f"Added key to {authorized_keys_path}") return True - except subprocess.TimeoutExpired: - raise RuntimeError("Timed out adding key to authorized_keys") except Exception as e: raise RuntimeError(f"Failed to add key to authorized_keys: {e}") From 9f4e1df2a170ee65cafedc6feeb73bcd00076702 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 17:39:16 -0500 Subject: [PATCH 13/14] linting --- frontend/src/components/SSHKeys.tsx | 4 ++-- frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/SSHKeys.tsx b/frontend/src/components/SSHKeys.tsx index 8d1ca1e6..50273463 100644 --- a/frontend/src/components/SSHKeys.tsx +++ b/frontend/src/components/SSHKeys.tsx @@ -32,8 +32,8 @@ export default function SSHKeys() { SSH keys allow you to securely connect to cluster nodes without entering a password. Specifically, you need an ed25519 SSH key to - use Seqera Platform to run pipelines on the cluster. This page - lets you view your existing ed25519 SSH key or generate a new one. + use Seqera Platform to run pipelines on the cluster. This page lets + you view your existing ed25519 SSH key or generate a new one.
diff --git a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx index 9ca7ad7a..97a87c81 100644 --- a/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx +++ b/frontend/src/components/ui/SSHKeys/SSHKeyCard.tsx @@ -1,10 +1,17 @@ import { useState } from 'react'; import { Button, Card, Chip, Typography } from '@material-tailwind/react'; -import { HiOutlineClipboardCopy, HiOutlineKey, HiOutlinePlus } from 'react-icons/hi'; +import { + HiOutlineClipboardCopy, + HiOutlineKey, + HiOutlinePlus +} from 'react-icons/hi'; import toast from 'react-hot-toast'; import { Spinner } from '@/components/ui/widgets/Loaders'; -import { useAuthorizeSSHKeyMutation, fetchSSHKeyContent } from '@/queries/sshKeyQueries'; +import { + useAuthorizeSSHKeyMutation, + fetchSSHKeyContent +} from '@/queries/sshKeyQueries'; import type { SSHKeyInfo } from '@/queries/sshKeyQueries'; type SSHKeyCardProps = { From ee31cfd6d966f9fa6457ae529d18fa86cb91d3d4 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 13 Jan 2026 17:43:39 -0500 Subject: [PATCH 14/14] add optional passphraset --- fileglancer/app.py | 3 +- fileglancer/sshkeys.py | 10 +++++-- frontend/src/components/SSHKeys.tsx | 2 +- .../ui/SSHKeys/GenerateKeyDialog.tsx | 28 +++++++++++++++++-- frontend/src/queries/sshKeyQueries.ts | 19 ++++++++++--- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 8b3a1398..c655feab 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -880,13 +880,14 @@ async def list_ssh_keys(username: str = Depends(get_current_user)): @app.post("/api/ssh-keys", response_model=sshkeys.GenerateKeyResponse, description="Generate the default ed25519 SSH key (id_ed25519)") async def generate_ssh_key( + request: sshkeys.GenerateKeyRequest = Body(default=sshkeys.GenerateKeyRequest()), username: str = Depends(get_current_user) ): """Generate the default SSH key (id_ed25519) and add it to authorized_keys""" with _get_user_context(username): try: ssh_dir = sshkeys.get_ssh_directory() - key_info = sshkeys.generate_ssh_key(ssh_dir) + key_info = sshkeys.generate_ssh_key(ssh_dir, request.passphrase) # Read public key content to add to authorized_keys pubkey_path = os.path.join(ssh_dir, f"{key_info.filename}.pub") diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py index b03b9994..3dd49562 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -90,6 +90,11 @@ class SSHKeyContent(BaseModel): key: str = Field(description="The requested key content") +class GenerateKeyRequest(BaseModel): + """Request body for generating an SSH key""" + passphrase: Optional[str] = Field(default=None, description="Optional passphrase to protect the private key") + + def get_ssh_directory() -> str: """Get the path to the current user's .ssh directory. @@ -301,11 +306,12 @@ def list_ssh_keys(ssh_dir: str) -> List[SSHKeyInfo]: return keys -def generate_ssh_key(ssh_dir: str) -> SSHKeyInfo: +def generate_ssh_key(ssh_dir: str, passphrase: Optional[str] = None) -> SSHKeyInfo: """Generate the default ed25519 SSH key (id_ed25519). Args: ssh_dir: Path to the .ssh directory + passphrase: Optional passphrase to protect the private key Returns: SSHKeyInfo for the generated key @@ -331,7 +337,7 @@ def generate_ssh_key(ssh_dir: str) -> SSHKeyInfo: cmd = [ 'ssh-keygen', '-t', 'ed25519', - '-N', '', # No passphrase + '-N', passphrase or '', # Empty string if no passphrase '-f', key_path, ] diff --git a/frontend/src/components/SSHKeys.tsx b/frontend/src/components/SSHKeys.tsx index 50273463..2d84c22e 100644 --- a/frontend/src/components/SSHKeys.tsx +++ b/frontend/src/components/SSHKeys.tsx @@ -65,7 +65,7 @@ export default function SSHKeys() { - No SSH key found + No ed25519 SSH key found Generate an ed25519 SSH key to enable passwordless access to cluster diff --git a/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx b/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx index f37368bc..77790283 100644 --- a/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx +++ b/frontend/src/components/ui/SSHKeys/GenerateKeyDialog.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; -import { Button, Typography } from '@material-tailwind/react'; +import { Button, Input, Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; @@ -16,14 +17,18 @@ export default function GenerateKeyDialog({ setShowDialog }: GenerateKeyDialogProps) { const generateMutation = useGenerateSSHKeyMutation(); + const [passphrase, setPassphrase] = useState(''); const handleClose = () => { setShowDialog(false); + setPassphrase(''); }; const handleGenerate = async () => { try { - const result = await generateMutation.mutateAsync(); + const result = await generateMutation.mutateAsync( + passphrase ? { passphrase } : undefined + ); toast.success(result.message); handleClose(); } catch (error) { @@ -45,11 +50,28 @@ export default function GenerateKeyDialog({ directory and add it to your authorized_keys file. - + Once created, you can use this key to SSH to cluster nodes and copy the private key for use with Seqera Platform. +
+ + Passphrase (optional) + + setPassphrase(e.target.value)} + placeholder="Leave empty for no passphrase" + type="password" + value={passphrase} + /> + + A passphrase adds extra security but must be entered each time you use + the key. + +
+