diff --git a/crates/atuin-desktop-runtime/bindings/BlockErrorData.ts b/crates/atuin-desktop-runtime/bindings/BlockErrorData.ts index 7c49d43a..3df88b8f 100644 --- a/crates/atuin-desktop-runtime/bindings/BlockErrorData.ts +++ b/crates/atuin-desktop-runtime/bindings/BlockErrorData.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Data for block error lifecycle event + */ export type BlockErrorData = { message: string, }; diff --git a/crates/atuin-desktop-runtime/bindings/BlockFinishedData.ts b/crates/atuin-desktop-runtime/bindings/BlockFinishedData.ts index 7bcb7d7a..77e87b18 100644 --- a/crates/atuin-desktop-runtime/bindings/BlockFinishedData.ts +++ b/crates/atuin-desktop-runtime/bindings/BlockFinishedData.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Data for block finished lifecycle event + */ export type BlockFinishedData = { exit_code: number | null, success: boolean, }; diff --git a/crates/atuin-desktop-runtime/bindings/BlockLifecycleEvent.ts b/crates/atuin-desktop-runtime/bindings/BlockLifecycleEvent.ts index 3a8ff558..32768519 100644 --- a/crates/atuin-desktop-runtime/bindings/BlockLifecycleEvent.ts +++ b/crates/atuin-desktop-runtime/bindings/BlockLifecycleEvent.ts @@ -2,4 +2,9 @@ import type { BlockErrorData } from "./BlockErrorData"; import type { BlockFinishedData } from "./BlockFinishedData"; -export type BlockLifecycleEvent = { "type": "started" } | { "type": "finished", "data": BlockFinishedData } | { "type": "cancelled" } | { "type": "error", "data": BlockErrorData }; +/** + * Block lifecycle events + * + * Indicates state transitions during block execution. + */ +export type BlockLifecycleEvent = { "type": "started", "data": string } | { "type": "finished", "data": BlockFinishedData } | { "type": "cancelled" } | { "type": "error", "data": BlockErrorData } | { "type": "paused" }; diff --git a/crates/atuin-desktop-runtime/bindings/ClientPrompt.ts b/crates/atuin-desktop-runtime/bindings/ClientPrompt.ts index a5069d9d..a1ec10c5 100644 --- a/crates/atuin-desktop-runtime/bindings/ClientPrompt.ts +++ b/crates/atuin-desktop-runtime/bindings/ClientPrompt.ts @@ -3,4 +3,9 @@ import type { PromptIcon } from "./PromptIcon"; import type { PromptInput } from "./PromptInput"; import type { PromptOption } from "./PromptOption"; +/** + * A prompt displayed to the user in the client application + * + * Prompts can include text input fields, dropdowns, and action buttons. + */ export type ClientPrompt = { title: string, prompt: string, icon: PromptIcon | null, input: PromptInput | null, options: Array, }; diff --git a/crates/atuin-desktop-runtime/bindings/ClientPromptResult.ts b/crates/atuin-desktop-runtime/bindings/ClientPromptResult.ts index 0b460a5a..ad090227 100644 --- a/crates/atuin-desktop-runtime/bindings/ClientPromptResult.ts +++ b/crates/atuin-desktop-runtime/bindings/ClientPromptResult.ts @@ -1,3 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ClientPromptResult = { button: string, value: string | null, }; +/** + * The result from a client prompt interaction + */ +export type ClientPromptResult = { +/** + * The value of the button that was clicked + */ +button: string, +/** + * The value entered in an input field, if any + */ +value: string | null, }; diff --git a/crates/atuin-desktop-runtime/bindings/DocumentBridgeMessage.ts b/crates/atuin-desktop-runtime/bindings/DocumentBridgeMessage.ts index bfce9d9a..2b05018c 100644 --- a/crates/atuin-desktop-runtime/bindings/DocumentBridgeMessage.ts +++ b/crates/atuin-desktop-runtime/bindings/DocumentBridgeMessage.ts @@ -1,6 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { BlockOutput } from "./BlockOutput"; import type { ClientPrompt } from "./ClientPrompt"; import type { ResolvedContext } from "./ResolvedContext"; +import type { StreamingBlockOutput } from "./StreamingBlockOutput"; +import type { JsonValue } from "./serde_json/JsonValue"; -export type DocumentBridgeMessage = { "type": "blockContextUpdate", "data": { blockId: string, context: ResolvedContext, } } | { "type": "blockOutput", "data": { blockId: string, output: BlockOutput, } } | { "type": "clientPrompt", "data": { executionId: string, promptId: string, prompt: ClientPrompt, } }; +/** + * Messages sent from the runtime to the client application + * + * These messages communicate execution state, output, and context updates + * to the desktop application frontend. + */ +export type DocumentBridgeMessage = { "type": "blockContextUpdate", "data": { blockId: string, context: ResolvedContext, } } | { "type": "blockStateChanged", "data": { blockId: string, state: JsonValue, } } | { "type": "blockExecutionOutputChanged", "data": { blockId: string, } } | { "type": "blockOutput", "data": { blockId: string, output: StreamingBlockOutput, } } | { "type": "clientPrompt", "data": { executionId: string, promptId: string, prompt: ClientPrompt, } }; diff --git a/crates/atuin-desktop-runtime/bindings/ExecutionStatus.ts b/crates/atuin-desktop-runtime/bindings/ExecutionStatus.ts index be31884d..43d9cd54 100644 --- a/crates/atuin-desktop-runtime/bindings/ExecutionStatus.ts +++ b/crates/atuin-desktop-runtime/bindings/ExecutionStatus.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Current status of block execution + */ export type ExecutionStatus = { "type": "Running" } | { "type": "Success" } | { "type": "Failed", "data": string } | { "type": "Cancelled" }; diff --git a/crates/atuin-desktop-runtime/bindings/GCEvent.ts b/crates/atuin-desktop-runtime/bindings/GCEvent.ts index d49f2b93..bdbcfaf1 100644 --- a/crates/atuin-desktop-runtime/bindings/GCEvent.ts +++ b/crates/atuin-desktop-runtime/bindings/GCEvent.ts @@ -2,6 +2,9 @@ import type { PtyMetadata } from "./PtyMetadata"; /** - * Grand Central Event - all events that can be emitted by the runtime + * Events emitted by the runtime for monitoring and telemetry + * + * These events provide visibility into runtime operations including block execution, + * SSH connections, PTY lifecycle, and runbook state changes. */ -export type GCEvent = { "type": "ptyOpened", "data": PtyMetadata } | { "type": "ptyClosed", "data": { pty_id: string, } } | { "type": "blockStarted", "data": { block_id: string, runbook_id: string, } } | { "type": "blockFinished", "data": { block_id: string, runbook_id: string, success: boolean, } } | { "type": "blockFailed", "data": { block_id: string, runbook_id: string, error: string, } } | { "type": "blockCancelled", "data": { block_id: string, runbook_id: string, } } | { "type": "sshConnected", "data": { host: string, username: string | null, } } | { "type": "sshConnectionFailed", "data": { host: string, error: string, } } | { "type": "sshDisconnected", "data": { host: string, } } | { "type": "runbookStarted", "data": { runbook_id: string, } } | { "type": "runbookCompleted", "data": { runbook_id: string, } } | { "type": "runbookFailed", "data": { runbook_id: string, error: string, } }; +export type GCEvent = { "type": "serialExecutionStarted", "data": { runbook_id: string, } } | { "type": "serialExecutionCompleted", "data": { runbook_id: string, } } | { "type": "serialExecutionCancelled", "data": { runbook_id: string, } } | { "type": "serialExecutionFailed", "data": { runbook_id: string, error: string, } } | { "type": "serialExecutionPaused", "data": { runbook_id: string, block_id: string, } } | { "type": "ptyOpened", "data": PtyMetadata } | { "type": "ptyClosed", "data": { pty_id: string, } } | { "type": "blockStarted", "data": { block_id: string, runbook_id: string, } } | { "type": "blockFinished", "data": { block_id: string, runbook_id: string, success: boolean, } } | { "type": "blockFailed", "data": { block_id: string, runbook_id: string, error: string, } } | { "type": "blockCancelled", "data": { block_id: string, runbook_id: string, } } | { "type": "sshConnected", "data": { host: string, username: string | null, } } | { "type": "sshConnectionFailed", "data": { host: string, error: string, } } | { "type": "sshDisconnected", "data": { host: string, } } | { "type": "sshCertificateLoadFailed", "data": { host: string, cert_path: string, error: string, } } | { "type": "sshCertificateExpired", "data": { host: string, cert_path: string, valid_until: string, } } | { "type": "sshCertificateNotYetValid", "data": { host: string, cert_path: string, valid_from: string, } } | { "type": "runbookStarted", "data": { runbook_id: string, } } | { "type": "runbookCompleted", "data": { runbook_id: string, } } | { "type": "runbookFailed", "data": { runbook_id: string, error: string, } }; diff --git a/crates/atuin-desktop-runtime/bindings/PromptIcon.ts b/crates/atuin-desktop-runtime/bindings/PromptIcon.ts index 0a23274e..a46330e5 100644 --- a/crates/atuin-desktop-runtime/bindings/PromptIcon.ts +++ b/crates/atuin-desktop-runtime/bindings/PromptIcon.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Icon types for client prompts + */ export type PromptIcon = { "type": "info" } | { "type": "warning" } | { "type": "error" } | { "type": "success" } | { "type": "question" }; diff --git a/crates/atuin-desktop-runtime/bindings/PromptInput.ts b/crates/atuin-desktop-runtime/bindings/PromptInput.ts index 316d4df9..146d02e9 100644 --- a/crates/atuin-desktop-runtime/bindings/PromptInput.ts +++ b/crates/atuin-desktop-runtime/bindings/PromptInput.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Input types for client prompts + */ export type PromptInput = { "type": "string" } | { "type": "text" } | { "type": "dropdown", "data": Array<[string, string]> }; diff --git a/crates/atuin-desktop-runtime/bindings/PromptOption.ts b/crates/atuin-desktop-runtime/bindings/PromptOption.ts index 2167b443..ac7a412e 100644 --- a/crates/atuin-desktop-runtime/bindings/PromptOption.ts +++ b/crates/atuin-desktop-runtime/bindings/PromptOption.ts @@ -2,4 +2,7 @@ import type { PromptOptionColor } from "./PromptOptionColor"; import type { PromptOptionVariant } from "./PromptOptionVariant"; +/** + * A button option in a client prompt dialog + */ export type PromptOption = { label: string, value: string, variant: PromptOptionVariant | null, color: PromptOptionColor | null, }; diff --git a/crates/atuin-desktop-runtime/bindings/PromptOptionColor.ts b/crates/atuin-desktop-runtime/bindings/PromptOptionColor.ts index 8149d1df..8798dd92 100644 --- a/crates/atuin-desktop-runtime/bindings/PromptOptionColor.ts +++ b/crates/atuin-desktop-runtime/bindings/PromptOptionColor.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Color scheme for prompt options (buttons) + */ export type PromptOptionColor = { "type": "default" } | { "type": "primary" } | { "type": "secondary" } | { "type": "success" } | { "type": "warning" } | { "type": "danger" }; diff --git a/crates/atuin-desktop-runtime/bindings/PromptOptionVariant.ts b/crates/atuin-desktop-runtime/bindings/PromptOptionVariant.ts index 352b01e9..32d367b6 100644 --- a/crates/atuin-desktop-runtime/bindings/PromptOptionVariant.ts +++ b/crates/atuin-desktop-runtime/bindings/PromptOptionVariant.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Visual variant for prompt options (buttons) + */ export type PromptOptionVariant = { "type": "flat" } | { "type": "light" } | { "type": "shadow" } | { "type": "solid" } | { "type": "bordered" } | { "type": "faded" } | { "type": "ghost" }; diff --git a/crates/atuin-desktop-runtime/bindings/PtyMetadata.ts b/crates/atuin-desktop-runtime/bindings/PtyMetadata.ts index ab129b3f..9b2e011b 100644 --- a/crates/atuin-desktop-runtime/bindings/PtyMetadata.ts +++ b/crates/atuin-desktop-runtime/bindings/PtyMetadata.ts @@ -1,3 +1,22 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PtyMetadata = { pid: string, runbook: string, block: string, created_at: bigint, }; +/** + * Metadata about a PTY instance + */ +export type PtyMetadata = { +/** + * Unique PTY identifier + */ +pid: string, +/** + * Runbook ID this PTY belongs to + */ +runbook: string, +/** + * Block ID that created this PTY + */ +block: string, +/** + * Unix timestamp when PTY was created + */ +created_at: bigint, }; diff --git a/crates/atuin-desktop-runtime/bindings/ResolvedContext.ts b/crates/atuin-desktop-runtime/bindings/ResolvedContext.ts index 0694bbf2..00b7d258 100644 --- a/crates/atuin-desktop-runtime/bindings/ResolvedContext.ts +++ b/crates/atuin-desktop-runtime/bindings/ResolvedContext.ts @@ -5,4 +5,4 @@ * Since it's built from a `ContextResolver`, it's a snapshot * of the final context based on the blocks above it. */ -export type ResolvedContext = { variables: { [key in string]?: string }, cwd: string, envVars: { [key in string]?: string }, sshHost: string | null, }; +export type ResolvedContext = { variables: { [key in string]?: string }, variablesSources: { [key in string]?: string }, cwd: string, envVars: { [key in string]?: string }, sshHost: string | null, }; diff --git a/crates/atuin-desktop-runtime/src/blocks/script.rs b/crates/atuin-desktop-runtime/src/blocks/script.rs index 49509731..c43b10fb 100644 --- a/crates/atuin-desktop-runtime/src/blocks/script.rs +++ b/crates/atuin-desktop-runtime/src/blocks/script.rs @@ -13,10 +13,11 @@ use uuid::Uuid; use crate::blocks::{Block, BlockBehavior}; use crate::context::{fs_var, BlockExecutionOutput, BlockVars}; +use crate::events::GCEvent; use crate::execution::{ CancellationToken, ExecutionContext, ExecutionHandle, ExecutionStatus, StreamingBlockOutput, }; -use crate::ssh::OutputLine as SessionOutputLine; +use crate::ssh::{OutputLine as SessionOutputLine, SshWarning}; use super::FromDocument; @@ -705,6 +706,7 @@ impl Script { let channel_id = self.id.to_string(); let (output_sender, mut output_receiver) = mpsc::channel::(100); let (result_tx, result_rx) = oneshot::channel::<()>(); + let (warnings_tx, warnings_rx) = oneshot::channel::>(); let captured_output = Arc::new(RwLock::new(Vec::new())); let captured_output_clone = captured_output.clone(); @@ -733,6 +735,7 @@ impl Script { output_sender, result_tx, ssh_config, + Some(warnings_tx), ) => { result } @@ -757,6 +760,54 @@ impl Script { } return (Err(error_msg.into()), Vec::new(), None); } + + // Receive and emit SSH authentication warnings (certificate issues, etc.) + if let Ok(warnings) = warnings_rx.await { + for warning in warnings { + match warning { + SshWarning::CertificateLoadFailed { + host, + cert_path, + error, + } => { + let _ = context + .emit_gc_event(GCEvent::SshCertificateLoadFailed { + host, + cert_path, + error, + }) + .await; + } + SshWarning::CertificateExpired { + host, + cert_path, + valid_until, + } => { + let _ = context + .emit_gc_event(GCEvent::SshCertificateExpired { + host, + cert_path, + valid_until, + }) + .await; + } + SshWarning::CertificateNotYetValid { + host, + cert_path, + valid_from, + } => { + let _ = context + .emit_gc_event(GCEvent::SshCertificateNotYetValid { + host, + cert_path, + valid_from, + }) + .await; + } + } + } + } + let context_clone = context.clone(); let block_id = self.id; let ssh_pool_clone = ssh_pool.clone(); diff --git a/crates/atuin-desktop-runtime/src/blocks/ssh_connect.rs b/crates/atuin-desktop-runtime/src/blocks/ssh_connect.rs index 299e3eae..8cd978b4 100644 --- a/crates/atuin-desktop-runtime/src/blocks/ssh_connect.rs +++ b/crates/atuin-desktop-runtime/src/blocks/ssh_connect.rs @@ -2,7 +2,8 @@ use crate::{ blocks::{Block, BlockBehavior, FromDocument}, client::LocalValueProvider, context::{ - BlockContext, ContextResolver, DocumentSshConfig, DocumentSshHost, SshIdentityKeyConfig, + BlockContext, ContextResolver, DocumentSshConfig, DocumentSshHost, SshCertificateConfig, + SshIdentityKeyConfig, }, }; use async_trait::async_trait; @@ -120,6 +121,38 @@ impl SshConnect { } } + /// Parse certificate configuration from local storage value + fn parse_certificate_from_local(value: &str) -> Option { + // The value is JSON-encoded: {"mode": "...", "value": "..."} + let parsed: serde_json::Value = serde_json::from_str(value).ok()?; + + let mode = parsed.get("mode").and_then(|v| v.as_str())?; + let cert_value = parsed.get("value").and_then(|v| v.as_str()).unwrap_or(""); + + match mode { + "none" | "" => Some(SshCertificateConfig::None), + "paste" => { + if cert_value.is_empty() { + None + } else { + Some(SshCertificateConfig::Paste { + content: cert_value.to_string(), + }) + } + } + "path" => { + if cert_value.is_empty() { + None + } else { + Some(SshCertificateConfig::Path { + path: cert_value.to_string(), + }) + } + } + _ => None, + } + } + /// Check if explicit settings are configured (user or hostname set) pub fn has_explicit_config(&self) -> bool { self.user.is_some() || self.hostname.is_some() @@ -242,8 +275,8 @@ impl BlockBehavior for SshConnect { return Err("Invalid SSH user_host format".into()); } - let identity_key = if let Some(provider) = block_local_value_provider { - match provider.get_block_local_value(self.id, "identityKey").await { + let (identity_key, certificate) = if let Some(provider) = block_local_value_provider { + let identity_key = match provider.get_block_local_value(self.id, "identityKey").await { Ok(Some(value)) => { tracing::debug!("Block {} read identityKey from KV: {}", self.id, value); Self::parse_identity_key_from_local(&value) @@ -256,15 +289,33 @@ impl BlockBehavior for SshConnect { tracing::warn!("Failed to get identity key from local storage: {}", e); None } - } + }; + + let certificate = match provider.get_block_local_value(self.id, "certificate").await { + Ok(Some(value)) => { + tracing::debug!("Block {} read certificate from KV: {}", self.id, value); + Self::parse_certificate_from_local(&value) + } + Ok(None) => { + tracing::debug!("Block {} has no certificate in KV", self.id); + None + } + Err(e) => { + tracing::warn!("Failed to get certificate from local storage: {}", e); + None + } + }; + + (identity_key, certificate) } else { tracing::debug!("Block {} has no block_local_value_provider", self.id); - None + (None, None) }; tracing::debug!( - "Block {} resolved identity_key to: {:?}", + "Block {} resolved identity_key to: {:?}, certificate to: {:?}", self.id, - identity_key + identity_key, + certificate ); // Backwards compatibility with older blocks that only check DocumentSshHost @@ -276,6 +327,7 @@ impl BlockBehavior for SshConnect { hostname: resolved_hostname, port: self.port, identity_key, + certificate, }); Ok(Some(context)) diff --git a/crates/atuin-desktop-runtime/src/blocks/terminal.rs b/crates/atuin-desktop-runtime/src/blocks/terminal.rs index cb16bcdb..52f589d4 100644 --- a/crates/atuin-desktop-runtime/src/blocks/terminal.rs +++ b/crates/atuin-desktop-runtime/src/blocks/terminal.rs @@ -14,7 +14,7 @@ use crate::execution::{ CancellationToken, ExecutionContext, ExecutionHandle, ExecutionStatus, StreamingBlockOutput, }; use crate::pty::{Pty, PtyLike}; -use crate::ssh::SshPty; +use crate::ssh::{SshPty, SshWarning}; /// Output structure for Terminal blocks that implements BlockExecutionOutput /// for template access to terminal output. @@ -303,7 +303,52 @@ impl Terminal { } }; - let (pty_tx, resize_tx) = ssh_result?; + let (pty_tx, resize_tx, warnings) = ssh_result?; + + // Emit events for any authentication warnings + for warning in warnings { + match warning { + SshWarning::CertificateLoadFailed { + host, + cert_path, + error, + } => { + let _ = context + .emit_gc_event(GCEvent::SshCertificateLoadFailed { + host, + cert_path, + error, + }) + .await; + } + SshWarning::CertificateExpired { + host, + cert_path, + valid_until, + } => { + let _ = context + .emit_gc_event(GCEvent::SshCertificateExpired { + host, + cert_path, + valid_until, + }) + .await; + } + SshWarning::CertificateNotYetValid { + host, + cert_path, + valid_from, + } => { + let _ = context + .emit_gc_event(GCEvent::SshCertificateNotYetValid { + host, + cert_path, + valid_from, + }) + .await; + } + } + } // Forward SSH output to binary channel and accumulate let context_clone = context.clone(); diff --git a/crates/atuin-desktop-runtime/src/context/block_context.rs b/crates/atuin-desktop-runtime/src/context/block_context.rs index 0faf7ea0..c5a99fbf 100644 --- a/crates/atuin-desktop-runtime/src/context/block_context.rs +++ b/crates/atuin-desktop-runtime/src/context/block_context.rs @@ -392,6 +392,18 @@ pub enum SshIdentityKeyConfig { Path { path: String }, } +/// SSH certificate configuration from SSH Connect block +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "camelCase")] +pub enum SshCertificateConfig { + /// No custom certificate - auto-detect from key path or none + None, + /// Certificate content pasted directly + Paste { content: String }, + /// Path to certificate file on local machine + Path { path: String }, +} + /// Rich SSH configuration from SSH Connect block /// /// This provides detailed SSH connection settings that override ~/.ssh/config. @@ -410,6 +422,8 @@ pub struct DocumentSshConfig { pub port: Option, /// Identity key configuration pub identity_key: Option, + /// SSH certificate configuration + pub certificate: Option, } #[typetag::serde] diff --git a/crates/atuin-desktop-runtime/src/context/mod.rs b/crates/atuin-desktop-runtime/src/context/mod.rs index 986f2d17..247cf572 100644 --- a/crates/atuin-desktop-runtime/src/context/mod.rs +++ b/crates/atuin-desktop-runtime/src/context/mod.rs @@ -21,7 +21,7 @@ pub use block_context::BlockState; pub use block_context::{ BlockContext, BlockContextItem, BlockExecutionOutput, BlockStateUpdater, BlockVars, DocumentBlock, DocumentCwd, DocumentEnvVar, DocumentEnvVars, DocumentSshConfig, - DocumentSshHost, DocumentVar, DocumentVars, SshIdentityKeyConfig, + DocumentSshHost, DocumentVar, DocumentVars, SshCertificateConfig, SshIdentityKeyConfig, }; pub use resolution::{ContextResolver, ResolvedContext}; diff --git a/crates/atuin-desktop-runtime/src/events/mod.rs b/crates/atuin-desktop-runtime/src/events/mod.rs index 3d1364a6..9e11e1e5 100644 --- a/crates/atuin-desktop-runtime/src/events/mod.rs +++ b/crates/atuin-desktop-runtime/src/events/mod.rs @@ -72,6 +72,30 @@ pub enum GCEvent { /// SSH connection closed SshDisconnected { host: String }, + /// SSH certificate file exists but failed to load (likely corrupted or invalid) + /// This is a warning - authentication will fall back to key-based auth + SshCertificateLoadFailed { + host: String, + cert_path: String, + error: String, + }, + + /// SSH certificate has expired + /// This is a warning - authentication fell back to key-based auth + SshCertificateExpired { + host: String, + cert_path: String, + valid_until: String, + }, + + /// SSH certificate is not yet valid + /// This is a warning - authentication fell back to key-based auth + SshCertificateNotYetValid { + host: String, + cert_path: String, + valid_from: String, + }, + /// Runbook execution started RunbookStarted { runbook_id: Uuid }, diff --git a/crates/atuin-desktop-runtime/src/ssh/integration_tests.rs b/crates/atuin-desktop-runtime/src/ssh/integration_tests.rs index 5357c3b3..fcbaa512 100644 --- a/crates/atuin-desktop-runtime/src/ssh/integration_tests.rs +++ b/crates/atuin-desktop-runtime/src/ssh/integration_tests.rs @@ -21,7 +21,7 @@ use std::path::PathBuf; -use super::{Authentication, Session}; +use super::{Authentication, Session, SshWarning}; // CommandResult is returned by exec_and_capture #[allow(unused_imports)] @@ -79,7 +79,9 @@ async fn connect_with_key(key_name: &str) -> eyre::Result { let key_path = test_keys_dir().join(key_name); let mut session = Session::open(&host).await?; - session.key_auth(&test_user(), key_path).await?; + session + .key_auth(&test_user(), &test_host(), key_path) + .await?; Ok(session) } @@ -165,7 +167,7 @@ async fn test_auth_invalid_key_fails() { let fake_key = temp_dir.path().join("fake_key"); std::fs::write(&fake_key, "not a valid key").unwrap(); - let result = session.key_auth(&test_user(), fake_key).await; + let result = session.key_auth(&test_user(), &test_host(), fake_key).await; assert!(result.is_err(), "Invalid key should fail authentication"); } @@ -536,3 +538,175 @@ async fn test_connection_to_wrong_port_fails() { Err(_timeout) => (), // Timeout is fine - proves it would hang } } + +// ============================================================================= +// Certificate Authentication Tests +// ============================================================================= + +/// Test certificate authentication with a valid certificate +/// This key has NO entry in authorized_keys - it can ONLY authenticate via certificate +#[tokio::test] +#[ignore] +async fn test_auth_certificate_valid() { + let host = host_string(); + let key_path = test_keys_dir().join("id_ed25519_cert_only"); + + let mut session = Session::open(&host).await.expect("Failed to open session"); + let auth_result = session.key_auth(&test_user(), &test_host(), key_path).await; + + assert!( + auth_result.is_ok(), + "Certificate authentication should succeed: {:?}", + auth_result.err() + ); + + // Should have no warnings for a valid certificate + let auth_result = auth_result.unwrap(); + assert!( + auth_result.warnings.is_empty(), + "Valid certificate should produce no warnings, got: {:?}", + auth_result.warnings + ); + + // Verify connection works by running a command + let cmd_result = session + .exec_and_capture("echo 'cert auth works'") + .await + .expect("Command execution failed"); + assert_eq!(cmd_result.exit_code, 0); + assert!(cmd_result.stdout.contains("cert auth works")); +} + +/// Test that expired certificate falls back to key auth with a warning +/// This key has an entry in authorized_keys, so fallback should succeed +#[tokio::test] +#[ignore] +async fn test_auth_certificate_expired_fallback() { + let host = host_string(); + let key_path = test_keys_dir().join("id_ed25519_expired_cert"); + + let mut session = Session::open(&host).await.expect("Failed to open session"); + let auth_result = session.key_auth(&test_user(), &test_host(), key_path).await; + + assert!( + auth_result.is_ok(), + "Expired cert should fall back to key auth: {:?}", + auth_result.err() + ); + + // Should have a warning about the expired certificate + let auth_result = auth_result.unwrap(); + assert!( + !auth_result.warnings.is_empty(), + "Expired certificate should produce a warning" + ); + + // Check the warning type + let has_expired_warning = auth_result + .warnings + .iter() + .any(|w| matches!(w, SshWarning::CertificateExpired { .. })); + assert!( + has_expired_warning, + "Should have CertificateExpired warning, got: {:?}", + auth_result.warnings + ); +} + +/// Test that not-yet-valid certificate falls back to key auth with a warning +/// This key has an entry in authorized_keys, so fallback should succeed +#[tokio::test] +#[ignore] +async fn test_auth_certificate_not_yet_valid_fallback() { + let host = host_string(); + let key_path = test_keys_dir().join("id_ed25519_future_cert"); + + let mut session = Session::open(&host).await.expect("Failed to open session"); + let auth_result = session.key_auth(&test_user(), &test_host(), key_path).await; + + assert!( + auth_result.is_ok(), + "Not-yet-valid cert should fall back to key auth: {:?}", + auth_result.err() + ); + + // Should have a warning about the not-yet-valid certificate + let auth_result = auth_result.unwrap(); + assert!( + !auth_result.warnings.is_empty(), + "Not-yet-valid certificate should produce a warning" + ); + + // Check the warning type + let has_future_warning = auth_result + .warnings + .iter() + .any(|w| matches!(w, SshWarning::CertificateNotYetValid { .. })); + assert!( + has_future_warning, + "Should have CertificateNotYetValid warning, got: {:?}", + auth_result.warnings + ); +} + +/// Test that certificate is automatically detected when present +/// key_auth should find id_ed25519_cert_only-cert.pub automatically +#[tokio::test] +#[ignore] +async fn test_auth_certificate_auto_detection() { + let host = host_string(); + let key_path = test_keys_dir().join("id_ed25519_cert_only"); + let cert_path = test_keys_dir().join("id_ed25519_cert_only-cert.pub"); + + // Verify the certificate file exists (sanity check) + assert!( + cert_path.exists(), + "Certificate file should exist at {:?}", + cert_path + ); + + let mut session = Session::open(&host).await.expect("Failed to open session"); + + // key_auth should automatically detect and use the certificate + let auth_result = session.key_auth(&test_user(), &test_host(), key_path).await; + + assert!( + auth_result.is_ok(), + "Auto-detected certificate auth should succeed: {:?}", + auth_result.err() + ); +} + +/// Test direct cert_auth method with explicit paths +#[tokio::test] +#[ignore] +async fn test_auth_cert_auth_explicit() { + let host = host_string(); + let key_path = test_keys_dir().join("id_ed25519_cert_only"); + let cert_path = test_keys_dir().join("id_ed25519_cert_only-cert.pub"); + + let mut session = Session::open(&host).await.expect("Failed to open session"); + + let auth_result = session + .cert_auth(&test_user(), &test_host(), key_path, cert_path) + .await; + + assert!( + auth_result.is_ok(), + "Explicit cert_auth should succeed: {:?}", + auth_result.err() + ); +} + +/// Test that a key without a certificate still works via authorized_keys +#[tokio::test] +#[ignore] +async fn test_auth_key_without_cert_still_works() { + // id_ed25519 has an authorized_keys entry but no certificate + let result = connect_with_key("id_ed25519").await; + assert!( + result.is_ok(), + "Key without certificate should authenticate via authorized_keys: {:?}", + result.err() + ); +} diff --git a/crates/atuin-desktop-runtime/src/ssh/mod.rs b/crates/atuin-desktop-runtime/src/ssh/mod.rs index 6e447aa0..cafac459 100644 --- a/crates/atuin-desktop-runtime/src/ssh/mod.rs +++ b/crates/atuin-desktop-runtime/src/ssh/mod.rs @@ -6,8 +6,19 @@ //! Features: //! - Connection pooling with automatic cleanup //! - SSH configuration file parsing -//! - Multiple authentication methods +//! - Multiple authentication methods (keys and certificates) //! - Remote PTY support +//! +//! ## Certificate Support +//! +//! SSH certificate authentication is supported for file-based certificates. +//! If a key file (e.g., `~/.ssh/id_ed25519`) has a companion certificate file +//! (e.g., `~/.ssh/id_ed25519-cert.pub`), the certificate will be used for authentication. +//! +//! **Known limitation:** SSH certificates loaded in an SSH agent are not currently +//! supported due to limitations in the russh library. Users relying on agent-based +//! certificate authentication should ensure the private key and certificate files +//! are available on disk. mod pool; mod session; @@ -17,5 +28,5 @@ mod ssh_pool; mod integration_tests; pub use pool::Pool; -pub use session::{Authentication, CommandResult, OutputLine, Session, SshConfig}; +pub use session::{Authentication, CommandResult, OutputLine, Session, SshConfig, SshWarning}; pub use ssh_pool::{SshPoolHandle, SshPty}; diff --git a/crates/atuin-desktop-runtime/src/ssh/pool.rs b/crates/atuin-desktop-runtime/src/ssh/pool.rs index d3c23548..b4587979 100644 --- a/crates/atuin-desktop-runtime/src/ssh/pool.rs +++ b/crates/atuin-desktop-runtime/src/ssh/pool.rs @@ -1,4 +1,4 @@ -use super::session::{Authentication, Session}; +use super::session::{AuthResult, Authentication, Session}; use crate::context::DocumentSshConfig; use eyre::Result; use std::collections::HashMap; @@ -29,8 +29,8 @@ impl Pool { } } - /// Connect to a host and return a session - /// If the session already exists, return it + /// Connect to a host and return a session along with any authentication warnings + /// If the session already exists, return it (with no warnings) /// If the existing session is dead, remove it and create a new one pub async fn connect( &mut self, @@ -38,13 +38,13 @@ impl Pool { username: Option<&str>, auth: Option, cancellation_rx: Option>, - ) -> Result> { + ) -> Result<(Arc, AuthResult)> { self.connect_with_config(host, username, auth, cancellation_rx, None) .await } /// Connect to a host with optional block configuration overrides - /// If the session already exists, return it + /// If the session already exists, return it (with no warnings) /// If the existing session is dead, remove it and create a new one pub async fn connect_with_config( &mut self, @@ -53,7 +53,7 @@ impl Pool { auth: Option, cancellation_rx: Option>, ssh_config_override: Option<&DocumentSshConfig>, - ) -> Result> { + ) -> Result<(Arc, AuthResult)> { let ssh_config = Session::resolve_ssh_config(host); // Determine username: block override > provided > SSH config > current user @@ -72,7 +72,8 @@ impl Pool { tracing::debug!("found existing ssh session in pool"); if session.send_keepalive().await { tracing::debug!("session keepalive success"); - return Ok(session); + // Existing connection, no new warnings + return Ok((session, AuthResult::default())); } else { tracing::debug!("Removing dead SSH connection for {key}"); self.connections.remove(&key); @@ -80,22 +81,29 @@ impl Pool { } let identity_key_config = ssh_config_override.and_then(|cfg| cfg.identity_key.as_ref()); + let certificate_config = ssh_config_override.and_then(|cfg| cfg.certificate.as_ref()); tracing::debug!( - "Pool connect_with_config: ssh_config_override={:?}, identity_key_config={:?}", + "Pool connect_with_config: ssh_config_override={:?}, identity_key_config={:?}, certificate_config={:?}", ssh_config_override, - identity_key_config + identity_key_config, + certificate_config ); let async_session = async { let mut session = Session::open_with_config(host, ssh_config_override).await?; - session - .authenticate_with_config(auth, Some(&username), identity_key_config) + let auth_result = session + .authenticate_with_config( + auth, + Some(&username), + identity_key_config, + certificate_config, + ) .await?; - Ok::<_, eyre::Report>(session) + Ok::<_, eyre::Report>((session, auth_result)) }; tracing::debug!("Creating new SSH connection for {key}"); - let session = if let Some(mut cancellation_rx) = cancellation_rx { + let (session, auth_result) = if let Some(mut cancellation_rx) = cancellation_rx { tokio::select! { result = async_session => { result? @@ -112,7 +120,7 @@ impl Pool { let session = Arc::new(session); self.connections.insert(key, session.clone()); - Ok(session) + Ok((session, auth_result)) } pub fn get(&self, host: &str, username: &str) -> Option> { diff --git a/crates/atuin-desktop-runtime/src/ssh/session.rs b/crates/atuin-desktop-runtime/src/ssh/session.rs index 37b158d0..1886d260 100644 --- a/crates/atuin-desktop-runtime/src/ssh/session.rs +++ b/crates/atuin-desktop-runtime/src/ssh/session.rs @@ -2,7 +2,7 @@ // This is essentially a wrapper around the russh crate. use bytes::Bytes; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; @@ -14,9 +14,22 @@ use russh::client::Handle; use russh::*; use russh_config::*; -use crate::context::{DocumentSshConfig, SshIdentityKeyConfig}; +use time::OffsetDateTime; + +use crate::context::{DocumentSshConfig, SshCertificateConfig, SshIdentityKeyConfig}; use crate::ssh::SshPoolHandle; +/// Guard struct to ensure temp file cleanup on drop +struct TempFileGuard { + path: PathBuf, +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + /// Result of executing a simple command on the remote system #[derive(Debug, Clone)] pub struct CommandResult { @@ -49,6 +62,37 @@ pub enum Authentication { Password(String, String), } +/// Warnings that can occur during SSH operations +/// These are non-fatal issues that the user should be aware of +#[derive(Debug, Clone)] +pub enum SshWarning { + /// Certificate file exists but failed to load (corrupted, invalid, etc.) + /// Authentication fell back to key-based auth + CertificateLoadFailed { + host: String, + cert_path: String, + error: String, + }, + /// Certificate has expired, fell back to key-based auth + CertificateExpired { + host: String, + cert_path: String, + valid_until: String, + }, + /// Certificate is not yet valid, fell back to key-based auth + CertificateNotYetValid { + host: String, + cert_path: String, + valid_from: String, + }, +} + +/// Result of authentication including any warnings encountered +#[derive(Debug, Default)] +pub struct AuthResult { + pub warnings: Vec, +} + pub enum OutputLine { Stdout(String), Stderr(String), @@ -598,8 +642,35 @@ impl Session { .collect() } - /// Public key authentication - pub async fn key_auth(&mut self, username: &str, key_path: PathBuf) -> Result<()> { + /// Find a companion certificate file for a given key path + /// OpenSSH convention: certificate for `id_ed25519` is `id_ed25519-cert.pub` + async fn find_certificate_for_key(key_path: &Path) -> Option { + let key_name = key_path.file_name()?.to_str()?; + let cert_name = format!("{}-cert.pub", key_name); + let cert_path = key_path.parent()?.join(cert_name); + + if tokio::fs::try_exists(&cert_path).await.unwrap_or(false) { + Some(cert_path) + } else { + None + } + } + + /// Public key or certificate authentication + /// If a companion certificate file exists (e.g., id_ed25519-cert.pub), uses certificate auth + /// Returns AuthResult containing any warnings from the authentication process + pub async fn key_auth( + &mut self, + username: &str, + host: &str, + key_path: PathBuf, + ) -> Result { + // Check if there's a companion certificate for this key + if let Some(cert_path) = Self::find_certificate_for_key(&key_path).await { + return self.cert_auth(username, host, key_path, cert_path).await; + } + + // No certificate found, use regular public key authentication tracing::info!( "Attempting public key authentication with {}", key_path.display() @@ -628,7 +699,7 @@ impl Session { match auth_res { russh::client::AuthResult::Success => { tracing::info!("✓ Authentication successful with {}", key_path.display()); - Ok(()) + Ok(AuthResult::default()) } russh::client::AuthResult::Failure { remaining_methods, @@ -647,6 +718,215 @@ impl Session { } } + /// Certificate-based SSH authentication + /// Uses the private key for signing but presents the certificate to the server + /// Returns AuthResult with any warnings (e.g., if cert failed to load but key auth succeeded) + pub async fn cert_auth( + &mut self, + username: &str, + host: &str, + key_path: PathBuf, + cert_path: PathBuf, + ) -> Result { + tracing::info!( + "Attempting certificate authentication with key {} and cert {}", + key_path.display(), + cert_path.display() + ); + + // Load the private key + let key_pair = match russh::keys::load_secret_key(&key_path, None) { + Ok(kp) => kp, + Err(e) => { + tracing::warn!("Failed to load key {}: {e}", key_path.display()); + return Err(e.into()); + } + }; + + // Read certificate file content + let cert_content = std::fs::read_to_string(&cert_path).map_err(|e| { + eyre::eyre!( + "Failed to read certificate file {}: {e}", + cert_path.display() + ) + })?; + + let cert_source = cert_path.display().to_string(); + self.cert_auth_impl(username, host, key_pair, &cert_content, &cert_source) + .await + } + + /// Attempt public key authentication with an already-loaded key. + /// Returns Ok(()) on success, Err on failure. + async fn try_publickey_auth( + &mut self, + username: &str, + key_pair: russh::keys::PrivateKey, + ) -> Result<()> { + let best_hash = self.session.best_supported_rsa_hash().await?.flatten(); + let key_with_alg = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key_pair), best_hash); + let auth_res = self + .session + .authenticate_publickey(username, key_with_alg) + .await?; + + match auth_res { + russh::client::AuthResult::Success => Ok(()), + _ => Err(eyre::eyre!("Public key authentication failed")), + } + } + + /// Core certificate authentication implementation. + /// Takes certificate content as a string (callers read file or pass directly). + /// Handles parsing, validation, fallback to key auth, and returns warnings. + async fn cert_auth_impl( + &mut self, + username: &str, + host: &str, + key_pair: russh::keys::PrivateKey, + cert_content: &str, + cert_source: &str, // For display: path or "(pasted content)" + ) -> Result { + // Parse certificate via temp file (russh requires a path) + let temp_dir = std::env::temp_dir(); + let temp_cert_path = temp_dir.join(format!("ssh-cert-{}.pub", uuid::Uuid::new_v4())); + std::fs::write(&temp_cert_path, cert_content) + .map_err(|e| eyre::eyre!("Failed to write temp certificate: {e}"))?; + + // Guard ensures temp file is cleaned up on any exit path + let _temp_guard = TempFileGuard { + path: temp_cert_path.clone(), + }; + + // Load the certificate + let cert = match russh::keys::load_openssh_certificate(&temp_cert_path) { + Ok(c) => c, + Err(e) => { + let error_msg = e.to_string(); + tracing::error!( + "Failed to load SSH certificate {}: {e}. Falling back to key authentication.", + cert_source + ); + return match self.try_publickey_auth(username, key_pair).await { + Ok(()) => Ok(AuthResult { + warnings: vec![SshWarning::CertificateLoadFailed { + host: host.to_string(), + cert_path: cert_source.to_string(), + error: error_msg, + }], + }), + Err(_) => Err(eyre::eyre!( + "Certificate load failed ({}) and fallback key authentication also failed", + error_msg + )), + }; + } + }; + + // Validate certificate timing + let now = std::time::SystemTime::now(); + let valid_after = cert.valid_after_time(); + let valid_before = cert.valid_before_time(); + + if now < valid_after { + let valid_from_str = OffsetDateTime::from(valid_after).to_string(); + tracing::warn!( + "Certificate {} is not yet valid (valid from {}). Falling back to key authentication.", + cert_source, + valid_from_str + ); + return match self.try_publickey_auth(username, key_pair).await { + Ok(()) => Ok(AuthResult { + warnings: vec![SshWarning::CertificateNotYetValid { + host: host.to_string(), + cert_path: cert_source.to_string(), + valid_from: valid_from_str, + }], + }), + Err(_) => Err(eyre::eyre!( + "Certificate not yet valid (valid from {}) and fallback key authentication also failed", + valid_from_str + )), + }; + } + + if now > valid_before { + let valid_until_str = OffsetDateTime::from(valid_before).to_string(); + tracing::warn!( + "Certificate {} has expired (valid until {}). Falling back to key authentication.", + cert_source, + valid_until_str + ); + return match self.try_publickey_auth(username, key_pair).await { + Ok(()) => Ok(AuthResult { + warnings: vec![SshWarning::CertificateExpired { + host: host.to_string(), + cert_path: cert_source.to_string(), + valid_until: valid_until_str, + }], + }), + Err(_) => Err(eyre::eyre!( + "Certificate expired (valid until {}) and fallback key authentication also failed", + valid_until_str + )), + }; + } + + // Check if certificate authorizes the requested principal + let principals = cert.valid_principals(); + if !principals.is_empty() && !principals.iter().any(|p| p == username) { + tracing::warn!( + "Certificate does not explicitly authorize principal '{}' (authorized: {:?}). \ + Server may still accept it if wildcards or other matching rules apply.", + username, + principals + ); + } + + tracing::debug!( + "Certificate loaded and validated: type={:?}, key_id={}, principals={:?}", + cert.cert_type(), + cert.key_id(), + cert.valid_principals() + ); + + // Certificate is valid, try cert auth + let auth_res = self + .session + .authenticate_openssh_cert(username, Arc::new(key_pair), cert) + .await?; + + match auth_res { + russh::client::AuthResult::Success => { + tracing::info!( + "✓ Certificate authentication successful with {}", + cert_source + ); + Ok(AuthResult::default()) + } + russh::client::AuthResult::Failure { + remaining_methods, + partial_success, + } => { + tracing::warn!( + "Server rejected certificate {} (remaining methods: {:?}, partial: {})", + cert_source, + remaining_methods, + partial_success + ); + Err(eyre::eyre!( + "Certificate authentication failed: server rejected certificate" + )) + } + } + } + + /// Authenticate using keys from the SSH agent + /// + /// Note: SSH certificates loaded in the agent are NOT currently supported due to + /// limitations in the russh library. Certificate-based auth works with file-based + /// certificates (id_ed25519-cert.pub, etc.) but not with certificates held in an agent. + /// See: https://github.com/Eugeny/russh/issues/438 pub async fn agent_auth(&mut self, username: &str) -> Result { tracing::info!("Attempting SSH agent authentication for {username}"); @@ -711,14 +991,17 @@ impl Session { /// 2. SSH config identity files /// 3. Default SSH keys (id_rsa, id_ecdsa, id_ecdsa_sk, id_ed25519, id_ed25519_sk, id_xmss, id_dsa) /// 4. Provided authentication method (password or key) + /// + /// Returns AuthResult containing any warnings from the authentication process pub async fn authenticate( &mut self, auth: Option, username: Option<&str>, - ) -> Result<()> { + ) -> Result { // Clone values we need before any mutable borrows let config_username = self.ssh_config.username.clone(); let identity_files = self.ssh_config.identity_files.clone(); + let hostname = self.ssh_config.hostname.clone(); let current_user = whoami::username(); // Use provided username, or SSH config username, or default to current user @@ -739,7 +1022,7 @@ impl Session { tracing::info!("Step 1/4: Trying SSH agent authentication"); if self.agent_auth(username).await? { tracing::info!("✓ SSH authentication successful with agent"); - return Ok(()); + return Ok(AuthResult::default()); } tracing::info!("✗ SSH agent authentication failed or unavailable"); @@ -749,8 +1032,11 @@ impl Session { identity_files.len() ); for identity_file in &identity_files { - if let Ok(()) = self.key_auth(username, identity_file.clone()).await { - return Ok(()); + if let Ok(auth_result) = self + .key_auth(username, &hostname, identity_file.clone()) + .await + { + return Ok(auth_result); } } @@ -769,9 +1055,9 @@ impl Session { continue; } - match self.key_auth(username, key_path.clone()).await { - Ok(()) => { - return Ok(()); + match self.key_auth(username, &hostname, key_path.clone()).await { + Ok(auth_result) => { + return Ok(auth_result); } Err(e) => { tracing::debug!("Default SSH key failed: {e}"); @@ -785,12 +1071,11 @@ impl Session { Some(Authentication::Password(_user, password)) => { tracing::info!("Trying password authentication"); self.password_auth(username, &password).await?; - return Ok(()); + return Ok(AuthResult::default()); } Some(Authentication::Key(key_path)) => { tracing::info!("Trying explicitly provided key: {}", key_path.display()); - self.key_auth(username, key_path).await?; - return Ok(()); + return self.key_auth(username, &hostname, key_path).await; } None => { tracing::warn!("All SSH authentication methods exhausted"); @@ -805,23 +1090,28 @@ impl Session { Err(eyre::eyre!("All SSH authentication methods exhausted")) } - /// Authenticate with optional block-provided identity key configuration. + /// Authenticate with optional block-provided identity key and certificate configuration. /// If an identity key is provided from block settings, it is tried FIRST before other methods. /// /// Authentication order when identity_key_config is provided: /// 0. Block-provided identity key (FIRST - overrides everything) + /// - If certificate_config is also provided, use that cert instead of auto-detecting /// 1. SSH Agent authentication /// 2. SSH config identity files /// 3. Default SSH keys /// 4. Provided authentication method (password or key) + /// + /// Returns AuthResult containing any warnings from the authentication process pub async fn authenticate_with_config( &mut self, auth: Option, username: Option<&str>, identity_key_config: Option<&SshIdentityKeyConfig>, - ) -> Result<()> { + certificate_config: Option<&SshCertificateConfig>, + ) -> Result { // Clone values we need before any mutable borrows let config_username = self.ssh_config.username.clone(); + let hostname = self.ssh_config.hostname.clone(); let current_user = whoami::username(); // Use provided username, or SSH config username, or default to current user @@ -837,8 +1127,9 @@ impl Session { // Step 0: Try block-provided identity key FIRST (overrides everything) // If an explicit key is configured and fails, we do NOT fall back to agent/defaults tracing::debug!( - "authenticate_with_config called with identity_key_config: {:?}", - identity_key_config + "authenticate_with_config called with identity_key_config: {:?}, certificate_config: {:?}", + identity_key_config, + certificate_config ); if let Some(key_config) = identity_key_config { match key_config { @@ -849,10 +1140,19 @@ impl Session { } SshIdentityKeyConfig::Paste { content } => { tracing::info!("Step 0: Trying block-provided pasted key"); - match self.key_auth_from_content(username, content).await { - Ok(()) => { + // For pasted key content with explicit certificate, use cert_auth_from_content + match self + .key_auth_from_content_with_cert( + username, + &hostname, + content, + certificate_config, + ) + .await + { + Ok(auth_result) => { tracing::info!("✓ SSH authentication successful with pasted key"); - return Ok(()); + return Ok(auth_result); } Err(e) => { // Explicit key was configured but failed - do not fall back @@ -864,10 +1164,18 @@ impl Session { } SshIdentityKeyConfig::Path { path } => { tracing::info!("Step 0: Trying block-provided key path: {}", path); - match self.key_auth(username, PathBuf::from(path)).await { - Ok(()) => { + match self + .key_auth_with_cert_config( + username, + &hostname, + PathBuf::from(path), + certificate_config, + ) + .await + { + Ok(auth_result) => { tracing::info!("✓ SSH authentication successful with key: {}", path); - return Ok(()); + return Ok(auth_result); } Err(e) => { // Explicit key was configured but failed - do not fall back @@ -885,41 +1193,105 @@ impl Session { self.authenticate(auth, Some(username)).await } - /// Authenticate using a key from pasted content - async fn key_auth_from_content(&mut self, username: &str, key_content: &str) -> Result<()> { + /// Authenticate using a key from pasted content with optional certificate config + async fn key_auth_from_content_with_cert( + &mut self, + username: &str, + host: &str, + key_content: &str, + certificate_config: Option<&SshCertificateConfig>, + ) -> Result { tracing::debug!("Attempting authentication with pasted key content"); let key_pair = russh::keys::decode_secret_key(key_content, None) .map_err(|e| eyre::eyre!("Failed to decode pasted key: {e}"))?; - // Query the server for the best RSA hash algorithm it supports - let best_hash = self.session.best_supported_rsa_hash().await?.flatten(); - let key_with_alg = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key_pair), best_hash); + // If explicit certificate provided, use cert auth + if let Some(SshCertificateConfig::Path { path }) = certificate_config { + tracing::info!("Using explicit certificate path: {}", path); + return self + .cert_auth_with_key(username, host, key_pair, PathBuf::from(path)) + .await; + } - let auth_res = self - .session - .authenticate_publickey(username, key_with_alg) - .await?; + if let Some(SshCertificateConfig::Paste { content }) = certificate_config { + tracing::info!("Using pasted certificate content"); + return self + .cert_auth_with_key_and_cert_content(username, host, key_pair, content) + .await; + } - match auth_res { - russh::client::AuthResult::Success => { + // No certificate - regular key auth + self.try_publickey_auth(username, key_pair) + .await + .map(|()| { tracing::info!("✓ Pasted key authentication successful"); - Ok(()) - } - russh::client::AuthResult::Failure { - remaining_methods, - partial_success, - } => { - tracing::warn!( - "Server rejected pasted key (remaining methods: {:?}, partial: {})", - remaining_methods, - partial_success - ); - Err(eyre::eyre!( - "Pasted key authentication failed: server rejected key" - )) - } + AuthResult::default() + }) + .map_err(|_| eyre::eyre!("Pasted key authentication failed: server rejected key")) + } + + /// Authenticate using a key file path with optional certificate config + /// If certificate_config is provided, use that instead of auto-detecting + async fn key_auth_with_cert_config( + &mut self, + username: &str, + host: &str, + key_path: PathBuf, + certificate_config: Option<&SshCertificateConfig>, + ) -> Result { + if let Some(SshCertificateConfig::Path { path }) = certificate_config { + tracing::info!("Using explicit certificate path: {}", path); + return self + .cert_auth(username, host, key_path, PathBuf::from(path)) + .await; } + + if let Some(SshCertificateConfig::Paste { content }) = certificate_config { + tracing::info!("Using pasted certificate content"); + let key_pair = russh::keys::load_secret_key(&key_path, None) + .map_err(|e| eyre::eyre!("Failed to load key {}: {e}", key_path.display()))?; + return self + .cert_auth_with_key_and_cert_content(username, host, key_pair, content) + .await; + } + + // No explicit certificate - use default behavior (auto-detect) + self.key_auth(username, host, key_path).await + } + + /// Certificate auth with an already-loaded key pair and a certificate path + async fn cert_auth_with_key( + &mut self, + username: &str, + host: &str, + key_pair: russh::keys::PrivateKey, + cert_path: PathBuf, + ) -> Result { + // Read certificate file content and delegate to impl + let cert_content = std::fs::read_to_string(&cert_path).map_err(|e| { + eyre::eyre!( + "Failed to read certificate file {}: {e}", + cert_path.display() + ) + })?; + + let cert_source = cert_path.display().to_string(); + self.cert_auth_impl(username, host, key_pair, &cert_content, &cert_source) + .await + } + + /// Certificate auth with an already-loaded key pair and pasted certificate content + async fn cert_auth_with_key_and_cert_content( + &mut self, + username: &str, + host: &str, + key_pair: russh::keys::PrivateKey, + cert_content: &str, + ) -> Result { + // Delegate directly to impl with pasted content indicator + self.cert_auth_impl(username, host, key_pair, cert_content, "(pasted content)") + .await } pub async fn disconnect(&self) -> Result<()> { @@ -1471,4 +1843,56 @@ Host example.com } } } + + #[tokio::test] + async fn test_find_certificate_for_key_when_cert_exists() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + let cert_path = temp_dir.path().join("id_ed25519-cert.pub"); + + // Create the key file (content doesn't matter for this test) + fs::write(&key_path, "fake key content").unwrap(); + // Create the certificate file + fs::write(&cert_path, "fake cert content").unwrap(); + + let result = Session::find_certificate_for_key(&key_path).await; + assert!(result.is_some()); + assert_eq!(result.unwrap(), cert_path); + } + + #[tokio::test] + async fn test_find_certificate_for_key_when_cert_missing() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + // Create only the key file, no certificate + fs::write(&key_path, "fake key content").unwrap(); + + let result = Session::find_certificate_for_key(&key_path).await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_find_certificate_for_key_rsa() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + let cert_path = temp_dir.path().join("id_rsa-cert.pub"); + + fs::write(&key_path, "fake key content").unwrap(); + fs::write(&cert_path, "fake cert content").unwrap(); + + let result = Session::find_certificate_for_key(&key_path).await; + assert!(result.is_some()); + assert_eq!(result.unwrap(), cert_path); + } + + #[tokio::test] + async fn test_find_certificate_for_key_relative_path() { + // Test with a relative path - the cert lookup will check for a certificate + // at the relative path, which won't exist + let key_path = PathBuf::from("just_a_filename"); + let result = Session::find_certificate_for_key(&key_path).await; + // Should return None since no certificate file exists at the relative path + assert!(result.is_none()); + } } diff --git a/crates/atuin-desktop-runtime/src/ssh/ssh_pool.rs b/crates/atuin-desktop-runtime/src/ssh/ssh_pool.rs index 56097859..05981e35 100644 --- a/crates/atuin-desktop-runtime/src/ssh/ssh_pool.rs +++ b/crates/atuin-desktop-runtime/src/ssh/ssh_pool.rs @@ -11,7 +11,7 @@ use tokio::time::interval; use crate::context::DocumentSshConfig; use crate::pty::PtyMetadata; use crate::ssh::pool::Pool; -use crate::ssh::session::{Authentication, OutputLine, Session}; +use crate::ssh::session::{Authentication, OutputLine, Session, SshWarning}; use eyre::Result; use std::sync::Arc; @@ -94,6 +94,9 @@ pub enum SshPoolMessage { // Optional SSH config overrides from block settings ssh_config: Option, + + // Channel to send authentication warnings (certificate issues, etc.) + warnings_tx: Option>>, }, ExecFinished { channel: String, @@ -114,9 +117,15 @@ pub enum SshPoolMessage { ssh_config: Option, // The actual result of the open_pty command - // returns a channel to send input to the pty + // returns a channel to send input to the pty, plus any auth warnings #[allow(clippy::type_complexity)] - reply_to: oneshot::Sender, mpsc::Sender<(u16, u16)>)>>, + reply_to: oneshot::Sender< + Result<( + mpsc::Sender, + mpsc::Sender<(u16, u16)>, + Vec, + )>, + >, }, ClosePty { channel: String, @@ -258,6 +267,7 @@ impl SshPoolHandle { output_stream, result_tx, None, + None, ) .await } @@ -273,6 +283,7 @@ impl SshPoolHandle { output_stream: mpsc::Sender, result_tx: oneshot::Sender<()>, ssh_config: Option, + warnings_tx: Option>>, ) -> Result<()> { let (sender, receiver) = oneshot::channel(); let msg = SshPoolMessage::Exec { @@ -285,6 +296,7 @@ impl SshPoolHandle { reply_to: sender, result_tx, ssh_config, + warnings_tx, }; let _ = self.sender.send(msg).await; @@ -319,7 +331,11 @@ impl SshPoolHandle { output_stream: mpsc::Sender, width: u16, height: u16, - ) -> Result<(mpsc::Sender, mpsc::Sender<(u16, u16)>)> { + ) -> Result<( + mpsc::Sender, + mpsc::Sender<(u16, u16)>, + Vec, + )> { self.open_pty_with_config(host, username, channel, output_stream, width, height, None) .await } @@ -334,7 +350,11 @@ impl SshPoolHandle { width: u16, height: u16, ssh_config: Option, - ) -> Result<(mpsc::Sender, mpsc::Sender<(u16, u16)>)> { + ) -> Result<( + mpsc::Sender, + mpsc::Sender<(u16, u16)>, + Vec, + )> { let (reply_sender, reply_receiver) = oneshot::channel(); let msg = SshPoolMessage::OpenPty { @@ -530,7 +550,8 @@ impl SshPool { .write() .await .connect(&host, username.as_deref(), auth, None) - .await; + .await + .map(|(session, _auth_result)| session); let _ = reply_to.send(result); } @@ -564,6 +585,7 @@ impl SshPool { reply_to, result_tx, ssh_config, + warnings_tx, } => { tracing::trace!( "Executing command on {host} with {interpreter} with username {username:?}" @@ -597,20 +619,31 @@ impl SshPool { tokio::spawn(async move { tracing::trace!("Connecting to SSH host {host} with username {username}"); let mut pool_guard = pool.write().await; - let session: Result, SshPoolConnectionError> = tokio::select! { + let (session, warnings): ( + Result, SshPoolConnectionError>, + Vec, + ) = tokio::select! { result = pool_guard.connect_with_config(&host, Some(username.as_str()), None, Some(connect_cancel_rx), ssh_config.as_ref()) => { tracing::trace!("SSH connection to {host} with username {username} successful"); - result.map_err(SshPoolConnectionError::from) + match result { + Ok((session, auth_result)) => (Ok(session), auth_result.warnings), + Err(e) => (Err(SshPoolConnectionError::from(e)), Vec::new()), + } } _ = &mut cancel_rx => { tracing::trace!("SSH connection to {host} with username {username} cancelled"); let _ = connect_cancel_tx.send(()); let _ = pool_guard.disconnect(&host, &username).await; - Err(SshPoolConnectionError::Cancelled) + (Err(SshPoolConnectionError::Cancelled), Vec::new()) } }; drop(pool_guard); + // Send warnings to caller if they requested them + if let Some(tx) = warnings_tx { + let _ = tx.send(warnings); + } + let session = match session { Ok(session) => session, Err(e) => { @@ -713,7 +746,7 @@ impl SshPool { .or(resolved_ssh_config.username) .unwrap_or_else(whoami::username); - let session = self + let connect_result = self .pool .write() .await @@ -726,8 +759,8 @@ impl SshPool { ) .await; - let session = match session { - Ok(session) => session, + let (session, auth_result) = match connect_result { + Ok((session, auth_result)) => (session, auth_result), Err(e) => { tracing::error!("Failed to connect to SSH host {host}: {e}"); if let Err(e) = reply_to.send(Err(e)) { @@ -797,7 +830,7 @@ impl SshPool { let _ = reply_to.send(Err(e)); } Ok(_) => { - let _ = reply_to.send(Ok((input_tx, resize_tx))); + let _ = reply_to.send(Ok((input_tx, resize_tx, auth_result.warnings))); } } } @@ -876,7 +909,7 @@ impl SshPool { .connect(&host, username.as_deref(), None, None) .await { - Ok(session) => session, + Ok((session, _auth_result)) => session, Err(e) => { let _ = reply_to.send(Err(e)); return; @@ -899,7 +932,7 @@ impl SshPool { .connect(&host, username.as_deref(), None, None) .await { - Ok(session) => session, + Ok((session, _auth_result)) => session, Err(e) => { let _ = reply_to.send(Err(e)); return; @@ -922,7 +955,7 @@ impl SshPool { .connect(&host, username.as_deref(), None, None) .await { - Ok(session) => session, + Ok((session, _auth_result)) => session, Err(e) => { let _ = reply_to.send(Err(e)); return; diff --git a/docker/ssh-test/.gitignore b/docker/ssh-test/.gitignore index c25ef63e..a50d3f06 100644 --- a/docker/ssh-test/.gitignore +++ b/docker/ssh-test/.gitignore @@ -1,3 +1,4 @@ # Generated test keys - don't commit these test-keys/ authorized_keys +ca_key.pub diff --git a/docker/ssh-test/Dockerfile b/docker/ssh-test/Dockerfile index af320384..0f0ddcff 100644 --- a/docker/ssh-test/Dockerfile +++ b/docker/ssh-test/Dockerfile @@ -15,6 +15,10 @@ RUN mkdir -p /home/testuser/.ssh && \ # Copy custom sshd_config COPY sshd_config /etc/ssh/sshd_config +# Copy CA public key for certificate authentication +# This file is generated by setup-keys.sh +COPY ca_key.pub /etc/ssh/ca_key.pub + # Generate host keys RUN ssh-keygen -A diff --git a/docker/ssh-test/setup-keys.sh b/docker/ssh-test/setup-keys.sh index a65e644f..b517f389 100755 --- a/docker/ssh-test/setup-keys.sh +++ b/docker/ssh-test/setup-keys.sh @@ -13,7 +13,7 @@ echo "Generating test SSH keys in $KEY_DIR..." mkdir -p "$KEY_DIR" # Remove old keys if they exist -rm -f "$KEY_DIR"/id_* "$SCRIPT_DIR/authorized_keys" +rm -f "$KEY_DIR"/id_* "$KEY_DIR"/*-cert.pub "$KEY_DIR"/ca_* "$SCRIPT_DIR/authorized_keys" "$SCRIPT_DIR/ca_key.pub" # Generate RSA key (4096 bits for security) ssh-keygen -t rsa -b 4096 -f "$KEY_DIR/id_rsa" -N "" -C "test-rsa-key" @@ -27,19 +27,86 @@ echo "Generated: id_ecdsa (ECDSA nistp256)" ssh-keygen -t ed25519 -f "$KEY_DIR/id_ed25519" -N "" -C "test-ed25519-key" echo "Generated: id_ed25519 (Ed25519)" -# Create authorized_keys file with all public keys -cat "$KEY_DIR"/*.pub > "$SCRIPT_DIR/authorized_keys" +# Generate Ed25519 key specifically for certificate testing (no authorized_keys entry) +ssh-keygen -t ed25519 -f "$KEY_DIR/id_ed25519_cert_only" -N "" -C "test-ed25519-cert-only" +echo "Generated: id_ed25519_cert_only (Ed25519 - certificate auth only)" + +# ============================================================================= +# Certificate Authority Setup +# ============================================================================= +echo "" +echo "Setting up Certificate Authority..." + +# Generate CA key (Ed25519 for CA) +ssh-keygen -t ed25519 -f "$KEY_DIR/ca_key" -N "" -C "test-ca-key" +echo "Generated: ca_key (Certificate Authority)" + +# Sign user keys to create certificates +# -s: CA private key +# -I: Key identifier (logged on server) +# -n: Principals (usernames allowed to use this cert) +# -V: Validity period + +# Sign the cert-only key with valid certificate +ssh-keygen -s "$KEY_DIR/ca_key" \ + -I "test-cert-valid" \ + -n "testuser" \ + -V "-5m:+1h" \ + "$KEY_DIR/id_ed25519_cert_only.pub" +echo "Signed: id_ed25519_cert_only-cert.pub (valid for 1 hour)" + +# Create an expired certificate for testing fallback behavior +# First, create a key for the expired cert test +ssh-keygen -t ed25519 -f "$KEY_DIR/id_ed25519_expired_cert" -N "" -C "test-ed25519-expired-cert" +ssh-keygen -s "$KEY_DIR/ca_key" \ + -I "test-cert-expired" \ + -n "testuser" \ + -V "-2h:-1h" \ + "$KEY_DIR/id_ed25519_expired_cert.pub" +echo "Signed: id_ed25519_expired_cert-cert.pub (expired 1 hour ago)" + +# Create a not-yet-valid certificate for testing +ssh-keygen -t ed25519 -f "$KEY_DIR/id_ed25519_future_cert" -N "" -C "test-ed25519-future-cert" +ssh-keygen -s "$KEY_DIR/ca_key" \ + -I "test-cert-future" \ + -n "testuser" \ + -V "+1h:+2h" \ + "$KEY_DIR/id_ed25519_future_cert.pub" +echo "Signed: id_ed25519_future_cert-cert.pub (valid in 1 hour)" + +# Copy CA public key to script dir for sshd to use +cp "$KEY_DIR/ca_key.pub" "$SCRIPT_DIR/ca_key.pub" +echo "Copied: ca_key.pub to $SCRIPT_DIR" + +# ============================================================================= +# Authorized Keys Setup +# ============================================================================= +echo "" + +# Create authorized_keys file with public keys (NOT including cert-only keys) +# Certificate-authenticated users don't need entries in authorized_keys +cat "$KEY_DIR/id_rsa.pub" "$KEY_DIR/id_ecdsa.pub" "$KEY_DIR/id_ed25519.pub" > "$SCRIPT_DIR/authorized_keys" +# Also add the expired/future cert keys so they can fall back to key auth +cat "$KEY_DIR/id_ed25519_expired_cert.pub" "$KEY_DIR/id_ed25519_future_cert.pub" >> "$SCRIPT_DIR/authorized_keys" chmod 644 "$SCRIPT_DIR/authorized_keys" -echo "Created: authorized_keys with all public keys" +echo "Created: authorized_keys with public keys (cert-only key excluded)" # Set correct permissions on private keys chmod 600 "$KEY_DIR"/id_* +chmod 600 "$KEY_DIR"/ca_key chmod 644 "$KEY_DIR"/*.pub echo "" echo "Test keys generated successfully!" echo "Keys directory: $KEY_DIR" echo "" +echo "Generated keys:" +echo " - id_rsa, id_ecdsa, id_ed25519: Standard key auth" +echo " - id_ed25519_cert_only: Certificate auth only (no authorized_keys entry)" +echo " - id_ed25519_expired_cert: Expired certificate (falls back to key auth)" +echo " - id_ed25519_future_cert: Not-yet-valid certificate (falls back to key auth)" +echo " - ca_key: Certificate Authority for signing" +echo "" echo "To start the test SSH server:" echo " cd $SCRIPT_DIR" echo " docker-compose up -d" diff --git a/docker/ssh-test/sshd_config b/docker/ssh-test/sshd_config index 0de350c1..5d544f9a 100644 --- a/docker/ssh-test/sshd_config +++ b/docker/ssh-test/sshd_config @@ -11,8 +11,12 @@ PasswordAuthentication yes PubkeyAuthentication yes AuthorizedKeysFile .ssh/authorized_keys +# Certificate Authentication +# Trust certificates signed by our test CA +TrustedUserCAKeys /etc/ssh/ca_key.pub + # Allow all key algorithms for comprehensive testing -PubkeyAcceptedAlgorithms +ssh-rsa,rsa-sha2-256,rsa-sha2-512,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519 +PubkeyAcceptedAlgorithms +ssh-rsa,rsa-sha2-256,rsa-sha2-512,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com HostKeyAlgorithms +ssh-rsa,rsa-sha2-256,rsa-sha2-512,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519 # Subsystems diff --git a/src/components/runbooks/List/Workspace.tsx b/src/components/runbooks/List/Workspace.tsx index 768fbbb5..ca5cbf30 100644 --- a/src/components/runbooks/List/Workspace.tsx +++ b/src/components/runbooks/List/Workspace.tsx @@ -862,7 +862,7 @@ export default function WorkspaceComponent(props: WorkspaceProps) { break; default: errorType = "Unknown Error"; - errorText = `An unknown error occurred: ${error.data.message}`; + errorText = `An unknown error occurred: ${"message" in error.data ? error.data.message : error.type}`; } async function confirmDeleteWorkspace() { diff --git a/src/components/runbooks/editor/blocks/ssh/SshConnect.tsx b/src/components/runbooks/editor/blocks/ssh/SshConnect.tsx index 153c217d..a7d1974c 100644 --- a/src/components/runbooks/editor/blocks/ssh/SshConnect.tsx +++ b/src/components/runbooks/editor/blocks/ssh/SshConnect.tsx @@ -51,6 +51,12 @@ interface IdentityKeySettings { value: string; } +// Local-only settings for SSH certificate (not synced to other users) +interface CertificateSettings { + mode: string; + value: string; +} + const SshConnect = ({ blockId, userHost, @@ -73,6 +79,14 @@ const SshConnect = ({ { mode: "none", value: "" } ); + // Certificate settings are stored locally per-user (not synced) + // Uses KV store so the backend runtime can access the value + const [certificate, setCertificate] = useBlockKvValue( + blockId, + "certificate", + { mode: "none", value: "" } + ); + const hasExplicitConfig = user || hostname; const displayValue = hasExplicitConfig @@ -127,6 +141,21 @@ const SshConnect = ({ } }; + const selectCertFile = async () => { + if (!isEditable) return; + try { + const selectedPath = await open({ + multiple: false, + directory: false, + }); + if (selectedPath) { + await setCertificate({ ...certificate, value: selectedPath }); + } + } catch (err) { + console.error("Failed to select certificate file:", err); + } + }; + const hasIncompleteConfig = (user && !hostname) || (!user && hostname); @@ -188,14 +217,16 @@ const SshConnect = ({ setSettingsOpen(false)} - size="md" + size="2xl" + scrollBehavior="inside" > SSH Connect Settings -
+
+ {/* Left column: Connection */}

