From 993792e193e7bca7c3563278a5ea90fd7015546f Mon Sep 17 00:00:00 2001 From: Samuel James <143277412+SamuelSJames@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:29:36 -0400 Subject: [PATCH] Fix terminal failing for SSH hosts with a non-cert "certificate" secret (#26) The Terminal page failed to open a shell for one host (Linode) while Host Metrics worked fine for the same host, and other hosts (pve1/pve2) worked everywhere. Root cause: the terminal route takes a special certificate-auth path whenever an SSH integration has ANY `certificate` secret set, and that path shells out to the system `ssh` binary under a pty instead of using the ssh2 library. The metrics path always uses ssh2, which is why it was unaffected. That host's `certificate` secret was actually a plain public key (`ssh-ed25519 AAAA...`), not an OpenSSH certificate. ssh discarded it ("is not a certificate") and then could not load the private key under the container's libcrypto ("error in libcrypto: unsupported"), ending in "Permission denied (publickey)". With ssh2 (the metrics path), the same private key authenticates fine. Two fixes: - Only take the cert-auth path when the secret is a genuine OpenSSH certificate (key type ends in `-cert-v01@openssh.com`). A plain public key now falls through to the normal ssh2 key/password path, which already works (proven by the metrics endpoint using the same key). - Add `-o IdentitiesOnly=yes` to the cert-auth ssh invocation so it only offers the provided key/cert and isn't confused by a stray file. No server-side or key changes were needed on the affected host; this is purely a routing/robustness fix in the terminal WebSocket handler. Co-authored-by: Samuel James Co-authored-by: Kiro --- backend/src/routes/terminal.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 6a4c2b2..458f973 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -17,6 +17,18 @@ interface ClientMessage { const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/ +/** + * True only if the stored secret is a genuine OpenSSH certificate, i.e. its key type ends in + * `-cert-v01@openssh.com` (e.g. `ssh-ed25519-cert-v01@openssh.com AAAA...`). A plain public key + * (`ssh-ed25519 AAAA...`) is NOT a certificate — storing one in the `certificate` field must not + * trigger the cert-auth path, which shells out to the system `ssh` binary and fails to load some + * key formats. In that case we fall through to the normal ssh2 key/password path instead. */ +function isOpenSshCertificate(value: string | undefined): boolean { + if (!value) return false + const firstToken = value.trim().split(/\s+/)[0] ?? '' + return firstToken.endsWith('-cert-v01@openssh.com') +} + const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs' function send(socket: { send: (data: string) => void }, payload: Record) { @@ -50,6 +62,7 @@ function connectWithCertificate( '-p', String(Number(target.config.port) || 22), '-i', keyFile, '-o', `CertificateFile=${certFile}`, + '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`, `${target.config.username}@${target.config.host}`, @@ -156,7 +169,7 @@ export async function terminalRoutes(app: FastifyInstance) { const cols = msg.cols ?? 80 const rows = msg.rows ?? 24 - if (target.secrets.certificate) { + if (isOpenSshCertificate(target.secrets.certificate)) { connectWithCertificate( target, cols,