Connection @@ -205,33 +236,30 @@ const SshConnect = ({ over the quick input field.

-
- onSettingsChange({ user: v })} - isDisabled={!isEditable} - size="sm" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck="false" - /> - onSettingsChange({ hostname: v })} - isDisabled={!isEditable} - size="sm" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck="false" - /> -
- + onSettingsChange({ user: v })} + isDisabled={!isEditable} + size="sm" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck="false" + /> + onSettingsChange({ hostname: v })} + isDisabled={!isEditable} + size="sm" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck="false" + /> -
-

- Identity Key -

-

- Specify a private key for authentication. Overrides SSH config - and agent. -

+ {/* Right column: Identity Key & Certificate */} +
+
+

+ Identity Key +

+

+ Specify a private key for authentication. Overrides SSH config + and agent. +

- { - await setIdentityKey({ mode: v, value: "" }); - }} - isDisabled={!isEditable} - size="sm" - > - Use SSH config/agent (default) - Specify key path - Paste key content - - - {identityKey.mode === "path" && ( -
- -

Or browse/enter a custom path:

-
- - { - await setIdentityKey({ ...identityKey, value: v }); + { + await setIdentityKey({ mode: v, value: "" }); + }} + isDisabled={!isEditable} + size="sm" + > + Use SSH config/agent (default) + Specify key path + Paste key content + + + {identityKey.mode === "path" && ( +
+ +

Or browse/enter a custom path:

+
+ + { + await setIdentityKey({ ...identityKey, value: v }); + }} + isDisabled={!isEditable} + size="sm" + className="flex-1" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck="false" + /> +
-
- )} + )} + + {identityKey.mode === "paste" && ( +